From 37268801a17f01dddebf1240a10710464dc83db4 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Thu, 14 May 2026 20:39:14 +0200 Subject: [PATCH] feat(absences): list + edit + delete + CSV export /absences/ filtered list with pagination + reason badges; /absences//edit/ syncs adjustment on save; /absences//delete/ cascades unpaid adjustment, refuses if paid; /absences/export/ admin-only CSV. 10 tests. --- core/templates/core/absences/edit.html | 87 +++++++++ core/templates/core/absences/list.html | 182 ++++++++++++++++++ core/tests.py | 181 ++++++++++++++++++ core/urls.py | 6 +- core/views.py | 255 ++++++++++++++++++++++++- 5 files changed, 708 insertions(+), 3 deletions(-) create mode 100644 core/templates/core/absences/edit.html create mode 100644 core/templates/core/absences/list.html diff --git a/core/templates/core/absences/edit.html b/core/templates/core/absences/edit.html new file mode 100644 index 0000000..5563834 --- /dev/null +++ b/core/templates/core/absences/edit.html @@ -0,0 +1,87 @@ +{% extends 'base.html' %} + +{% block title %}Edit Absence | FoxFitt{% endblock %} + +{% block content %} +{# === EDIT ABSENCE PAGE === + Single form for editing one Absence row. The is_paid checkbox is + the magic field — toggling it on creates a Bonus PayrollAdjustment + at the worker's daily rate; toggling it off deletes the adjustment + (UNLESS it's already been paid, in which case the view surfaces an + error). + + Why two forms? HTML doesn't allow nested
tags. The delete + action needs its own form to POST to /absences//delete/. So + the delete form lives OUTSIDE the edit form (hidden), and the + Delete button inside the edit form uses the HTML5 `form="..."` + attribute to submit the delete form instead of its parent edit + form. +#} +
+

Edit Absence

+ + {% if messages %} + {% for m in messages %}
{{ m }}
{% endfor %} + {% endif %} + + {# Hidden sibling form so the Delete button can submit to its own URL. + Hidden via inline style — only the button inside the edit form is visible. #} + + {% csrf_token %} + + +
+ {% csrf_token %} +
+
+
+ + {{ form.worker }} + {{ form.worker.errors }} +
+
+ + {{ form.date }} + {{ form.date.errors }} +
+
+ + {{ form.reason }} + {{ form.reason.errors }} +
+
+
+ {{ form.is_paid }} + +
+
+
+ + {{ form.notes }} + {{ form.notes.errors }} +
+
+ + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} + +
+ {# `form="absence-delete-form"` makes this button submit the + hidden delete form rather than its enclosing edit form. #} + +
+ Cancel + +
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/absences/list.html b/core/templates/core/absences/list.html new file mode 100644 index 0000000..c9b550d --- /dev/null +++ b/core/templates/core/absences/list.html @@ -0,0 +1,182 @@ +{% extends 'base.html' %} +{% load static format_tags %} + +{% block title %}Absences | FoxFitt{% endblock %} + +{% block content %} +{# === ABSENCES LIST PAGE === + Filtered, paginated table of absences. Each row links to edit and + has an inline delete form. CSV export button only shows for admin. +#} +
+ + {# === Page header — title + log/export action buttons === #} +
+
+

+ Absences +

+ {{ page.paginator.count }} record{{ page.paginator.count|pluralize }} +
+
+ + Log Absence + + {% if is_admin %} + + CSV + + {% endif %} +
+
+ + {# === Flash messages (e.g. "Absence deleted") === #} + {% if messages %} + {% for m in messages %}
{{ m }}
{% endfor %} + {% endif %} + + {# === Filter bar — 7 fields stacked on mobile, wrapped on desktop === #} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Clear +
+
+ + {# === Table of absences === #} +
+
+ + + + + + + + + + + + + + {% for a in page %} + + + + + + + + + + {% empty %} + + {% endfor %} + +
DateWorkerReasonPaid?Logged byNotes
{{ a.date|date:"d M Y" }}{{ a.worker.name }}{{ a.get_reason_display }} + {% if a.is_paid %} + + {% if a.payroll_adjustment %}({{ a.payroll_adjustment.amount|money }}){% endif %} + {% else %} + + {% endif %} + {{ a.logged_by.username|default:"—" }}{{ a.notes|truncatechars:60 }} + + + +
+ {% csrf_token %} + +
+
No absences match the filters.
+
+
+ + {# === Pagination — only shown when there's more than one page === #} + {% if page.has_other_pages %} + + {% endif %} +
+ +{# === Reason badge colours === + Reuses the existing semantic badge palette from custom.css so dark/ + light theme switching works out of the box. Green-ish for "valid" + leave (sick/family/annual), neutral for unpaid/other, amber for IOD, + purple-ish (deduction) for the disciplinary reasons (suspension, + absconded). +#} + +{% endblock %} diff --git a/core/tests.py b/core/tests.py index 4a4a211..f8f4933 100644 --- a/core/tests.py +++ b/core/tests.py @@ -2240,3 +2240,184 @@ class AbsenceConfirmViewTests(TestCase): }) self.assertEqual(Absence.objects.count(), 1) # Still creates absence self.assertEqual(resp.status_code, 302) # No 500 + + +# === ABSENCE LIST / EDIT / DELETE / EXPORT VIEW TESTS ============================ +# Covers Task 5 of the Worker Absences feature: browsing absences via /absences/ +# with filters and pagination; editing a single absence and the paid-flag side +# effect; deleting absences with cascade rules; admin CSV export. + +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): + """?reason=sick → table rows show only sick-absence workers. + (WB has annual leave, so WB's name should not appear in any + table row; but WB CAN appear in the worker filter dropdown — + that's the option list, which is intentionally NOT narrowed + by the current filter so users can pivot between filters.) + """ + self.client.force_login(self.admin) + resp = self.client.get('/absences/?reason=sick') + # Check the row content — both workers may appear in the filter + # dropdown