38686-vm/docs/plans/2026-05-14-worker-absences-plan.md
Konrad du Plessis f146af0e35 docs(absences): task-by-task implementation plan
7 tasks + 1 mid-flight checkpoint after Task 5 (CRUD complete).
Each task is TDD: failing test → minimal impl → verify pass →
commit. Target ~38 new tests, ~1700 LOC. Skipped doctor's note
upload (Q7 decision). Risks flagged: OneToOneField SET_NULL
drift, atomic-failure path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:04:43 +02:00

2463 lines
98 KiB
Markdown

# 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_<wl_id>_<worker_id>'
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 %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h1 class="page-title mb-0">
<i class="fas fa-user-clock me-2" style="color: var(--accent);"></i>
Log Absences
</h1>
<small class="text-muted">Record workers who were not on site today.</small>
</div>
<a href="{% url 'absence_list' %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-list me-1"></i> View All
</a>
</div>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% if form.non_field_errors %}
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
{% endif %}
<form method="post" class="card">
{% csrf_token %}
<div class="card-body p-3 p-md-4">
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label">Date <span class="text-danger">*</span></label>
{{ form.date }}
{{ form.date.errors }}
</div>
<div class="col-12 col-md-6">
<label class="form-label">End Date (optional)</label>
{{ form.end_date }}
<small class="text-muted">{{ form.end_date.help_text }}</small>
{{ form.end_date.errors }}
</div>
<div class="col-12 col-md-6 d-flex gap-3 align-items-center">
<div class="form-check">
{{ form.include_saturday }}
<label class="form-check-label" for="{{ form.include_saturday.id_for_label }}">
Include Saturdays
</label>
</div>
<div class="form-check">
{{ form.include_sunday }}
<label class="form-check-label" for="{{ form.include_sunday.id_for_label }}">
Include Sundays
</label>
</div>
</div>
</div>
<hr class="my-3">
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label">Reason <span class="text-danger">*</span></label>
{{ form.reason }}
{{ form.reason.errors }}
</div>
<div class="col-12 col-md-6 d-flex align-items-end">
<div class="form-check">
{{ form.is_paid }}
<label class="form-check-label" for="{{ form.is_paid.id_for_label }}">
Paid at daily rate
</label>
<div><small class="text-muted">Creates a Bonus payroll adjustment when ticked.</small></div>
</div>
</div>
</div>
<hr class="my-3">
<div class="row g-3">
<div class="col-12 col-md-4">
<label class="form-label">Filter by Team</label>
{{ form.team }}
</div>
<div class="col-12 col-md-8">
<label class="form-label">Workers <span class="text-danger">*</span></label>
<input type="text" class="form-control mb-2" id="workerSearch" placeholder="Search workers...">
<div class="border rounded p-2" style="max-height: 300px; overflow-y: auto;">
{% for worker in form.workers %}
<div class="form-check worker-row" data-name="{{ worker.choice_label|lower }}">
{{ worker.tag }}
<label class="form-check-label">{{ worker.choice_label }}</label>
</div>
{% endfor %}
</div>
{{ form.workers.errors }}
</div>
</div>
<hr class="my-3">
<div>
<label class="form-label">Notes (optional)</label>
{{ form.notes }}
</div>
<div class="d-flex justify-content-end gap-2 mt-3">
<a href="{% url 'home' %}" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-accent">
<i class="fas fa-save me-1"></i> Log Absences
</button>
</div>
</div>
</form>
</div>
<script>
// === Worker search filter ===
document.getElementById('workerSearch').addEventListener('input', function() {
const q = this.value.toLowerCase();
document.querySelectorAll('.worker-row').forEach(row => {
const name = row.dataset.name || '';
row.style.display = name.includes(q) ? '' : 'none';
});
});
</script>
{% endblock %}
```
Create `core/templates/core/absences/log_confirm.html`:
```html
{% extends 'base.html' %}
{% load static %}
{% block title %}Confirm Absences | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<h1 class="page-title mb-3">
<i class="fas fa-exclamation-triangle me-2" style="color: var(--accent);"></i>
Confirm Absences
</h1>
<div class="alert alert-warning">
<strong>{{ conflicts|length }} worker(s) already have work logs on these dates.</strong><br>
Tick the boxes below to also remove them from those work logs (recommended if you're correcting a mistake).
</div>
<form method="post" class="card">
{% csrf_token %}
<div class="card-body">
<h6 class="text-uppercase mb-3" style="font-size: 0.75rem; color: var(--text-secondary);">
Conflicts
</h6>
<table class="table table-sm">
<thead>
<tr>
<th>Worker</th>
<th>Date</th>
<th>WorkLog</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for c in conflicts %}
<tr>
<td>{{ c.worker_name }}</td>
<td>{{ c.date }}</td>
<td>{{ c.project_name }} (WorkLog #{{ c.work_log_id }})</td>
<td>
<div class="form-check">
<input class="form-check-input" type="checkbox"
name="remove_from_worklog_{{ c.work_log_id }}_{{ c.worker_id }}"
id="remove_{{ forloop.counter }}">
<label class="form-check-label" for="remove_{{ forloop.counter }}">
Also remove from WorkLog
</label>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<hr>
<p class="mb-2"><strong>{{ absence_count }} absence(s) will be created:</strong></p>
<p>Reason: <strong>{{ reason }}</strong>. Paid: <strong>{% if is_paid %}Yes{% else %}No{% endif %}</strong>.</p>
<div class="d-flex justify-content-end gap-2 mt-3">
<a href="{% url 'absence_log' %}" class="btn btn-outline-secondary">← Back to form</a>
<button type="submit" class="btn btn-accent">
<i class="fas fa-check me-1"></i> Confirm & Create Absences
</button>
</div>
</div>
</form>
</div>
{% 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/<id>/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/<int:absence_id>/edit/', views.absence_edit, name='absence_edit'),
path('absences/<int:absence_id>/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 %}
<div class="container-fluid py-3">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h1 class="page-title mb-0"><i class="fas fa-user-clock me-2" style="color: var(--accent);"></i>Absences</h1>
<small class="text-muted">{{ page.paginator.count }} record(s)</small>
</div>
<div class="d-flex gap-2">
<a href="{% url 'absence_log' %}" class="btn btn-accent btn-sm">
<i class="fas fa-plus me-1"></i> Log Absence
</a>
{% if is_admin %}
<a href="{% url 'absence_export_csv' %}?{{ request.GET.urlencode }}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-download me-1"></i> CSV
</a>
{% endif %}
</div>
</div>
{% if messages %}
{% for m in messages %}<div class="alert alert-{{ m.tags }}">{{ m }}</div>{% endfor %}
{% endif %}
{# === Filter bar === #}
<form method="get" class="card mb-3">
<div class="card-body p-2 d-flex flex-wrap gap-2 align-items-end">
<div>
<label class="form-label small mb-0">Worker</label>
<select name="worker" class="form-select form-select-sm" style="min-width: 160px;">
<option value="">All</option>
{% for w in workers_qs %}
<option value="{{ w.id }}" {% if filter_worker == w.id|stringformat:"s" %}selected{% endif %}>{{ w.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label small mb-0">Team</label>
<select name="team" class="form-select form-select-sm" style="min-width: 140px;">
<option value="">All</option>
{% for t in teams_qs %}
<option value="{{ t.id }}" {% if filter_team == t.id|stringformat:"s" %}selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label small mb-0">Project</label>
<select name="project" class="form-select form-select-sm" style="min-width: 140px;">
<option value="">All</option>
{% for p in projects_qs %}
<option value="{{ p.id }}" {% if filter_project == p.id|stringformat:"s" %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label small mb-0">Reason</label>
<select name="reason" class="form-select form-select-sm">
<option value="">All</option>
{% for key, label in reason_choices %}
<option value="{{ key }}" {% if filter_reason == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label small mb-0">From</label>
<input type="date" name="date_from" value="{{ filter_date_from }}" class="form-control form-control-sm">
</div>
<div>
<label class="form-label small mb-0">To</label>
<input type="date" name="date_to" value="{{ filter_date_to }}" class="form-control form-control-sm">
</div>
<div>
<label class="form-label small mb-0">Paid?</label>
<select name="paid" class="form-select form-select-sm">
<option value="">All</option>
<option value="paid" {% if filter_paid == 'paid' %}selected{% endif %}>Paid</option>
<option value="unpaid" {% if filter_paid == 'unpaid' %}selected{% endif %}>Unpaid</option>
</select>
</div>
<button type="submit" class="btn btn-outline-primary btn-sm">Apply</button>
<a href="{% url 'absence_list' %}" class="btn btn-outline-secondary btn-sm">Clear</a>
</div>
</form>
{# === Table === #}
<div class="card">
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Date</th>
<th>Worker</th>
<th>Reason</th>
<th>Paid?</th>
<th>Logged by</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
{% for a in page %}
<tr>
<td>{{ a.date|date:"d M Y" }}</td>
<td>{{ a.worker.name }}</td>
<td><span class="badge badge-absence-{{ a.reason }}">{{ a.get_reason_display }}</span></td>
<td>
{% if a.is_paid %}
<i class="fas fa-check-circle" style="color: var(--badge-bonus-bg);"></i>
{% if a.payroll_adjustment %}<small class="text-muted">({{ a.payroll_adjustment.amount|money }})</small>{% endif %}
{% else %}
<i class="far fa-circle text-muted"></i>
{% endif %}
</td>
<td>{{ a.logged_by.username|default:"—" }}</td>
<td class="text-muted">{{ a.notes|truncatechars:60 }}</td>
<td>
<a href="{% url 'absence_edit' a.id %}" class="btn btn-sm btn-outline-secondary"><i class="fas fa-pen"></i></a>
<form method="post" action="{% url 'absence_delete' a.id %}" style="display: inline;" onsubmit="return confirm('Delete this absence?');">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i></button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="7" class="text-center text-muted py-4">No absences match the filters.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{# === Pagination === #}
{% if page.has_other_pages %}
<nav class="mt-3">
<ul class="pagination pagination-sm justify-content-center">
{% if page.has_previous %}
<li class="page-item"><a class="page-link" href="?{% url_replace 'page' page.previous_page_number %}">Previous</a></li>
{% endif %}
<li class="page-item disabled"><span class="page-link">Page {{ page.number }} of {{ page.paginator.num_pages }}</span></li>
{% if page.has_next %}
<li class="page-item"><a class="page-link" href="?{% url_replace 'page' page.next_page_number %}">Next</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
<style>
/* === Reason badges — reuse existing semantic palette === */
.badge-absence-sick { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
.badge-absence-family { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
.badge-absence-annual { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
.badge-absence-unpaid { background: var(--badge-neutral-bg, #6c757d); color: #fff; }
.badge-absence-iod { background: var(--badge-overtime-bg, #ffc107); color: #000; }
.badge-absence-suspension { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); }
.badge-absence-absconded { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); }
.badge-absence-other { background: var(--badge-neutral-bg, #6c757d); color: #fff; }
</style>
{% 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 %}
<div class="container py-4">
<h1 class="page-title mb-3"><i class="fas fa-pen me-2"></i>Edit Absence</h1>
{% if messages %}{% for m in messages %}<div class="alert alert-{{ m.tags }}">{{ m }}</div>{% endfor %}{% endif %}
<form method="post" class="card">
{% csrf_token %}
<div class="card-body p-3 p-md-4">
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label">Worker</label>{{ form.worker }}{{ form.worker.errors }}
</div>
<div class="col-12 col-md-6">
<label class="form-label">Date</label>{{ form.date }}{{ form.date.errors }}
</div>
<div class="col-12 col-md-6">
<label class="form-label">Reason</label>{{ form.reason }}
</div>
<div class="col-12 col-md-6 d-flex align-items-end">
<div class="form-check">
{{ form.is_paid }}<label class="form-check-label">Paid at daily rate</label>
</div>
</div>
<div class="col-12">
<label class="form-label">Notes</label>{{ form.notes }}
</div>
</div>
{% if form.non_field_errors %}<div class="alert alert-danger mt-3">{{ form.non_field_errors }}</div>{% endif %}
<div class="d-flex justify-content-between mt-3">
<form method="post" action="{% url 'absence_delete' absence.id %}" onsubmit="return confirm('Delete?');">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger"><i class="fas fa-trash me-1"></i>Delete</button>
</form>
<div>
<a href="{% url 'absence_list' %}" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-accent">Save</button>
</div>
</div>
</div>
</form>
</div>
{% 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/<id>/edit/ syncs adjustment on save; /absences/<id>/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
<div class="modal fade" id="markAbsentModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'mark_absent_quick' %}">
{% csrf_token %}
<input type="hidden" name="worker_id" id="absentWorkerId">
<input type="hidden" name="date" id="absentDate">
<div class="modal-header">
<h5 class="modal-title">Mark <span id="absentWorkerName"></span> Absent</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Reason</label>
<select name="reason" class="form-select" required>
<option value="">— Select —</option>
<option value="sick">Sick</option>
<option value="family">Family Responsibility</option>
<option value="annual">Annual Leave</option>
<option value="unpaid">Personal / Unpaid Leave</option>
<option value="iod">Injury on Duty</option>
<option value="suspension">Suspension</option>
<option value="absconded">Absconded</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-check mb-3">
<input type="checkbox" name="is_paid" class="form-check-input" id="absentPaid">
<label class="form-check-label" for="absentPaid">Paid at daily rate</label>
</div>
<div>
<label class="form-label">Notes (optional)</label>
<textarea name="notes" class="form-control" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-accent">Save Absence</button>
</div>
</form>
</div>
</div>
</div>
```
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' %}
<script>
// === Mark Absent quick-action ===
document.querySelectorAll('.btn-mark-absent').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
document.getElementById('absentWorkerId').value = this.dataset.workerId;
document.getElementById('absentWorkerName').textContent = this.dataset.workerName;
const dateInput = document.querySelector('input[name="date"]');
document.getElementById('absentDate').value = dateInput ? dateInput.value : '';
new bootstrap.Modal(document.getElementById('markAbsentModal')).show();
});
});
</script>
```
The button per worker row:
```html
<button type="button" class="btn btn-sm btn-outline-danger ms-2 btn-mark-absent"
data-worker-id="{{ worker.choice_value }}"
data-worker-name="{{ worker.choice_label }}"
title="Mark absent">
<i class="fas fa-times"></i>
</button>
```
### 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/<id>/` 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
<li class="nav-item">
<a class="nav-link" id="absences-tab" data-bs-toggle="tab" href="#absences">
<i class="fas fa-user-clock me-1"></i>Absences
</a>
</li>
```
And tab pane:
```html
<div class="tab-pane fade" id="absences">
<div class="d-flex flex-wrap gap-2 mb-3">
{% for key, label in absence_reason_choices %}
{% if absence_ytd_totals|dictlookup:key %}
<span class="badge badge-absence-{{ key }}">
{{ label }}: {{ absence_ytd_totals|dictlookup:key }}
</span>
{% endif %}
{% endfor %}
</div>
<table class="table table-sm">
<thead><tr><th>Date</th><th>Reason</th><th>Paid?</th><th>Notes</th></tr></thead>
<tbody>
{% for a in worker_absences %}
<tr>
<td>{{ a.date|date:"d M Y" }}</td>
<td><span class="badge badge-absence-{{ a.reason }}">{{ a.get_reason_display }}</span></td>
<td>{% if a.is_paid %}✓{% else %}—{% endif %}</td>
<td class="text-muted">{{ a.notes|truncatechars:60 }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="text-center text-muted py-3">No absences recorded.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
```
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 %}
<div class="col-md-3 col-sm-6 mb-3">
<a href="{% url 'absence_list' %}?date_from={{ seven_days_ago|date:'Y-m-d' }}" class="text-decoration-none">
<div class="card stat-card" style="border-left: 4px solid var(--badge-deduction-bg);">
<div class="card-body p-3">
<div style="font-size: 1.5rem; font-weight: 700;">{{ absences_recent_count }}</div>
<div class="text-muted">absent in last 7 days</div>
</div>
</div>
</a>
</div>
{% 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/<id>/edit/` | `absence_edit` | Edit one absence; syncs PayrollAdjustment |
| `/absences/<id>/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/<id>/ 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/<id>/` → 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).