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:
parent
8f2d3e9dfe
commit
b5833f675d
151
core/templates/core/absences/log.html
Normal file
151
core/templates/core/absences/log.html
Normal 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 %}
|
||||||
76
core/templates/core/absences/log_confirm.html
Normal file
76
core/templates/core/absences/log_confirm.html
Normal 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">← Back to form</a>
|
||||||
|
<button type="submit" class="btn btn-accent">
|
||||||
|
<i class="fas fa-check me-1"></i> Confirm & Create Absences
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
176
core/tests.py
176
core/tests.py
@ -2064,3 +2064,179 @@ class AbsenceFormTests(TestCase):
|
|||||||
# Error must be on the 'date' field, not non-field
|
# Error must be on the 'date' field, not non-field
|
||||||
self.assertIn('date', form.errors)
|
self.assertIn('date', form.errors)
|
||||||
self.assertIn('already', str(form.errors['date']).lower())
|
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
|
||||||
|
|||||||
11
core/urls.py
11
core/urls.py
@ -110,6 +110,17 @@ urlpatterns = [
|
|||||||
path('projects/<int:project_id>/', views.project_detail, name='project_detail'),
|
path('projects/<int:project_id>/', views.project_detail, name='project_detail'),
|
||||||
path('projects/<int:project_id>/edit/', views.project_edit, name='project_edit'),
|
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 ===
|
# === EXPENSE RECEIPTS ===
|
||||||
# Create a new expense receipt — emails HTML + PDF to Spark Receipt
|
# Create a new expense receipt — emails HTML + PDF to Spark Receipt
|
||||||
path('receipts/create/', views.create_receipt, name='create_receipt'),
|
path('receipts/create/', views.create_receipt, name='create_receipt'),
|
||||||
|
|||||||
197
core/views.py
197
core/views.py
@ -35,6 +35,7 @@ from .forms import (
|
|||||||
WorkerForm, WorkerCertificateFormSet, WorkerWarningFormSet,
|
WorkerForm, WorkerCertificateFormSet, WorkerWarningFormSet,
|
||||||
TeamForm, ProjectForm,
|
TeamForm, ProjectForm,
|
||||||
SiteReportForm,
|
SiteReportForm,
|
||||||
|
AbsenceLogForm,
|
||||||
)
|
)
|
||||||
from .site_report_schema import COUNT_METRICS, CHECK_METRICS, label_for
|
from .site_report_schema import COUNT_METRICS, CHECK_METRICS, label_for
|
||||||
# NOTE: render_to_pdf is NOT imported here at the top level.
|
# 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>'
|
' <a href="/" style="margin-left: 10px;">Cancel</a></p>'
|
||||||
'</form></body></html>'
|
'</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.')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user