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(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.
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.
# 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/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 ===
# 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.utils import timezone
from django.utils.dateparse import parse_date
from django.db import transaction
from django.db.models import Sum, Count, Q, F, Prefetch, Max, Min
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
def absence_list(request):
"""Stub — full implementation in Task 5 (list + filters + edit links)."""
return HttpResponse('Absence list — Task 5 will implement.')
"""Filtered list of absences with pagination + reason badges.
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