feat(absences): 'Submit + Log Absences' button on attendance form

After logging attendance, admins can jump straight to /absences/log/
with the date, team, and project pre-filled — no need to re-pick them.
Default Submit button keeps the existing SiteReport flow unchanged.
4 new tests covering both submit paths and URL-param prefill.
This commit is contained in:
Konrad du Plessis 2026-05-14 22:17:41 +02:00
parent 32972276b5
commit 8c749f3f52
3 changed files with 190 additions and 3 deletions

View File

@ -36,6 +36,7 @@
<div class="d-flex gap-2">
<form method="POST" class="d-inline">
{% csrf_token %}
{# Round C: 'next_action' deliberately flows through here, so 'Log Work + Add Absences' intent survives conflict resolution. #}
{% for key, value in form.data.items %}
{% if key != 'csrfmiddlewaretoken' and key != 'conflict_action' and key != 'workers' %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
@ -51,6 +52,7 @@
</form>
<form method="POST" class="d-inline">
{% csrf_token %}
{# Round C: 'next_action' deliberately flows through here, so 'Log Work + Add Absences' intent survives conflict resolution. #}
{% for key, value in form.data.items %}
{% if key != 'csrfmiddlewaretoken' and key != 'conflict_action' and key != 'workers' %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
@ -164,10 +166,19 @@
</div>
{# --- Submit --- #}
<div class="d-grid mt-5">
<button type="submit" class="btn btn-lg btn-accent">
{# Two submit buttons share the same form. The `name=value` pair #}
{# of whichever button the user clicks ends up in request.POST so #}
{# the view can branch on `next_action`. Default ('log_only' or #}
{# missing) keeps the existing Site Report flow. 'log_absences' #}
{# redirects to /absences/log/ pre-filled with this date/team/project. #}
<div class="d-grid gap-2 mt-5">
<button type="submit" name="next_action" value="log_only" class="btn btn-lg btn-accent">
<i class="fas fa-save me-2"></i>Log Work
</button>
<button type="submit" name="next_action" value="log_absences" class="btn btn-lg btn-outline-secondary"
title="Submit this work log, then jump straight to the absence form pre-filled with the same date / team / project.">
<i class="fas fa-user-clock me-2"></i>Log Work + Add Absences
</button>
</div>
</form>
</div>

View File

@ -2571,3 +2571,142 @@ class AbsenceProjectTests(TestCase):
self.assertIsNotNone(a.payroll_adjustment)
self.assertEqual(a.payroll_adjustment.project, self.project)
self.assertEqual(a.payroll_adjustment.type, 'Bonus')
# ====================================================================
# === Worker Absence — Round C: Attendance → Absence shortcut =========
# Konrad's ask: after submitting attendance, give us a button that
# jumps straight to /absences/log/ pre-filled with the same date /
# team / project. The attendance form has two submit buttons
# differentiated by name=next_action value=log_only|log_absences.
# ====================================================================
class AbsenceAttendanceShortcutTests(TestCase):
"""Round C — Submit + Log Absences button on /attendance/log/.
Submitting attendance with next_action='log_absences' redirects to
/absences/log/ pre-filled with the date / team / project. The
default submit ('log_only' or absent) keeps the existing Site
Report flow."""
@classmethod
def setUpTestData(cls):
cls.admin = User.objects.create_user(
username='roundc-admin', password='pw', is_staff=True,
)
cls.worker = Worker.objects.create(
name='W', id_number='RC1', monthly_salary=Decimal('6000'),
)
cls.project = Project.objects.create(name='Solar Farm Alpha')
cls.team = Team.objects.create(name='Team A', supervisor=cls.admin)
cls.team.workers.add(cls.worker)
def setUp(self):
self.client.force_login(self.admin)
def test_default_attendance_submit_unchanged(self):
"""next_action absent or 'log_only' → existing Site Report redirect."""
resp = self.client.post(reverse('attendance_log'), data={
'date': '2026-05-14',
'project': self.project.id,
'team': self.team.id,
'workers': [self.worker.id],
'overtime_amount': '0.00',
'notes': '',
# next_action omitted on purpose — should fall through to
# the existing Site Report behaviour.
})
self.assertEqual(resp.status_code, 302)
self.assertIn('/site-report/', resp.url)
def test_log_only_explicit_value_still_goes_to_site_report(self):
"""An explicit next_action='log_only' (the default button) keeps
the existing behaviour important for backwards compatibility."""
resp = self.client.post(reverse('attendance_log'), data={
'date': '2026-05-14',
'project': self.project.id,
'team': self.team.id,
'workers': [self.worker.id],
'overtime_amount': '0.00',
'notes': '',
'next_action': 'log_only',
})
self.assertEqual(resp.status_code, 302)
self.assertIn('/site-report/', resp.url)
def test_log_absences_button_redirects_to_absence_log(self):
"""next_action='log_absences' → redirect to /absences/log/ with
date / team / project query-string params pre-filled."""
resp = self.client.post(reverse('attendance_log'), data={
'date': '2026-05-14',
'project': self.project.id,
'team': self.team.id,
'workers': [self.worker.id],
'overtime_amount': '0.00',
'notes': '',
'next_action': 'log_absences',
})
self.assertEqual(resp.status_code, 302)
self.assertIn('/absences/log/', resp.url)
self.assertIn('date=2026-05-14', resp.url)
self.assertIn(f'team={self.team.id}', resp.url)
self.assertIn(f'project={self.project.id}', resp.url)
def test_absence_log_prefills_from_url_params(self):
"""GET /absences/log/?date=X&team=Y&project=Z → form is
pre-populated. We do a cheap rendered-HTML check for the
date value (the most user-visible signal)."""
url = (
reverse('absence_log')
+ f'?date=2026-05-14&team={self.team.id}&project={self.project.id}'
)
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
# Date field should render with value="2026-05-14"
self.assertContains(resp, 'value="2026-05-14"')
def test_log_absences_intent_survives_conflict_resolution(self):
"""Round C — when conflicts force a re-render of the attendance form,
the next_action='log_absences' value is preserved as a hidden field
via the conflict-form's form.data.items loop. Resolving the conflict
(e.g. Overwrite) then redirects to /absences/log/ as originally intended."""
# Pre-create a conflicting WorkLog for the same worker+date
pre_existing_log = WorkLog.objects.create(
date=datetime.date(2026, 5, 14),
project=self.project,
supervisor=self.admin,
)
pre_existing_log.workers.add(self.worker)
# First POST — should hit conflict detection (not redirect)
resp = self.client.post('/attendance/log/', data={
'date': '2026-05-14',
'project': self.project.id,
'team': self.team.id,
'workers': [self.worker.id],
'overtime_amount': '0.00',
'notes': '',
'next_action': 'log_absences',
})
# Should render the conflict resolution screen (200), not redirect
self.assertEqual(resp.status_code, 200)
# The next_action value must be present as a hidden input in the rendered conflict form
self.assertContains(resp, 'name="next_action"')
self.assertContains(resp, 'value="log_absences"')
# Second POST — resolve the conflict via Overwrite, carrying next_action through
resp2 = self.client.post('/attendance/log/', data={
'date': '2026-05-14',
'project': self.project.id,
'team': self.team.id,
'workers': [self.worker.id],
'overtime_amount': '0.00',
'notes': '',
'next_action': 'log_absences',
'conflict_action': 'overwrite',
})
# Now should redirect to /absences/log/ with prefill params
self.assertEqual(resp2.status_code, 302)
self.assertIn('/absences/log/', resp2.url)
self.assertIn('date=2026-05-14', resp2.url)

View File

@ -632,6 +632,31 @@ def attendance_log(request):
else:
messages.warning(request, 'No work logs created — all entries were conflicts.')
# === ROUND C: pick post-submit destination ===
# The attendance form has TWO submit buttons, both named
# `next_action` with different values. Whichever button the
# user clicked, that value lands in request.POST:
# - 'log_only' (default) → existing Site Report flow
# - 'log_absences' → jump to /absences/log/ pre-filled
# Konrad's ask: "it is tedious to find the absence form after
# logging work — give us a 'Log and add Absence' button."
next_action = request.POST.get('next_action', 'log_only')
if next_action == 'log_absences' and created_log_ids:
# Pre-fill the absence form with the same date / team /
# project the user just used. We use the LAST log's date
# (matches the site_report_edit behaviour for date ranges:
# the supervisor lands on the most recent day).
# Use the local-scope variables we already have from the
# form's cleaned_data — no need to re-fetch the WorkLog.
from urllib.parse import urlencode
params = {'date': dates_to_log[-1].isoformat()}
if team:
params['team'] = team.id
if project:
params['project'] = project.id
return redirect(f"{reverse('absence_log')}?{urlencode(params)}")
# Two-step flow: after attendance, send the supervisor to the
# site-report form so they can log progress + weather while it's
# fresh in their head. The form has a "Skip" link to home for
@ -5269,7 +5294,19 @@ def absence_log(request):
messages.success(request, f'{len(form.expanded_pairs())} absence(s) logged.')
return redirect('absence_list')
else:
form = AbsenceLogForm(user=request.user)
# === ROUND C: pre-fill from URL params ===
# When the user clicked "Log Work + Add Absences" on the attendance
# form, we land here with ?date=...&team=...&project=... in the
# query string. Drop those values into form `initial=` so the
# fields render pre-populated. Plain GET (no params) → blank form.
initial = {}
if request.GET.get('date'):
initial['date'] = request.GET.get('date')
if request.GET.get('team', '').isdigit():
initial['team'] = request.GET.get('team')
if request.GET.get('project', '').isdigit():
initial['project'] = request.GET.get('project')
form = AbsenceLogForm(user=request.user, initial=initial)
# === TEAM → WORKERS MAP for the in-page team filter (Fix A1, May 2026) ===
# Mirrors the pattern in attendance_log(): build a dict of team_id →