Code-review followups on 16d4399: - CLAUDE.md's "When CSS changes don't appear" diagnostic steps were written for the old per-request token. Under mtime-based caching, a stable ?v= number is the healthy expected state, not a broken one. Rewrote steps 1 + 3 so someone debugging a real production CSS issue gets the right advice. - Dropped unused `original = cp._compute_cache_bust_token` line in test_token_falls_back_if_file_missing - it misled readers into thinking the function itself was patched. Added a one- line comment clarifying the monkey-patch is path-only. Tests: still 68/68. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
56 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
- Django template comments
{# ... #}are SINGLE-LINE only. Multi-line blocks need{% comment %}...{% endcomment %}. A{#on line N with no closing#}on the same line renders the whole block as literal text onto the page (and silently — no error). This bit us 4× during the Adjustments feature. Also: the literal tokens{#and#}cannot appear inside a{% comment %}block — they'll be parsed as a nested comment marker. Rephrase meta-notes about comment syntax OUTSIDE the block. - Duplicate
id=""attributes cause silent bugs.document.getElementById()returns only the FIRST match in DOM order, so adding a second element with an existing id silently steals the handler from the original. Grep the template before assigning any new id (caughtadjSelectAllcollision in Task 6 — header checkbox stole the Add-Adjustment modal's Select-All handler).
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` (ZAR), `money_abs` (signed callers), `type_slug` (type→CSS class), `url_replace` (swap one query-param), `dictlookup`
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
Partials: _adjustment_row.html (shared row for flat + grouped Adjustments tab)
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) — NOT in git (build artifact, regenerated by collectstatic)
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.
Schema name-drifts to remember
Fields / accessors that differ from what you'd guess. Each has bitten multiple
sessions; grep core/models.py before using any field you haven't used before:
PayrollAdjustment.description— NOTreasonlog.adjustments_by_work_log(reverse accessor for PayrollAdjustment.work_log FK) — NOTpayrolladjustment_set(the FK hasrelated_nameset)log.overtime_amount(DecimalField, default 0.00) — NOTlog.overtimePayrollRecord.amount_paid(DecimalField) +PayrollRecord.work_logs(M2M reverse) — NOTtotal_amount/days_worked(easy to guess wrong when writing test fixtures)Loan.principal_amount— NOTprincipal.Loan.save()auto-setsremaining_balance = principal_amounton create, so tests rarely need to pass both.
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
Django ORM gotcha — M2M filter + aggregate inflation
Chained .filter(m2m__field=X).filter(m2m__other=Y) creates separate JOIN
aliases, producing a cartesian product of rows. .aggregate(Sum(...)) dedupes
via subquery when distinct() is present; .values().annotate(Sum(...)) does
NOT — it GROUP BYs the inflated rows and multiplies sums by N×M (where N and
M are the counts of matching related rows). Fix pattern: use
.filter(id__in=Model.objects.filter(m2m__field=X).values('id')) to keep the
outer queryset JOIN-free. See _build_report_context in core/views.py and
ReportContextFilterInflationTests in core/tests.py for the reference
implementation (commit f1e246c, Apr 2026).
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
# Run the test suite (sets env vars inline — works in Git Bash; on cmd.exe use `set` first)
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
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. - Inline Filters on the Report page:
/report/has three pill-buttons (Date / Projects / Teams) in a sticky strip. Clicking a pill opens an inline popover with the editor for that filter. Popover's OK rebuilds the URL and navigates — no "dirty state", no global Apply. Date popover has a Single/Range/Custom mode toggle; Projects + Teams use Choices.js multi-select. Bidirectional project↔team cross-filter disables workers/projects that never paired in the selected date range. Context keyproject_team_pairs_jsonis consumed via|json_script(raw Python list — NEVERjson.dumpsit first; the filter does the serialisation and double-encoding silently breaks.forEach(...)). Deleted the old_report_config_modal.html; Dashboard "Generate Report" button is now a plain link with the current month pre-filled. - Adjustments tab (
/payroll/?status=adjustments): the 4th payroll-dashboard tab, next to Pending / History / Loans. Browse everyPayrollAdjustmentacross all workers with 5 pill-style filters (Type / Workers / Teams / Status / Date — all popover-checkbox/radio, no native<select>s). Semantic colour badges per type: 7 types × 2 themes = 14--badge-*-bg/fgCSS tokens, with+15%saturation siblings forLoan Repayment/Advance Repaymentso "money coming back" reads as hotter than "money going out". Group-by toggle (Flat / By Type / By Worker) with collapsible sections; By-Type headers get a 4px left-border in the matching badge colour via[data-type="..."]attribute selectors. Sortable column headers (Date / Worker / Amount / Status) toggle?sort=X&order=asc|desc. Bulk-delete via row checkboxes + floating action bar →POST /payroll/adjustments/bulk-delete/. Row actions reuse existing modals (Worker Lookup, Preview Payslip, Edit adjustment) — clicking a worker name or paid-row eye icon opens a modal rather than navigating away; project name links to/projects/<id>/#history(a tiny hash-based tab-activation helper inprojects/detail.htmlactivates whichever tab matches the URL hash). - Adjustment cascade helper:
_delete_adjustment_with_cascade(adj)incore/views.pyis the single authority for "delete this adjustment, cleaning up linked objects". Returns(ok: bool, reason: str|None)—reasonis'paid'or'has_paid_repayments'. ForNew Loan/Advance Paymentit deletes the linkedLoan+ any still-unpaid repayment adjustments (aborts if any are already paid). ForOvertimeit removes the worker fromwork_log.priced_workers. Bothdelete_adjustment(single-row) ANDbulk_delete_adjustmentsdelegate to this helper, so bulk and single-row have identical semantics. Without it, bulk-delete was silently orphaningLoanrows (was a critical bug caught in code review — seetest_bulk_delete_cascades_new_loan). - Pill-popover filter pattern (used by both Inline Filters on
/report/AND the Adjustments tab's 5 filters): each filter is a.filter-pill-wrapcontaining a<button class="filter-pill filter-pill--editable">+ a hidden<div class="filter-popover">. Committed state lives in hidden<input>s inside a.adj-hidden-inputs[data-adj-filter="X"]container (rewritten on popover OK viareplaceChildren()+createElement— XSS-safe, noinnerHTML). Popover OK commits + closes; Cancel/Esc/click-outside revert + close. Reusable CSS classes live instatic/css/custom.cssunder/* === Inline Filters === */.
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/?status=adjustments |
payroll_dashboard |
Admin: browse ALL payroll adjustments (filter by type, worker, team, status, date; group-by type/worker; bulk-delete unpaid; row actions open existing modals) |
/payroll/adjustments/bulk-delete/ |
bulk_delete_adjustments |
Admin: POST-only; delete multiple unpaid adjustments in one shot via fetch() with X-CSRFToken cookie |
/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[data-theme="light"]block. A sun/moon toggle in the topbar flips thedata-themeattribute 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
Static Assets & Cache-Busting (Cloudflare is in front)
Production traffic reaches the Flatlogic VM through Cloudflare (response headers
include cf-ray, cf-cache-status, and a cache-control: max-age=14400). Static
assets — including custom.css — are cached at Cloudflare's edge for up to 4 hours
per unique URL. This is great for performance and bad for deploys if the URL doesn't
change when the file does.
How cache-busting works now
base.html loads CSS as:
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp|default:'1' }}">
deployment_timestamp comes from core/context_processors.py::project_context
via _compute_cache_bust_token() — returns the mtime of
static/css/custom.css as an integer. The token only changes when the
CSS file is modified, so Cloudflare's edge cache holds each version for
its full 4h TTL and repeat page loads in a session hit the browser
cache (304 Not Modified). Deploys that include a CSS change bump the
mtime → new token → cache busts. Pre-24-Apr-2026 this was
int(time.time()) per-request, which defeated the CDN cache entirely
(effectively 0% hit rate on CSS). Degraded-mode fallback: if
custom.css isn't on disk (e.g., fresh container before
collectstatic), the function falls back to the old per-request
timestamp rather than crashing.
The pitfall this replaced
Pre-Apr 2026, the template used {{ request.timestamp|default:'1.0' }}. But
request.timestamp is not a Django request attribute — the variable always
fell back to the literal '1.0'. Every deploy's CSS URL resolved to the same
custom.css?v=1.0, so Cloudflare held onto a pre-redesign copy for hours while
the VM served the new one. Symptom was "the deploy worked but the page looks wrong"
that only a hard refresh in incognito temporarily fixed. Never use request.timestamp
in templates — it doesn't exist.
When CSS changes don't appear on production
- Confirm Django is rendering a stable URL:
curl -s https://foxlog.flatlogic.app/ | grep -oE 'custom\.css\?v=[^"]+'— run it twice; thev=number must be IDENTICAL across requests. Under the mtime-based token (see previous subsection), the number only changes afterstatic/css/custom.cssis edited. If it DOES change every request, the fallback branch of_compute_cache_bust_token()is active (the CSS file couldn't be stat'd) — check the file exists and is readable. - Confirm the CDN honours it:
curl -sI "https://foxlog.flatlogic.app/static/css/custom.css?cb=test$(date +%s)" | grep -i cache— expectcf-cache-status: MISSthenHITon repeat - If you've just deployed a CSS change and the old file is still showing: confirm
collectstaticran on the VM after pull (Flatlogic doesn't auto-run it). Without collectstatic, the mtime of the collected copy understaticfiles/stays the same, so the token doesn't bump and Cloudflare keeps serving the old file from its 4h edge cache. - If
deployment_timestampisn't being injected at all (the?v=query string is missing from the rendered URL): check thatcore.context_processors.project_contextis listed inTEMPLATES[0]['OPTIONS']['context_processors']inconfig/settings.py.
collectstatic is required after CSS/JS changes on production
Flatlogic's rebuild does NOT automatically run collectstatic. If new CSS is on
disk but the VM's staticfiles/ hasn't been refreshed, Apache serves the old
collected copy. Have Gemini run python3 manage.py collectstatic --noinput
after any PR that touches static/.
staticfiles/ is NOT tracked in git (since Apr 2026)
staticfiles/ is gitignored because it's a build artifact — collectstatic
regenerates it from static/ and each installed-app's static dirs on every
deploy. Previously it was tracked, which caused two problems:
- Flatlogic-auto-noise commits. Every time Gemini ran
collectstatic, Flatlogic's web UI detected the modified files instaticfiles/and auto-committed them with a genericVer XX.YYmessage (e.g. the notorious "Ver 30.04 Fix reports and add Supervisor" commit that diverged our branch from GitHub in Apr 2026). These commits reached gitea but not GitHub, creating recurring reconciliation pain. - Misleading diffs in PRs. Every CSS change showed up twice in git
(once in
static/, once instaticfiles/), doubling review surface.
Deploy consequence: after pulling a commit that modifies static/ files,
Gemini MUST run collectstatic BEFORE restarting the service. If the pull
removed the staticfiles/ directory from the working tree (which happens
the first time after the gitignore change), collectstatic recreates everything
from source. Brief window (~seconds) of possible 404s on static assets during
the deploy; acceptable at this scale.
First-time migration note: the commit that added staticfiles/ to
.gitignore also removed all previously-tracked files via
git rm -r --cached staticfiles/. The VM's filesystem still holds the
directory — git just stops tracking it. On next deploy the pull will
delete the working-tree copies (because they no longer exist in the
commit tree), so collectstatic --noinput MUST run immediately after
pull to repopulate. After that, all is stable.
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).order_by('username')
Any active user can be picked. The picker is deliberately NOT pre-filtered
by group/staff flags because is_supervisor(user) (views.py) grants
supervisor powers to anyone assigned to a team/project FK/M2M — so the
picker shouldn't be stricter than the permission model. Pre-Apr 2026 the
picker required Work Logger group membership, which hid valid supervisors
(see commit 0ceceeb for the fix + regression tests). Deactivated accounts
are still 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). - (Optional) Add them to the Work Logger group if you want
is_supervisor(user)to return True even without a team/project assignment. Not required for the picker to show them — the picker shows any active user (see commit0ceceeb, Apr 2026). - (Optional) Assign them as the supervisor of one or more teams via
/teams/<id>/edit/(Supervisor dropdown — they'll appear in the list because they're active). - (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 # required in prod — startup fails without it
DJANGO_DEBUG # "true"/"false"; defaults to false; keep false in prod
HOST_FQDN, CSRF_TRUSTED_ORIGIN # trusted hostnames (scheme-less ok, auto-prefixed https://)
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 (local dev only)
EMAIL_HOST_USER # Gmail address — required for any outbound email
EMAIL_HOST_PASSWORD # Gmail App Password (16 chars, no spaces/non-breaking-space)
DEFAULT_FROM_EMAIL # Optional — falls back to EMAIL_HOST_USER if unset
SPARK_RECEIPT_EMAIL # Optional — defaults to FoxFitt's Spark Receipt address
PROJECT_DESCRIPTION, PROJECT_IMAGE_URL # Flatlogic branding
Email fallback behaviour
DEFAULT_FROM_EMAIL is not strictly required — config/settings.py sets it as:
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "") or EMAIL_HOST_USER
…so if the env var is unset or empty, the "From" address on every outbound email
falls back to the authenticated Gmail address (which is always valid since we
send AS that account). Without this fallback, receipt and payslip emails fail
with Invalid address "". If you want to send FROM a different display address
than the authenticated one (e.g. "FoxFitt Payroll payroll@foxfitt.co.za"),
set DEFAULT_FROM_EMAIL explicitly — but Gmail will likely rewrite it to the
authenticated user anyway unless you've configured a "Send mail as" alias.
Where env vars live on Flatlogic
Flatlogic's platform has no env-var UI. Values are set in a .env file at
BASE_DIR.parent / ".env" on the VM (one level up from the repo). Edit via
Gemini/shell — the user cannot modify via Flatlogic's web editor because
.env is outside the project tree. The file is loaded by
python-dotenv in config/settings.py before any os.getenv() calls.
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. - Static files: Flatlogic's rebuild does NOT auto-run
collectstatic. After CSS/JS changes have Gemini runpython3 manage.py collectstatic --noinput+ restart the service, otherwise Apache keeps serving the previously-collected copy. - Service: The Django app runs as
django-dev.service(systemd). Gemini restarts it viasudo systemctl restart django-dev.service. It runspython manage.py runserver 0.0.0.0:8000— a development server, not gunicorn/uwsgi (Flatlogic default, works fine at this scale). - CDN: All production traffic goes through Cloudflare. Response headers show
cf-ray/cf-cache-status. Static assets are cached at the edge for 4h — see "Static Assets & Cache-Busting" section for how thedeployment_timestamptoken breaks stale caches. - 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
Git remotes on the VM
The Flatlogic VM has TWO git remotes, both kept in sync:
github→https://github.com/Konradzar/LabourPay_v5.git(our canonical source)gitea→https://gitea.flatlogic.app/admin/<id>-vm.git(Flatlogic's internal mirror — the one the platform UI watches)
Any push the VM makes must go to BOTH: git push github ai-dev && git push gitea ai-dev.
If the two diverge, Flatlogic's dashboard can show a different commit than GitHub,
which silently confuses deploys. Flatlogic's UI occasionally commits as
Flatlogic Bot <support@flatlogic.com> (autosaves from the in-browser file editor) —
those commits land on gitea but don't propagate to GitHub unless someone pushes.
VM-local safety branches
When doing risky deploys (model migrations, branch resets, history rewrites), we
create a safety branch on the VM at the pre-deploy HEAD so Gemini can
git reset --hard <safety-branch> + service-restart to roll back in ~60 seconds:
git branch pre-<purpose>-YYYYMMDD HEAD
git branch --list "pre-*"
Safety branches are VM-local — not pushed to GitHub by default. They're
single-use rollback anchors; delete after 7 days of confirmed stability via
git branch -D pre-<purpose>-YYYYMMDD.
Workflow options going forward
Either works — pick one and stick to it per change to avoid divergence:
- Claude → GitHub → Flatlogic pulls: Claude pushes to origin/ai-dev; you click "Pull Latest" in the Flatlogic UI (or ask Gemini to
git pull + push gitea + restart). - Flatlogic UI → GitHub: edit in Flatlogic's file editor; click "Push to GitHub" in their UI; Claude pulls locally with
git pull origin ai-dev. Don't mix paths in the same change — that's how divergence (and the "Ver XX.YY screeeewup" commits) happen.
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