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>
2463 lines
98 KiB
Markdown
2463 lines
98 KiB
Markdown
# Worker Absence Records Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (or
|
|
> superpowers:subagent-driven-development for in-session execution) to
|
|
> implement this plan task-by-task.
|
|
|
|
**Goal:** Build a per-worker absence-tracking system: log absences with
|
|
date ranges, mark them paid (auto-create Bonus PayrollAdjustment), view
|
|
filtered list, edit/delete, surface YTD totals on the worker profile, and
|
|
add a quick-action button on the existing attendance form.
|
|
|
|
**Architecture:** New `Absence` model 1:1 with optional `PayrollAdjustment`
|
|
(via OneToOneField with `SET_NULL`). Standalone form at `/absences/log/`
|
|
mirrors `AttendanceLogForm`'s date-range pattern. Conflict-warning is a
|
|
two-page flow (POST stashes pending data in session → redirects to
|
|
`/absences/log/confirm/`). Permission scoping is admin (all) or supervisor
|
|
(workers in their teams). Payroll sync via one helper function called from
|
|
all save paths.
|
|
|
|
**Tech Stack:** Django 5.2.7, Python 3.13, SQLite (local) / MySQL (prod),
|
|
Bootstrap 5, vanilla JS. No new dependencies.
|
|
|
|
**Pre-reading for the implementer:**
|
|
- `docs/plans/2026-05-14-worker-absences-design.md` — full design (7 brainstorm Qs answered, 5 sections approved by Konrad)
|
|
- `CLAUDE.md` — root project guide (coding style, naming-drift gotchas, existing patterns)
|
|
- `core/models.py::WorkerWarning` — the closest existing model pattern (per-worker, dated, severity choices, optional doc, ordered `-date`)
|
|
- `core/forms.py::AttendanceLogForm` — the date-range form pattern (date + optional end_date + Sat/Sun toggles)
|
|
- `core/views.py::attendance_log` — the conflict-detection flow precedent (different approach — re-renders same template; we deliberately do a separate-page flow per Konrad's preference)
|
|
- `core/views.py::_delete_adjustment_with_cascade` — the helper-with-cascade pattern this should mirror
|
|
|
|
**Branch:** `ai-dev`. Phase A.1 SiteReport (commit `864ae72`) is also on
|
|
this branch and not deployed to production. The absences feature is
|
|
strictly additive and can ship together with or independently of
|
|
SiteReport.
|
|
|
|
**Test invocation (local, Git Bash on Windows):**
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
|
|
```
|
|
On cmd.exe: `set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests -v 2`
|
|
|
|
**Baseline test count before starting:** 85 (verify with the command above
|
|
before Task 1).
|
|
|
|
**Target after all tasks:** ~123 (85 baseline + ~38 new).
|
|
|
|
---
|
|
|
|
## Task overview (7 tasks + 1 checkpoint)
|
|
|
|
| # | Task | LOC | Tests added | Demo-able outcome |
|
|
|---|---|---|---|---|
|
|
| 1 | Model + migration + admin | ~120 | +4 | `Absence` exists, /admin/core/absence/ works |
|
|
| 2 | Permission queryset + payroll sync helpers | ~110 | +5 | Helpers callable; payroll sync covered |
|
|
| 3 | Forms (Log + Quick + Edit) | ~200 | +6 | Forms validate, expand date ranges, detect conflicts |
|
|
| 4 | Log + Confirm views + templates + URLs | ~450 | +9 | `/absences/log/` works end-to-end including conflict flow |
|
|
| 5 | List + Edit + Delete + CSV export | ~430 | +9 | Full CRUD on absences. **🚦 CHECKPOINT — Konrad demos.** |
|
|
| 6 | Quick-action modal on attendance form | ~180 | +2 | ✗ button on /attendance/log/ creates absence |
|
|
| 7 | Worker-detail tab + dashboard alert + CLAUDE.md | ~190 | +3 | Polish + docs. Feature shippable. |
|
|
|
|
**Total:** ~1700 LOC net, ~38 new tests.
|
|
|
|
---
|
|
|
|
## Task 1 — Model layer + migration + admin registration
|
|
|
|
**Goal:** `Absence` model exists in the database, registered in Django admin, with 4 model-level tests passing.
|
|
|
|
**Files:**
|
|
- Modify: `core/models.py` — add `Absence` class at end of file (after `WorkerWarning`)
|
|
- Create: `core/migrations/0014_add_absence.py` (auto-generated)
|
|
- Modify: `core/admin.py` — register `Absence`
|
|
- Modify: `core/tests.py` — add `AbsenceModelTests` class
|
|
|
|
### Step 1 — Write failing model tests
|
|
|
|
Add at end of `core/tests.py`:
|
|
|
|
```python
|
|
# ====================================================================
|
|
# === Worker Absence — Phase 1: Model layer ==========================
|
|
# ====================================================================
|
|
|
|
from datetime import date as _date
|
|
from django.db import IntegrityError
|
|
from django.utils import timezone
|
|
from core.models import Absence
|
|
|
|
|
|
class AbsenceModelTests(TestCase):
|
|
"""Per-worker dated absence records. Mirrors WorkerWarning shape."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.worker = Worker.objects.create(
|
|
name='Joe Mokoena', id_number='8001015800086',
|
|
monthly_salary=Decimal('6000.00'),
|
|
)
|
|
|
|
def test_defaults(self):
|
|
"""is_paid defaults to False; payroll_adjustment is null; date defaults to today."""
|
|
a = Absence.objects.create(worker=self.worker, reason='sick')
|
|
self.assertFalse(a.is_paid)
|
|
self.assertIsNone(a.payroll_adjustment)
|
|
self.assertEqual(a.date, _date.today())
|
|
|
|
def test_unique_per_worker_per_day(self):
|
|
"""Cannot have two absences for the same worker on the same day."""
|
|
d = _date(2026, 5, 14)
|
|
Absence.objects.create(worker=self.worker, date=d, reason='sick')
|
|
with self.assertRaises(IntegrityError):
|
|
Absence.objects.create(worker=self.worker, date=d, reason='family')
|
|
|
|
def test_ordering_newest_first(self):
|
|
"""Default queryset order is -date."""
|
|
Absence.objects.create(worker=self.worker, date=_date(2026, 5, 10), reason='sick')
|
|
Absence.objects.create(worker=self.worker, date=_date(2026, 5, 15), reason='annual')
|
|
Absence.objects.create(worker=self.worker, date=_date(2026, 5, 12), reason='other')
|
|
dates = list(Absence.objects.values_list('date', flat=True))
|
|
self.assertEqual(dates, [_date(2026, 5, 15), _date(2026, 5, 12), _date(2026, 5, 10)])
|
|
|
|
def test_reverse_accessor_on_worker(self):
|
|
"""worker.absences.all() works (related_name='absences')."""
|
|
Absence.objects.create(worker=self.worker, date=_date(2026, 5, 1), reason='sick')
|
|
Absence.objects.create(worker=self.worker, date=_date(2026, 5, 2), reason='family')
|
|
self.assertEqual(self.worker.absences.count(), 2)
|
|
```
|
|
|
|
### Step 2 — Run tests, confirm they fail
|
|
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceModelTests -v 2
|
|
```
|
|
Expected: ImportError "cannot import name 'Absence' from 'core.models'" (because the model doesn't exist yet).
|
|
|
|
### Step 3 — Implement the model
|
|
|
|
Append to `core/models.py` (after `WorkerWarning` class, before `SiteReport`):
|
|
|
|
```python
|
|
class Absence(models.Model):
|
|
"""Per-worker, dated record of a day NOT worked. The supervisor or
|
|
admin captured WHY (sick, annual leave, absconded, etc.) and
|
|
optionally whether the day is to be paid at the worker's daily rate.
|
|
|
|
When `is_paid` is True the save flow (in views) creates a Bonus
|
|
PayrollAdjustment for that worker on that date, linked via the
|
|
OneToOneField below. Edit/delete of the Absence propagates the
|
|
adjustment (mirrors the Advance Payment → Loan → Repayment
|
|
cascade pattern in payroll).
|
|
"""
|
|
|
|
# === ABSENCE REASONS ===
|
|
# SA labour-relations terminology. The DB stores the snake_case key;
|
|
# users see the human label via get_reason_display().
|
|
REASON_CHOICES = [
|
|
('sick', 'Sick'),
|
|
('family', 'Family Responsibility'),
|
|
('annual', 'Annual Leave'),
|
|
('unpaid', 'Personal / Unpaid Leave'),
|
|
('iod', 'Injury on Duty'),
|
|
('suspension', 'Suspension'),
|
|
('absconded', 'Absconded'),
|
|
('other', 'Other'),
|
|
]
|
|
|
|
worker = models.ForeignKey(
|
|
Worker, related_name='absences', on_delete=models.CASCADE,
|
|
)
|
|
date = models.DateField(default=timezone.now)
|
|
reason = models.CharField(max_length=20, choices=REASON_CHOICES)
|
|
notes = models.TextField(
|
|
blank=True,
|
|
help_text='Free-form context (e.g. "flu, doctor\'s note", "bus broke down").',
|
|
)
|
|
|
|
# === PAYROLL LINK ===
|
|
is_paid = models.BooleanField(
|
|
default=False,
|
|
help_text='Tick to pay the worker their daily rate for this day.',
|
|
)
|
|
payroll_adjustment = models.OneToOneField(
|
|
'PayrollAdjustment',
|
|
on_delete=models.SET_NULL,
|
|
null=True, blank=True,
|
|
related_name='absence',
|
|
help_text='Auto-created when is_paid=True. Cleared if is_paid is unchecked.',
|
|
)
|
|
|
|
# === AUDIT ===
|
|
logged_by = models.ForeignKey(
|
|
User, on_delete=models.SET_NULL, null=True, blank=True,
|
|
related_name='absences_logged',
|
|
)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
# One absence per worker per day — enforced at the DB layer.
|
|
unique_together = [('worker', 'date')]
|
|
# Newest first on lists.
|
|
ordering = ['-date', '-created_at']
|
|
|
|
def __str__(self):
|
|
return f'{self.worker.name} — {self.get_reason_display()} — {self.date:%d %b %Y}'
|
|
```
|
|
|
|
### Step 4 — Generate migration
|
|
|
|
```
|
|
USE_SQLITE=true python manage.py makemigrations core
|
|
```
|
|
Expected output:
|
|
```
|
|
Migrations for 'core':
|
|
core/migrations/0014_add_absence.py
|
|
- Create model Absence
|
|
```
|
|
|
|
Open the generated file and confirm it's a pure `migrations.CreateModel` with a `unique_together` constraint. No data migration needed.
|
|
|
|
### Step 5 — Apply migration locally
|
|
|
|
```
|
|
USE_SQLITE=true python manage.py migrate
|
|
```
|
|
Expected: `Applying core.0014_add_absence... OK`
|
|
|
|
### Step 6 — Register in Django admin
|
|
|
|
Add to `core/admin.py` (after `WorkerWarningAdmin`, before the next existing admin):
|
|
|
|
```python
|
|
from .models import Absence # at top with other imports
|
|
|
|
|
|
@admin.register(Absence)
|
|
class AbsenceAdmin(admin.ModelAdmin):
|
|
list_display = ('worker', 'date', 'reason', 'is_paid', 'logged_by', 'created_at')
|
|
list_filter = ('reason', 'is_paid', 'date')
|
|
search_fields = ('worker__name', 'worker__id_number', 'notes')
|
|
raw_id_fields = ('worker', 'logged_by', 'payroll_adjustment')
|
|
readonly_fields = ('created_at', 'updated_at')
|
|
date_hierarchy = 'date'
|
|
```
|
|
|
|
### Step 7 — Run tests, confirm they pass
|
|
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceModelTests -v 2
|
|
```
|
|
Expected: `OK` with 4 tests passing.
|
|
|
|
Then run the full suite to make sure nothing else broke:
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
|
|
```
|
|
Expected: `Ran 89 tests in X.XXXs — OK` (was 85, +4).
|
|
|
|
### Step 8 — Commit
|
|
|
|
```bash
|
|
git add core/models.py core/admin.py core/migrations/0014_add_absence.py core/tests.py
|
|
git commit -m "feat(absences): Absence model + migration + admin registration
|
|
|
|
Per-worker dated records with 8 reason choices (Sick/Family/Annual/
|
|
Personal-Unpaid/IOD/Suspension/Absconded/Other), is_paid flag, optional
|
|
OneToOne to PayrollAdjustment for the auto-Bonus path, audit fields.
|
|
Unique-per-day at DB layer. 4 model tests."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2 — Permission queryset + payroll sync helpers
|
|
|
|
**Goal:** Two helper functions in `core/views.py` are callable and tested: `_absence_user_queryset(user)` returns scoped Absence queryset, and `_sync_absence_payroll_adjustment(absence)` keeps the Bonus PayrollAdjustment in sync with `is_paid`.
|
|
|
|
**Files:**
|
|
- Modify: `core/views.py` — add 2 functions near the existing helpers (right after `_delete_adjustment_with_cascade`)
|
|
- Modify: `core/tests.py` — add `AbsencePayrollSyncTests` class
|
|
|
|
### Step 1 — Write failing helper tests
|
|
|
|
Append to `core/tests.py`:
|
|
|
|
```python
|
|
class AbsencePayrollSyncTests(TestCase):
|
|
"""The _sync_absence_payroll_adjustment helper keeps Absence.payroll_adjustment
|
|
in sync with Absence.is_paid. Mirrors the cascade pattern used for
|
|
Advance Payment → Loan auto-creation."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.user = User.objects.create_user(username='admin', is_staff=True)
|
|
cls.worker = Worker.objects.create(
|
|
name='Sipho Dlamini', id_number='8501015800087',
|
|
monthly_salary=Decimal('6000.00'),
|
|
)
|
|
cls.project = Project.objects.create(name='Solar Farm Alpha')
|
|
|
|
def _make_absence(self, **kw):
|
|
defaults = dict(
|
|
worker=self.worker, date=_date(2026, 5, 14), reason='sick',
|
|
logged_by=self.user, is_paid=False,
|
|
)
|
|
defaults.update(kw)
|
|
return Absence.objects.create(**defaults)
|
|
|
|
def test_is_paid_false_no_adjustment(self):
|
|
"""Unpaid absence: sync is a no-op, no adjustment created."""
|
|
from core.views import _sync_absence_payroll_adjustment
|
|
a = self._make_absence(is_paid=False)
|
|
_sync_absence_payroll_adjustment(a)
|
|
self.assertIsNone(a.payroll_adjustment)
|
|
self.assertEqual(PayrollAdjustment.objects.count(), 0)
|
|
|
|
def test_is_paid_true_creates_bonus(self):
|
|
"""Paid absence creates a Bonus PayrollAdjustment at daily_rate."""
|
|
from core.views import _sync_absence_payroll_adjustment
|
|
a = self._make_absence(is_paid=True)
|
|
_sync_absence_payroll_adjustment(a)
|
|
a.refresh_from_db()
|
|
self.assertIsNotNone(a.payroll_adjustment)
|
|
adj = a.payroll_adjustment
|
|
self.assertEqual(adj.type, 'Bonus')
|
|
# daily_rate = monthly_salary / 20 = 6000 / 20 = 300
|
|
self.assertEqual(adj.amount, Decimal('300.00'))
|
|
self.assertEqual(adj.worker, self.worker)
|
|
self.assertEqual(adj.date, _date(2026, 5, 14))
|
|
|
|
def test_toggle_paid_off_deletes_adjustment(self):
|
|
"""is_paid True → False: existing adjustment is deleted."""
|
|
from core.views import _sync_absence_payroll_adjustment
|
|
a = self._make_absence(is_paid=True)
|
|
_sync_absence_payroll_adjustment(a)
|
|
self.assertEqual(PayrollAdjustment.objects.count(), 1)
|
|
|
|
a.is_paid = False
|
|
a.save()
|
|
_sync_absence_payroll_adjustment(a)
|
|
a.refresh_from_db()
|
|
self.assertIsNone(a.payroll_adjustment)
|
|
self.assertEqual(PayrollAdjustment.objects.count(), 0)
|
|
|
|
def test_toggle_paid_on_creates_fresh(self):
|
|
"""is_paid False → True: new adjustment is created."""
|
|
from core.views import _sync_absence_payroll_adjustment
|
|
a = self._make_absence(is_paid=False)
|
|
_sync_absence_payroll_adjustment(a)
|
|
self.assertIsNone(a.payroll_adjustment)
|
|
|
|
a.is_paid = True
|
|
a.save()
|
|
_sync_absence_payroll_adjustment(a)
|
|
a.refresh_from_db()
|
|
self.assertIsNotNone(a.payroll_adjustment)
|
|
|
|
def test_refuses_if_adjustment_already_paid(self):
|
|
"""If the linked adjustment has a PayrollRecord (i.e. already paid),
|
|
the sync helper refuses to delete it — surface to admin instead."""
|
|
from core.views import _sync_absence_payroll_adjustment
|
|
a = self._make_absence(is_paid=True)
|
|
_sync_absence_payroll_adjustment(a)
|
|
a.refresh_from_db()
|
|
|
|
# Simulate the adjustment being paid by attaching a PayrollRecord
|
|
pr = PayrollRecord.objects.create(worker=self.worker, amount_paid=Decimal('300.00'), date=_date(2026, 5, 30))
|
|
a.payroll_adjustment.payroll_record = pr
|
|
a.payroll_adjustment.save()
|
|
|
|
a.is_paid = False
|
|
a.save()
|
|
with self.assertRaises(ValueError):
|
|
_sync_absence_payroll_adjustment(a)
|
|
# Adjustment still exists
|
|
self.assertEqual(PayrollAdjustment.objects.count(), 1)
|
|
|
|
|
|
class AbsenceUserQuerysetTests(TestCase):
|
|
"""_absence_user_queryset scopes the queryset to admin (all) or supervisor
|
|
(workers in their supervised teams)."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.admin = User.objects.create_user(username='admin', is_staff=True)
|
|
cls.sup_a = User.objects.create_user(username='sup_a')
|
|
cls.sup_b = User.objects.create_user(username='sup_b')
|
|
cls.outsider = User.objects.create_user(username='out')
|
|
|
|
cls.worker_a = Worker.objects.create(name='WA', id_number='1', monthly_salary=Decimal('6000'))
|
|
cls.worker_b = Worker.objects.create(name='WB', id_number='2', monthly_salary=Decimal('6000'))
|
|
|
|
cls.team_a = Team.objects.create(name='TA', supervisor=cls.sup_a)
|
|
cls.team_a.workers.add(cls.worker_a)
|
|
cls.team_b = Team.objects.create(name='TB', supervisor=cls.sup_b)
|
|
cls.team_b.workers.add(cls.worker_b)
|
|
|
|
Absence.objects.create(worker=cls.worker_a, date=_date(2026, 5, 1), reason='sick')
|
|
Absence.objects.create(worker=cls.worker_b, date=_date(2026, 5, 1), reason='annual')
|
|
|
|
def test_admin_sees_all(self):
|
|
from core.views import _absence_user_queryset
|
|
qs = _absence_user_queryset(self.admin)
|
|
self.assertEqual(qs.count(), 2)
|
|
|
|
def test_supervisor_sees_only_own_team(self):
|
|
from core.views import _absence_user_queryset
|
|
qs_a = _absence_user_queryset(self.sup_a)
|
|
self.assertEqual(qs_a.count(), 1)
|
|
self.assertEqual(qs_a.first().worker, self.worker_a)
|
|
|
|
def test_outsider_sees_none(self):
|
|
from core.views import _absence_user_queryset
|
|
qs = _absence_user_queryset(self.outsider)
|
|
self.assertEqual(qs.count(), 0)
|
|
```
|
|
|
|
### Step 2 — Run tests, confirm they fail
|
|
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsencePayrollSyncTests core.tests.AbsenceUserQuerysetTests -v 2
|
|
```
|
|
Expected: ImportError "cannot import name '_sync_absence_payroll_adjustment' from 'core.views'".
|
|
|
|
### Step 3 — Implement the helpers
|
|
|
|
Add to `core/views.py` (right after `_delete_adjustment_with_cascade` — locate it first with `grep -n "_delete_adjustment_with_cascade" core/views.py`):
|
|
|
|
```python
|
|
# === ABSENCE HELPERS ===
|
|
# Two helpers used by every save/edit/delete path on Absence rows.
|
|
# Kept here (not on the model) so admin / supervisor screens can
|
|
# react to "the linked adjustment is already paid" — silent
|
|
# model-side cascades would hide that case from the user.
|
|
|
|
def _absence_user_queryset(user):
|
|
"""Absences visible to this user.
|
|
|
|
Admin → all absences.
|
|
Supervisor → absences for workers in any team they supervise.
|
|
Anyone else → nothing.
|
|
"""
|
|
if is_admin(user):
|
|
return Absence.objects.all()
|
|
return Absence.objects.filter(
|
|
worker__teams__supervisor=user
|
|
).distinct()
|
|
|
|
|
|
def _sync_absence_payroll_adjustment(absence):
|
|
"""Keep absence.payroll_adjustment in sync with absence.is_paid.
|
|
|
|
Cases:
|
|
- is_paid=False + no adjustment → no-op.
|
|
- is_paid=False + adjustment exists → delete the adjustment
|
|
(but raise ValueError if the adjustment is already paid —
|
|
caller surfaces this to the admin).
|
|
- is_paid=True + no adjustment → create a Bonus PayrollAdjustment
|
|
at worker.daily_rate, save the FK back onto the absence.
|
|
- is_paid=True + adjustment exists → leave alone (admin may have
|
|
edited the amount manually; we don't second-guess).
|
|
|
|
Returns the linked adjustment (or None).
|
|
"""
|
|
adj = absence.payroll_adjustment
|
|
|
|
if not absence.is_paid:
|
|
if adj is None:
|
|
return None
|
|
# Refuse to delete an already-paid adjustment — caller decides
|
|
# how to surface this (toast, inline error, etc.)
|
|
if adj.payroll_record_id is not None:
|
|
raise ValueError(
|
|
'Linked PayrollAdjustment has already been paid '
|
|
'(via PayrollRecord). Cannot auto-delete.'
|
|
)
|
|
adj.delete()
|
|
absence.payroll_adjustment = None
|
|
absence.save(update_fields=['payroll_adjustment'])
|
|
return None
|
|
|
|
# is_paid=True branch
|
|
if adj is not None:
|
|
# Already linked — leave it alone.
|
|
return adj
|
|
|
|
# Create a fresh Bonus adjustment.
|
|
new_adj = PayrollAdjustment.objects.create(
|
|
worker=absence.worker,
|
|
type='Bonus',
|
|
amount=absence.worker.daily_rate,
|
|
date=absence.date,
|
|
description=f'Paid {absence.get_reason_display().lower()} — auto-created from Absence #{absence.id}',
|
|
)
|
|
absence.payroll_adjustment = new_adj
|
|
absence.save(update_fields=['payroll_adjustment'])
|
|
return new_adj
|
|
```
|
|
|
|
Also add `Absence` to the imports near the top of `core/views.py`:
|
|
|
|
```python
|
|
from .models import (
|
|
...existing imports...,
|
|
Absence,
|
|
)
|
|
```
|
|
|
|
### Step 4 — Run tests, confirm they pass
|
|
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsencePayrollSyncTests core.tests.AbsenceUserQuerysetTests -v 2
|
|
```
|
|
Expected: 8 tests pass (5 sync + 3 queryset).
|
|
|
|
Full suite:
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
|
|
```
|
|
Expected: Ran ~94 tests, OK.
|
|
|
|
### Step 5 — Commit
|
|
|
|
```bash
|
|
git add core/views.py core/tests.py
|
|
git commit -m "feat(absences): _absence_user_queryset + _sync_absence_payroll_adjustment
|
|
|
|
Two helpers covering the recurring 'which absences can this user see'
|
|
and 'sync is_paid with the linked Bonus PayrollAdjustment' patterns.
|
|
The sync helper refuses to delete an already-paid adjustment — caller
|
|
surfaces this to the user. Mirrors _delete_adjustment_with_cascade
|
|
semantics. 8 tests."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3 — Forms (AbsenceLogForm + AbsenceQuickForm + AbsenceEditForm)
|
|
|
|
**Goal:** Three form classes in `core/forms.py` validate user input, expand date ranges respecting Sat/Sun toggles, and surface conflicts. Form-level tests pass.
|
|
|
|
**Files:**
|
|
- Modify: `core/forms.py` — add 3 form classes at end
|
|
- Modify: `core/tests.py` — add `AbsenceFormTests` class
|
|
|
|
### Step 1 — Write failing form tests
|
|
|
|
```python
|
|
class AbsenceFormTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.admin = User.objects.create_user(username='admin', is_staff=True)
|
|
cls.worker_a = Worker.objects.create(name='WA', id_number='1', monthly_salary=Decimal('6000'))
|
|
cls.worker_b = Worker.objects.create(name='WB', id_number='2', monthly_salary=Decimal('6000'))
|
|
cls.project = Project.objects.create(name='P1')
|
|
cls.team = Team.objects.create(name='T', supervisor=cls.admin)
|
|
cls.team.workers.add(cls.worker_a, cls.worker_b)
|
|
|
|
def test_single_date_submission(self):
|
|
from core.forms import AbsenceLogForm
|
|
form = AbsenceLogForm(
|
|
data={
|
|
'date': '2026-05-14',
|
|
'reason': 'sick',
|
|
'is_paid': False,
|
|
'notes': 'flu',
|
|
'team': self.team.id,
|
|
'workers': [self.worker_a.id],
|
|
},
|
|
user=self.admin,
|
|
)
|
|
self.assertTrue(form.is_valid(), msg=form.errors)
|
|
# cleaned data has expanded (worker, date) tuples
|
|
tuples = form.expanded_pairs()
|
|
self.assertEqual(len(tuples), 1)
|
|
self.assertEqual(tuples[0], (self.worker_a, _date(2026, 5, 14)))
|
|
|
|
def test_range_expansion_skips_weekends_by_default(self):
|
|
from core.forms import AbsenceLogForm
|
|
# Thursday → Monday (5 days, Sat+Sun skipped → 3 days)
|
|
form = AbsenceLogForm(
|
|
data={
|
|
'date': '2026-05-14', # Thursday
|
|
'end_date': '2026-05-18', # Monday
|
|
'reason': 'annual',
|
|
'workers': [self.worker_a.id],
|
|
},
|
|
user=self.admin,
|
|
)
|
|
self.assertTrue(form.is_valid())
|
|
dates = [d for _w, d in form.expanded_pairs()]
|
|
# 14 Thu, 15 Fri, 18 Mon (16 Sat / 17 Sun skipped)
|
|
self.assertEqual(dates, [_date(2026, 5, 14), _date(2026, 5, 15), _date(2026, 5, 18)])
|
|
|
|
def test_range_expansion_with_weekend_toggles(self):
|
|
from core.forms import AbsenceLogForm
|
|
form = AbsenceLogForm(
|
|
data={
|
|
'date': '2026-05-14',
|
|
'end_date': '2026-05-17', # Thursday → Sunday
|
|
'reason': 'sick',
|
|
'include_saturday': True,
|
|
'include_sunday': True,
|
|
'workers': [self.worker_a.id],
|
|
},
|
|
user=self.admin,
|
|
)
|
|
self.assertTrue(form.is_valid())
|
|
dates = [d for _w, d in form.expanded_pairs()]
|
|
self.assertEqual(len(dates), 4) # all 4 days
|
|
|
|
def test_end_date_before_start_rejected(self):
|
|
from core.forms import AbsenceLogForm
|
|
form = AbsenceLogForm(
|
|
data={
|
|
'date': '2026-05-15',
|
|
'end_date': '2026-05-10',
|
|
'reason': 'sick',
|
|
'workers': [self.worker_a.id],
|
|
},
|
|
user=self.admin,
|
|
)
|
|
self.assertFalse(form.is_valid())
|
|
self.assertIn('end_date', form.errors)
|
|
|
|
def test_duplicate_absence_rejected(self):
|
|
"""If absence(worker, date) already exists, form is invalid."""
|
|
from core.forms import AbsenceLogForm
|
|
Absence.objects.create(worker=self.worker_a, date=_date(2026, 5, 14), reason='sick')
|
|
form = AbsenceLogForm(
|
|
data={
|
|
'date': '2026-05-14',
|
|
'reason': 'family',
|
|
'workers': [self.worker_a.id],
|
|
},
|
|
user=self.admin,
|
|
)
|
|
self.assertFalse(form.is_valid())
|
|
# Error message names the worker
|
|
self.assertIn('already', str(form.errors).lower())
|
|
|
|
def test_worklog_conflict_flagged_not_blocking(self):
|
|
"""If worker has a WorkLog on the absence date, form is still valid
|
|
but conflicting_worklogs() returns the conflict rows."""
|
|
from core.forms import AbsenceLogForm
|
|
wl = WorkLog.objects.create(
|
|
date=_date(2026, 5, 14), project=self.project, supervisor=self.admin,
|
|
)
|
|
wl.workers.add(self.worker_a)
|
|
form = AbsenceLogForm(
|
|
data={
|
|
'date': '2026-05-14',
|
|
'reason': 'sick',
|
|
'workers': [self.worker_a.id],
|
|
},
|
|
user=self.admin,
|
|
)
|
|
self.assertTrue(form.is_valid()) # Conflicts are warnings, not errors
|
|
conflicts = form.conflicting_worklogs()
|
|
self.assertEqual(len(conflicts), 1)
|
|
self.assertEqual(conflicts[0]['worker_id'], self.worker_a.id)
|
|
self.assertEqual(conflicts[0]['work_log_id'], wl.id)
|
|
```
|
|
|
|
### Step 2 — Run tests, confirm they fail
|
|
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceFormTests -v 2
|
|
```
|
|
Expected: ImportError "cannot import name 'AbsenceLogForm' from 'core.forms'".
|
|
|
|
### Step 3 — Implement the forms
|
|
|
|
Append to `core/forms.py`:
|
|
|
|
```python
|
|
# ====================================================================
|
|
# === ABSENCE FORMS ==================================================
|
|
# ====================================================================
|
|
# Three forms mirror the SiteReport / WorkerWarning patterns:
|
|
# - AbsenceLogForm: standalone /absences/log/ with date-range support,
|
|
# team filter, worker checkbox list, conflict detection.
|
|
# - AbsenceQuickForm: minimal form for the "Mark Absent" modal on
|
|
# /attendance/log/ — worker + date come from URL/POST, form only
|
|
# asks for reason / paid / notes.
|
|
# - AbsenceEditForm: edit one existing absence; can correct
|
|
# worker/date as well as the other fields.
|
|
|
|
from .models import Absence # add at top of file with other imports
|
|
|
|
|
|
class AbsenceLogForm(forms.ModelForm):
|
|
"""Standalone form for /absences/log/. Supports date ranges and
|
|
multiple workers per submission. Validates conflicts; the view
|
|
consumes expanded_pairs() and conflicting_worklogs() to drive the
|
|
confirm/save flow.
|
|
"""
|
|
|
|
# === DATE RANGE EXTRAS (copied from AttendanceLogForm) ===
|
|
end_date = forms.DateField(
|
|
required=False,
|
|
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
|
label='End Date',
|
|
help_text='Leave blank to log a single day.',
|
|
)
|
|
include_saturday = forms.BooleanField(
|
|
required=False, initial=False, label='Include Saturdays',
|
|
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
)
|
|
include_sunday = forms.BooleanField(
|
|
required=False, initial=False, label='Include Sundays',
|
|
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
)
|
|
|
|
# === WORKER PICKER ===
|
|
team = forms.ModelChoiceField(
|
|
queryset=Team.objects.filter(active=True),
|
|
required=False,
|
|
widget=forms.Select(attrs={'class': 'form-select'}),
|
|
help_text='Optional — narrows the worker list below.',
|
|
)
|
|
workers = forms.ModelMultipleChoiceField(
|
|
queryset=Worker.objects.filter(active=True),
|
|
widget=forms.CheckboxSelectMultiple,
|
|
)
|
|
|
|
class Meta:
|
|
model = Absence
|
|
fields = ['date', 'reason', 'is_paid', 'notes']
|
|
widgets = {
|
|
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
|
'reason': forms.Select(attrs={'class': 'form-select'}),
|
|
'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
'notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}),
|
|
}
|
|
|
|
def __init__(self, *args, user=None, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.user = user
|
|
# Supervisor scope: limit team + workers querysets to user's reach.
|
|
if user is not None and not (user.is_staff or user.is_superuser):
|
|
self.fields['team'].queryset = (
|
|
Team.objects.filter(active=True, supervisor=user)
|
|
)
|
|
self.fields['workers'].queryset = (
|
|
Worker.objects.filter(active=True, teams__supervisor=user).distinct()
|
|
)
|
|
|
|
def clean(self):
|
|
cleaned = super().clean()
|
|
start = cleaned.get('date')
|
|
end = cleaned.get('end_date')
|
|
if end and start and end < start:
|
|
self.add_error('end_date', 'End date must be on or after the start date.')
|
|
|
|
# Cache expanded (worker, date) pairs so view can reuse them.
|
|
self._pairs = self._expand_pairs(cleaned)
|
|
|
|
# Reject if any pair already has an Absence (unique key).
|
|
existing = []
|
|
for worker, d in self._pairs:
|
|
if Absence.objects.filter(worker=worker, date=d).exists():
|
|
existing.append(f'{worker.name} on {d:%d %b %Y}')
|
|
if existing:
|
|
self.add_error(None,
|
|
f'Absence already exists for: {", ".join(existing)}. '
|
|
'Edit the existing record instead.'
|
|
)
|
|
return cleaned
|
|
|
|
def _expand_pairs(self, cleaned):
|
|
"""Build the (worker, date) tuple list from cleaned data, respecting
|
|
Sat/Sun toggles."""
|
|
from datetime import timedelta
|
|
workers = cleaned.get('workers') or []
|
|
start = cleaned.get('date')
|
|
end = cleaned.get('end_date') or start
|
|
inc_sat = cleaned.get('include_saturday') or False
|
|
inc_sun = cleaned.get('include_sunday') or False
|
|
if not start:
|
|
return []
|
|
days = []
|
|
d = start
|
|
while d <= end:
|
|
wd = d.weekday() # Mon=0, Sun=6
|
|
if wd == 5 and not inc_sat:
|
|
d += timedelta(days=1); continue
|
|
if wd == 6 and not inc_sun:
|
|
d += timedelta(days=1); continue
|
|
days.append(d)
|
|
d += timedelta(days=1)
|
|
return [(w, d) for w in workers for d in days]
|
|
|
|
def expanded_pairs(self):
|
|
"""Return the (worker, date) tuples produced by clean(). Must call
|
|
is_valid() first."""
|
|
return getattr(self, '_pairs', [])
|
|
|
|
def conflicting_worklogs(self):
|
|
"""Return list of dicts describing (worker, date) pairs that have an
|
|
existing WorkLog. Each dict has worker_id, worker_name, date,
|
|
work_log_id, project_name."""
|
|
rows = []
|
|
for worker, d in self.expanded_pairs():
|
|
for wl in WorkLog.objects.filter(date=d, workers=worker).select_related('project'):
|
|
rows.append({
|
|
'worker_id': worker.id,
|
|
'worker_name': worker.name,
|
|
'date': d,
|
|
'work_log_id': wl.id,
|
|
'project_name': wl.project.name if wl.project else '—',
|
|
})
|
|
return rows
|
|
|
|
|
|
class AbsenceQuickForm(forms.ModelForm):
|
|
"""Minimal form for the ✗ Mark Absent modal on /attendance/log/.
|
|
Worker and date come from URL/POST, so this form only asks for the
|
|
three fields the admin still needs to fill in."""
|
|
|
|
class Meta:
|
|
model = Absence
|
|
fields = ['reason', 'is_paid', 'notes']
|
|
widgets = {
|
|
'reason': forms.Select(attrs={'class': 'form-select'}),
|
|
'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
'notes': forms.Textarea(attrs={'rows': 2, 'class': 'form-control'}),
|
|
}
|
|
|
|
|
|
class AbsenceEditForm(forms.ModelForm):
|
|
"""Edit one existing Absence. Can correct worker/date if logged wrong."""
|
|
|
|
class Meta:
|
|
model = Absence
|
|
fields = ['worker', 'date', 'reason', 'is_paid', 'notes']
|
|
widgets = {
|
|
'worker': forms.Select(attrs={'class': 'form-select'}),
|
|
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
|
'reason': forms.Select(attrs={'class': 'form-select'}),
|
|
'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
'notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}),
|
|
}
|
|
|
|
def clean(self):
|
|
cleaned = super().clean()
|
|
worker = cleaned.get('worker')
|
|
d = cleaned.get('date')
|
|
if worker and d:
|
|
# Check uniqueness, excluding self
|
|
qs = Absence.objects.filter(worker=worker, date=d)
|
|
if self.instance and self.instance.pk:
|
|
qs = qs.exclude(pk=self.instance.pk)
|
|
if qs.exists():
|
|
raise forms.ValidationError(
|
|
f'{worker.name} already has an absence on {d:%d %b %Y}.'
|
|
)
|
|
return cleaned
|
|
```
|
|
|
|
### Step 4 — Run tests, confirm they pass
|
|
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceFormTests -v 2
|
|
```
|
|
Expected: 6 tests pass.
|
|
|
|
Full suite:
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
|
|
```
|
|
Expected: ~100 tests OK.
|
|
|
|
### Step 5 — Commit
|
|
|
|
```bash
|
|
git add core/forms.py core/tests.py
|
|
git commit -m "feat(absences): AbsenceLogForm + AbsenceQuickForm + AbsenceEditForm
|
|
|
|
Three forms covering the three entry points: standalone date-range form
|
|
(/absences/log/), quick-action modal (/attendance/log/), and edit one
|
|
existing record. Log form expands (worker, date) pairs respecting
|
|
Sat/Sun toggles, validates uniqueness, surfaces WorkLog conflicts as a
|
|
non-blocking warning via conflicting_worklogs(). 6 tests."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4 — Log + Confirm views + templates + URLs
|
|
|
|
**Goal:** `/absences/log/` accepts form submissions; if conflicts → redirects to `/absences/log/confirm/` (yellow warning page with per-row WorkLog-removal checkboxes); confirm POST does the atomic transaction. End-to-end demoable.
|
|
|
|
**Files:**
|
|
- Modify: `core/views.py` — `absence_log`, `absence_log_confirm`
|
|
- Modify: `core/urls.py` — 2 new URL patterns
|
|
- Create: `core/templates/core/absences/log.html`
|
|
- Create: `core/templates/core/absences/log_confirm.html`
|
|
- Modify: `core/tests.py` — `AbsenceLogViewTests`, `AbsenceConfirmViewTests`
|
|
|
|
### Step 1 — Write failing view tests
|
|
|
|
```python
|
|
class AbsenceLogViewTests(TestCase):
|
|
"""GET shows form; POST without conflicts creates absences immediately;
|
|
POST with conflicts stashes pending data in session + redirects to
|
|
/absences/log/confirm/."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True)
|
|
cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000'))
|
|
cls.project = Project.objects.create(name='P')
|
|
cls.team = Team.objects.create(name='T', supervisor=cls.admin)
|
|
cls.team.workers.add(cls.worker)
|
|
|
|
def setUp(self):
|
|
self.client.force_login(self.admin)
|
|
|
|
def test_get_returns_200(self):
|
|
resp = self.client.get('/absences/log/')
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
def test_post_creates_absences_when_no_conflict(self):
|
|
resp = self.client.post('/absences/log/', data={
|
|
'date': '2026-05-14',
|
|
'reason': 'sick',
|
|
'workers': [self.worker.id],
|
|
'notes': 'flu',
|
|
})
|
|
self.assertEqual(Absence.objects.count(), 1)
|
|
absence = Absence.objects.first()
|
|
self.assertEqual(absence.worker, self.worker)
|
|
self.assertEqual(absence.reason, 'sick')
|
|
self.assertFalse(absence.is_paid)
|
|
self.assertEqual(absence.logged_by, self.admin)
|
|
self.assertRedirects(resp, '/absences/', fetch_redirect_response=False)
|
|
|
|
def test_post_with_paid_creates_adjustment(self):
|
|
self.client.post('/absences/log/', data={
|
|
'date': '2026-05-14',
|
|
'reason': 'sick',
|
|
'is_paid': 'on',
|
|
'workers': [self.worker.id],
|
|
})
|
|
absence = Absence.objects.first()
|
|
self.assertTrue(absence.is_paid)
|
|
self.assertIsNotNone(absence.payroll_adjustment)
|
|
self.assertEqual(absence.payroll_adjustment.type, 'Bonus')
|
|
|
|
def test_post_with_worklog_conflict_redirects_to_confirm(self):
|
|
wl = WorkLog.objects.create(date=_date(2026, 5, 14), project=self.project, supervisor=self.admin)
|
|
wl.workers.add(self.worker)
|
|
resp = self.client.post('/absences/log/', data={
|
|
'date': '2026-05-14',
|
|
'reason': 'sick',
|
|
'workers': [self.worker.id],
|
|
})
|
|
self.assertEqual(Absence.objects.count(), 0) # NOT created yet
|
|
self.assertRedirects(resp, '/absences/log/confirm/', fetch_redirect_response=False)
|
|
# Session has stashed pending data
|
|
self.assertIn('absence_pending', self.client.session)
|
|
|
|
def test_supervisor_can_post(self):
|
|
sup = User.objects.create_user(username='sup', password='pw')
|
|
sup_team = Team.objects.create(name='ST', supervisor=sup)
|
|
sup_team.workers.add(self.worker)
|
|
self.client.force_login(sup)
|
|
resp = self.client.post('/absences/log/', data={
|
|
'date': '2026-05-14',
|
|
'reason': 'sick',
|
|
'workers': [self.worker.id],
|
|
})
|
|
self.assertEqual(Absence.objects.count(), 1)
|
|
|
|
def test_outsider_gets_403(self):
|
|
outsider = User.objects.create_user(username='out', password='pw')
|
|
self.client.force_login(outsider)
|
|
resp = self.client.get('/absences/log/')
|
|
self.assertEqual(resp.status_code, 403)
|
|
|
|
|
|
class AbsenceConfirmViewTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True)
|
|
cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000'))
|
|
cls.project = Project.objects.create(name='P')
|
|
|
|
def setUp(self):
|
|
self.client.force_login(self.admin)
|
|
# Pre-stash pending data + create the conflict WorkLog
|
|
self.wl = WorkLog.objects.create(date=_date(2026, 5, 14), project=self.project, supervisor=self.admin)
|
|
self.wl.workers.add(self.worker)
|
|
session = self.client.session
|
|
session['absence_pending'] = {
|
|
'pairs': [[self.worker.id, '2026-05-14']],
|
|
'reason': 'sick',
|
|
'is_paid': False,
|
|
'notes': 'flu',
|
|
'conflicts': [{
|
|
'worker_id': self.worker.id, 'worker_name': 'W',
|
|
'date': '2026-05-14', 'work_log_id': self.wl.id,
|
|
'project_name': 'P',
|
|
}],
|
|
}
|
|
session.save()
|
|
|
|
def test_get_without_session_redirects_back(self):
|
|
# Clear session
|
|
session = self.client.session
|
|
session.pop('absence_pending', None)
|
|
session.save()
|
|
resp = self.client.get('/absences/log/confirm/')
|
|
self.assertRedirects(resp, '/absences/log/', fetch_redirect_response=False)
|
|
|
|
def test_get_with_session_shows_warning(self):
|
|
resp = self.client.get('/absences/log/confirm/')
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertContains(resp, 'already logged as working') # warning text
|
|
|
|
def test_post_creates_absences_and_removes_from_worklog(self):
|
|
resp = self.client.post('/absences/log/confirm/', data={
|
|
f'remove_from_worklog_{self.wl.id}_{self.worker.id}': 'on',
|
|
})
|
|
self.assertEqual(Absence.objects.count(), 1)
|
|
self.assertNotIn(self.worker, self.wl.workers.all())
|
|
self.assertRedirects(resp, '/absences/', fetch_redirect_response=False)
|
|
self.assertNotIn('absence_pending', self.client.session)
|
|
|
|
def test_post_without_removal_still_creates_absences(self):
|
|
resp = self.client.post('/absences/log/confirm/', data={})
|
|
self.assertEqual(Absence.objects.count(), 1)
|
|
# Worker still in WorkLog because admin didn't tick the box
|
|
self.assertIn(self.worker, self.wl.workers.all())
|
|
```
|
|
|
|
### Step 2 — Run tests, confirm they fail
|
|
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceLogViewTests core.tests.AbsenceConfirmViewTests -v 2
|
|
```
|
|
Expected: NoReverseMatch or 404 — URLs don't exist yet.
|
|
|
|
### Step 3 — Implement the views
|
|
|
|
Add to `core/views.py` (near the bottom, after the existing absence helpers added in Task 2):
|
|
|
|
```python
|
|
# === ABSENCE VIEWS — LOG + CONFIRM ===
|
|
|
|
def _user_can_log_absences(user):
|
|
"""Admin OR supervisor (anyone supervising at least one team)."""
|
|
return is_admin(user) or user.supervised_teams.exists()
|
|
|
|
|
|
@login_required
|
|
def absence_log(request):
|
|
"""GET shows blank AbsenceLogForm. POST validates; if no conflicts,
|
|
creates Absence rows in transaction.atomic() + redirects to list.
|
|
If conflicts, stashes pending data in session + redirects to
|
|
/absences/log/confirm/."""
|
|
if not _user_can_log_absences(request.user):
|
|
return HttpResponseForbidden('Permission denied.')
|
|
|
|
if request.method == 'POST':
|
|
form = AbsenceLogForm(request.POST, user=request.user)
|
|
if form.is_valid():
|
|
conflicts = form.conflicting_worklogs()
|
|
if conflicts:
|
|
# Stash + redirect to confirm page
|
|
request.session['absence_pending'] = {
|
|
'pairs': [[w.id, d.isoformat()] for w, d in form.expanded_pairs()],
|
|
'reason': form.cleaned_data['reason'],
|
|
'is_paid': form.cleaned_data.get('is_paid') or False,
|
|
'notes': form.cleaned_data.get('notes') or '',
|
|
'conflicts': [
|
|
{**c, 'date': c['date'].isoformat()} for c in conflicts
|
|
],
|
|
}
|
|
return redirect('absence_log_confirm')
|
|
# No conflicts — create immediately.
|
|
_create_absences_atomic(
|
|
pairs=form.expanded_pairs(),
|
|
reason=form.cleaned_data['reason'],
|
|
is_paid=form.cleaned_data.get('is_paid') or False,
|
|
notes=form.cleaned_data.get('notes') or '',
|
|
user=request.user,
|
|
worklog_removals=[],
|
|
)
|
|
messages.success(request, f'{len(form.expanded_pairs())} absence(s) logged.')
|
|
return redirect('absence_list')
|
|
else:
|
|
form = AbsenceLogForm(user=request.user)
|
|
|
|
return render(request, 'core/absences/log.html', {'form': form})
|
|
|
|
|
|
@login_required
|
|
def absence_log_confirm(request):
|
|
"""GET reads pending data from session, shows warning page.
|
|
POST processes the WorkLog removals + creates absences in one
|
|
transaction.atomic() block."""
|
|
if not _user_can_log_absences(request.user):
|
|
return HttpResponseForbidden('Permission denied.')
|
|
|
|
pending = request.session.get('absence_pending')
|
|
if not pending:
|
|
return redirect('absence_log')
|
|
|
|
if request.method == 'POST':
|
|
from datetime import date as _d
|
|
# Re-hydrate pairs
|
|
pairs = [
|
|
(Worker.objects.get(id=wid), _d.fromisoformat(ds))
|
|
for wid, ds in pending['pairs']
|
|
]
|
|
# Parse worklog removals from POST: keys like 'remove_from_worklog_<wl_id>_<worker_id>'
|
|
removals = []
|
|
for key in request.POST:
|
|
if key.startswith('remove_from_worklog_'):
|
|
_, _, _, wl_id, w_id = key.split('_', 4)
|
|
removals.append((int(wl_id), int(w_id)))
|
|
_create_absences_atomic(
|
|
pairs=pairs,
|
|
reason=pending['reason'],
|
|
is_paid=pending['is_paid'],
|
|
notes=pending['notes'],
|
|
user=request.user,
|
|
worklog_removals=removals,
|
|
)
|
|
request.session.pop('absence_pending', None)
|
|
messages.success(request, f'{len(pairs)} absence(s) logged.')
|
|
return redirect('absence_list')
|
|
|
|
# GET — render warning page
|
|
return render(request, 'core/absences/log_confirm.html', {
|
|
'conflicts': pending['conflicts'],
|
|
'reason': pending['reason'],
|
|
'is_paid': pending['is_paid'],
|
|
'absence_count': len(pending['pairs']),
|
|
})
|
|
|
|
|
|
def _create_absences_atomic(pairs, reason, is_paid, notes, user, worklog_removals):
|
|
"""Atomically: (1) remove flagged workers from WorkLogs, (2) create
|
|
Absence rows, (3) sync payroll adjustments."""
|
|
with transaction.atomic():
|
|
for wl_id, w_id in worklog_removals:
|
|
wl = WorkLog.objects.get(id=wl_id)
|
|
wl.workers.remove(w_id)
|
|
for worker, d in pairs:
|
|
a = Absence.objects.create(
|
|
worker=worker, date=d, reason=reason,
|
|
is_paid=is_paid, notes=notes, logged_by=user,
|
|
)
|
|
_sync_absence_payroll_adjustment(a)
|
|
```
|
|
|
|
Add `messages` import at top of `core/views.py` if not already present:
|
|
```python
|
|
from django.contrib import messages
|
|
```
|
|
|
|
Add to `core/urls.py`:
|
|
```python
|
|
# === Absences ===
|
|
path('absences/log/', views.absence_log, name='absence_log'),
|
|
path('absences/log/confirm/', views.absence_log_confirm, name='absence_log_confirm'),
|
|
path('absences/', views.absence_list, name='absence_list'), # placeholder — Task 5 will implement
|
|
```
|
|
|
|
For now, add a stub `absence_list` in `core/views.py` so the redirect works:
|
|
```python
|
|
@login_required
|
|
def absence_list(request):
|
|
"""Stub — full implementation in Task 5."""
|
|
return HttpResponse('Absence list — Task 5 will implement.')
|
|
```
|
|
|
|
### Step 4 — Create templates
|
|
|
|
Create `core/templates/core/absences/log.html`:
|
|
|
|
```html
|
|
{% extends 'base.html' %}
|
|
{% load static %}
|
|
|
|
{% block title %}Log Worker Absences | FoxFitt{% endblock %}
|
|
|
|
{% block content %}
|
|
{% comment %}
|
|
Standalone absence-logging form. Date range with optional Sat/Sun
|
|
inclusion (mirror of /attendance/log/). After successful submit
|
|
either redirects to /absences/ (no conflicts) or to
|
|
/absences/log/confirm/ (one or more workers were on a WorkLog for
|
|
one of the selected dates — admin chooses whether to also remove
|
|
them from the WorkLog).
|
|
{% endcomment %}
|
|
|
|
<div class="container py-4">
|
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
|
<div>
|
|
<h1 class="page-title mb-0">
|
|
<i class="fas fa-user-clock me-2" style="color: var(--accent);"></i>
|
|
Log Absences
|
|
</h1>
|
|
<small class="text-muted">Record workers who were not on site today.</small>
|
|
</div>
|
|
<a href="{% url 'absence_list' %}" class="btn btn-outline-secondary btn-sm">
|
|
<i class="fas fa-list me-1"></i> View All
|
|
</a>
|
|
</div>
|
|
|
|
{% if messages %}
|
|
{% for message in messages %}
|
|
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
|
|
{% endfor %}
|
|
{% endif %}
|
|
|
|
{% if form.non_field_errors %}
|
|
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
|
|
{% endif %}
|
|
|
|
<form method="post" class="card">
|
|
{% csrf_token %}
|
|
<div class="card-body p-3 p-md-4">
|
|
|
|
<div class="row g-3">
|
|
<div class="col-12 col-md-6">
|
|
<label class="form-label">Date <span class="text-danger">*</span></label>
|
|
{{ form.date }}
|
|
{{ form.date.errors }}
|
|
</div>
|
|
<div class="col-12 col-md-6">
|
|
<label class="form-label">End Date (optional)</label>
|
|
{{ form.end_date }}
|
|
<small class="text-muted">{{ form.end_date.help_text }}</small>
|
|
{{ form.end_date.errors }}
|
|
</div>
|
|
<div class="col-12 col-md-6 d-flex gap-3 align-items-center">
|
|
<div class="form-check">
|
|
{{ form.include_saturday }}
|
|
<label class="form-check-label" for="{{ form.include_saturday.id_for_label }}">
|
|
Include Saturdays
|
|
</label>
|
|
</div>
|
|
<div class="form-check">
|
|
{{ form.include_sunday }}
|
|
<label class="form-check-label" for="{{ form.include_sunday.id_for_label }}">
|
|
Include Sundays
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr class="my-3">
|
|
|
|
<div class="row g-3">
|
|
<div class="col-12 col-md-6">
|
|
<label class="form-label">Reason <span class="text-danger">*</span></label>
|
|
{{ form.reason }}
|
|
{{ form.reason.errors }}
|
|
</div>
|
|
<div class="col-12 col-md-6 d-flex align-items-end">
|
|
<div class="form-check">
|
|
{{ form.is_paid }}
|
|
<label class="form-check-label" for="{{ form.is_paid.id_for_label }}">
|
|
Paid at daily rate
|
|
</label>
|
|
<div><small class="text-muted">Creates a Bonus payroll adjustment when ticked.</small></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr class="my-3">
|
|
|
|
<div class="row g-3">
|
|
<div class="col-12 col-md-4">
|
|
<label class="form-label">Filter by Team</label>
|
|
{{ form.team }}
|
|
</div>
|
|
<div class="col-12 col-md-8">
|
|
<label class="form-label">Workers <span class="text-danger">*</span></label>
|
|
<input type="text" class="form-control mb-2" id="workerSearch" placeholder="Search workers...">
|
|
<div class="border rounded p-2" style="max-height: 300px; overflow-y: auto;">
|
|
{% for worker in form.workers %}
|
|
<div class="form-check worker-row" data-name="{{ worker.choice_label|lower }}">
|
|
{{ worker.tag }}
|
|
<label class="form-check-label">{{ worker.choice_label }}</label>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{{ form.workers.errors }}
|
|
</div>
|
|
</div>
|
|
|
|
<hr class="my-3">
|
|
|
|
<div>
|
|
<label class="form-label">Notes (optional)</label>
|
|
{{ form.notes }}
|
|
</div>
|
|
|
|
<div class="d-flex justify-content-end gap-2 mt-3">
|
|
<a href="{% url 'home' %}" class="btn btn-outline-secondary">Cancel</a>
|
|
<button type="submit" class="btn btn-accent">
|
|
<i class="fas fa-save me-1"></i> Log Absences
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<script>
|
|
// === Worker search filter ===
|
|
document.getElementById('workerSearch').addEventListener('input', function() {
|
|
const q = this.value.toLowerCase();
|
|
document.querySelectorAll('.worker-row').forEach(row => {
|
|
const name = row.dataset.name || '';
|
|
row.style.display = name.includes(q) ? '' : 'none';
|
|
});
|
|
});
|
|
</script>
|
|
{% endblock %}
|
|
```
|
|
|
|
Create `core/templates/core/absences/log_confirm.html`:
|
|
|
|
```html
|
|
{% extends 'base.html' %}
|
|
{% load static %}
|
|
|
|
{% block title %}Confirm Absences | FoxFitt{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container py-4">
|
|
<h1 class="page-title mb-3">
|
|
<i class="fas fa-exclamation-triangle me-2" style="color: var(--accent);"></i>
|
|
Confirm Absences
|
|
</h1>
|
|
|
|
<div class="alert alert-warning">
|
|
<strong>{{ conflicts|length }} worker(s) already have work logs on these dates.</strong><br>
|
|
Tick the boxes below to also remove them from those work logs (recommended if you're correcting a mistake).
|
|
</div>
|
|
|
|
<form method="post" class="card">
|
|
{% csrf_token %}
|
|
<div class="card-body">
|
|
<h6 class="text-uppercase mb-3" style="font-size: 0.75rem; color: var(--text-secondary);">
|
|
Conflicts
|
|
</h6>
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Worker</th>
|
|
<th>Date</th>
|
|
<th>WorkLog</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for c in conflicts %}
|
|
<tr>
|
|
<td>{{ c.worker_name }}</td>
|
|
<td>{{ c.date }}</td>
|
|
<td>{{ c.project_name }} (WorkLog #{{ c.work_log_id }})</td>
|
|
<td>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox"
|
|
name="remove_from_worklog_{{ c.work_log_id }}_{{ c.worker_id }}"
|
|
id="remove_{{ forloop.counter }}">
|
|
<label class="form-check-label" for="remove_{{ forloop.counter }}">
|
|
Also remove from WorkLog
|
|
</label>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
|
|
<hr>
|
|
<p class="mb-2"><strong>{{ absence_count }} absence(s) will be created:</strong></p>
|
|
<p>Reason: <strong>{{ reason }}</strong>. Paid: <strong>{% if is_paid %}Yes{% else %}No{% endif %}</strong>.</p>
|
|
|
|
<div class="d-flex justify-content-end gap-2 mt-3">
|
|
<a href="{% url 'absence_log' %}" class="btn btn-outline-secondary">← Back to form</a>
|
|
<button type="submit" class="btn btn-accent">
|
|
<i class="fas fa-check me-1"></i> Confirm & Create Absences
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
{% endblock %}
|
|
```
|
|
|
|
### Step 5 — Run tests, confirm they pass
|
|
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceLogViewTests core.tests.AbsenceConfirmViewTests -v 2
|
|
```
|
|
Expected: 9 tests pass.
|
|
|
|
Full suite:
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
|
|
```
|
|
Expected: ~109 tests OK.
|
|
|
|
### Step 6 — Commit
|
|
|
|
```bash
|
|
git add core/views.py core/urls.py core/templates/core/absences/ core/tests.py
|
|
git commit -m "feat(absences): log + confirm views + templates + URLs
|
|
|
|
/absences/log/ accepts form; no-conflict path creates absences
|
|
atomically; conflict path stashes pending data in session and
|
|
redirects to /absences/log/confirm/ (yellow warning + per-row
|
|
'Remove from WorkLog' checkboxes). Confirm POST runs atomic
|
|
transaction: remove flagged workers from WorkLogs, create
|
|
Absences, sync payroll adjustments. 9 tests."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5 — List + Edit + Delete + CSV export
|
|
|
|
**Goal:** Full CRUD on absences. `/absences/` shows filtered list with pagination + reason badges; `/absences/<id>/edit/` updates one absence (syncs adjustment); delete is POST-only with cascade. `/absences/export/` is admin-only CSV download.
|
|
|
|
**Files:**
|
|
- Modify: `core/views.py` — replace stub `absence_list`, add `absence_edit`, `absence_delete`, `absence_export_csv`
|
|
- Modify: `core/urls.py` — 3 more URL patterns
|
|
- Create: `core/templates/core/absences/list.html`
|
|
- Create: `core/templates/core/absences/edit.html`
|
|
- Modify: `core/tests.py` — `AbsenceListViewTests`, `AbsenceEditDeleteTests`, `AbsenceExportCSVTests`
|
|
|
|
### Step 1 — Write failing tests
|
|
|
|
```python
|
|
class AbsenceListViewTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True)
|
|
cls.sup = User.objects.create_user(username='sup', password='pw')
|
|
cls.worker_a = Worker.objects.create(name='WA', id_number='1', monthly_salary=Decimal('6000'))
|
|
cls.worker_b = Worker.objects.create(name='WB', id_number='2', monthly_salary=Decimal('6000'))
|
|
cls.team_a = Team.objects.create(name='TA', supervisor=cls.sup)
|
|
cls.team_a.workers.add(cls.worker_a)
|
|
Absence.objects.create(worker=cls.worker_a, date=_date(2026, 5, 1), reason='sick')
|
|
Absence.objects.create(worker=cls.worker_b, date=_date(2026, 5, 1), reason='annual')
|
|
|
|
def test_admin_sees_all(self):
|
|
self.client.force_login(self.admin)
|
|
resp = self.client.get('/absences/')
|
|
self.assertContains(resp, 'WA')
|
|
self.assertContains(resp, 'WB')
|
|
|
|
def test_supervisor_only_sees_own(self):
|
|
self.client.force_login(self.sup)
|
|
resp = self.client.get('/absences/')
|
|
self.assertContains(resp, 'WA')
|
|
self.assertNotContains(resp, 'WB')
|
|
|
|
def test_filter_by_reason(self):
|
|
self.client.force_login(self.admin)
|
|
resp = self.client.get('/absences/?reason=sick')
|
|
self.assertContains(resp, 'WA')
|
|
self.assertNotContains(resp, 'WB')
|
|
|
|
|
|
class AbsenceEditDeleteTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True)
|
|
cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000'))
|
|
cls.absence = Absence.objects.create(worker=cls.worker, date=_date(2026, 5, 14), reason='sick')
|
|
|
|
def setUp(self):
|
|
self.client.force_login(self.admin)
|
|
|
|
def test_edit_toggling_paid_creates_adjustment(self):
|
|
resp = self.client.post(f'/absences/{self.absence.id}/edit/', data={
|
|
'worker': self.worker.id,
|
|
'date': '2026-05-14',
|
|
'reason': 'sick',
|
|
'is_paid': 'on',
|
|
'notes': '',
|
|
})
|
|
self.absence.refresh_from_db()
|
|
self.assertTrue(self.absence.is_paid)
|
|
self.assertIsNotNone(self.absence.payroll_adjustment)
|
|
|
|
def test_edit_untoggling_paid_deletes_adjustment(self):
|
|
self.absence.is_paid = True
|
|
self.absence.save()
|
|
from core.views import _sync_absence_payroll_adjustment
|
|
_sync_absence_payroll_adjustment(self.absence)
|
|
self.absence.refresh_from_db()
|
|
adj_id = self.absence.payroll_adjustment.id
|
|
|
|
self.client.post(f'/absences/{self.absence.id}/edit/', data={
|
|
'worker': self.worker.id,
|
|
'date': '2026-05-14',
|
|
'reason': 'sick',
|
|
'notes': '',
|
|
# is_paid not in POST → unchecked
|
|
})
|
|
self.assertFalse(PayrollAdjustment.objects.filter(id=adj_id).exists())
|
|
|
|
def test_delete_cascade_unpaid_adjustment(self):
|
|
self.absence.is_paid = True
|
|
self.absence.save()
|
|
from core.views import _sync_absence_payroll_adjustment
|
|
_sync_absence_payroll_adjustment(self.absence)
|
|
adj_id = self.absence.payroll_adjustment.id
|
|
|
|
resp = self.client.post(f'/absences/{self.absence.id}/delete/')
|
|
self.assertFalse(Absence.objects.filter(id=self.absence.id).exists())
|
|
self.assertFalse(PayrollAdjustment.objects.filter(id=adj_id).exists())
|
|
|
|
def test_delete_refuses_when_adjustment_paid(self):
|
|
self.absence.is_paid = True
|
|
self.absence.save()
|
|
from core.views import _sync_absence_payroll_adjustment
|
|
_sync_absence_payroll_adjustment(self.absence)
|
|
self.absence.refresh_from_db()
|
|
# Mark the adjustment as paid
|
|
pr = PayrollRecord.objects.create(worker=self.worker, amount_paid=Decimal('300'), date=_date(2026, 5, 30))
|
|
self.absence.payroll_adjustment.payroll_record = pr
|
|
self.absence.payroll_adjustment.save()
|
|
|
|
resp = self.client.post(f'/absences/{self.absence.id}/delete/')
|
|
self.assertTrue(Absence.objects.filter(id=self.absence.id).exists()) # NOT deleted
|
|
|
|
def test_supervisor_cannot_edit_other_team_absence(self):
|
|
sup = User.objects.create_user(username='sup', password='pw')
|
|
# sup doesn't supervise the team that worker is on
|
|
self.client.force_login(sup)
|
|
resp = self.client.get(f'/absences/{self.absence.id}/edit/')
|
|
self.assertEqual(resp.status_code, 404)
|
|
|
|
|
|
class AbsenceExportCSVTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True)
|
|
cls.sup = User.objects.create_user(username='sup', password='pw')
|
|
cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000'))
|
|
Absence.objects.create(worker=cls.worker, date=_date(2026, 5, 14), reason='sick')
|
|
|
|
def test_admin_can_export(self):
|
|
self.client.force_login(self.admin)
|
|
resp = self.client.get('/absences/export/')
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertEqual(resp['Content-Type'], 'text/csv')
|
|
self.assertIn(b'W,', resp.content)
|
|
self.assertIn(b'Sick', resp.content)
|
|
|
|
def test_supervisor_forbidden(self):
|
|
self.client.force_login(self.sup)
|
|
resp = self.client.get('/absences/export/')
|
|
self.assertEqual(resp.status_code, 403)
|
|
```
|
|
|
|
### Step 2 — Implement views
|
|
|
|
In `core/views.py`, replace the stub `absence_list` and add the other views:
|
|
|
|
```python
|
|
@login_required
|
|
def absence_list(request):
|
|
"""Filtered list of absences with pagination + reason badges.
|
|
|
|
URL params: worker, team, project, reason, date_from, date_to, paid.
|
|
Permissions: admin sees all, supervisor sees scoped, others forbidden.
|
|
"""
|
|
user = request.user
|
|
if not (is_admin(user) or user.supervised_teams.exists()):
|
|
return HttpResponseForbidden('Permission denied.')
|
|
|
|
qs = _absence_user_queryset(user).select_related(
|
|
'worker', 'logged_by', 'payroll_adjustment'
|
|
)
|
|
|
|
# === Filters ===
|
|
worker_id = request.GET.get('worker')
|
|
team_id = request.GET.get('team')
|
|
project_id = request.GET.get('project')
|
|
reason = request.GET.get('reason')
|
|
date_from = request.GET.get('date_from')
|
|
date_to = request.GET.get('date_to')
|
|
paid = request.GET.get('paid')
|
|
|
|
if worker_id and worker_id.isdigit():
|
|
qs = qs.filter(worker_id=worker_id)
|
|
if team_id and team_id.isdigit():
|
|
qs = qs.filter(worker__teams__id=team_id).distinct()
|
|
if project_id and project_id.isdigit():
|
|
qs = qs.filter(worker__work_logs__project_id=project_id).distinct()
|
|
if reason and reason in dict(Absence.REASON_CHOICES):
|
|
qs = qs.filter(reason=reason)
|
|
if date_from:
|
|
try:
|
|
qs = qs.filter(date__gte=date_from)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
if date_to:
|
|
try:
|
|
qs = qs.filter(date__lte=date_to)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
if paid == 'paid':
|
|
qs = qs.filter(is_paid=True)
|
|
elif paid == 'unpaid':
|
|
qs = qs.filter(is_paid=False)
|
|
|
|
# === Pagination ===
|
|
paginator = Paginator(qs, 25)
|
|
page = paginator.get_page(request.GET.get('page'))
|
|
|
|
# === Dropdown options ===
|
|
if is_admin(user):
|
|
workers_qs = Worker.objects.filter(active=True).order_by('name')
|
|
teams_qs = Team.objects.filter(active=True).order_by('name')
|
|
projects_qs = Project.objects.filter(active=True).order_by('name')
|
|
else:
|
|
workers_qs = Worker.objects.filter(active=True, teams__supervisor=user).distinct().order_by('name')
|
|
teams_qs = Team.objects.filter(active=True, supervisor=user).order_by('name')
|
|
projects_qs = Project.objects.filter(supervisors=user, active=True).order_by('name')
|
|
|
|
return render(request, 'core/absences/list.html', {
|
|
'page': page,
|
|
'reason_choices': Absence.REASON_CHOICES,
|
|
'workers_qs': workers_qs,
|
|
'teams_qs': teams_qs,
|
|
'projects_qs': projects_qs,
|
|
'filter_worker': worker_id or '',
|
|
'filter_team': team_id or '',
|
|
'filter_project': project_id or '',
|
|
'filter_reason': reason or '',
|
|
'filter_date_from': date_from or '',
|
|
'filter_date_to': date_to or '',
|
|
'filter_paid': paid or '',
|
|
'is_admin': is_admin(user),
|
|
})
|
|
|
|
|
|
@login_required
|
|
def absence_edit(request, absence_id):
|
|
"""Edit one absence. is_paid changes propagate to PayrollAdjustment."""
|
|
user = request.user
|
|
qs = _absence_user_queryset(user)
|
|
absence = get_object_or_404(qs, id=absence_id)
|
|
|
|
if request.method == 'POST':
|
|
form = AbsenceEditForm(request.POST, instance=absence)
|
|
if form.is_valid():
|
|
try:
|
|
with transaction.atomic():
|
|
form.save()
|
|
_sync_absence_payroll_adjustment(absence)
|
|
messages.success(request, 'Absence updated.')
|
|
return redirect('absence_list')
|
|
except ValueError as e:
|
|
messages.error(request, str(e))
|
|
else:
|
|
form = AbsenceEditForm(instance=absence)
|
|
|
|
return render(request, 'core/absences/edit.html', {
|
|
'form': form,
|
|
'absence': absence,
|
|
})
|
|
|
|
|
|
@login_required
|
|
def absence_delete(request, absence_id):
|
|
"""POST-only. Delete absence + cascade unpaid PayrollAdjustment.
|
|
Refuses if adjustment is already paid."""
|
|
if request.method != 'POST':
|
|
return redirect('absence_list')
|
|
user = request.user
|
|
qs = _absence_user_queryset(user)
|
|
absence = get_object_or_404(qs, id=absence_id)
|
|
adj = absence.payroll_adjustment
|
|
if adj and adj.payroll_record_id is not None:
|
|
messages.error(request, 'Cannot delete: the linked payroll adjustment has already been paid.')
|
|
return redirect('absence_list')
|
|
with transaction.atomic():
|
|
if adj:
|
|
adj.delete()
|
|
absence.delete()
|
|
messages.success(request, 'Absence deleted.')
|
|
return redirect('absence_list')
|
|
|
|
|
|
@login_required
|
|
def absence_export_csv(request):
|
|
"""Admin-only CSV download. Same filters as list view."""
|
|
if not is_admin(request.user):
|
|
return HttpResponseForbidden('Admin access required.')
|
|
qs = _absence_user_queryset(request.user).select_related('worker', 'logged_by')
|
|
|
|
# Apply same filters as list view (DRY: extract helper if this grows)
|
|
if r := request.GET.get('reason'):
|
|
if r in dict(Absence.REASON_CHOICES):
|
|
qs = qs.filter(reason=r)
|
|
if df := request.GET.get('date_from'):
|
|
try: qs = qs.filter(date__gte=df)
|
|
except: pass
|
|
if dt := request.GET.get('date_to'):
|
|
try: qs = qs.filter(date__lte=dt)
|
|
except: pass
|
|
|
|
import csv
|
|
from django.http import HttpResponse
|
|
resp = HttpResponse(content_type='text/csv')
|
|
resp['Content-Disposition'] = 'attachment; filename="absences.csv"'
|
|
writer = csv.writer(resp)
|
|
writer.writerow(['Worker', 'Date', 'Reason', 'Paid', 'Notes', 'Logged By'])
|
|
for a in qs:
|
|
writer.writerow([
|
|
a.worker.name,
|
|
a.date.isoformat(),
|
|
a.get_reason_display(),
|
|
'Yes' if a.is_paid else 'No',
|
|
a.notes,
|
|
a.logged_by.username if a.logged_by else '',
|
|
])
|
|
return resp
|
|
```
|
|
|
|
Update `core/urls.py`:
|
|
```python
|
|
path('absences/', views.absence_list, name='absence_list'),
|
|
path('absences/<int:absence_id>/edit/', views.absence_edit, name='absence_edit'),
|
|
path('absences/<int:absence_id>/delete/', views.absence_delete, name='absence_delete'),
|
|
path('absences/export/', views.absence_export_csv, name='absence_export_csv'),
|
|
```
|
|
|
|
### Step 3 — Create templates
|
|
|
|
Create `core/templates/core/absences/list.html`:
|
|
|
|
```html
|
|
{% extends 'base.html' %}
|
|
{% load static format_tags %}
|
|
|
|
{% block title %}Absences | FoxFitt{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid py-3">
|
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
|
<div>
|
|
<h1 class="page-title mb-0"><i class="fas fa-user-clock me-2" style="color: var(--accent);"></i>Absences</h1>
|
|
<small class="text-muted">{{ page.paginator.count }} record(s)</small>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<a href="{% url 'absence_log' %}" class="btn btn-accent btn-sm">
|
|
<i class="fas fa-plus me-1"></i> Log Absence
|
|
</a>
|
|
{% if is_admin %}
|
|
<a href="{% url 'absence_export_csv' %}?{{ request.GET.urlencode }}" class="btn btn-outline-secondary btn-sm">
|
|
<i class="fas fa-download me-1"></i> CSV
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{% if messages %}
|
|
{% for m in messages %}<div class="alert alert-{{ m.tags }}">{{ m }}</div>{% endfor %}
|
|
{% endif %}
|
|
|
|
{# === Filter bar === #}
|
|
<form method="get" class="card mb-3">
|
|
<div class="card-body p-2 d-flex flex-wrap gap-2 align-items-end">
|
|
<div>
|
|
<label class="form-label small mb-0">Worker</label>
|
|
<select name="worker" class="form-select form-select-sm" style="min-width: 160px;">
|
|
<option value="">All</option>
|
|
{% for w in workers_qs %}
|
|
<option value="{{ w.id }}" {% if filter_worker == w.id|stringformat:"s" %}selected{% endif %}>{{ w.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label small mb-0">Team</label>
|
|
<select name="team" class="form-select form-select-sm" style="min-width: 140px;">
|
|
<option value="">All</option>
|
|
{% for t in teams_qs %}
|
|
<option value="{{ t.id }}" {% if filter_team == t.id|stringformat:"s" %}selected{% endif %}>{{ t.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label small mb-0">Project</label>
|
|
<select name="project" class="form-select form-select-sm" style="min-width: 140px;">
|
|
<option value="">All</option>
|
|
{% for p in projects_qs %}
|
|
<option value="{{ p.id }}" {% if filter_project == p.id|stringformat:"s" %}selected{% endif %}>{{ p.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label small mb-0">Reason</label>
|
|
<select name="reason" class="form-select form-select-sm">
|
|
<option value="">All</option>
|
|
{% for key, label in reason_choices %}
|
|
<option value="{{ key }}" {% if filter_reason == key %}selected{% endif %}>{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label small mb-0">From</label>
|
|
<input type="date" name="date_from" value="{{ filter_date_from }}" class="form-control form-control-sm">
|
|
</div>
|
|
<div>
|
|
<label class="form-label small mb-0">To</label>
|
|
<input type="date" name="date_to" value="{{ filter_date_to }}" class="form-control form-control-sm">
|
|
</div>
|
|
<div>
|
|
<label class="form-label small mb-0">Paid?</label>
|
|
<select name="paid" class="form-select form-select-sm">
|
|
<option value="">All</option>
|
|
<option value="paid" {% if filter_paid == 'paid' %}selected{% endif %}>Paid</option>
|
|
<option value="unpaid" {% if filter_paid == 'unpaid' %}selected{% endif %}>Unpaid</option>
|
|
</select>
|
|
</div>
|
|
<button type="submit" class="btn btn-outline-primary btn-sm">Apply</button>
|
|
<a href="{% url 'absence_list' %}" class="btn btn-outline-secondary btn-sm">Clear</a>
|
|
</div>
|
|
</form>
|
|
|
|
{# === Table === #}
|
|
<div class="card">
|
|
<div class="card-body p-0">
|
|
<table class="table table-sm mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Worker</th>
|
|
<th>Reason</th>
|
|
<th>Paid?</th>
|
|
<th>Logged by</th>
|
|
<th>Notes</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for a in page %}
|
|
<tr>
|
|
<td>{{ a.date|date:"d M Y" }}</td>
|
|
<td>{{ a.worker.name }}</td>
|
|
<td><span class="badge badge-absence-{{ a.reason }}">{{ a.get_reason_display }}</span></td>
|
|
<td>
|
|
{% if a.is_paid %}
|
|
<i class="fas fa-check-circle" style="color: var(--badge-bonus-bg);"></i>
|
|
{% if a.payroll_adjustment %}<small class="text-muted">({{ a.payroll_adjustment.amount|money }})</small>{% endif %}
|
|
{% else %}
|
|
<i class="far fa-circle text-muted"></i>
|
|
{% endif %}
|
|
</td>
|
|
<td>{{ a.logged_by.username|default:"—" }}</td>
|
|
<td class="text-muted">{{ a.notes|truncatechars:60 }}</td>
|
|
<td>
|
|
<a href="{% url 'absence_edit' a.id %}" class="btn btn-sm btn-outline-secondary"><i class="fas fa-pen"></i></a>
|
|
<form method="post" action="{% url 'absence_delete' a.id %}" style="display: inline;" onsubmit="return confirm('Delete this absence?');">
|
|
{% csrf_token %}
|
|
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i></button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{% empty %}
|
|
<tr><td colspan="7" class="text-center text-muted py-4">No absences match the filters.</td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{# === Pagination === #}
|
|
{% if page.has_other_pages %}
|
|
<nav class="mt-3">
|
|
<ul class="pagination pagination-sm justify-content-center">
|
|
{% if page.has_previous %}
|
|
<li class="page-item"><a class="page-link" href="?{% url_replace 'page' page.previous_page_number %}">Previous</a></li>
|
|
{% endif %}
|
|
<li class="page-item disabled"><span class="page-link">Page {{ page.number }} of {{ page.paginator.num_pages }}</span></li>
|
|
{% if page.has_next %}
|
|
<li class="page-item"><a class="page-link" href="?{% url_replace 'page' page.next_page_number %}">Next</a></li>
|
|
{% endif %}
|
|
</ul>
|
|
</nav>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<style>
|
|
/* === Reason badges — reuse existing semantic palette === */
|
|
.badge-absence-sick { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
|
|
.badge-absence-family { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
|
|
.badge-absence-annual { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
|
|
.badge-absence-unpaid { background: var(--badge-neutral-bg, #6c757d); color: #fff; }
|
|
.badge-absence-iod { background: var(--badge-overtime-bg, #ffc107); color: #000; }
|
|
.badge-absence-suspension { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); }
|
|
.badge-absence-absconded { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); }
|
|
.badge-absence-other { background: var(--badge-neutral-bg, #6c757d); color: #fff; }
|
|
</style>
|
|
{% endblock %}
|
|
```
|
|
|
|
Create `core/templates/core/absences/edit.html` (similar shape to log.html but for a single record).
|
|
|
|
```html
|
|
{% extends 'base.html' %}
|
|
{% block title %}Edit Absence | FoxFitt{% endblock %}
|
|
{% block content %}
|
|
<div class="container py-4">
|
|
<h1 class="page-title mb-3"><i class="fas fa-pen me-2"></i>Edit Absence</h1>
|
|
|
|
{% if messages %}{% for m in messages %}<div class="alert alert-{{ m.tags }}">{{ m }}</div>{% endfor %}{% endif %}
|
|
|
|
<form method="post" class="card">
|
|
{% csrf_token %}
|
|
<div class="card-body p-3 p-md-4">
|
|
<div class="row g-3">
|
|
<div class="col-12 col-md-6">
|
|
<label class="form-label">Worker</label>{{ form.worker }}{{ form.worker.errors }}
|
|
</div>
|
|
<div class="col-12 col-md-6">
|
|
<label class="form-label">Date</label>{{ form.date }}{{ form.date.errors }}
|
|
</div>
|
|
<div class="col-12 col-md-6">
|
|
<label class="form-label">Reason</label>{{ form.reason }}
|
|
</div>
|
|
<div class="col-12 col-md-6 d-flex align-items-end">
|
|
<div class="form-check">
|
|
{{ form.is_paid }}<label class="form-check-label">Paid at daily rate</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label">Notes</label>{{ form.notes }}
|
|
</div>
|
|
</div>
|
|
{% if form.non_field_errors %}<div class="alert alert-danger mt-3">{{ form.non_field_errors }}</div>{% endif %}
|
|
<div class="d-flex justify-content-between mt-3">
|
|
<form method="post" action="{% url 'absence_delete' absence.id %}" onsubmit="return confirm('Delete?');">
|
|
{% csrf_token %}
|
|
<button type="submit" class="btn btn-outline-danger"><i class="fas fa-trash me-1"></i>Delete</button>
|
|
</form>
|
|
<div>
|
|
<a href="{% url 'absence_list' %}" class="btn btn-outline-secondary">Cancel</a>
|
|
<button type="submit" class="btn btn-accent">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
{% endblock %}
|
|
```
|
|
|
|
### Step 4 — Run tests, confirm they pass
|
|
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceListViewTests core.tests.AbsenceEditDeleteTests core.tests.AbsenceExportCSVTests -v 2
|
|
```
|
|
Expected: 9 tests pass.
|
|
|
|
Full suite:
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
|
|
```
|
|
Expected: ~118 tests OK.
|
|
|
|
### Step 5 — Commit
|
|
|
|
```bash
|
|
git add core/views.py core/urls.py core/templates/core/absences/ core/tests.py
|
|
git commit -m "feat(absences): list + edit + delete + CSV export
|
|
|
|
/absences/ filtered list with pagination + reason badges;
|
|
/absences/<id>/edit/ syncs adjustment on save; /absences/<id>/delete/
|
|
cascades unpaid adjustment, refuses if paid; /absences/export/
|
|
admin-only CSV. 9 tests."
|
|
```
|
|
|
|
---
|
|
|
|
## 🚦 CHECKPOINT — pause for Konrad to demo
|
|
|
|
After Task 5, the feature is **demo-able end-to-end** for the primary
|
|
flow. Stop here, push the 5 commits, and wait for Konrad to:
|
|
|
|
1. Run `python manage.py migrate` locally.
|
|
2. Open `/absences/log/`, log a few absences (single date + range).
|
|
3. Trigger a conflict (log absence for a date that has WorkLogs), confirm
|
|
the warning page appears + the "Remove from WorkLog" checkbox works.
|
|
4. Browse `/absences/`, try each filter.
|
|
5. Edit an absence, toggle Paid on → check `/payroll/?status=adjustments`
|
|
for the new Bonus row.
|
|
6. Delete an absence.
|
|
7. Click CSV export.
|
|
|
|
**If everything works → proceed to Task 6.**
|
|
**If something feels off → fix before adding more polish.**
|
|
|
|
Quick smoke-test command (skip database setup; verify URLs resolve):
|
|
```
|
|
USE_SQLITE=true python manage.py check
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6 — Quick-action modal on attendance form
|
|
|
|
**Goal:** ✗ button next to each worker on `/attendance/log/` opens a modal that creates an Absence for that worker on the attendance date.
|
|
|
|
**Files:**
|
|
- Modify: `core/views.py` — `mark_absent_quick`
|
|
- Modify: `core/urls.py` — 1 URL pattern
|
|
- Create: `core/templates/core/absences/_quick_modal.html`
|
|
- Modify: `core/templates/core/attendance_log.html` — ✗ button + modal include + JS
|
|
- Modify: `core/tests.py` — `MarkAbsentQuickViewTests`
|
|
|
|
### Step 1 — Write failing tests
|
|
|
|
```python
|
|
class MarkAbsentQuickViewTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True)
|
|
cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000'))
|
|
|
|
def setUp(self):
|
|
self.client.force_login(self.admin)
|
|
|
|
def test_post_creates_absence(self):
|
|
resp = self.client.post('/absences/quick/', data={
|
|
'worker_id': self.worker.id,
|
|
'date': '2026-05-14',
|
|
'reason': 'sick',
|
|
'notes': 'flu',
|
|
})
|
|
self.assertEqual(Absence.objects.count(), 1)
|
|
a = Absence.objects.first()
|
|
self.assertEqual(a.worker, self.worker)
|
|
self.assertEqual(a.date, _date(2026, 5, 14))
|
|
self.assertEqual(a.reason, 'sick')
|
|
|
|
def test_post_missing_worker_400s(self):
|
|
resp = self.client.post('/absences/quick/', data={
|
|
'date': '2026-05-14',
|
|
'reason': 'sick',
|
|
})
|
|
self.assertEqual(resp.status_code, 400)
|
|
```
|
|
|
|
### Step 2 — Implement view
|
|
|
|
```python
|
|
@login_required
|
|
def mark_absent_quick(request):
|
|
"""POST-only. The ✗ Mark Absent button on /attendance/log/.
|
|
Creates ONE Absence for the given worker on the given date."""
|
|
if request.method != 'POST':
|
|
return HttpResponseBadRequest('POST only.')
|
|
if not _user_can_log_absences(request.user):
|
|
return HttpResponseForbidden('Permission denied.')
|
|
worker_id = request.POST.get('worker_id')
|
|
date_str = request.POST.get('date')
|
|
reason = request.POST.get('reason')
|
|
if not (worker_id and date_str and reason):
|
|
return HttpResponseBadRequest('Missing required field.')
|
|
worker = get_object_or_404(Worker, id=worker_id)
|
|
from datetime import date as _d
|
|
try:
|
|
d = _d.fromisoformat(date_str)
|
|
except ValueError:
|
|
return HttpResponseBadRequest('Invalid date.')
|
|
is_paid = bool(request.POST.get('is_paid'))
|
|
notes = request.POST.get('notes', '')
|
|
if Absence.objects.filter(worker=worker, date=d).exists():
|
|
messages.error(request, f'{worker.name} already has an absence on {d:%d %b %Y}.')
|
|
return redirect('attendance_log')
|
|
with transaction.atomic():
|
|
a = Absence.objects.create(
|
|
worker=worker, date=d, reason=reason,
|
|
is_paid=is_paid, notes=notes, logged_by=request.user,
|
|
)
|
|
_sync_absence_payroll_adjustment(a)
|
|
messages.success(request, f'Marked {worker.name} absent on {d:%d %b %Y}.')
|
|
return redirect('attendance_log')
|
|
```
|
|
|
|
Add URL:
|
|
```python
|
|
path('absences/quick/', views.mark_absent_quick, name='mark_absent_quick'),
|
|
```
|
|
|
|
### Step 3 — Create modal partial + tweak attendance template
|
|
|
|
Create `core/templates/core/absences/_quick_modal.html`:
|
|
|
|
```html
|
|
<div class="modal fade" id="markAbsentModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<form method="post" action="{% url 'mark_absent_quick' %}">
|
|
{% csrf_token %}
|
|
<input type="hidden" name="worker_id" id="absentWorkerId">
|
|
<input type="hidden" name="date" id="absentDate">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Mark <span id="absentWorkerName"></span> Absent</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Reason</label>
|
|
<select name="reason" class="form-select" required>
|
|
<option value="">— Select —</option>
|
|
<option value="sick">Sick</option>
|
|
<option value="family">Family Responsibility</option>
|
|
<option value="annual">Annual Leave</option>
|
|
<option value="unpaid">Personal / Unpaid Leave</option>
|
|
<option value="iod">Injury on Duty</option>
|
|
<option value="suspension">Suspension</option>
|
|
<option value="absconded">Absconded</option>
|
|
<option value="other">Other</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-check mb-3">
|
|
<input type="checkbox" name="is_paid" class="form-check-input" id="absentPaid">
|
|
<label class="form-check-label" for="absentPaid">Paid at daily rate</label>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">Notes (optional)</label>
|
|
<textarea name="notes" class="form-control" rows="2"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-accent">Save Absence</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
Modify `core/templates/core/attendance_log.html`:
|
|
|
|
1. Locate the worker checkbox list (around line 138).
|
|
2. Next to each worker, add the ✗ button.
|
|
3. At the bottom of the file (before `{% endblock %}`), add the include + JS:
|
|
|
|
```html
|
|
{% include 'core/absences/_quick_modal.html' %}
|
|
|
|
<script>
|
|
// === Mark Absent quick-action ===
|
|
document.querySelectorAll('.btn-mark-absent').forEach(btn => {
|
|
btn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
document.getElementById('absentWorkerId').value = this.dataset.workerId;
|
|
document.getElementById('absentWorkerName').textContent = this.dataset.workerName;
|
|
const dateInput = document.querySelector('input[name="date"]');
|
|
document.getElementById('absentDate').value = dateInput ? dateInput.value : '';
|
|
new bootstrap.Modal(document.getElementById('markAbsentModal')).show();
|
|
});
|
|
});
|
|
</script>
|
|
```
|
|
|
|
The button per worker row:
|
|
```html
|
|
<button type="button" class="btn btn-sm btn-outline-danger ms-2 btn-mark-absent"
|
|
data-worker-id="{{ worker.choice_value }}"
|
|
data-worker-name="{{ worker.choice_label }}"
|
|
title="Mark absent">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
```
|
|
|
|
### Step 4 — Run tests, confirm they pass
|
|
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.MarkAbsentQuickViewTests -v 2
|
|
```
|
|
Expected: 2 tests pass.
|
|
|
|
### Step 5 — Commit
|
|
|
|
```bash
|
|
git add core/views.py core/urls.py core/templates/core/absences/_quick_modal.html core/templates/core/attendance_log.html core/tests.py
|
|
git commit -m "feat(absences): quick-action modal on attendance form
|
|
|
|
✗ Mark Absent button next to each worker on /attendance/log/.
|
|
Click opens modal pre-filled with worker + date; admin picks reason
|
|
+ optional paid + notes. POST to /absences/quick/. 2 tests."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7 — Worker-detail tab + dashboard alert card + CLAUDE.md
|
|
|
|
**Goal:** Polish — Absences tab on `/workers/<id>/` with YTD totals; conditional "X absent in last 7 days" alert card on admin dashboard; CLAUDE.md updated.
|
|
|
|
**Files:**
|
|
- Modify: `core/views.py` — `worker_detail` + `index` view context
|
|
- Modify: `core/templates/core/workers/detail.html` — Absences tab
|
|
- Modify: `core/templates/core/index.html` — alert card
|
|
- Modify: `core/tests.py` — `AbsenceYTDPanelTests`
|
|
- Modify: `CLAUDE.md` — Absence section
|
|
|
|
### Step 1 — Write failing tests
|
|
|
|
```python
|
|
class AbsenceYTDPanelTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True)
|
|
cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000'))
|
|
# Current year + last year
|
|
current_year = timezone.now().year
|
|
Absence.objects.create(worker=cls.worker, date=_date(current_year, 1, 5), reason='sick')
|
|
Absence.objects.create(worker=cls.worker, date=_date(current_year, 2, 10), reason='sick')
|
|
Absence.objects.create(worker=cls.worker, date=_date(current_year, 3, 1), reason='annual')
|
|
Absence.objects.create(worker=cls.worker, date=_date(current_year - 1, 5, 1), reason='sick')
|
|
|
|
def test_worker_detail_ytd_totals(self):
|
|
self.client.force_login(self.admin)
|
|
resp = self.client.get(f'/workers/{self.worker.id}/')
|
|
ytd = resp.context['absence_ytd_totals']
|
|
self.assertEqual(ytd.get('sick'), 2)
|
|
self.assertEqual(ytd.get('annual'), 1)
|
|
|
|
def test_dashboard_recent_count_7_days(self):
|
|
# Wipe + recreate to control dates
|
|
Absence.objects.all().delete()
|
|
today = timezone.now().date()
|
|
from datetime import timedelta
|
|
Absence.objects.create(worker=self.worker, date=today, reason='sick')
|
|
Absence.objects.create(worker=self.worker, date=today - timedelta(days=3), reason='annual')
|
|
Absence.objects.create(worker=self.worker, date=today - timedelta(days=10), reason='other') # outside 7-day window
|
|
self.client.force_login(self.admin)
|
|
resp = self.client.get('/')
|
|
self.assertEqual(resp.context['absences_recent_count'], 2)
|
|
|
|
def test_dashboard_card_hidden_when_zero(self):
|
|
Absence.objects.all().delete()
|
|
self.client.force_login(self.admin)
|
|
resp = self.client.get('/')
|
|
self.assertEqual(resp.context['absences_recent_count'], 0)
|
|
```
|
|
|
|
### Step 2 — Implement view updates
|
|
|
|
In `core/views.py`, modify `worker_detail`:
|
|
|
|
```python
|
|
# === ABSENCE YTD TOTALS ===
|
|
# Year-to-date counts per reason for this worker. Used by the
|
|
# Absences tab on the worker detail page.
|
|
from django.db.models import Count
|
|
ytd = (
|
|
worker.absences
|
|
.filter(date__year=timezone.now().year)
|
|
.values('reason')
|
|
.annotate(total=Count('id'))
|
|
)
|
|
absence_ytd_totals = {row['reason']: row['total'] for row in ytd}
|
|
```
|
|
|
|
Then add to the template context:
|
|
```python
|
|
'absence_ytd_totals': absence_ytd_totals,
|
|
'worker_absences': worker.absences.all()[:50], # most recent 50
|
|
```
|
|
|
|
Modify `index` view:
|
|
|
|
```python
|
|
# === ABSENCES IN LAST 7 DAYS (admin dashboard card) ===
|
|
if is_admin(request.user):
|
|
from datetime import timedelta
|
|
seven_days_ago = timezone.now().date() - timedelta(days=7)
|
|
absences_recent_count = Absence.objects.filter(date__gte=seven_days_ago).count()
|
|
else:
|
|
absences_recent_count = 0
|
|
```
|
|
|
|
Add to context: `'absences_recent_count': absences_recent_count`.
|
|
|
|
### Step 3 — Modify templates
|
|
|
|
In `core/templates/core/workers/detail.html`, add a tab after the Warnings tab:
|
|
|
|
```html
|
|
<li class="nav-item">
|
|
<a class="nav-link" id="absences-tab" data-bs-toggle="tab" href="#absences">
|
|
<i class="fas fa-user-clock me-1"></i>Absences
|
|
</a>
|
|
</li>
|
|
```
|
|
|
|
And tab pane:
|
|
```html
|
|
<div class="tab-pane fade" id="absences">
|
|
<div class="d-flex flex-wrap gap-2 mb-3">
|
|
{% for key, label in absence_reason_choices %}
|
|
{% if absence_ytd_totals|dictlookup:key %}
|
|
<span class="badge badge-absence-{{ key }}">
|
|
{{ label }}: {{ absence_ytd_totals|dictlookup:key }}
|
|
</span>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
<table class="table table-sm">
|
|
<thead><tr><th>Date</th><th>Reason</th><th>Paid?</th><th>Notes</th></tr></thead>
|
|
<tbody>
|
|
{% for a in worker_absences %}
|
|
<tr>
|
|
<td>{{ a.date|date:"d M Y" }}</td>
|
|
<td><span class="badge badge-absence-{{ a.reason }}">{{ a.get_reason_display }}</span></td>
|
|
<td>{% if a.is_paid %}✓{% else %}—{% endif %}</td>
|
|
<td class="text-muted">{{ a.notes|truncatechars:60 }}</td>
|
|
</tr>
|
|
{% empty %}
|
|
<tr><td colspan="4" class="text-center text-muted py-3">No absences recorded.</td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
```
|
|
|
|
Add `Absence.REASON_CHOICES` to the worker_detail context too: `'absence_reason_choices': Absence.REASON_CHOICES`.
|
|
|
|
In `core/templates/core/index.html` (admin section), find an appropriate stat-card row and add:
|
|
|
|
```html
|
|
{% if absences_recent_count > 0 %}
|
|
<div class="col-md-3 col-sm-6 mb-3">
|
|
<a href="{% url 'absence_list' %}?date_from={{ seven_days_ago|date:'Y-m-d' }}" class="text-decoration-none">
|
|
<div class="card stat-card" style="border-left: 4px solid var(--badge-deduction-bg);">
|
|
<div class="card-body p-3">
|
|
<div style="font-size: 1.5rem; font-weight: 700;">{{ absences_recent_count }}</div>
|
|
<div class="text-muted">absent in last 7 days</div>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
{% endif %}
|
|
```
|
|
|
|
Add `seven_days_ago` to index context: `'seven_days_ago': timezone.now().date() - timedelta(days=7)`.
|
|
|
|
### Step 4 — Update CLAUDE.md
|
|
|
|
Insert after the `SiteReport` model entry in **Key Models**:
|
|
|
|
```markdown
|
|
- **Absence** — per-worker dated record of a day not worked. 8 reason choices (Sick, Family Responsibility, Annual Leave, Personal/Unpaid Leave, Injury on Duty, Suspension, Absconded, Other). `is_paid` boolean (default False) — when ticked, the save flow auto-creates a Bonus PayrollAdjustment linked via `payroll_adjustment` OneToOneField. Unique per (worker, date) at DB layer. Permission scoping: admin (all) or supervisor (workers in their teams).
|
|
```
|
|
|
|
Append URL routes table:
|
|
```markdown
|
|
| `/absences/log/` | `absence_log` | Admin/supervisor: log absences (date range, multi-worker) |
|
|
| `/absences/log/confirm/` | `absence_log_confirm` | Yellow conflict-warning page; per-row Remove-from-WorkLog checkboxes |
|
|
| `/absences/` | `absence_list` | Filtered list with pagination |
|
|
| `/absences/<id>/edit/` | `absence_edit` | Edit one absence; syncs PayrollAdjustment |
|
|
| `/absences/<id>/delete/` | `absence_delete` | POST-only; cascades unpaid adjustment |
|
|
| `/absences/export/` | `absence_export_csv` | Admin-only CSV |
|
|
| `/absences/quick/` | `mark_absent_quick` | POST from ✗ Mark Absent on /attendance/log/ |
|
|
```
|
|
|
|
Add a new section after "SiteReport metric schema":
|
|
|
|
```markdown
|
|
## Absence-to-PayrollAdjustment cascade (May 2026)
|
|
|
|
`Absence.is_paid=True` auto-creates a Bonus PayrollAdjustment at
|
|
`worker.daily_rate`, linked via the OneToOneField. Logic lives in
|
|
`_sync_absence_payroll_adjustment(absence)` in `core/views.py` — called
|
|
from `absence_log`, `absence_log_confirm`, `absence_edit`, and
|
|
`mark_absent_quick` save paths.
|
|
|
|
Edit flows:
|
|
- Toggle is_paid True → False → adjustment is deleted (refuses if
|
|
adjustment is already paid, surfaces error to admin).
|
|
- Toggle is_paid False → True → fresh adjustment created.
|
|
- Edit an already-paid absence in any other way (date / reason / notes) →
|
|
adjustment is NOT updated (admin may have edited the amount manually).
|
|
|
|
Delete cascade: deleting an Absence with a linked unpaid adjustment
|
|
cascades to delete the adjustment. If the adjustment is already paid,
|
|
delete is refused (surfaces error).
|
|
```
|
|
|
|
### Step 5 — Run tests, confirm they pass
|
|
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AbsenceYTDPanelTests -v 2
|
|
```
|
|
Expected: 3 tests pass.
|
|
|
|
Full suite:
|
|
```
|
|
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
|
|
```
|
|
Expected: ~123 tests OK.
|
|
|
|
### Step 6 — Commit + push everything
|
|
|
|
```bash
|
|
git add core/views.py core/templates/core/workers/detail.html core/templates/core/index.html core/tests.py CLAUDE.md
|
|
git commit -m "feat(absences): worker-detail tab + dashboard alert + CLAUDE.md
|
|
|
|
Absences tab on /workers/<id>/ with YTD totals chip row + table.
|
|
Conditional 'X absent in last 7 days' alert card on admin dashboard
|
|
(only renders when count > 0). CLAUDE.md model summary + URL routes
|
|
+ cascade-pattern section. 3 tests."
|
|
|
|
git push origin ai-dev
|
|
```
|
|
|
|
---
|
|
|
|
## Final verification
|
|
|
|
After Task 7:
|
|
1. Full test suite: 85 baseline + ~38 new = ~123 tests, all passing.
|
|
2. Manual smoke test:
|
|
- `/absences/log/` → log single + range absences
|
|
- Trigger conflict → confirm page → tick removal → confirm → land on list
|
|
- `/absences/` → all filters work
|
|
- Edit absence → toggle paid → check `/payroll/?status=adjustments`
|
|
- Toggle paid off → adjustment gone
|
|
- Delete absence → cascade works (or refused if paid)
|
|
- `/workers/<id>/` → Absences tab visible, YTD chips correct
|
|
- `/` (admin home) → alert card visible if absences in last 7 days
|
|
- `/absences/export/` → CSV downloads
|
|
- `/attendance/log/` → ✗ button next to worker → modal → submit → absence created
|
|
3. Push: `git push origin ai-dev` (all 7 task commits + design doc + plan doc + previous parked-work commit).
|
|
4. Don't deploy to Flatlogic yet — Konrad explicitly held off on the SiteReport deploy too. Both features ship together when ready.
|
|
|
|
---
|
|
|
|
## Notes for the implementer
|
|
|
|
- **DRY filter logic** between `absence_list` and `absence_export_csv` — if the filters expand much, refactor into a helper `_apply_absence_filters(qs, request)`. YAGNI for v1.
|
|
- **Atomic transactions everywhere** — every save path uses `with transaction.atomic():`. If any one record fails, all roll back.
|
|
- **No new dependencies** — pure Django + the existing template tags.
|
|
- **Reason badge palette** — uses existing `--badge-*-bg` tokens from `static/css/custom.css`. No new CSS variables added.
|
|
- **No `--no-verify` on commits** — let pre-commit hooks run.
|
|
- **Don't push to master** — work happens on `ai-dev`, master gets merged later.
|
|
- **Test naming convention** — `Test{Component}{Behaviour}` per existing pattern in `core/tests.py`.
|
|
- **Permission queryset path** — `worker__teams__supervisor=user` is the simplified version (verified against `core/models.py` line 110-111 during plan writing). If you find a case where supervisors should see project-supervised workers too, broaden with `Q(worker__teams__supervisor=user) | Q(worker__work_logs__project__supervisors=user)` — but flag this with Konrad before adding.
|
|
|
|
## Risks flagged during design
|
|
|
|
1. **`OneToOneField` SET_NULL drift.** If admin deletes a PayrollAdjustment directly via `/payroll/?status=adjustments`, the Absence's `payroll_adjustment` FK goes NULL but `is_paid` stays True. Inconsistent. **Recommendation:** add a `post_delete` signal on PayrollAdjustment that sets `Absence.is_paid=False` when the linked adjustment is deleted. NOT in this plan's scope — add as a follow-up if Konrad hits the case.
|
|
2. **Atomic transaction failure during confirm POST.** If WorkLog removal succeeds but Absence creation fails, the WorkLog change rolls back too (atomic). Verify with an explicit test if behaviour matters.
|
|
3. **Pagination on `/absences/export/` CSV.** Currently exports the full filtered queryset, not paginated. Acceptable for v1 (at FoxFitt's scale, ~hundreds of rows max).
|