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.
This commit is contained in:
Konrad du Plessis 2026-05-14 20:14:19 +02:00
parent 8f2d3e9dfe
commit b5833f675d
5 changed files with 611 additions and 0 deletions

View File

@ -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 %}
<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">
{# === DATE RANGE === #}
<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">
{# === REASON + PAID FLAG === #}
<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">
{# === TEAM FILTER + WORKER PICKER === #}
<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">
{# === FREE-FORM NOTES === #}
<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 ===
// Quick client-side filter for the worker checkbox list — types into the
// search box → only matching rows stay visible. Match is case-insensitive
// against the worker's display name (stored in data-name on the row).
(function() {
var searchInput = document.getElementById('workerSearch');
if (!searchInput) return;
searchInput.addEventListener('input', function() {
var q = this.value.toLowerCase();
document.querySelectorAll('.worker-row').forEach(function(row) {
var name = row.dataset.name || '';
row.style.display = name.indexOf(q) > -1 ? '' : 'none';
});
});
})();
</script>
{% endblock %}

View File

@ -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 %}
<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">&larr; Back to form</a>
<button type="submit" class="btn btn-accent">
<i class="fas fa-check me-1"></i> Confirm &amp; Create Absences
</button>
</div>
</div>
</form>
</div>
{% endblock %}

View File

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

View File

@ -110,6 +110,17 @@ urlpatterns = [
path('projects/<int:project_id>/', views.project_detail, name='project_detail'),
path('projects/<int:project_id>/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'),

View File

@ -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):
' <a href="/" style="margin-left: 10px;">Cancel</a></p>'
'</form></body></html>'
)
# =============================================================================
# === 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_<wl_id>_<worker_id>'. 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', '<wl_id>', '<worker_id>']
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.')