diff --git a/docs/plans/2026-05-14-worker-absences-plan.md b/docs/plans/2026-05-14-worker-absences-plan.md new file mode 100644 index 0000000..9d90c14 --- /dev/null +++ b/docs/plans/2026-05-14-worker-absences-plan.md @@ -0,0 +1,2462 @@ +# Worker Absence Records Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (or +> superpowers:subagent-driven-development for in-session execution) to +> implement this plan task-by-task. + +**Goal:** Build a per-worker absence-tracking system: log absences with +date ranges, mark them paid (auto-create Bonus PayrollAdjustment), view +filtered list, edit/delete, surface YTD totals on the worker profile, and +add a quick-action button on the existing attendance form. + +**Architecture:** New `Absence` model 1:1 with optional `PayrollAdjustment` +(via OneToOneField with `SET_NULL`). Standalone form at `/absences/log/` +mirrors `AttendanceLogForm`'s date-range pattern. Conflict-warning is a +two-page flow (POST stashes pending data in session → redirects to +`/absences/log/confirm/`). Permission scoping is admin (all) or supervisor +(workers in their teams). Payroll sync via one helper function called from +all save paths. + +**Tech Stack:** Django 5.2.7, Python 3.13, SQLite (local) / MySQL (prod), +Bootstrap 5, vanilla JS. No new dependencies. + +**Pre-reading for the implementer:** +- `docs/plans/2026-05-14-worker-absences-design.md` — full design (7 brainstorm Qs answered, 5 sections approved by Konrad) +- `CLAUDE.md` — root project guide (coding style, naming-drift gotchas, existing patterns) +- `core/models.py::WorkerWarning` — the closest existing model pattern (per-worker, dated, severity choices, optional doc, ordered `-date`) +- `core/forms.py::AttendanceLogForm` — the date-range form pattern (date + optional end_date + Sat/Sun toggles) +- `core/views.py::attendance_log` — the conflict-detection flow precedent (different approach — re-renders same template; we deliberately do a separate-page flow per Konrad's preference) +- `core/views.py::_delete_adjustment_with_cascade` — the helper-with-cascade pattern this should mirror + +**Branch:** `ai-dev`. Phase A.1 SiteReport (commit `864ae72`) is also on +this branch and not deployed to production. The absences feature is +strictly additive and can ship together with or independently of +SiteReport. + +**Test invocation (local, Git Bash on Windows):** +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2 +``` +On cmd.exe: `set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests -v 2` + +**Baseline test count before starting:** 85 (verify with the command above +before Task 1). + +**Target after all tasks:** ~123 (85 baseline + ~38 new). + +--- + +## Task overview (7 tasks + 1 checkpoint) + +| # | Task | LOC | Tests added | Demo-able outcome | +|---|---|---|---|---| +| 1 | Model + migration + admin | ~120 | +4 | `Absence` exists, /admin/core/absence/ works | +| 2 | Permission queryset + payroll sync helpers | ~110 | +5 | Helpers callable; payroll sync covered | +| 3 | Forms (Log + Quick + Edit) | ~200 | +6 | Forms validate, expand date ranges, detect conflicts | +| 4 | Log + Confirm views + templates + URLs | ~450 | +9 | `/absences/log/` works end-to-end including conflict flow | +| 5 | List + Edit + Delete + CSV export | ~430 | +9 | Full CRUD on absences. **🚦 CHECKPOINT — Konrad demos.** | +| 6 | Quick-action modal on attendance form | ~180 | +2 | ✗ button on /attendance/log/ creates absence | +| 7 | Worker-detail tab + dashboard alert + CLAUDE.md | ~190 | +3 | Polish + docs. Feature shippable. | + +**Total:** ~1700 LOC net, ~38 new tests. + +--- + +## Task 1 — Model layer + migration + admin registration + +**Goal:** `Absence` model exists in the database, registered in Django admin, with 4 model-level tests passing. + +**Files:** +- Modify: `core/models.py` — add `Absence` class at end of file (after `WorkerWarning`) +- Create: `core/migrations/0014_add_absence.py` (auto-generated) +- Modify: `core/admin.py` — register `Absence` +- Modify: `core/tests.py` — add `AbsenceModelTests` class + +### Step 1 — Write failing model tests + +Add at end of `core/tests.py`: + +```python +# ==================================================================== +# === Worker Absence — Phase 1: Model layer ========================== +# ==================================================================== + +from datetime import date as _date +from django.db import IntegrityError +from django.utils import timezone +from core.models import Absence + + +class AbsenceModelTests(TestCase): + """Per-worker dated absence records. Mirrors WorkerWarning shape.""" + + @classmethod + def setUpTestData(cls): + cls.worker = Worker.objects.create( + name='Joe Mokoena', id_number='8001015800086', + monthly_salary=Decimal('6000.00'), + ) + + def test_defaults(self): + """is_paid defaults to False; payroll_adjustment is null; date defaults to today.""" + a = Absence.objects.create(worker=self.worker, reason='sick') + self.assertFalse(a.is_paid) + self.assertIsNone(a.payroll_adjustment) + self.assertEqual(a.date, _date.today()) + + def test_unique_per_worker_per_day(self): + """Cannot have two absences for the same worker on the same day.""" + d = _date(2026, 5, 14) + Absence.objects.create(worker=self.worker, date=d, reason='sick') + with self.assertRaises(IntegrityError): + Absence.objects.create(worker=self.worker, date=d, reason='family') + + def test_ordering_newest_first(self): + """Default queryset order is -date.""" + Absence.objects.create(worker=self.worker, date=_date(2026, 5, 10), reason='sick') + Absence.objects.create(worker=self.worker, date=_date(2026, 5, 15), reason='annual') + Absence.objects.create(worker=self.worker, date=_date(2026, 5, 12), reason='other') + dates = list(Absence.objects.values_list('date', flat=True)) + self.assertEqual(dates, [_date(2026, 5, 15), _date(2026, 5, 12), _date(2026, 5, 10)]) + + def test_reverse_accessor_on_worker(self): + """worker.absences.all() works (related_name='absences').""" + Absence.objects.create(worker=self.worker, date=_date(2026, 5, 1), reason='sick') + Absence.objects.create(worker=self.worker, date=_date(2026, 5, 2), reason='family') + self.assertEqual(self.worker.absences.count(), 2) +``` + +### Step 2 — Run tests, confirm they fail + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceModelTests -v 2 +``` +Expected: ImportError "cannot import name 'Absence' from 'core.models'" (because the model doesn't exist yet). + +### Step 3 — Implement the model + +Append to `core/models.py` (after `WorkerWarning` class, before `SiteReport`): + +```python +class Absence(models.Model): + """Per-worker, dated record of a day NOT worked. The supervisor or + admin captured WHY (sick, annual leave, absconded, etc.) and + optionally whether the day is to be paid at the worker's daily rate. + + When `is_paid` is True the save flow (in views) creates a Bonus + PayrollAdjustment for that worker on that date, linked via the + OneToOneField below. Edit/delete of the Absence propagates the + adjustment (mirrors the Advance Payment → Loan → Repayment + cascade pattern in payroll). + """ + + # === ABSENCE REASONS === + # SA labour-relations terminology. The DB stores the snake_case key; + # users see the human label via get_reason_display(). + REASON_CHOICES = [ + ('sick', 'Sick'), + ('family', 'Family Responsibility'), + ('annual', 'Annual Leave'), + ('unpaid', 'Personal / Unpaid Leave'), + ('iod', 'Injury on Duty'), + ('suspension', 'Suspension'), + ('absconded', 'Absconded'), + ('other', 'Other'), + ] + + worker = models.ForeignKey( + Worker, related_name='absences', on_delete=models.CASCADE, + ) + date = models.DateField(default=timezone.now) + reason = models.CharField(max_length=20, choices=REASON_CHOICES) + notes = models.TextField( + blank=True, + help_text='Free-form context (e.g. "flu, doctor\'s note", "bus broke down").', + ) + + # === PAYROLL LINK === + is_paid = models.BooleanField( + default=False, + help_text='Tick to pay the worker their daily rate for this day.', + ) + payroll_adjustment = models.OneToOneField( + 'PayrollAdjustment', + on_delete=models.SET_NULL, + null=True, blank=True, + related_name='absence', + help_text='Auto-created when is_paid=True. Cleared if is_paid is unchecked.', + ) + + # === AUDIT === + logged_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + related_name='absences_logged', + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + # One absence per worker per day — enforced at the DB layer. + unique_together = [('worker', 'date')] + # Newest first on lists. + ordering = ['-date', '-created_at'] + + def __str__(self): + return f'{self.worker.name} — {self.get_reason_display()} — {self.date:%d %b %Y}' +``` + +### Step 4 — Generate migration + +``` +USE_SQLITE=true python manage.py makemigrations core +``` +Expected output: +``` +Migrations for 'core': + core/migrations/0014_add_absence.py + - Create model Absence +``` + +Open the generated file and confirm it's a pure `migrations.CreateModel` with a `unique_together` constraint. No data migration needed. + +### Step 5 — Apply migration locally + +``` +USE_SQLITE=true python manage.py migrate +``` +Expected: `Applying core.0014_add_absence... OK` + +### Step 6 — Register in Django admin + +Add to `core/admin.py` (after `WorkerWarningAdmin`, before the next existing admin): + +```python +from .models import Absence # at top with other imports + + +@admin.register(Absence) +class AbsenceAdmin(admin.ModelAdmin): + list_display = ('worker', 'date', 'reason', 'is_paid', 'logged_by', 'created_at') + list_filter = ('reason', 'is_paid', 'date') + search_fields = ('worker__name', 'worker__id_number', 'notes') + raw_id_fields = ('worker', 'logged_by', 'payroll_adjustment') + readonly_fields = ('created_at', 'updated_at') + date_hierarchy = 'date' +``` + +### Step 7 — Run tests, confirm they pass + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceModelTests -v 2 +``` +Expected: `OK` with 4 tests passing. + +Then run the full suite to make sure nothing else broke: +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2 +``` +Expected: `Ran 89 tests in X.XXXs — OK` (was 85, +4). + +### Step 8 — Commit + +```bash +git add core/models.py core/admin.py core/migrations/0014_add_absence.py core/tests.py +git commit -m "feat(absences): Absence model + migration + admin registration + +Per-worker dated records with 8 reason choices (Sick/Family/Annual/ +Personal-Unpaid/IOD/Suspension/Absconded/Other), is_paid flag, optional +OneToOne to PayrollAdjustment for the auto-Bonus path, audit fields. +Unique-per-day at DB layer. 4 model tests." +``` + +--- + +## Task 2 — Permission queryset + payroll sync helpers + +**Goal:** Two helper functions in `core/views.py` are callable and tested: `_absence_user_queryset(user)` returns scoped Absence queryset, and `_sync_absence_payroll_adjustment(absence)` keeps the Bonus PayrollAdjustment in sync with `is_paid`. + +**Files:** +- Modify: `core/views.py` — add 2 functions near the existing helpers (right after `_delete_adjustment_with_cascade`) +- Modify: `core/tests.py` — add `AbsencePayrollSyncTests` class + +### Step 1 — Write failing helper tests + +Append to `core/tests.py`: + +```python +class AbsencePayrollSyncTests(TestCase): + """The _sync_absence_payroll_adjustment helper keeps Absence.payroll_adjustment + in sync with Absence.is_paid. Mirrors the cascade pattern used for + Advance Payment → Loan auto-creation.""" + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user(username='admin', is_staff=True) + cls.worker = Worker.objects.create( + name='Sipho Dlamini', id_number='8501015800087', + monthly_salary=Decimal('6000.00'), + ) + cls.project = Project.objects.create(name='Solar Farm Alpha') + + def _make_absence(self, **kw): + defaults = dict( + worker=self.worker, date=_date(2026, 5, 14), reason='sick', + logged_by=self.user, is_paid=False, + ) + defaults.update(kw) + return Absence.objects.create(**defaults) + + def test_is_paid_false_no_adjustment(self): + """Unpaid absence: sync is a no-op, no adjustment created.""" + from core.views import _sync_absence_payroll_adjustment + a = self._make_absence(is_paid=False) + _sync_absence_payroll_adjustment(a) + self.assertIsNone(a.payroll_adjustment) + self.assertEqual(PayrollAdjustment.objects.count(), 0) + + def test_is_paid_true_creates_bonus(self): + """Paid absence creates a Bonus PayrollAdjustment at daily_rate.""" + from core.views import _sync_absence_payroll_adjustment + a = self._make_absence(is_paid=True) + _sync_absence_payroll_adjustment(a) + a.refresh_from_db() + self.assertIsNotNone(a.payroll_adjustment) + adj = a.payroll_adjustment + self.assertEqual(adj.type, 'Bonus') + # daily_rate = monthly_salary / 20 = 6000 / 20 = 300 + self.assertEqual(adj.amount, Decimal('300.00')) + self.assertEqual(adj.worker, self.worker) + self.assertEqual(adj.date, _date(2026, 5, 14)) + + def test_toggle_paid_off_deletes_adjustment(self): + """is_paid True → False: existing adjustment is deleted.""" + from core.views import _sync_absence_payroll_adjustment + a = self._make_absence(is_paid=True) + _sync_absence_payroll_adjustment(a) + self.assertEqual(PayrollAdjustment.objects.count(), 1) + + a.is_paid = False + a.save() + _sync_absence_payroll_adjustment(a) + a.refresh_from_db() + self.assertIsNone(a.payroll_adjustment) + self.assertEqual(PayrollAdjustment.objects.count(), 0) + + def test_toggle_paid_on_creates_fresh(self): + """is_paid False → True: new adjustment is created.""" + from core.views import _sync_absence_payroll_adjustment + a = self._make_absence(is_paid=False) + _sync_absence_payroll_adjustment(a) + self.assertIsNone(a.payroll_adjustment) + + a.is_paid = True + a.save() + _sync_absence_payroll_adjustment(a) + a.refresh_from_db() + self.assertIsNotNone(a.payroll_adjustment) + + def test_refuses_if_adjustment_already_paid(self): + """If the linked adjustment has a PayrollRecord (i.e. already paid), + the sync helper refuses to delete it — surface to admin instead.""" + from core.views import _sync_absence_payroll_adjustment + a = self._make_absence(is_paid=True) + _sync_absence_payroll_adjustment(a) + a.refresh_from_db() + + # Simulate the adjustment being paid by attaching a PayrollRecord + pr = PayrollRecord.objects.create(worker=self.worker, amount_paid=Decimal('300.00'), date=_date(2026, 5, 30)) + a.payroll_adjustment.payroll_record = pr + a.payroll_adjustment.save() + + a.is_paid = False + a.save() + with self.assertRaises(ValueError): + _sync_absence_payroll_adjustment(a) + # Adjustment still exists + self.assertEqual(PayrollAdjustment.objects.count(), 1) + + +class AbsenceUserQuerysetTests(TestCase): + """_absence_user_queryset scopes the queryset to admin (all) or supervisor + (workers in their supervised teams).""" + + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user(username='admin', is_staff=True) + cls.sup_a = User.objects.create_user(username='sup_a') + cls.sup_b = User.objects.create_user(username='sup_b') + cls.outsider = User.objects.create_user(username='out') + + cls.worker_a = Worker.objects.create(name='WA', id_number='1', monthly_salary=Decimal('6000')) + cls.worker_b = Worker.objects.create(name='WB', id_number='2', monthly_salary=Decimal('6000')) + + cls.team_a = Team.objects.create(name='TA', supervisor=cls.sup_a) + cls.team_a.workers.add(cls.worker_a) + cls.team_b = Team.objects.create(name='TB', supervisor=cls.sup_b) + cls.team_b.workers.add(cls.worker_b) + + Absence.objects.create(worker=cls.worker_a, date=_date(2026, 5, 1), reason='sick') + Absence.objects.create(worker=cls.worker_b, date=_date(2026, 5, 1), reason='annual') + + def test_admin_sees_all(self): + from core.views import _absence_user_queryset + qs = _absence_user_queryset(self.admin) + self.assertEqual(qs.count(), 2) + + def test_supervisor_sees_only_own_team(self): + from core.views import _absence_user_queryset + qs_a = _absence_user_queryset(self.sup_a) + self.assertEqual(qs_a.count(), 1) + self.assertEqual(qs_a.first().worker, self.worker_a) + + def test_outsider_sees_none(self): + from core.views import _absence_user_queryset + qs = _absence_user_queryset(self.outsider) + self.assertEqual(qs.count(), 0) +``` + +### Step 2 — Run tests, confirm they fail + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsencePayrollSyncTests core.tests.AbsenceUserQuerysetTests -v 2 +``` +Expected: ImportError "cannot import name '_sync_absence_payroll_adjustment' from 'core.views'". + +### Step 3 — Implement the helpers + +Add to `core/views.py` (right after `_delete_adjustment_with_cascade` — locate it first with `grep -n "_delete_adjustment_with_cascade" core/views.py`): + +```python +# === ABSENCE HELPERS === +# Two helpers used by every save/edit/delete path on Absence rows. +# Kept here (not on the model) so admin / supervisor screens can +# react to "the linked adjustment is already paid" — silent +# model-side cascades would hide that case from the user. + +def _absence_user_queryset(user): + """Absences visible to this user. + + Admin → all absences. + Supervisor → absences for workers in any team they supervise. + Anyone else → nothing. + """ + if is_admin(user): + return Absence.objects.all() + return Absence.objects.filter( + worker__teams__supervisor=user + ).distinct() + + +def _sync_absence_payroll_adjustment(absence): + """Keep absence.payroll_adjustment in sync with absence.is_paid. + + Cases: + - is_paid=False + no adjustment → no-op. + - is_paid=False + adjustment exists → delete the adjustment + (but raise ValueError if the adjustment is already paid — + caller surfaces this to the admin). + - is_paid=True + no adjustment → create a Bonus PayrollAdjustment + at worker.daily_rate, save the FK back onto the absence. + - is_paid=True + adjustment exists → leave alone (admin may have + edited the amount manually; we don't second-guess). + + Returns the linked adjustment (or None). + """ + adj = absence.payroll_adjustment + + if not absence.is_paid: + if adj is None: + return None + # Refuse to delete an already-paid adjustment — caller decides + # how to surface this (toast, inline error, etc.) + if adj.payroll_record_id is not None: + raise ValueError( + 'Linked PayrollAdjustment has already been paid ' + '(via PayrollRecord). Cannot auto-delete.' + ) + adj.delete() + absence.payroll_adjustment = None + absence.save(update_fields=['payroll_adjustment']) + return None + + # is_paid=True branch + if adj is not None: + # Already linked — leave it alone. + return adj + + # Create a fresh Bonus adjustment. + new_adj = PayrollAdjustment.objects.create( + worker=absence.worker, + type='Bonus', + amount=absence.worker.daily_rate, + date=absence.date, + description=f'Paid {absence.get_reason_display().lower()} — auto-created from Absence #{absence.id}', + ) + absence.payroll_adjustment = new_adj + absence.save(update_fields=['payroll_adjustment']) + return new_adj +``` + +Also add `Absence` to the imports near the top of `core/views.py`: + +```python +from .models import ( + ...existing imports..., + Absence, +) +``` + +### Step 4 — Run tests, confirm they pass + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsencePayrollSyncTests core.tests.AbsenceUserQuerysetTests -v 2 +``` +Expected: 8 tests pass (5 sync + 3 queryset). + +Full suite: +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2 +``` +Expected: Ran ~94 tests, OK. + +### Step 5 — Commit + +```bash +git add core/views.py core/tests.py +git commit -m "feat(absences): _absence_user_queryset + _sync_absence_payroll_adjustment + +Two helpers covering the recurring 'which absences can this user see' +and 'sync is_paid with the linked Bonus PayrollAdjustment' patterns. +The sync helper refuses to delete an already-paid adjustment — caller +surfaces this to the user. Mirrors _delete_adjustment_with_cascade +semantics. 8 tests." +``` + +--- + +## Task 3 — Forms (AbsenceLogForm + AbsenceQuickForm + AbsenceEditForm) + +**Goal:** Three form classes in `core/forms.py` validate user input, expand date ranges respecting Sat/Sun toggles, and surface conflicts. Form-level tests pass. + +**Files:** +- Modify: `core/forms.py` — add 3 form classes at end +- Modify: `core/tests.py` — add `AbsenceFormTests` class + +### Step 1 — Write failing form tests + +```python +class AbsenceFormTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user(username='admin', is_staff=True) + cls.worker_a = Worker.objects.create(name='WA', id_number='1', monthly_salary=Decimal('6000')) + cls.worker_b = Worker.objects.create(name='WB', id_number='2', monthly_salary=Decimal('6000')) + cls.project = Project.objects.create(name='P1') + cls.team = Team.objects.create(name='T', supervisor=cls.admin) + cls.team.workers.add(cls.worker_a, cls.worker_b) + + def test_single_date_submission(self): + from core.forms import AbsenceLogForm + form = AbsenceLogForm( + data={ + 'date': '2026-05-14', + 'reason': 'sick', + 'is_paid': False, + 'notes': 'flu', + 'team': self.team.id, + 'workers': [self.worker_a.id], + }, + user=self.admin, + ) + self.assertTrue(form.is_valid(), msg=form.errors) + # cleaned data has expanded (worker, date) tuples + tuples = form.expanded_pairs() + self.assertEqual(len(tuples), 1) + self.assertEqual(tuples[0], (self.worker_a, _date(2026, 5, 14))) + + def test_range_expansion_skips_weekends_by_default(self): + from core.forms import AbsenceLogForm + # Thursday → Monday (5 days, Sat+Sun skipped → 3 days) + form = AbsenceLogForm( + data={ + 'date': '2026-05-14', # Thursday + 'end_date': '2026-05-18', # Monday + 'reason': 'annual', + 'workers': [self.worker_a.id], + }, + user=self.admin, + ) + self.assertTrue(form.is_valid()) + dates = [d for _w, d in form.expanded_pairs()] + # 14 Thu, 15 Fri, 18 Mon (16 Sat / 17 Sun skipped) + self.assertEqual(dates, [_date(2026, 5, 14), _date(2026, 5, 15), _date(2026, 5, 18)]) + + def test_range_expansion_with_weekend_toggles(self): + from core.forms import AbsenceLogForm + form = AbsenceLogForm( + data={ + 'date': '2026-05-14', + 'end_date': '2026-05-17', # Thursday → Sunday + 'reason': 'sick', + 'include_saturday': True, + 'include_sunday': True, + 'workers': [self.worker_a.id], + }, + user=self.admin, + ) + self.assertTrue(form.is_valid()) + dates = [d for _w, d in form.expanded_pairs()] + self.assertEqual(len(dates), 4) # all 4 days + + def test_end_date_before_start_rejected(self): + from core.forms import AbsenceLogForm + form = AbsenceLogForm( + data={ + 'date': '2026-05-15', + 'end_date': '2026-05-10', + 'reason': 'sick', + 'workers': [self.worker_a.id], + }, + user=self.admin, + ) + self.assertFalse(form.is_valid()) + self.assertIn('end_date', form.errors) + + def test_duplicate_absence_rejected(self): + """If absence(worker, date) already exists, form is invalid.""" + from core.forms import AbsenceLogForm + Absence.objects.create(worker=self.worker_a, date=_date(2026, 5, 14), reason='sick') + form = AbsenceLogForm( + data={ + 'date': '2026-05-14', + 'reason': 'family', + 'workers': [self.worker_a.id], + }, + user=self.admin, + ) + self.assertFalse(form.is_valid()) + # Error message names the worker + self.assertIn('already', str(form.errors).lower()) + + def test_worklog_conflict_flagged_not_blocking(self): + """If worker has a WorkLog on the absence date, form is still valid + but conflicting_worklogs() returns the conflict rows.""" + from core.forms import AbsenceLogForm + wl = WorkLog.objects.create( + date=_date(2026, 5, 14), project=self.project, supervisor=self.admin, + ) + wl.workers.add(self.worker_a) + form = AbsenceLogForm( + data={ + 'date': '2026-05-14', + 'reason': 'sick', + 'workers': [self.worker_a.id], + }, + user=self.admin, + ) + self.assertTrue(form.is_valid()) # Conflicts are warnings, not errors + conflicts = form.conflicting_worklogs() + self.assertEqual(len(conflicts), 1) + self.assertEqual(conflicts[0]['worker_id'], self.worker_a.id) + self.assertEqual(conflicts[0]['work_log_id'], wl.id) +``` + +### Step 2 — Run tests, confirm they fail + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceFormTests -v 2 +``` +Expected: ImportError "cannot import name 'AbsenceLogForm' from 'core.forms'". + +### Step 3 — Implement the forms + +Append to `core/forms.py`: + +```python +# ==================================================================== +# === ABSENCE FORMS ================================================== +# ==================================================================== +# Three forms mirror the SiteReport / WorkerWarning patterns: +# - AbsenceLogForm: standalone /absences/log/ with date-range support, +# team filter, worker checkbox list, conflict detection. +# - AbsenceQuickForm: minimal form for the "Mark Absent" modal on +# /attendance/log/ — worker + date come from URL/POST, form only +# asks for reason / paid / notes. +# - AbsenceEditForm: edit one existing absence; can correct +# worker/date as well as the other fields. + +from .models import Absence # add at top of file with other imports + + +class AbsenceLogForm(forms.ModelForm): + """Standalone form for /absences/log/. Supports date ranges and + multiple workers per submission. Validates conflicts; the view + consumes expanded_pairs() and conflicting_worklogs() to drive the + confirm/save flow. + """ + + # === DATE RANGE EXTRAS (copied from AttendanceLogForm) === + end_date = forms.DateField( + required=False, + widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + label='End Date', + help_text='Leave blank to log a single day.', + ) + include_saturday = forms.BooleanField( + required=False, initial=False, label='Include Saturdays', + widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}), + ) + include_sunday = forms.BooleanField( + required=False, initial=False, label='Include Sundays', + widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}), + ) + + # === WORKER PICKER === + team = forms.ModelChoiceField( + queryset=Team.objects.filter(active=True), + required=False, + widget=forms.Select(attrs={'class': 'form-select'}), + help_text='Optional — narrows the worker list below.', + ) + workers = forms.ModelMultipleChoiceField( + queryset=Worker.objects.filter(active=True), + widget=forms.CheckboxSelectMultiple, + ) + + class Meta: + model = Absence + fields = ['date', 'reason', 'is_paid', 'notes'] + widgets = { + 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'reason': forms.Select(attrs={'class': 'form-select'}), + 'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}), + } + + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + # Supervisor scope: limit team + workers querysets to user's reach. + if user is not None and not (user.is_staff or user.is_superuser): + self.fields['team'].queryset = ( + Team.objects.filter(active=True, supervisor=user) + ) + self.fields['workers'].queryset = ( + Worker.objects.filter(active=True, teams__supervisor=user).distinct() + ) + + def clean(self): + cleaned = super().clean() + start = cleaned.get('date') + end = cleaned.get('end_date') + if end and start and end < start: + self.add_error('end_date', 'End date must be on or after the start date.') + + # Cache expanded (worker, date) pairs so view can reuse them. + self._pairs = self._expand_pairs(cleaned) + + # Reject if any pair already has an Absence (unique key). + existing = [] + for worker, d in self._pairs: + if Absence.objects.filter(worker=worker, date=d).exists(): + existing.append(f'{worker.name} on {d:%d %b %Y}') + if existing: + self.add_error(None, + f'Absence already exists for: {", ".join(existing)}. ' + 'Edit the existing record instead.' + ) + return cleaned + + def _expand_pairs(self, cleaned): + """Build the (worker, date) tuple list from cleaned data, respecting + Sat/Sun toggles.""" + from datetime import timedelta + workers = cleaned.get('workers') or [] + start = cleaned.get('date') + end = cleaned.get('end_date') or start + inc_sat = cleaned.get('include_saturday') or False + inc_sun = cleaned.get('include_sunday') or False + if not start: + return [] + days = [] + d = start + while d <= end: + wd = d.weekday() # Mon=0, Sun=6 + if wd == 5 and not inc_sat: + d += timedelta(days=1); continue + if wd == 6 and not inc_sun: + d += timedelta(days=1); continue + days.append(d) + d += timedelta(days=1) + return [(w, d) for w in workers for d in days] + + def expanded_pairs(self): + """Return the (worker, date) tuples produced by clean(). Must call + is_valid() first.""" + return getattr(self, '_pairs', []) + + def conflicting_worklogs(self): + """Return list of dicts describing (worker, date) pairs that have an + existing WorkLog. Each dict has worker_id, worker_name, date, + work_log_id, project_name.""" + rows = [] + for worker, d in self.expanded_pairs(): + for wl in WorkLog.objects.filter(date=d, workers=worker).select_related('project'): + rows.append({ + 'worker_id': worker.id, + 'worker_name': worker.name, + 'date': d, + 'work_log_id': wl.id, + 'project_name': wl.project.name if wl.project else '—', + }) + return rows + + +class AbsenceQuickForm(forms.ModelForm): + """Minimal form for the ✗ Mark Absent modal on /attendance/log/. + Worker and date come from URL/POST, so this form only asks for the + three fields the admin still needs to fill in.""" + + class Meta: + model = Absence + fields = ['reason', 'is_paid', 'notes'] + widgets = { + 'reason': forms.Select(attrs={'class': 'form-select'}), + 'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'notes': forms.Textarea(attrs={'rows': 2, 'class': 'form-control'}), + } + + +class AbsenceEditForm(forms.ModelForm): + """Edit one existing Absence. Can correct worker/date if logged wrong.""" + + class Meta: + model = Absence + fields = ['worker', 'date', 'reason', 'is_paid', 'notes'] + widgets = { + 'worker': forms.Select(attrs={'class': 'form-select'}), + 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'reason': forms.Select(attrs={'class': 'form-select'}), + 'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}), + } + + def clean(self): + cleaned = super().clean() + worker = cleaned.get('worker') + d = cleaned.get('date') + if worker and d: + # Check uniqueness, excluding self + qs = Absence.objects.filter(worker=worker, date=d) + if self.instance and self.instance.pk: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + raise forms.ValidationError( + f'{worker.name} already has an absence on {d:%d %b %Y}.' + ) + return cleaned +``` + +### Step 4 — Run tests, confirm they pass + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceFormTests -v 2 +``` +Expected: 6 tests pass. + +Full suite: +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2 +``` +Expected: ~100 tests OK. + +### Step 5 — Commit + +```bash +git add core/forms.py core/tests.py +git commit -m "feat(absences): AbsenceLogForm + AbsenceQuickForm + AbsenceEditForm + +Three forms covering the three entry points: standalone date-range form +(/absences/log/), quick-action modal (/attendance/log/), and edit one +existing record. Log form expands (worker, date) pairs respecting +Sat/Sun toggles, validates uniqueness, surfaces WorkLog conflicts as a +non-blocking warning via conflicting_worklogs(). 6 tests." +``` + +--- + +## Task 4 — Log + Confirm views + templates + URLs + +**Goal:** `/absences/log/` accepts form submissions; if conflicts → redirects to `/absences/log/confirm/` (yellow warning page with per-row WorkLog-removal checkboxes); confirm POST does the atomic transaction. End-to-end demoable. + +**Files:** +- Modify: `core/views.py` — `absence_log`, `absence_log_confirm` +- Modify: `core/urls.py` — 2 new URL patterns +- Create: `core/templates/core/absences/log.html` +- Create: `core/templates/core/absences/log_confirm.html` +- Modify: `core/tests.py` — `AbsenceLogViewTests`, `AbsenceConfirmViewTests` + +### Step 1 — Write failing view tests + +```python +class AbsenceLogViewTests(TestCase): + """GET shows form; POST without conflicts creates absences immediately; + POST with conflicts stashes pending data in session + redirects to + /absences/log/confirm/.""" + + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True) + cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000')) + cls.project = Project.objects.create(name='P') + cls.team = Team.objects.create(name='T', supervisor=cls.admin) + cls.team.workers.add(cls.worker) + + def setUp(self): + self.client.force_login(self.admin) + + def test_get_returns_200(self): + resp = self.client.get('/absences/log/') + self.assertEqual(resp.status_code, 200) + + def test_post_creates_absences_when_no_conflict(self): + resp = self.client.post('/absences/log/', data={ + 'date': '2026-05-14', + 'reason': 'sick', + 'workers': [self.worker.id], + 'notes': 'flu', + }) + self.assertEqual(Absence.objects.count(), 1) + absence = Absence.objects.first() + self.assertEqual(absence.worker, self.worker) + self.assertEqual(absence.reason, 'sick') + self.assertFalse(absence.is_paid) + self.assertEqual(absence.logged_by, self.admin) + self.assertRedirects(resp, '/absences/', fetch_redirect_response=False) + + def test_post_with_paid_creates_adjustment(self): + self.client.post('/absences/log/', data={ + 'date': '2026-05-14', + 'reason': 'sick', + 'is_paid': 'on', + 'workers': [self.worker.id], + }) + absence = Absence.objects.first() + self.assertTrue(absence.is_paid) + self.assertIsNotNone(absence.payroll_adjustment) + self.assertEqual(absence.payroll_adjustment.type, 'Bonus') + + def test_post_with_worklog_conflict_redirects_to_confirm(self): + wl = WorkLog.objects.create(date=_date(2026, 5, 14), project=self.project, supervisor=self.admin) + wl.workers.add(self.worker) + resp = self.client.post('/absences/log/', data={ + 'date': '2026-05-14', + 'reason': 'sick', + 'workers': [self.worker.id], + }) + self.assertEqual(Absence.objects.count(), 0) # NOT created yet + self.assertRedirects(resp, '/absences/log/confirm/', fetch_redirect_response=False) + # Session has stashed pending data + self.assertIn('absence_pending', self.client.session) + + def test_supervisor_can_post(self): + sup = User.objects.create_user(username='sup', password='pw') + sup_team = Team.objects.create(name='ST', supervisor=sup) + sup_team.workers.add(self.worker) + self.client.force_login(sup) + resp = self.client.post('/absences/log/', data={ + 'date': '2026-05-14', + 'reason': 'sick', + 'workers': [self.worker.id], + }) + self.assertEqual(Absence.objects.count(), 1) + + def test_outsider_gets_403(self): + outsider = User.objects.create_user(username='out', password='pw') + self.client.force_login(outsider) + resp = self.client.get('/absences/log/') + self.assertEqual(resp.status_code, 403) + + +class AbsenceConfirmViewTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True) + cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000')) + cls.project = Project.objects.create(name='P') + + def setUp(self): + self.client.force_login(self.admin) + # Pre-stash pending data + create the conflict WorkLog + self.wl = WorkLog.objects.create(date=_date(2026, 5, 14), project=self.project, supervisor=self.admin) + self.wl.workers.add(self.worker) + session = self.client.session + session['absence_pending'] = { + 'pairs': [[self.worker.id, '2026-05-14']], + 'reason': 'sick', + 'is_paid': False, + 'notes': 'flu', + 'conflicts': [{ + 'worker_id': self.worker.id, 'worker_name': 'W', + 'date': '2026-05-14', 'work_log_id': self.wl.id, + 'project_name': 'P', + }], + } + session.save() + + def test_get_without_session_redirects_back(self): + # Clear session + session = self.client.session + session.pop('absence_pending', None) + session.save() + resp = self.client.get('/absences/log/confirm/') + self.assertRedirects(resp, '/absences/log/', fetch_redirect_response=False) + + def test_get_with_session_shows_warning(self): + resp = self.client.get('/absences/log/confirm/') + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'already logged as working') # warning text + + def test_post_creates_absences_and_removes_from_worklog(self): + resp = self.client.post('/absences/log/confirm/', data={ + f'remove_from_worklog_{self.wl.id}_{self.worker.id}': 'on', + }) + self.assertEqual(Absence.objects.count(), 1) + self.assertNotIn(self.worker, self.wl.workers.all()) + self.assertRedirects(resp, '/absences/', fetch_redirect_response=False) + self.assertNotIn('absence_pending', self.client.session) + + def test_post_without_removal_still_creates_absences(self): + resp = self.client.post('/absences/log/confirm/', data={}) + self.assertEqual(Absence.objects.count(), 1) + # Worker still in WorkLog because admin didn't tick the box + self.assertIn(self.worker, self.wl.workers.all()) +``` + +### Step 2 — Run tests, confirm they fail + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceLogViewTests core.tests.AbsenceConfirmViewTests -v 2 +``` +Expected: NoReverseMatch or 404 — URLs don't exist yet. + +### Step 3 — Implement the views + +Add to `core/views.py` (near the bottom, after the existing absence helpers added in Task 2): + +```python +# === ABSENCE VIEWS — LOG + CONFIRM === + +def _user_can_log_absences(user): + """Admin OR supervisor (anyone supervising at least one team).""" + return is_admin(user) or user.supervised_teams.exists() + + +@login_required +def absence_log(request): + """GET shows blank AbsenceLogForm. POST validates; if no conflicts, + creates Absence rows in transaction.atomic() + redirects to list. + If conflicts, stashes pending data in session + redirects to + /absences/log/confirm/.""" + if not _user_can_log_absences(request.user): + return HttpResponseForbidden('Permission denied.') + + if request.method == 'POST': + form = AbsenceLogForm(request.POST, user=request.user) + if form.is_valid(): + conflicts = form.conflicting_worklogs() + if conflicts: + # Stash + redirect to confirm page + request.session['absence_pending'] = { + 'pairs': [[w.id, d.isoformat()] for w, d in form.expanded_pairs()], + 'reason': form.cleaned_data['reason'], + 'is_paid': form.cleaned_data.get('is_paid') or False, + 'notes': form.cleaned_data.get('notes') or '', + 'conflicts': [ + {**c, 'date': c['date'].isoformat()} for c in conflicts + ], + } + return redirect('absence_log_confirm') + # No conflicts — create immediately. + _create_absences_atomic( + pairs=form.expanded_pairs(), + reason=form.cleaned_data['reason'], + is_paid=form.cleaned_data.get('is_paid') or False, + notes=form.cleaned_data.get('notes') or '', + user=request.user, + worklog_removals=[], + ) + messages.success(request, f'{len(form.expanded_pairs())} absence(s) logged.') + return redirect('absence_list') + else: + form = AbsenceLogForm(user=request.user) + + return render(request, 'core/absences/log.html', {'form': form}) + + +@login_required +def absence_log_confirm(request): + """GET reads pending data from session, shows warning page. + POST processes the WorkLog removals + creates absences in one + transaction.atomic() block.""" + if not _user_can_log_absences(request.user): + return HttpResponseForbidden('Permission denied.') + + pending = request.session.get('absence_pending') + if not pending: + return redirect('absence_log') + + if request.method == 'POST': + from datetime import date as _d + # Re-hydrate pairs + pairs = [ + (Worker.objects.get(id=wid), _d.fromisoformat(ds)) + for wid, ds in pending['pairs'] + ] + # Parse worklog removals from POST: keys like 'remove_from_worklog__' + removals = [] + for key in request.POST: + if key.startswith('remove_from_worklog_'): + _, _, _, wl_id, w_id = key.split('_', 4) + removals.append((int(wl_id), int(w_id))) + _create_absences_atomic( + pairs=pairs, + reason=pending['reason'], + is_paid=pending['is_paid'], + notes=pending['notes'], + user=request.user, + worklog_removals=removals, + ) + request.session.pop('absence_pending', None) + messages.success(request, f'{len(pairs)} absence(s) logged.') + return redirect('absence_list') + + # GET — render warning page + return render(request, 'core/absences/log_confirm.html', { + 'conflicts': pending['conflicts'], + 'reason': pending['reason'], + 'is_paid': pending['is_paid'], + 'absence_count': len(pending['pairs']), + }) + + +def _create_absences_atomic(pairs, reason, is_paid, notes, user, worklog_removals): + """Atomically: (1) remove flagged workers from WorkLogs, (2) create + Absence rows, (3) sync payroll adjustments.""" + with transaction.atomic(): + for wl_id, w_id in worklog_removals: + wl = WorkLog.objects.get(id=wl_id) + wl.workers.remove(w_id) + for worker, d in pairs: + a = Absence.objects.create( + worker=worker, date=d, reason=reason, + is_paid=is_paid, notes=notes, logged_by=user, + ) + _sync_absence_payroll_adjustment(a) +``` + +Add `messages` import at top of `core/views.py` if not already present: +```python +from django.contrib import messages +``` + +Add to `core/urls.py`: +```python +# === Absences === +path('absences/log/', views.absence_log, name='absence_log'), +path('absences/log/confirm/', views.absence_log_confirm, name='absence_log_confirm'), +path('absences/', views.absence_list, name='absence_list'), # placeholder — Task 5 will implement +``` + +For now, add a stub `absence_list` in `core/views.py` so the redirect works: +```python +@login_required +def absence_list(request): + """Stub — full implementation in Task 5.""" + return HttpResponse('Absence list — Task 5 will implement.') +``` + +### Step 4 — Create templates + +Create `core/templates/core/absences/log.html`: + +```html +{% extends 'base.html' %} +{% load static %} + +{% block title %}Log Worker Absences | FoxFitt{% endblock %} + +{% block content %} +{% comment %} +Standalone absence-logging form. Date range with optional Sat/Sun +inclusion (mirror of /attendance/log/). After successful submit +either redirects to /absences/ (no conflicts) or to +/absences/log/confirm/ (one or more workers were on a WorkLog for +one of the selected dates — admin chooses whether to also remove +them from the WorkLog). +{% endcomment %} + +
+
+
+

+ + Log Absences +

+ Record workers who were not on site today. +
+ + View All + +
+ + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} + +
+ {% csrf_token %} +
+ +
+
+ + {{ form.date }} + {{ form.date.errors }} +
+
+ + {{ form.end_date }} + {{ form.end_date.help_text }} + {{ form.end_date.errors }} +
+
+
+ {{ form.include_saturday }} + +
+
+ {{ form.include_sunday }} + +
+
+
+ +
+ +
+
+ + {{ form.reason }} + {{ form.reason.errors }} +
+
+
+ {{ form.is_paid }} + +
Creates a Bonus payroll adjustment when ticked.
+
+
+
+ +
+ +
+
+ + {{ form.team }} +
+
+ + +
+ {% for worker in form.workers %} +
+ {{ worker.tag }} + +
+ {% endfor %} +
+ {{ form.workers.errors }} +
+
+ +
+ +
+ + {{ form.notes }} +
+ +
+ Cancel + +
+
+
+
+ + +{% endblock %} +``` + +Create `core/templates/core/absences/log_confirm.html`: + +```html +{% extends 'base.html' %} +{% load static %} + +{% block title %}Confirm Absences | FoxFitt{% endblock %} + +{% block content %} +
+

+ + Confirm Absences +

+ +
+ {{ conflicts|length }} worker(s) already have work logs on these dates.
+ Tick the boxes below to also remove them from those work logs (recommended if you're correcting a mistake). +
+ +
+ {% csrf_token %} +
+
+ Conflicts +
+ + + + + + + + + + + {% for c in conflicts %} + + + + + + + {% endfor %} + +
WorkerDateWorkLogAction
{{ c.worker_name }}{{ c.date }}{{ c.project_name }} (WorkLog #{{ c.work_log_id }}) +
+ + +
+
+ +
+

{{ absence_count }} absence(s) will be created:

+

Reason: {{ reason }}. Paid: {% if is_paid %}Yes{% else %}No{% endif %}.

+ +
+ ← Back to form + +
+
+
+
+{% endblock %} +``` + +### Step 5 — Run tests, confirm they pass + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceLogViewTests core.tests.AbsenceConfirmViewTests -v 2 +``` +Expected: 9 tests pass. + +Full suite: +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2 +``` +Expected: ~109 tests OK. + +### Step 6 — Commit + +```bash +git add core/views.py core/urls.py core/templates/core/absences/ core/tests.py +git commit -m "feat(absences): log + confirm views + templates + URLs + +/absences/log/ accepts form; no-conflict path creates absences +atomically; conflict path stashes pending data in session and +redirects to /absences/log/confirm/ (yellow warning + per-row +'Remove from WorkLog' checkboxes). Confirm POST runs atomic +transaction: remove flagged workers from WorkLogs, create +Absences, sync payroll adjustments. 9 tests." +``` + +--- + +## Task 5 — List + Edit + Delete + CSV export + +**Goal:** Full CRUD on absences. `/absences/` shows filtered list with pagination + reason badges; `/absences//edit/` updates one absence (syncs adjustment); delete is POST-only with cascade. `/absences/export/` is admin-only CSV download. + +**Files:** +- Modify: `core/views.py` — replace stub `absence_list`, add `absence_edit`, `absence_delete`, `absence_export_csv` +- Modify: `core/urls.py` — 3 more URL patterns +- Create: `core/templates/core/absences/list.html` +- Create: `core/templates/core/absences/edit.html` +- Modify: `core/tests.py` — `AbsenceListViewTests`, `AbsenceEditDeleteTests`, `AbsenceExportCSVTests` + +### Step 1 — Write failing tests + +```python +class AbsenceListViewTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True) + cls.sup = User.objects.create_user(username='sup', password='pw') + cls.worker_a = Worker.objects.create(name='WA', id_number='1', monthly_salary=Decimal('6000')) + cls.worker_b = Worker.objects.create(name='WB', id_number='2', monthly_salary=Decimal('6000')) + cls.team_a = Team.objects.create(name='TA', supervisor=cls.sup) + cls.team_a.workers.add(cls.worker_a) + Absence.objects.create(worker=cls.worker_a, date=_date(2026, 5, 1), reason='sick') + Absence.objects.create(worker=cls.worker_b, date=_date(2026, 5, 1), reason='annual') + + def test_admin_sees_all(self): + self.client.force_login(self.admin) + resp = self.client.get('/absences/') + self.assertContains(resp, 'WA') + self.assertContains(resp, 'WB') + + def test_supervisor_only_sees_own(self): + self.client.force_login(self.sup) + resp = self.client.get('/absences/') + self.assertContains(resp, 'WA') + self.assertNotContains(resp, 'WB') + + def test_filter_by_reason(self): + self.client.force_login(self.admin) + resp = self.client.get('/absences/?reason=sick') + self.assertContains(resp, 'WA') + self.assertNotContains(resp, 'WB') + + +class AbsenceEditDeleteTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True) + cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000')) + cls.absence = Absence.objects.create(worker=cls.worker, date=_date(2026, 5, 14), reason='sick') + + def setUp(self): + self.client.force_login(self.admin) + + def test_edit_toggling_paid_creates_adjustment(self): + resp = self.client.post(f'/absences/{self.absence.id}/edit/', data={ + 'worker': self.worker.id, + 'date': '2026-05-14', + 'reason': 'sick', + 'is_paid': 'on', + 'notes': '', + }) + self.absence.refresh_from_db() + self.assertTrue(self.absence.is_paid) + self.assertIsNotNone(self.absence.payroll_adjustment) + + def test_edit_untoggling_paid_deletes_adjustment(self): + self.absence.is_paid = True + self.absence.save() + from core.views import _sync_absence_payroll_adjustment + _sync_absence_payroll_adjustment(self.absence) + self.absence.refresh_from_db() + adj_id = self.absence.payroll_adjustment.id + + self.client.post(f'/absences/{self.absence.id}/edit/', data={ + 'worker': self.worker.id, + 'date': '2026-05-14', + 'reason': 'sick', + 'notes': '', + # is_paid not in POST → unchecked + }) + self.assertFalse(PayrollAdjustment.objects.filter(id=adj_id).exists()) + + def test_delete_cascade_unpaid_adjustment(self): + self.absence.is_paid = True + self.absence.save() + from core.views import _sync_absence_payroll_adjustment + _sync_absence_payroll_adjustment(self.absence) + adj_id = self.absence.payroll_adjustment.id + + resp = self.client.post(f'/absences/{self.absence.id}/delete/') + self.assertFalse(Absence.objects.filter(id=self.absence.id).exists()) + self.assertFalse(PayrollAdjustment.objects.filter(id=adj_id).exists()) + + def test_delete_refuses_when_adjustment_paid(self): + self.absence.is_paid = True + self.absence.save() + from core.views import _sync_absence_payroll_adjustment + _sync_absence_payroll_adjustment(self.absence) + self.absence.refresh_from_db() + # Mark the adjustment as paid + pr = PayrollRecord.objects.create(worker=self.worker, amount_paid=Decimal('300'), date=_date(2026, 5, 30)) + self.absence.payroll_adjustment.payroll_record = pr + self.absence.payroll_adjustment.save() + + resp = self.client.post(f'/absences/{self.absence.id}/delete/') + self.assertTrue(Absence.objects.filter(id=self.absence.id).exists()) # NOT deleted + + def test_supervisor_cannot_edit_other_team_absence(self): + sup = User.objects.create_user(username='sup', password='pw') + # sup doesn't supervise the team that worker is on + self.client.force_login(sup) + resp = self.client.get(f'/absences/{self.absence.id}/edit/') + self.assertEqual(resp.status_code, 404) + + +class AbsenceExportCSVTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True) + cls.sup = User.objects.create_user(username='sup', password='pw') + cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000')) + Absence.objects.create(worker=cls.worker, date=_date(2026, 5, 14), reason='sick') + + def test_admin_can_export(self): + self.client.force_login(self.admin) + resp = self.client.get('/absences/export/') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp['Content-Type'], 'text/csv') + self.assertIn(b'W,', resp.content) + self.assertIn(b'Sick', resp.content) + + def test_supervisor_forbidden(self): + self.client.force_login(self.sup) + resp = self.client.get('/absences/export/') + self.assertEqual(resp.status_code, 403) +``` + +### Step 2 — Implement views + +In `core/views.py`, replace the stub `absence_list` and add the other views: + +```python +@login_required +def absence_list(request): + """Filtered list of absences with pagination + reason badges. + + URL params: worker, team, project, reason, date_from, date_to, paid. + Permissions: admin sees all, supervisor sees scoped, others forbidden. + """ + user = request.user + if not (is_admin(user) or user.supervised_teams.exists()): + return HttpResponseForbidden('Permission denied.') + + qs = _absence_user_queryset(user).select_related( + 'worker', 'logged_by', 'payroll_adjustment' + ) + + # === Filters === + worker_id = request.GET.get('worker') + team_id = request.GET.get('team') + project_id = request.GET.get('project') + reason = request.GET.get('reason') + date_from = request.GET.get('date_from') + date_to = request.GET.get('date_to') + paid = request.GET.get('paid') + + if worker_id and worker_id.isdigit(): + qs = qs.filter(worker_id=worker_id) + if team_id and team_id.isdigit(): + qs = qs.filter(worker__teams__id=team_id).distinct() + if project_id and project_id.isdigit(): + qs = qs.filter(worker__work_logs__project_id=project_id).distinct() + if reason and reason in dict(Absence.REASON_CHOICES): + qs = qs.filter(reason=reason) + if date_from: + try: + qs = qs.filter(date__gte=date_from) + except (ValueError, TypeError): + pass + if date_to: + try: + qs = qs.filter(date__lte=date_to) + except (ValueError, TypeError): + pass + if paid == 'paid': + qs = qs.filter(is_paid=True) + elif paid == 'unpaid': + qs = qs.filter(is_paid=False) + + # === Pagination === + paginator = Paginator(qs, 25) + page = paginator.get_page(request.GET.get('page')) + + # === Dropdown options === + if is_admin(user): + workers_qs = Worker.objects.filter(active=True).order_by('name') + teams_qs = Team.objects.filter(active=True).order_by('name') + projects_qs = Project.objects.filter(active=True).order_by('name') + else: + workers_qs = Worker.objects.filter(active=True, teams__supervisor=user).distinct().order_by('name') + teams_qs = Team.objects.filter(active=True, supervisor=user).order_by('name') + projects_qs = Project.objects.filter(supervisors=user, active=True).order_by('name') + + return render(request, 'core/absences/list.html', { + 'page': page, + 'reason_choices': Absence.REASON_CHOICES, + 'workers_qs': workers_qs, + 'teams_qs': teams_qs, + 'projects_qs': projects_qs, + 'filter_worker': worker_id or '', + 'filter_team': team_id or '', + 'filter_project': project_id or '', + 'filter_reason': reason or '', + 'filter_date_from': date_from or '', + 'filter_date_to': date_to or '', + 'filter_paid': paid or '', + 'is_admin': is_admin(user), + }) + + +@login_required +def absence_edit(request, absence_id): + """Edit one absence. is_paid changes propagate to PayrollAdjustment.""" + user = request.user + qs = _absence_user_queryset(user) + absence = get_object_or_404(qs, id=absence_id) + + if request.method == 'POST': + form = AbsenceEditForm(request.POST, instance=absence) + if form.is_valid(): + try: + with transaction.atomic(): + form.save() + _sync_absence_payroll_adjustment(absence) + messages.success(request, 'Absence updated.') + return redirect('absence_list') + except ValueError as e: + messages.error(request, str(e)) + else: + form = AbsenceEditForm(instance=absence) + + return render(request, 'core/absences/edit.html', { + 'form': form, + 'absence': absence, + }) + + +@login_required +def absence_delete(request, absence_id): + """POST-only. Delete absence + cascade unpaid PayrollAdjustment. + Refuses if adjustment is already paid.""" + if request.method != 'POST': + return redirect('absence_list') + user = request.user + qs = _absence_user_queryset(user) + absence = get_object_or_404(qs, id=absence_id) + adj = absence.payroll_adjustment + if adj and adj.payroll_record_id is not None: + messages.error(request, 'Cannot delete: the linked payroll adjustment has already been paid.') + return redirect('absence_list') + with transaction.atomic(): + if adj: + adj.delete() + absence.delete() + messages.success(request, 'Absence deleted.') + return redirect('absence_list') + + +@login_required +def absence_export_csv(request): + """Admin-only CSV download. Same filters as list view.""" + if not is_admin(request.user): + return HttpResponseForbidden('Admin access required.') + qs = _absence_user_queryset(request.user).select_related('worker', 'logged_by') + + # Apply same filters as list view (DRY: extract helper if this grows) + if r := request.GET.get('reason'): + if r in dict(Absence.REASON_CHOICES): + qs = qs.filter(reason=r) + if df := request.GET.get('date_from'): + try: qs = qs.filter(date__gte=df) + except: pass + if dt := request.GET.get('date_to'): + try: qs = qs.filter(date__lte=dt) + except: pass + + import csv + from django.http import HttpResponse + resp = HttpResponse(content_type='text/csv') + resp['Content-Disposition'] = 'attachment; filename="absences.csv"' + writer = csv.writer(resp) + writer.writerow(['Worker', 'Date', 'Reason', 'Paid', 'Notes', 'Logged By']) + for a in qs: + writer.writerow([ + a.worker.name, + a.date.isoformat(), + a.get_reason_display(), + 'Yes' if a.is_paid else 'No', + a.notes, + a.logged_by.username if a.logged_by else '', + ]) + return resp +``` + +Update `core/urls.py`: +```python +path('absences/', views.absence_list, name='absence_list'), +path('absences//edit/', views.absence_edit, name='absence_edit'), +path('absences//delete/', views.absence_delete, name='absence_delete'), +path('absences/export/', views.absence_export_csv, name='absence_export_csv'), +``` + +### Step 3 — Create templates + +Create `core/templates/core/absences/list.html`: + +```html +{% extends 'base.html' %} +{% load static format_tags %} + +{% block title %}Absences | FoxFitt{% endblock %} + +{% block content %} +
+
+
+

Absences

+ {{ page.paginator.count }} record(s) +
+
+ + Log Absence + + {% if is_admin %} + + CSV + + {% endif %} +
+
+ + {% if messages %} + {% for m in messages %}
{{ m }}
{% endfor %} + {% endif %} + + {# === Filter bar === #} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Clear +
+
+ + {# === Table === #} +
+
+ + + + + + + + + + + + + + {% for a in page %} + + + + + + + + + + {% empty %} + + {% endfor %} + +
DateWorkerReasonPaid?Logged byNotes
{{ a.date|date:"d M Y" }}{{ a.worker.name }}{{ a.get_reason_display }} + {% if a.is_paid %} + + {% if a.payroll_adjustment %}({{ a.payroll_adjustment.amount|money }}){% endif %} + {% else %} + + {% endif %} + {{ a.logged_by.username|default:"—" }}{{ a.notes|truncatechars:60 }} + +
+ {% csrf_token %} + +
+
No absences match the filters.
+
+
+ + {# === Pagination === #} + {% if page.has_other_pages %} + + {% endif %} +
+ + +{% endblock %} +``` + +Create `core/templates/core/absences/edit.html` (similar shape to log.html but for a single record). + +```html +{% extends 'base.html' %} +{% block title %}Edit Absence | FoxFitt{% endblock %} +{% block content %} +
+

Edit Absence

+ + {% if messages %}{% for m in messages %}
{{ m }}
{% endfor %}{% endif %} + +
+ {% csrf_token %} +
+
+
+ {{ form.worker }}{{ form.worker.errors }} +
+
+ {{ form.date }}{{ form.date.errors }} +
+
+ {{ form.reason }} +
+
+
+ {{ form.is_paid }} +
+
+
+ {{ form.notes }} +
+
+ {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} +
+ + {% csrf_token %} + + +
+ Cancel + +
+
+
+ +
+{% endblock %} +``` + +### Step 4 — Run tests, confirm they pass + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceListViewTests core.tests.AbsenceEditDeleteTests core.tests.AbsenceExportCSVTests -v 2 +``` +Expected: 9 tests pass. + +Full suite: +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2 +``` +Expected: ~118 tests OK. + +### Step 5 — Commit + +```bash +git add core/views.py core/urls.py core/templates/core/absences/ core/tests.py +git commit -m "feat(absences): list + edit + delete + CSV export + +/absences/ filtered list with pagination + reason badges; +/absences//edit/ syncs adjustment on save; /absences//delete/ +cascades unpaid adjustment, refuses if paid; /absences/export/ +admin-only CSV. 9 tests." +``` + +--- + +## 🚦 CHECKPOINT — pause for Konrad to demo + +After Task 5, the feature is **demo-able end-to-end** for the primary +flow. Stop here, push the 5 commits, and wait for Konrad to: + +1. Run `python manage.py migrate` locally. +2. Open `/absences/log/`, log a few absences (single date + range). +3. Trigger a conflict (log absence for a date that has WorkLogs), confirm + the warning page appears + the "Remove from WorkLog" checkbox works. +4. Browse `/absences/`, try each filter. +5. Edit an absence, toggle Paid on → check `/payroll/?status=adjustments` + for the new Bonus row. +6. Delete an absence. +7. Click CSV export. + +**If everything works → proceed to Task 6.** +**If something feels off → fix before adding more polish.** + +Quick smoke-test command (skip database setup; verify URLs resolve): +``` +USE_SQLITE=true python manage.py check +``` + +--- + +## Task 6 — Quick-action modal on attendance form + +**Goal:** ✗ button next to each worker on `/attendance/log/` opens a modal that creates an Absence for that worker on the attendance date. + +**Files:** +- Modify: `core/views.py` — `mark_absent_quick` +- Modify: `core/urls.py` — 1 URL pattern +- Create: `core/templates/core/absences/_quick_modal.html` +- Modify: `core/templates/core/attendance_log.html` — ✗ button + modal include + JS +- Modify: `core/tests.py` — `MarkAbsentQuickViewTests` + +### Step 1 — Write failing tests + +```python +class MarkAbsentQuickViewTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True) + cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000')) + + def setUp(self): + self.client.force_login(self.admin) + + def test_post_creates_absence(self): + resp = self.client.post('/absences/quick/', data={ + 'worker_id': self.worker.id, + 'date': '2026-05-14', + 'reason': 'sick', + 'notes': 'flu', + }) + self.assertEqual(Absence.objects.count(), 1) + a = Absence.objects.first() + self.assertEqual(a.worker, self.worker) + self.assertEqual(a.date, _date(2026, 5, 14)) + self.assertEqual(a.reason, 'sick') + + def test_post_missing_worker_400s(self): + resp = self.client.post('/absences/quick/', data={ + 'date': '2026-05-14', + 'reason': 'sick', + }) + self.assertEqual(resp.status_code, 400) +``` + +### Step 2 — Implement view + +```python +@login_required +def mark_absent_quick(request): + """POST-only. The ✗ Mark Absent button on /attendance/log/. + Creates ONE Absence for the given worker on the given date.""" + if request.method != 'POST': + return HttpResponseBadRequest('POST only.') + if not _user_can_log_absences(request.user): + return HttpResponseForbidden('Permission denied.') + worker_id = request.POST.get('worker_id') + date_str = request.POST.get('date') + reason = request.POST.get('reason') + if not (worker_id and date_str and reason): + return HttpResponseBadRequest('Missing required field.') + worker = get_object_or_404(Worker, id=worker_id) + from datetime import date as _d + try: + d = _d.fromisoformat(date_str) + except ValueError: + return HttpResponseBadRequest('Invalid date.') + is_paid = bool(request.POST.get('is_paid')) + notes = request.POST.get('notes', '') + if Absence.objects.filter(worker=worker, date=d).exists(): + messages.error(request, f'{worker.name} already has an absence on {d:%d %b %Y}.') + return redirect('attendance_log') + with transaction.atomic(): + a = Absence.objects.create( + worker=worker, date=d, reason=reason, + is_paid=is_paid, notes=notes, logged_by=request.user, + ) + _sync_absence_payroll_adjustment(a) + messages.success(request, f'Marked {worker.name} absent on {d:%d %b %Y}.') + return redirect('attendance_log') +``` + +Add URL: +```python +path('absences/quick/', views.mark_absent_quick, name='mark_absent_quick'), +``` + +### Step 3 — Create modal partial + tweak attendance template + +Create `core/templates/core/absences/_quick_modal.html`: + +```html + +``` + +Modify `core/templates/core/attendance_log.html`: + +1. Locate the worker checkbox list (around line 138). +2. Next to each worker, add the ✗ button. +3. At the bottom of the file (before `{% endblock %}`), add the include + JS: + +```html +{% include 'core/absences/_quick_modal.html' %} + + +``` + +The button per worker row: +```html + +``` + +### Step 4 — Run tests, confirm they pass + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.MarkAbsentQuickViewTests -v 2 +``` +Expected: 2 tests pass. + +### Step 5 — Commit + +```bash +git add core/views.py core/urls.py core/templates/core/absences/_quick_modal.html core/templates/core/attendance_log.html core/tests.py +git commit -m "feat(absences): quick-action modal on attendance form + +✗ Mark Absent button next to each worker on /attendance/log/. +Click opens modal pre-filled with worker + date; admin picks reason ++ optional paid + notes. POST to /absences/quick/. 2 tests." +``` + +--- + +## Task 7 — Worker-detail tab + dashboard alert card + CLAUDE.md + +**Goal:** Polish — Absences tab on `/workers//` with YTD totals; conditional "X absent in last 7 days" alert card on admin dashboard; CLAUDE.md updated. + +**Files:** +- Modify: `core/views.py` — `worker_detail` + `index` view context +- Modify: `core/templates/core/workers/detail.html` — Absences tab +- Modify: `core/templates/core/index.html` — alert card +- Modify: `core/tests.py` — `AbsenceYTDPanelTests` +- Modify: `CLAUDE.md` — Absence section + +### Step 1 — Write failing tests + +```python +class AbsenceYTDPanelTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True) + cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000')) + # Current year + last year + current_year = timezone.now().year + Absence.objects.create(worker=cls.worker, date=_date(current_year, 1, 5), reason='sick') + Absence.objects.create(worker=cls.worker, date=_date(current_year, 2, 10), reason='sick') + Absence.objects.create(worker=cls.worker, date=_date(current_year, 3, 1), reason='annual') + Absence.objects.create(worker=cls.worker, date=_date(current_year - 1, 5, 1), reason='sick') + + def test_worker_detail_ytd_totals(self): + self.client.force_login(self.admin) + resp = self.client.get(f'/workers/{self.worker.id}/') + ytd = resp.context['absence_ytd_totals'] + self.assertEqual(ytd.get('sick'), 2) + self.assertEqual(ytd.get('annual'), 1) + + def test_dashboard_recent_count_7_days(self): + # Wipe + recreate to control dates + Absence.objects.all().delete() + today = timezone.now().date() + from datetime import timedelta + Absence.objects.create(worker=self.worker, date=today, reason='sick') + Absence.objects.create(worker=self.worker, date=today - timedelta(days=3), reason='annual') + Absence.objects.create(worker=self.worker, date=today - timedelta(days=10), reason='other') # outside 7-day window + self.client.force_login(self.admin) + resp = self.client.get('/') + self.assertEqual(resp.context['absences_recent_count'], 2) + + def test_dashboard_card_hidden_when_zero(self): + Absence.objects.all().delete() + self.client.force_login(self.admin) + resp = self.client.get('/') + self.assertEqual(resp.context['absences_recent_count'], 0) +``` + +### Step 2 — Implement view updates + +In `core/views.py`, modify `worker_detail`: + +```python +# === ABSENCE YTD TOTALS === +# Year-to-date counts per reason for this worker. Used by the +# Absences tab on the worker detail page. +from django.db.models import Count +ytd = ( + worker.absences + .filter(date__year=timezone.now().year) + .values('reason') + .annotate(total=Count('id')) +) +absence_ytd_totals = {row['reason']: row['total'] for row in ytd} +``` + +Then add to the template context: +```python +'absence_ytd_totals': absence_ytd_totals, +'worker_absences': worker.absences.all()[:50], # most recent 50 +``` + +Modify `index` view: + +```python +# === ABSENCES IN LAST 7 DAYS (admin dashboard card) === +if is_admin(request.user): + from datetime import timedelta + seven_days_ago = timezone.now().date() - timedelta(days=7) + absences_recent_count = Absence.objects.filter(date__gte=seven_days_ago).count() +else: + absences_recent_count = 0 +``` + +Add to context: `'absences_recent_count': absences_recent_count`. + +### Step 3 — Modify templates + +In `core/templates/core/workers/detail.html`, add a tab after the Warnings tab: + +```html + +``` + +And tab pane: +```html +
+
+ {% for key, label in absence_reason_choices %} + {% if absence_ytd_totals|dictlookup:key %} + + {{ label }}: {{ absence_ytd_totals|dictlookup:key }} + + {% endif %} + {% endfor %} +
+ + + + {% for a in worker_absences %} + + + + + + + {% empty %} + + {% endfor %} + +
DateReasonPaid?Notes
{{ a.date|date:"d M Y" }}{{ a.get_reason_display }}{% if a.is_paid %}✓{% else %}—{% endif %}{{ a.notes|truncatechars:60 }}
No absences recorded.
+
+``` + +Add `Absence.REASON_CHOICES` to the worker_detail context too: `'absence_reason_choices': Absence.REASON_CHOICES`. + +In `core/templates/core/index.html` (admin section), find an appropriate stat-card row and add: + +```html +{% if absences_recent_count > 0 %} + +{% endif %} +``` + +Add `seven_days_ago` to index context: `'seven_days_ago': timezone.now().date() - timedelta(days=7)`. + +### Step 4 — Update CLAUDE.md + +Insert after the `SiteReport` model entry in **Key Models**: + +```markdown +- **Absence** — per-worker dated record of a day not worked. 8 reason choices (Sick, Family Responsibility, Annual Leave, Personal/Unpaid Leave, Injury on Duty, Suspension, Absconded, Other). `is_paid` boolean (default False) — when ticked, the save flow auto-creates a Bonus PayrollAdjustment linked via `payroll_adjustment` OneToOneField. Unique per (worker, date) at DB layer. Permission scoping: admin (all) or supervisor (workers in their teams). +``` + +Append URL routes table: +```markdown +| `/absences/log/` | `absence_log` | Admin/supervisor: log absences (date range, multi-worker) | +| `/absences/log/confirm/` | `absence_log_confirm` | Yellow conflict-warning page; per-row Remove-from-WorkLog checkboxes | +| `/absences/` | `absence_list` | Filtered list with pagination | +| `/absences//edit/` | `absence_edit` | Edit one absence; syncs PayrollAdjustment | +| `/absences//delete/` | `absence_delete` | POST-only; cascades unpaid adjustment | +| `/absences/export/` | `absence_export_csv` | Admin-only CSV | +| `/absences/quick/` | `mark_absent_quick` | POST from ✗ Mark Absent on /attendance/log/ | +``` + +Add a new section after "SiteReport metric schema": + +```markdown +## Absence-to-PayrollAdjustment cascade (May 2026) + +`Absence.is_paid=True` auto-creates a Bonus PayrollAdjustment at +`worker.daily_rate`, linked via the OneToOneField. Logic lives in +`_sync_absence_payroll_adjustment(absence)` in `core/views.py` — called +from `absence_log`, `absence_log_confirm`, `absence_edit`, and +`mark_absent_quick` save paths. + +Edit flows: +- Toggle is_paid True → False → adjustment is deleted (refuses if + adjustment is already paid, surfaces error to admin). +- Toggle is_paid False → True → fresh adjustment created. +- Edit an already-paid absence in any other way (date / reason / notes) → + adjustment is NOT updated (admin may have edited the amount manually). + +Delete cascade: deleting an Absence with a linked unpaid adjustment +cascades to delete the adjustment. If the adjustment is already paid, +delete is refused (surfaces error). +``` + +### Step 5 — Run tests, confirm they pass + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceYTDPanelTests -v 2 +``` +Expected: 3 tests pass. + +Full suite: +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2 +``` +Expected: ~123 tests OK. + +### Step 6 — Commit + push everything + +```bash +git add core/views.py core/templates/core/workers/detail.html core/templates/core/index.html core/tests.py CLAUDE.md +git commit -m "feat(absences): worker-detail tab + dashboard alert + CLAUDE.md + +Absences tab on /workers// with YTD totals chip row + table. +Conditional 'X absent in last 7 days' alert card on admin dashboard +(only renders when count > 0). CLAUDE.md model summary + URL routes ++ cascade-pattern section. 3 tests." + +git push origin ai-dev +``` + +--- + +## Final verification + +After Task 7: +1. Full test suite: 85 baseline + ~38 new = ~123 tests, all passing. +2. Manual smoke test: + - `/absences/log/` → log single + range absences + - Trigger conflict → confirm page → tick removal → confirm → land on list + - `/absences/` → all filters work + - Edit absence → toggle paid → check `/payroll/?status=adjustments` + - Toggle paid off → adjustment gone + - Delete absence → cascade works (or refused if paid) + - `/workers//` → Absences tab visible, YTD chips correct + - `/` (admin home) → alert card visible if absences in last 7 days + - `/absences/export/` → CSV downloads + - `/attendance/log/` → ✗ button next to worker → modal → submit → absence created +3. Push: `git push origin ai-dev` (all 7 task commits + design doc + plan doc + previous parked-work commit). +4. Don't deploy to Flatlogic yet — Konrad explicitly held off on the SiteReport deploy too. Both features ship together when ready. + +--- + +## Notes for the implementer + +- **DRY filter logic** between `absence_list` and `absence_export_csv` — if the filters expand much, refactor into a helper `_apply_absence_filters(qs, request)`. YAGNI for v1. +- **Atomic transactions everywhere** — every save path uses `with transaction.atomic():`. If any one record fails, all roll back. +- **No new dependencies** — pure Django + the existing template tags. +- **Reason badge palette** — uses existing `--badge-*-bg` tokens from `static/css/custom.css`. No new CSS variables added. +- **No `--no-verify` on commits** — let pre-commit hooks run. +- **Don't push to master** — work happens on `ai-dev`, master gets merged later. +- **Test naming convention** — `Test{Component}{Behaviour}` per existing pattern in `core/tests.py`. +- **Permission queryset path** — `worker__teams__supervisor=user` is the simplified version (verified against `core/models.py` line 110-111 during plan writing). If you find a case where supervisors should see project-supervised workers too, broaden with `Q(worker__teams__supervisor=user) | Q(worker__work_logs__project__supervisors=user)` — but flag this with Konrad before adding. + +## Risks flagged during design + +1. **`OneToOneField` SET_NULL drift.** If admin deletes a PayrollAdjustment directly via `/payroll/?status=adjustments`, the Absence's `payroll_adjustment` FK goes NULL but `is_paid` stays True. Inconsistent. **Recommendation:** add a `post_delete` signal on PayrollAdjustment that sets `Absence.is_paid=False` when the linked adjustment is deleted. NOT in this plan's scope — add as a follow-up if Konrad hits the case. +2. **Atomic transaction failure during confirm POST.** If WorkLog removal succeeds but Absence creation fails, the WorkLog change rolls back too (atomic). Verify with an explicit test if behaviour matters. +3. **Pagination on `/absences/export/` CSV.** Currently exports the full filtered queryset, not paginated. Acceptable for v1 (at FoxFitt's scale, ~hundreds of rows max).