# 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 %}
Worker Date WorkLog Action
{{ 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 %}
Date Worker Reason Paid? Logged by Notes
{{ 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).