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:
parent
32972276b5
commit
8c749f3f52
@ -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>
|
||||
|
||||
139
core/tests.py
139
core/tests.py
@ -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)
|
||||
|
||||
@ -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 →
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user