From b5833f675d7e1a325b4b01c5152256024225a37a Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Thu, 14 May 2026 20:14:19 +0200 Subject: [PATCH] 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. 10 tests. --- core/templates/core/absences/log.html | 151 ++++++++++++++ core/templates/core/absences/log_confirm.html | 76 +++++++ core/tests.py | 176 ++++++++++++++++ core/urls.py | 11 + core/views.py | 197 ++++++++++++++++++ 5 files changed, 611 insertions(+) create mode 100644 core/templates/core/absences/log.html create mode 100644 core/templates/core/absences/log_confirm.html diff --git a/core/templates/core/absences/log.html b/core/templates/core/absences/log.html new file mode 100644 index 0000000..3f26837 --- /dev/null +++ b/core/templates/core/absences/log.html @@ -0,0 +1,151 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Log Worker Absences | FoxFitt{% endblock %} + +{% block content %} +{% comment %} +Standalone absence-logging form. Date range with optional Sat/Sun +inclusion (mirror of /attendance/log/). After successful submit +either redirects to /absences/ (no conflicts) or to +/absences/log/confirm/ (one or more workers were on a WorkLog for +one of the selected dates — admin chooses whether to also remove +them from the WorkLog). +{% endcomment %} + +
+
+
+

+ + Log Absences +

+ Record workers who were not on site today. +
+ + View All + +
+ + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} + +
+ {% csrf_token %} +
+ + {# === DATE RANGE === #} +
+
+ + {{ form.date }} + {{ form.date.errors }} +
+
+ + {{ form.end_date }} + {{ form.end_date.help_text }} + {{ form.end_date.errors }} +
+
+
+ {{ form.include_saturday }} + +
+
+ {{ form.include_sunday }} + +
+
+
+ +
+ + {# === REASON + PAID FLAG === #} +
+
+ + {{ form.reason }} + {{ form.reason.errors }} +
+
+
+ {{ form.is_paid }} + +
Creates a Bonus payroll adjustment when ticked.
+
+
+
+ +
+ + {# === TEAM FILTER + WORKER PICKER === #} +
+
+ + {{ form.team }} +
+
+ + +
+ {% for worker in form.workers %} +
+ {{ worker.tag }} + +
+ {% endfor %} +
+ {{ form.workers.errors }} +
+
+ +
+ + {# === FREE-FORM NOTES === #} +
+ + {{ form.notes }} +
+ +
+ Cancel + +
+
+
+
+ + +{% endblock %} diff --git a/core/templates/core/absences/log_confirm.html b/core/templates/core/absences/log_confirm.html new file mode 100644 index 0000000..8b412a5 --- /dev/null +++ b/core/templates/core/absences/log_confirm.html @@ -0,0 +1,76 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Confirm Absences | FoxFitt{% endblock %} + +{% block content %} +{% comment %} +Conflict-confirmation page. Shown when at least one (worker, date) +pair on the absence-log form already has a WorkLog. The admin can +tick "Also remove from WorkLog" per conflict before committing — +useful when correcting an earlier mistake (worker was clocked in +but in fact was absent that day). Untouched rows keep their +WorkLog intact, so partial-day cases work too. +{% endcomment %} + +
+

+ + Confirm Absences +

+ +
+ {{ conflicts|length }} worker(s) already have work logs on these dates.
+ Tick the boxes below to also remove them from those work logs (recommended if you're correcting a mistake). +
+ +
+ {% csrf_token %} +
+
+ Conflicts +
+ + + + + + + + + + + {% for c in conflicts %} + + + + + + + {% endfor %} + +
WorkerDateWorkLogAction
{{ c.worker_name }}{{ c.date }}{{ c.project_name }} (WorkLog #{{ c.work_log_id }}) +
+ + +
+
+ +
+

{{ absence_count }} absence(s) will be created:

+

Reason: {{ reason }}. Paid: {% if is_paid %}Yes{% else %}No{% endif %}.

+ +
+ ← Back to form + +
+
+
+
+{% endblock %} diff --git a/core/tests.py b/core/tests.py index c86044b..4a4a211 100644 --- a/core/tests.py +++ b/core/tests.py @@ -2064,3 +2064,179 @@ class AbsenceFormTests(TestCase): # Error must be on the 'date' field, not non-field self.assertIn('date', form.errors) self.assertIn('already', str(form.errors['date']).lower()) + + +# === ABSENCE LOG + CONFIRM VIEW TESTS (Task 4) === +# GET shows the form. POST without conflicts creates Absence rows +# immediately. POST with conflicts stashes pending data in the session +# and redirects to /absences/log/confirm/ where the admin can opt to +# also remove the conflicting WorkLog entries before committing. + +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') # 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()) + + def test_post_drops_unauthorized_removal_keys(self): + """SECURITY: a POST key referencing a WorkLog that wasn't in the + stashed conflicts list must be silently ignored. The confirm page + is a strict accept-or-reject gate against what was shown.""" + # Create an UNRELATED WorkLog with a worker on it + other_worker = Worker.objects.create( + name='Other Worker', id_number='99', monthly_salary=Decimal('6000'), + ) + other_wl = WorkLog.objects.create( + date=_date(2026, 5, 14), project=self.project, supervisor=self.admin, + ) + other_wl.workers.add(other_worker) + + # Confirm POST tries to remove other_worker from other_wl + # (this pair is NOT in the stashed conflicts — only (self.wl.id, self.worker.id) is) + resp = self.client.post('/absences/log/confirm/', data={ + f'remove_from_worklog_{other_wl.id}_{other_worker.id}': 'on', + }) + + # The unauthorized removal must NOT have happened + self.assertIn(other_worker, other_wl.workers.all()) + # The legitimate absence creation still happens + self.assertEqual(Absence.objects.count(), 1) + self.assertRedirects(resp, '/absences/', fetch_redirect_response=False) + + def test_post_with_malformed_removal_key_is_dropped(self): + """Malformed POST keys like remove_from_worklog_abc_5 must not crash + the view — they're silently skipped.""" + resp = self.client.post('/absences/log/confirm/', data={ + 'remove_from_worklog_abc_5': 'on', # non-numeric wl_id + 'remove_from_worklog_42_xyz': 'on', # non-numeric worker_id + 'remove_from_worklog_only_three': 'on', # not enough parts + }) + self.assertEqual(Absence.objects.count(), 1) # Still creates absence + self.assertEqual(resp.status_code, 302) # No 500 diff --git a/core/urls.py b/core/urls.py index c3246ff..b38bf47 100644 --- a/core/urls.py +++ b/core/urls.py @@ -110,6 +110,17 @@ urlpatterns = [ path('projects//', views.project_detail, name='project_detail'), path('projects//edit/', views.project_edit, name='project_edit'), + # === ABSENCES === + # Standalone absence logging — admins + supervisors mark workers + # absent for a date or date range. Conflict flow: if any selected + # (worker, date) already has a WorkLog, the form redirects to + # /absences/log/confirm/ where the admin can optionally remove the + # worker from those WorkLogs before creating the Absence rows. + path('absences/log/', views.absence_log, name='absence_log'), + path('absences/log/confirm/', views.absence_log_confirm, name='absence_log_confirm'), + # Placeholder list page — Task 5 will implement the full list/filter UI. + path('absences/', views.absence_list, name='absence_list'), + # === EXPENSE RECEIPTS === # Create a new expense receipt — emails HTML + PDF to Spark Receipt path('receipts/create/', views.create_receipt, name='create_receipt'), diff --git a/core/views.py b/core/views.py index 9ab5583..a4f4f3b 100644 --- a/core/views.py +++ b/core/views.py @@ -35,6 +35,7 @@ from .forms import ( WorkerForm, WorkerCertificateFormSet, WorkerWarningFormSet, TeamForm, ProjectForm, SiteReportForm, + AbsenceLogForm, ) from .site_report_schema import COUNT_METRICS, CHECK_METRICS, label_for # NOTE: render_to_pdf is NOT imported here at the top level. @@ -5180,3 +5181,199 @@ def restore_data(request): ' Cancel

' '' ) + + +# ============================================================================= +# === ABSENCE VIEWS — LOG + CONFIRM === +# Two-step flow for the standalone /absences/log/ form. +# +# Step 1 (absence_log): GET shows the form. POST validates with +# AbsenceLogForm. If no (worker, date) pair in the submission collides +# with an existing WorkLog, the absences are created atomically and the +# user is redirected to the list page. If there ARE conflicts, the +# cleaned form data + the conflict list get stashed in +# request.session['absence_pending'] and the user is redirected to the +# confirm page (Step 2) where they can opt-in to also remove the +# affected workers from their WorkLogs before the Absence rows commit. +# +# Step 2 (absence_log_confirm): GET shows the warning + per-conflict +# "Also remove from WorkLog" checkboxes. POST runs the full atomic +# write (WorkLog removals + Absence creates + payroll-adjustment sync) +# and clears the session blob. +# +# The helper `_create_absences_atomic` is the single chokepoint for +# both paths — it always wraps everything in transaction.atomic() so a +# crash mid-write rolls back every change. +# ============================================================================= + + +def _user_can_log_absences(user): + """Admin OR supervisor (anyone supervising at least one team). + + Same gate logic as /attendance/log/ — keeps the two flows symmetric: + if you can log work for a worker, you can also log that the worker + was absent.""" + 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. We serialize dates as + # ISO strings so the session JSON encoder doesn't choke on + # date objects, and pairs as [worker_id, iso_date_string]. + 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 (via the _create_absences_atomic helper). + + If the session is empty (e.g. user hit Back then refreshed) we + redirect back to /absences/log/ rather than rendering an empty + confirm screen.""" + 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 from the session blob (worker_id + iso date string). + # If a worker was deleted between form submit and confirm POST, skip + # that row silently rather than 500-ing on Worker.DoesNotExist. + pairs = [] + for wid, ds in pending['pairs']: + try: + worker = Worker.objects.get(id=wid) + except Worker.DoesNotExist: + continue # silently skip rows for workers deleted mid-flow + pairs.append((worker, _d.fromisoformat(ds))) + + if not pairs: + # Everything stashed was for deleted workers — nothing to do. + request.session.pop('absence_pending', None) + messages.error(request, 'None of the originally-selected workers still exist. Please start over.') + return redirect('absence_log') + + # Parse worklog-removal opt-ins from POST. The confirm template + # emits one checkbox per conflict row with name + # 'remove_from_worklog__'. Anything ticked makes + # it into the POST dict; unticked boxes are absent. + # + # SECURITY: defense against an admin/supervisor injecting hidden form + # fields for WorkLogs they didn't see in the conflict list. The + # confirm page is a strict accept-or-reject gate against the + # session-stashed conflicts — anything else gets silently dropped. + allowed = { + (c['work_log_id'], c['worker_id']) + for c in pending['conflicts'] + } + removals = [] + for key in request.POST: + if key.startswith('remove_from_worklog_'): + # Format split: ['remove', 'from', 'worklog', '', ''] + parts = key.split('_') + if len(parts) == 5: + try: + pair = (int(parts[3]), int(parts[4])) + if pair in allowed: + removals.append(pair) + # else: silently drop — out-of-band injection attempt + except ValueError: + # Malformed key — skip silently rather than 500 + pass + _create_absences_atomic( + pairs=pairs, + reason=pending['reason'], + is_paid=pending['is_paid'], + notes=pending['notes'], + user=request.user, + worklog_removals=removals, + ) + # Clear the session blob now that the write succeeded — refreshing + # the list page should not re-trigger anything. + request.session.pop('absence_pending', None) + messages.success(request, f'{len(pairs)} absence(s) logged.') + return redirect('absence_list') + + # GET — render warning page using the stashed conflict list. + 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 via the existing helper. + + The whole thing runs in a single transaction.atomic() block — if any + step fails the entire batch rolls back, so we never end up with + half-committed state (e.g. workers removed from WorkLogs but no + absences created, or absences created without their adjustments).""" + with transaction.atomic(): + # Step 1: detach flagged workers from their conflicting WorkLogs. + # If a WorkLog was deleted between form submit and confirm POST, + # skip it silently rather than 500-ing on DoesNotExist. + for wl_id, w_id in worklog_removals: + try: + wl = WorkLog.objects.get(id=wl_id) + except WorkLog.DoesNotExist: + continue # silently skip — log was deleted between form submit and confirm + wl.workers.remove(w_id) + # Step 2 + 3: create each Absence and (if is_paid) its Bonus + # adjustment via the existing _sync_absence_payroll_adjustment. + 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) + + +@login_required +def absence_list(request): + """Stub — full implementation in Task 5 (list + filters + edit links).""" + return HttpResponse('Absence list — Task 5 will implement.')