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:
parent
b5833f675d
commit
37268801a1
87
core/templates/core/absences/edit.html
Normal file
87
core/templates/core/absences/edit.html
Normal 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 %}
|
||||
182
core/templates/core/absences/list.html
Normal file
182
core/templates/core/absences/list.html
Normal 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 %}
|
||||
181
core/tests.py
181
core/tests.py
@ -2240,3 +2240,184 @@ class AbsenceConfirmViewTests(TestCase):
|
||||
})
|
||||
self.assertEqual(Absence.objects.count(), 1) # Still creates absence
|
||||
self.assertEqual(resp.status_code, 302) # No 500
|
||||
|
||||
|
||||
# === ABSENCE LIST / EDIT / DELETE / EXPORT VIEW TESTS ============================
|
||||
# Covers Task 5 of the Worker Absences feature: browsing absences via /absences/
|
||||
# with filters and pagination; editing a single absence and the paid-flag side
|
||||
# effect; deleting absences with cascade rules; admin CSV export.
|
||||
|
||||
class AbsenceListViewTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True)
|
||||
cls.sup = User.objects.create_user(username='sup', password='pw')
|
||||
cls.worker_a = Worker.objects.create(name='WA', id_number='1', monthly_salary=Decimal('6000'))
|
||||
cls.worker_b = Worker.objects.create(name='WB', id_number='2', monthly_salary=Decimal('6000'))
|
||||
cls.team_a = Team.objects.create(name='TA', supervisor=cls.sup)
|
||||
cls.team_a.workers.add(cls.worker_a)
|
||||
Absence.objects.create(worker=cls.worker_a, date=_date(2026, 5, 1), reason='sick')
|
||||
Absence.objects.create(worker=cls.worker_b, date=_date(2026, 5, 1), reason='annual')
|
||||
|
||||
def test_admin_sees_all(self):
|
||||
self.client.force_login(self.admin)
|
||||
resp = self.client.get('/absences/')
|
||||
self.assertContains(resp, 'WA')
|
||||
self.assertContains(resp, 'WB')
|
||||
|
||||
def test_supervisor_only_sees_own(self):
|
||||
self.client.force_login(self.sup)
|
||||
resp = self.client.get('/absences/')
|
||||
self.assertContains(resp, 'WA')
|
||||
self.assertNotContains(resp, 'WB')
|
||||
|
||||
def test_filter_by_reason(self):
|
||||
"""?reason=sick → table rows show only sick-absence workers.
|
||||
(WB has annual leave, so WB's name should not appear in any
|
||||
table row; but WB CAN appear in the worker filter dropdown —
|
||||
that's the option list, which is intentionally NOT narrowed
|
||||
by the current filter so users can pivot between filters.)
|
||||
"""
|
||||
self.client.force_login(self.admin)
|
||||
resp = self.client.get('/absences/?reason=sick')
|
||||
# Check the row content — both workers may appear in the filter
|
||||
# dropdown <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)
|
||||
|
||||
@ -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
|
||||
|
||||
255
core/views.py
255
core/views.py
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user