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

98 KiB

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:

# ====================================================================
# === 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):

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):

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

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:

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):

# === 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:

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

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

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:

# ====================================================================
# === 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

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.pyabsence_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.pyAbsenceLogViewTests, AbsenceConfirmViewTests

Step 1 — Write failing view tests

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):

# === 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:

from django.contrib import messages

Add to core/urls.py:

# === 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:

@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:

{% 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:

{% 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

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.pyAbsenceListViewTests, AbsenceEditDeleteTests, AbsenceExportCSVTests

Step 1 — Write failing tests

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:

@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:

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:

{% 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).

{% 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

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.pymark_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.pyMarkAbsentQuickViewTests

Step 1 — Write failing tests

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

@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:

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:

<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:
{% 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:

<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

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.pyworker_detail + index view context
  • Modify: core/templates/core/workers/detail.html — Absences tab
  • Modify: core/templates/core/index.html — alert card
  • Modify: core/tests.pyAbsenceYTDPanelTests
  • Modify: CLAUDE.md — Absence section

Step 1 — Write failing tests

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:

# === 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:

'absence_ytd_totals': absence_ytd_totals,
'worker_absences': worker.absences.all()[:50],  # most recent 50

Modify index view:

# === 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:

<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:

<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:

{% 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:

- **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:

| `/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":

## 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

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 conventionTest{Component}{Behaviour} per existing pattern in core/tests.py.
  • Permission queryset pathworker__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).