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. 10 tests.
This commit is contained in:
Konrad du Plessis 2026-05-14 20:39:14 +02:00
parent b5833f675d
commit 37268801a1
5 changed files with 708 additions and 3 deletions

View File

@ -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 <form> tags. The delete
action needs its own form to POST to /absences/<id>/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.
#}
<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 %}
{# 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. #}
<form method="post" action="{% url 'absence_delete' absence.id %}"
onsubmit="return confirm('Delete this absence?');"
id="absence-delete-form" style="display: none;">
{% csrf_token %}
</form>
<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" for="{{ form.worker.id_for_label }}">Worker</label>
{{ form.worker }}
{{ form.worker.errors }}
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="{{ form.date.id_for_label }}">Date</label>
{{ form.date }}
{{ form.date.errors }}
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="{{ form.reason.id_for_label }}">Reason</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>
</div>
<div class="col-12">
<label class="form-label" for="{{ form.notes.id_for_label }}">Notes</label>
{{ form.notes }}
{{ form.notes.errors }}
</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="absence-delete-form"` makes this button submit the
hidden delete form rather than its enclosing edit form. #}
<button type="submit" form="absence-delete-form" class="btn btn-outline-danger">
<i class="fas fa-trash me-1"></i>Delete
</button>
<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 %}

View File

@ -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.
#}
<div class="container-fluid py-3">
{# === Page header — title + log/export action buttons === #}
<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{{ page.paginator.count|pluralize }}</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>
{# === Flash messages (e.g. "Absence deleted") === #}
{% if messages %}
{% for m in messages %}<div class="alert alert-{{ m.tags }}">{{ m }}</div>{% endfor %}
{% endif %}
{# === Filter bar — 7 fields stacked on mobile, wrapped on desktop === #}
<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 of absences === #}
<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" data-bs-toggle="tooltip" title="Edit">
<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" data-bs-toggle="tooltip" title="Delete">
<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 — only shown when there's more than one page === #}
{% 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="?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="?page={{ page.next_page_number }}">Next</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{# === 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).
#}
<style>
.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: var(--badge-overtime-fg, #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 %}

View File

@ -2240,3 +2240,184 @@ class AbsenceConfirmViewTests(TestCase):
}) })
self.assertEqual(Absence.objects.count(), 1) # Still creates absence self.assertEqual(Absence.objects.count(), 1) # Still creates absence
self.assertEqual(resp.status_code, 302) # No 500 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 <option>s, but only WA should have a table data cell.
self.assertContains(resp, '<td>WA</td>', html=False)
self.assertNotContains(resp, '<td>WB</td>', html=False)
def test_malformed_date_param_does_not_crash(self):
"""SECURITY: garbage in URL params must not 500. parse_date()
returns None on invalid input those filters get skipped.
"""
self.client.force_login(self.admin)
resp = self.client.get('/absences/?date_from=not-a-date&date_to=also-bad')
self.assertEqual(resp.status_code, 200)
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)
self.absence.refresh_from_db()
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)
def test_edit_refuses_to_untoggle_paid_adjustment(self):
"""SECURITY/CORRECTNESS: untoggling is_paid on an already-paid
absence must roll back atomically. The absence stays is_paid=True,
the adjustment stays linked, and an error is surfaced."""
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
# Simulate adjustment already paid
pr = PayrollRecord.objects.create(
worker=self.worker, amount_paid=Decimal('300'), date=_date(2026, 5, 30),
)
adj = self.absence.payroll_adjustment
adj.payroll_record = pr
adj.save()
# Admin tries to untick is_paid
resp = 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 → form sees unchecked
})
# Rollback verified:
self.absence.refresh_from_db()
self.assertTrue(self.absence.is_paid)
self.assertEqual(self.absence.payroll_adjustment_id, adj_id)
self.assertTrue(PayrollAdjustment.objects.filter(id=adj_id).exists())
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)

View File

@ -118,8 +118,12 @@ urlpatterns = [
# worker from those WorkLogs before creating the Absence rows. # worker from those WorkLogs before creating the Absence rows.
path('absences/log/', views.absence_log, name='absence_log'), path('absences/log/', views.absence_log, name='absence_log'),
path('absences/log/confirm/', views.absence_log_confirm, name='absence_log_confirm'), path('absences/log/confirm/', views.absence_log_confirm, name='absence_log_confirm'),
# Placeholder list page — Task 5 will implement the full list/filter UI. # Browse + manage. Admin-only export. The edit/delete pages reuse
# _absence_user_queryset so supervisor scope applies consistently.
path('absences/', views.absence_list, name='absence_list'), path('absences/', views.absence_list, name='absence_list'),
path('absences/export/', views.absence_export_csv, name='absence_export_csv'),
path('absences/<int:absence_id>/edit/', views.absence_edit, name='absence_edit'),
path('absences/<int:absence_id>/delete/', views.absence_delete, name='absence_delete'),
# === EXPENSE RECEIPTS === # === EXPENSE RECEIPTS ===
# Create a new expense receipt — emails HTML + PDF to Spark Receipt # Create a new expense receipt — emails HTML + PDF to Spark Receipt

View File

@ -10,6 +10,7 @@ from decimal import Decimal
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.utils import timezone from django.utils import timezone
from django.utils.dateparse import parse_date
from django.db import transaction from django.db import transaction
from django.db.models import Sum, Count, Q, F, Prefetch, Max, Min from django.db.models import Sum, Count, Q, F, Prefetch, Max, Min
from django.db.models.functions import Coalesce, TruncMonth from django.db.models.functions import Coalesce, TruncMonth
@ -5375,5 +5376,255 @@ def _create_absences_atomic(pairs, reason, is_paid, notes, user, worklog_removal
@login_required @login_required
def absence_list(request): def absence_list(request):
"""Stub — full implementation in Task 5 (list + filters + edit links).""" """Filtered list of absences with pagination + reason badges.
return HttpResponse('Absence list — Task 5 will implement.')
URL params: worker, team, project, reason, date_from, date_to, paid.
Permissions: admin sees all rows; a supervisor (someone with at
least one supervised team) sees only absences for workers on those
teams; anyone else is forbidden.
"""
# === LATE IMPORT — Paginator is only used in a handful of views ===
from django.core.paginator import Paginator
user = request.user
# === ACCESS GATE ===
# Admins always pass. Supervisors pass if they supervise at least
# one team. Everyone else gets a 403 instead of an empty list, so
# it's obvious the page wasn't meant for them.
if not (is_admin(user) or user.supervised_teams.exists()):
return HttpResponseForbidden('Permission denied.')
# Base queryset — scoped to what this user is allowed to see, with
# select_related on the FKs we render in the table to avoid N+1s.
qs = _absence_user_queryset(user).select_related(
'worker', 'logged_by', 'payroll_adjustment'
)
# === FILTERS ===
# Each filter is best-effort: bad input (non-numeric IDs, bad
# date strings, unknown reason keys) is silently ignored rather
# than 500-ing. Empty values are skipped.
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)
# parse_date() returns None for malformed input (e.g. "not-a-date")
# so the filter is simply skipped. Without this guard, Django's
# date coercion raises ValidationError (NOT ValueError/TypeError)
# and the request 500s — a tiny URL-fuzzing footgun.
if date_from:
parsed = parse_date(date_from)
if parsed:
qs = qs.filter(date__gte=parsed)
if date_to:
parsed = parse_date(date_to)
if parsed:
qs = qs.filter(date__lte=parsed)
if paid == 'paid':
qs = qs.filter(is_paid=True)
elif paid == 'unpaid':
qs = qs.filter(is_paid=False)
# === PAGINATION ===
# 25 per page — keeps the table snappy even with years of history.
paginator = Paginator(qs, 25)
page = paginator.get_page(request.GET.get('page'))
# === FILTER DROPDOWN OPTIONS ===
# The Worker dropdown is intentionally NOT narrowed to "workers in
# the current filtered queryset" — that would create a dead-end UX
# where filtering by Worker=Bob hides every other worker, so the
# user can't pivot to Worker=Sue without first clearing the filter.
# Instead: admins see every active worker; supervisors see active
# workers on the teams they supervise.
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 record.
The is_paid checkbox is the magic one toggling it on creates a
Bonus PayrollAdjustment at the worker's daily rate (via the
`_sync_absence_payroll_adjustment` helper). Toggling it off
deletes the adjustment, UNLESS it's already been paid (then the
helper raises ValueError, which we surface as a form error).
Scoping: a supervisor who doesn't supervise the worker's team
gets a 404 (not a 403 same response as if the absence didn't
exist, so we don't leak info about other teams' absences).
"""
from .forms import AbsenceEditForm
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, user=user)
if form.is_valid():
try:
# Save the absence + the adjustment sync inside one
# transaction — if the sync raises (e.g. trying to
# untick is_paid on an already-paid adjustment), the
# absence change is rolled back too.
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, user=user)
return render(request, 'core/absences/edit.html', {
'form': form,
'absence': absence,
})
@login_required
def absence_delete(request, absence_id):
"""POST-only delete. Cascades to the linked PayrollAdjustment if
one exists AND has not been paid yet. Refuses with an error
message if the adjustment has already been paid (i.e. has a
linked PayrollRecord) destroying payroll history silently
would be much worse than failing loudly.
GET requests are redirected back to the list (no confirmation
page; the list-row delete button uses a JS confirm() prompt).
"""
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')
# Atomic — if absence.delete() fails after adj.delete(), both roll back.
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 of absences.
Honours ALL the same filters as the list view (worker / team /
project / reason / date_from / date_to / paid) so that the
"Export CSV" button on the list page produces exactly the rows
the user currently sees on screen no surprises. Supervisors
get a 403; sensitive payroll-adjacent data that admins curate.
"""
if not is_admin(request.user):
return HttpResponseForbidden('Admin access required.')
qs = _absence_user_queryset(request.user).select_related('worker', 'logged_by')
# ===========================================================
# FILTER BLOCK — DUPLICATED from absence_list above.
# Kept verbatim (same params, same order) so the CSV export
# honours the list page's filter URL exactly. If a future
# change adds a filter to the list view, mirror it here too.
# Follow-up TODO: factor into a `_apply_absence_filters(qs, request)`
# helper so the two views can't drift apart.
# ===========================================================
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:
parsed = parse_date(date_from)
if parsed:
qs = qs.filter(date__gte=parsed)
if date_to:
parsed = parse_date(date_to)
if parsed:
qs = qs.filter(date__lte=parsed)
if paid == 'paid':
qs = qs.filter(is_paid=True)
elif paid == 'unpaid':
qs = qs.filter(is_paid=False)
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