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
|
||||
self.assertIn('date', form.errors)
|
||||
self.assertIn('already', str(form.errors['date']).lower())
|
||||
|
||||
|
||||
# === ABSENCE LOG + CONFIRM VIEW TESTS (Task 4) ===
|
||||
# GET shows the form. POST without conflicts creates Absence rows
|
||||
# immediately. POST with conflicts stashes pending data in the session
|
||||
# and redirects to /absences/log/confirm/ where the admin can opt to
|
||||
# also remove the conflicting WorkLog entries before committing.
|
||||
|
||||
class AbsenceLogViewTests(TestCase):
|
||||
"""GET shows form; POST without conflicts creates absences immediately;
|
||||
POST with conflicts stashes pending data in session + redirects to
|
||||
/absences/log/confirm/."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True)
|
||||
cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000'))
|
||||
cls.project = Project.objects.create(name='P')
|
||||
cls.team = Team.objects.create(name='T', supervisor=cls.admin)
|
||||
cls.team.workers.add(cls.worker)
|
||||
|
||||
def setUp(self):
|
||||
self.client.force_login(self.admin)
|
||||
|
||||
def test_get_returns_200(self):
|
||||
resp = self.client.get('/absences/log/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def test_post_creates_absences_when_no_conflict(self):
|
||||
resp = self.client.post('/absences/log/', data={
|
||||
'date': '2026-05-14',
|
||||
'reason': 'sick',
|
||||
'workers': [self.worker.id],
|
||||
'notes': 'flu',
|
||||
})
|
||||
self.assertEqual(Absence.objects.count(), 1)
|
||||
absence = Absence.objects.first()
|
||||
self.assertEqual(absence.worker, self.worker)
|
||||
self.assertEqual(absence.reason, 'sick')
|
||||
self.assertFalse(absence.is_paid)
|
||||
self.assertEqual(absence.logged_by, self.admin)
|
||||
self.assertRedirects(resp, '/absences/', fetch_redirect_response=False)
|
||||
|
||||
def test_post_with_paid_creates_adjustment(self):
|
||||
self.client.post('/absences/log/', data={
|
||||
'date': '2026-05-14',
|
||||
'reason': 'sick',
|
||||
'is_paid': 'on',
|
||||
'workers': [self.worker.id],
|
||||
})
|
||||
absence = Absence.objects.first()
|
||||
self.assertTrue(absence.is_paid)
|
||||
self.assertIsNotNone(absence.payroll_adjustment)
|
||||
self.assertEqual(absence.payroll_adjustment.type, 'Bonus')
|
||||
|
||||
def test_post_with_worklog_conflict_redirects_to_confirm(self):
|
||||
wl = WorkLog.objects.create(date=_date(2026, 5, 14), project=self.project, supervisor=self.admin)
|
||||
wl.workers.add(self.worker)
|
||||
resp = self.client.post('/absences/log/', data={
|
||||
'date': '2026-05-14',
|
||||
'reason': 'sick',
|
||||
'workers': [self.worker.id],
|
||||
})
|
||||
self.assertEqual(Absence.objects.count(), 0) # NOT created yet
|
||||
self.assertRedirects(resp, '/absences/log/confirm/', fetch_redirect_response=False)
|
||||
# Session has stashed pending data
|
||||
self.assertIn('absence_pending', self.client.session)
|
||||
|
||||
def test_supervisor_can_post(self):
|
||||
sup = User.objects.create_user(username='sup', password='pw')
|
||||
sup_team = Team.objects.create(name='ST', supervisor=sup)
|
||||
sup_team.workers.add(self.worker)
|
||||
self.client.force_login(sup)
|
||||
resp = self.client.post('/absences/log/', data={
|
||||
'date': '2026-05-14',
|
||||
'reason': 'sick',
|
||||
'workers': [self.worker.id],
|
||||
})
|
||||
self.assertEqual(Absence.objects.count(), 1)
|
||||
|
||||
def test_outsider_gets_403(self):
|
||||
outsider = User.objects.create_user(username='out', password='pw')
|
||||
self.client.force_login(outsider)
|
||||
resp = self.client.get('/absences/log/')
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
|
||||
|
||||
class AbsenceConfirmViewTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True)
|
||||
cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000'))
|
||||
cls.project = Project.objects.create(name='P')
|
||||
|
||||
def setUp(self):
|
||||
self.client.force_login(self.admin)
|
||||
# Pre-stash pending data + create the conflict WorkLog
|
||||
self.wl = WorkLog.objects.create(date=_date(2026, 5, 14), project=self.project, supervisor=self.admin)
|
||||
self.wl.workers.add(self.worker)
|
||||
session = self.client.session
|
||||
session['absence_pending'] = {
|
||||
'pairs': [[self.worker.id, '2026-05-14']],
|
||||
'reason': 'sick',
|
||||
'is_paid': False,
|
||||
'notes': 'flu',
|
||||
'conflicts': [{
|
||||
'worker_id': self.worker.id, 'worker_name': 'W',
|
||||
'date': '2026-05-14', 'work_log_id': self.wl.id,
|
||||
'project_name': 'P',
|
||||
}],
|
||||
}
|
||||
session.save()
|
||||
|
||||
def test_get_without_session_redirects_back(self):
|
||||
# Clear session
|
||||
session = self.client.session
|
||||
session.pop('absence_pending', None)
|
||||
session.save()
|
||||
resp = self.client.get('/absences/log/confirm/')
|
||||
self.assertRedirects(resp, '/absences/log/', fetch_redirect_response=False)
|
||||
|
||||
def test_get_with_session_shows_warning(self):
|
||||
resp = self.client.get('/absences/log/confirm/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertContains(resp, 'already') # warning text
|
||||
|
||||
def test_post_creates_absences_and_removes_from_worklog(self):
|
||||
resp = self.client.post('/absences/log/confirm/', data={
|
||||
f'remove_from_worklog_{self.wl.id}_{self.worker.id}': 'on',
|
||||
})
|
||||
self.assertEqual(Absence.objects.count(), 1)
|
||||
self.assertNotIn(self.worker, self.wl.workers.all())
|
||||
self.assertRedirects(resp, '/absences/', fetch_redirect_response=False)
|
||||
self.assertNotIn('absence_pending', self.client.session)
|
||||
|
||||
def test_post_without_removal_still_creates_absences(self):
|
||||
resp = self.client.post('/absences/log/confirm/', data={})
|
||||
self.assertEqual(Absence.objects.count(), 1)
|
||||
# Worker still in WorkLog because admin didn't tick the box
|
||||
self.assertIn(self.worker, self.wl.workers.all())
|
||||
|
||||
def test_post_drops_unauthorized_removal_keys(self):
|
||||
"""SECURITY: a POST key referencing a WorkLog that wasn't in the
|
||||
stashed conflicts list must be silently ignored. The confirm page
|
||||
is a strict accept-or-reject gate against what was shown."""
|
||||
# Create an UNRELATED WorkLog with a worker on it
|
||||
other_worker = Worker.objects.create(
|
||||
name='Other Worker', id_number='99', monthly_salary=Decimal('6000'),
|
||||
)
|
||||
other_wl = WorkLog.objects.create(
|
||||
date=_date(2026, 5, 14), project=self.project, supervisor=self.admin,
|
||||
)
|
||||
other_wl.workers.add(other_worker)
|
||||
|
||||
# Confirm POST tries to remove other_worker from other_wl
|
||||
# (this pair is NOT in the stashed conflicts — only (self.wl.id, self.worker.id) is)
|
||||
resp = self.client.post('/absences/log/confirm/', data={
|
||||
f'remove_from_worklog_{other_wl.id}_{other_worker.id}': 'on',
|
||||
})
|
||||
|
||||
# The unauthorized removal must NOT have happened
|
||||
self.assertIn(other_worker, other_wl.workers.all())
|
||||
# The legitimate absence creation still happens
|
||||
self.assertEqual(Absence.objects.count(), 1)
|
||||
self.assertRedirects(resp, '/absences/', fetch_redirect_response=False)
|
||||
|
||||
def test_post_with_malformed_removal_key_is_dropped(self):
|
||||
"""Malformed POST keys like remove_from_worklog_abc_5 must not crash
|
||||
the view — they're silently skipped."""
|
||||
resp = self.client.post('/absences/log/confirm/', data={
|
||||
'remove_from_worklog_abc_5': 'on', # non-numeric wl_id
|
||||
'remove_from_worklog_42_xyz': 'on', # non-numeric worker_id
|
||||
'remove_from_worklog_only_three': 'on', # not enough parts
|
||||
})
|
||||
self.assertEqual(Absence.objects.count(), 1) # Still creates absence
|
||||
self.assertEqual(resp.status_code, 302) # No 500
|
||||
|
||||
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>/edit/', views.project_edit, name='project_edit'),
|
||||
|
||||
# === ABSENCES ===
|
||||
# Standalone absence logging — admins + supervisors mark workers
|
||||
# absent for a date or date range. Conflict flow: if any selected
|
||||
# (worker, date) already has a WorkLog, the form redirects to
|
||||
# /absences/log/confirm/ where the admin can optionally remove the
|
||||
# worker from those WorkLogs before creating the Absence rows.
|
||||
path('absences/log/', views.absence_log, name='absence_log'),
|
||||
path('absences/log/confirm/', views.absence_log_confirm, name='absence_log_confirm'),
|
||||
# Placeholder list page — Task 5 will implement the full list/filter UI.
|
||||
path('absences/', views.absence_list, name='absence_list'),
|
||||
|
||||
# === EXPENSE RECEIPTS ===
|
||||
# Create a new expense receipt — emails HTML + PDF to Spark Receipt
|
||||
path('receipts/create/', views.create_receipt, name='create_receipt'),
|
||||
|
||||
197
core/views.py
197
core/views.py
@ -35,6 +35,7 @@ from .forms import (
|
||||
WorkerForm, WorkerCertificateFormSet, WorkerWarningFormSet,
|
||||
TeamForm, ProjectForm,
|
||||
SiteReportForm,
|
||||
AbsenceLogForm,
|
||||
)
|
||||
from .site_report_schema import COUNT_METRICS, CHECK_METRICS, label_for
|
||||
# NOTE: render_to_pdf is NOT imported here at the top level.
|
||||
@ -5180,3 +5181,199 @@ def restore_data(request):
|
||||
' <a href="/" style="margin-left: 10px;">Cancel</a></p>'
|
||||
'</form></body></html>'
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === ABSENCE VIEWS — LOG + CONFIRM ===
|
||||
# Two-step flow for the standalone /absences/log/ form.
|
||||
#
|
||||
# Step 1 (absence_log): GET shows the form. POST validates with
|
||||
# AbsenceLogForm. If no (worker, date) pair in the submission collides
|
||||
# with an existing WorkLog, the absences are created atomically and the
|
||||
# user is redirected to the list page. If there ARE conflicts, the
|
||||
# cleaned form data + the conflict list get stashed in
|
||||
# request.session['absence_pending'] and the user is redirected to the
|
||||
# confirm page (Step 2) where they can opt-in to also remove the
|
||||
# affected workers from their WorkLogs before the Absence rows commit.
|
||||
#
|
||||
# Step 2 (absence_log_confirm): GET shows the warning + per-conflict
|
||||
# "Also remove from WorkLog" checkboxes. POST runs the full atomic
|
||||
# write (WorkLog removals + Absence creates + payroll-adjustment sync)
|
||||
# and clears the session blob.
|
||||
#
|
||||
# The helper `_create_absences_atomic` is the single chokepoint for
|
||||
# both paths — it always wraps everything in transaction.atomic() so a
|
||||
# crash mid-write rolls back every change.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _user_can_log_absences(user):
|
||||
"""Admin OR supervisor (anyone supervising at least one team).
|
||||
|
||||
Same gate logic as /attendance/log/ — keeps the two flows symmetric:
|
||||
if you can log work for a worker, you can also log that the worker
|
||||
was absent."""
|
||||
return is_admin(user) or user.supervised_teams.exists()
|
||||
|
||||
|
||||
@login_required
|
||||
def absence_log(request):
|
||||
"""GET shows blank AbsenceLogForm. POST validates; if no conflicts,
|
||||
creates Absence rows in transaction.atomic() + redirects to list.
|
||||
If conflicts, stashes pending data in session + redirects to
|
||||
/absences/log/confirm/."""
|
||||
if not _user_can_log_absences(request.user):
|
||||
return HttpResponseForbidden('Permission denied.')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = AbsenceLogForm(request.POST, user=request.user)
|
||||
if form.is_valid():
|
||||
conflicts = form.conflicting_worklogs()
|
||||
if conflicts:
|
||||
# Stash + redirect to confirm page. We serialize dates as
|
||||
# ISO strings so the session JSON encoder doesn't choke on
|
||||
# date objects, and pairs as [worker_id, iso_date_string].
|
||||
request.session['absence_pending'] = {
|
||||
'pairs': [[w.id, d.isoformat()] for w, d in form.expanded_pairs()],
|
||||
'reason': form.cleaned_data['reason'],
|
||||
'is_paid': form.cleaned_data.get('is_paid') or False,
|
||||
'notes': form.cleaned_data.get('notes') or '',
|
||||
'conflicts': [
|
||||
{**c, 'date': c['date'].isoformat()} for c in conflicts
|
||||
],
|
||||
}
|
||||
return redirect('absence_log_confirm')
|
||||
# No conflicts — create immediately.
|
||||
_create_absences_atomic(
|
||||
pairs=form.expanded_pairs(),
|
||||
reason=form.cleaned_data['reason'],
|
||||
is_paid=form.cleaned_data.get('is_paid') or False,
|
||||
notes=form.cleaned_data.get('notes') or '',
|
||||
user=request.user,
|
||||
worklog_removals=[],
|
||||
)
|
||||
messages.success(request, f'{len(form.expanded_pairs())} absence(s) logged.')
|
||||
return redirect('absence_list')
|
||||
else:
|
||||
form = AbsenceLogForm(user=request.user)
|
||||
|
||||
return render(request, 'core/absences/log.html', {'form': form})
|
||||
|
||||
|
||||
@login_required
|
||||
def absence_log_confirm(request):
|
||||
"""GET reads pending data from session, shows warning page.
|
||||
POST processes the WorkLog removals + creates absences in one
|
||||
transaction.atomic() block (via the _create_absences_atomic helper).
|
||||
|
||||
If the session is empty (e.g. user hit Back then refreshed) we
|
||||
redirect back to /absences/log/ rather than rendering an empty
|
||||
confirm screen."""
|
||||
if not _user_can_log_absences(request.user):
|
||||
return HttpResponseForbidden('Permission denied.')
|
||||
|
||||
pending = request.session.get('absence_pending')
|
||||
if not pending:
|
||||
return redirect('absence_log')
|
||||
|
||||
if request.method == 'POST':
|
||||
from datetime import date as _d
|
||||
# Re-hydrate pairs from the session blob (worker_id + iso date string).
|
||||
# If a worker was deleted between form submit and confirm POST, skip
|
||||
# that row silently rather than 500-ing on Worker.DoesNotExist.
|
||||
pairs = []
|
||||
for wid, ds in pending['pairs']:
|
||||
try:
|
||||
worker = Worker.objects.get(id=wid)
|
||||
except Worker.DoesNotExist:
|
||||
continue # silently skip rows for workers deleted mid-flow
|
||||
pairs.append((worker, _d.fromisoformat(ds)))
|
||||
|
||||
if not pairs:
|
||||
# Everything stashed was for deleted workers — nothing to do.
|
||||
request.session.pop('absence_pending', None)
|
||||
messages.error(request, 'None of the originally-selected workers still exist. Please start over.')
|
||||
return redirect('absence_log')
|
||||
|
||||
# Parse worklog-removal opt-ins from POST. The confirm template
|
||||
# emits one checkbox per conflict row with name
|
||||
# 'remove_from_worklog_<wl_id>_<worker_id>'. Anything ticked makes
|
||||
# it into the POST dict; unticked boxes are absent.
|
||||
#
|
||||
# SECURITY: defense against an admin/supervisor injecting hidden form
|
||||
# fields for WorkLogs they didn't see in the conflict list. The
|
||||
# confirm page is a strict accept-or-reject gate against the
|
||||
# session-stashed conflicts — anything else gets silently dropped.
|
||||
allowed = {
|
||||
(c['work_log_id'], c['worker_id'])
|
||||
for c in pending['conflicts']
|
||||
}
|
||||
removals = []
|
||||
for key in request.POST:
|
||||
if key.startswith('remove_from_worklog_'):
|
||||
# Format split: ['remove', 'from', 'worklog', '<wl_id>', '<worker_id>']
|
||||
parts = key.split('_')
|
||||
if len(parts) == 5:
|
||||
try:
|
||||
pair = (int(parts[3]), int(parts[4]))
|
||||
if pair in allowed:
|
||||
removals.append(pair)
|
||||
# else: silently drop — out-of-band injection attempt
|
||||
except ValueError:
|
||||
# Malformed key — skip silently rather than 500
|
||||
pass
|
||||
_create_absences_atomic(
|
||||
pairs=pairs,
|
||||
reason=pending['reason'],
|
||||
is_paid=pending['is_paid'],
|
||||
notes=pending['notes'],
|
||||
user=request.user,
|
||||
worklog_removals=removals,
|
||||
)
|
||||
# Clear the session blob now that the write succeeded — refreshing
|
||||
# the list page should not re-trigger anything.
|
||||
request.session.pop('absence_pending', None)
|
||||
messages.success(request, f'{len(pairs)} absence(s) logged.')
|
||||
return redirect('absence_list')
|
||||
|
||||
# GET — render warning page using the stashed conflict list.
|
||||
return render(request, 'core/absences/log_confirm.html', {
|
||||
'conflicts': pending['conflicts'],
|
||||
'reason': pending['reason'],
|
||||
'is_paid': pending['is_paid'],
|
||||
'absence_count': len(pending['pairs']),
|
||||
})
|
||||
|
||||
|
||||
def _create_absences_atomic(pairs, reason, is_paid, notes, user, worklog_removals):
|
||||
"""Atomically: (1) remove flagged workers from WorkLogs, (2) create
|
||||
Absence rows, (3) sync payroll adjustments via the existing helper.
|
||||
|
||||
The whole thing runs in a single transaction.atomic() block — if any
|
||||
step fails the entire batch rolls back, so we never end up with
|
||||
half-committed state (e.g. workers removed from WorkLogs but no
|
||||
absences created, or absences created without their adjustments)."""
|
||||
with transaction.atomic():
|
||||
# Step 1: detach flagged workers from their conflicting WorkLogs.
|
||||
# If a WorkLog was deleted between form submit and confirm POST,
|
||||
# skip it silently rather than 500-ing on DoesNotExist.
|
||||
for wl_id, w_id in worklog_removals:
|
||||
try:
|
||||
wl = WorkLog.objects.get(id=wl_id)
|
||||
except WorkLog.DoesNotExist:
|
||||
continue # silently skip — log was deleted between form submit and confirm
|
||||
wl.workers.remove(w_id)
|
||||
# Step 2 + 3: create each Absence and (if is_paid) its Bonus
|
||||
# adjustment via the existing _sync_absence_payroll_adjustment.
|
||||
for worker, d in pairs:
|
||||
a = Absence.objects.create(
|
||||
worker=worker, date=d, reason=reason,
|
||||
is_paid=is_paid, notes=notes, logged_by=user,
|
||||
)
|
||||
_sync_absence_payroll_adjustment(a)
|
||||
|
||||
|
||||
@login_required
|
||||
def absence_list(request):
|
||||
"""Stub — full implementation in Task 5 (list + filters + edit links)."""
|
||||
return HttpResponse('Absence list — Task 5 will implement.')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user