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">
|
<div class="d-flex gap-2">
|
||||||
<form method="POST" class="d-inline">
|
<form method="POST" class="d-inline">
|
||||||
{% csrf_token %}
|
{% 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 %}
|
{% for key, value in form.data.items %}
|
||||||
{% if key != 'csrfmiddlewaretoken' and key != 'conflict_action' and key != 'workers' %}
|
{% if key != 'csrfmiddlewaretoken' and key != 'conflict_action' and key != 'workers' %}
|
||||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||||
@ -51,6 +52,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<form method="POST" class="d-inline">
|
<form method="POST" class="d-inline">
|
||||||
{% csrf_token %}
|
{% 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 %}
|
{% for key, value in form.data.items %}
|
||||||
{% if key != 'csrfmiddlewaretoken' and key != 'conflict_action' and key != 'workers' %}
|
{% if key != 'csrfmiddlewaretoken' and key != 'conflict_action' and key != 'workers' %}
|
||||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||||
@ -164,10 +166,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# --- Submit --- #}
|
{# --- Submit --- #}
|
||||||
<div class="d-grid mt-5">
|
{# Two submit buttons share the same form. The `name=value` pair #}
|
||||||
<button type="submit" class="btn btn-lg btn-accent">
|
{# 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
|
<i class="fas fa-save me-2"></i>Log Work
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
139
core/tests.py
139
core/tests.py
@ -2571,3 +2571,142 @@ class AbsenceProjectTests(TestCase):
|
|||||||
self.assertIsNotNone(a.payroll_adjustment)
|
self.assertIsNotNone(a.payroll_adjustment)
|
||||||
self.assertEqual(a.payroll_adjustment.project, self.project)
|
self.assertEqual(a.payroll_adjustment.project, self.project)
|
||||||
self.assertEqual(a.payroll_adjustment.type, 'Bonus')
|
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:
|
else:
|
||||||
messages.warning(request, 'No work logs created — all entries were conflicts.')
|
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
|
# Two-step flow: after attendance, send the supervisor to the
|
||||||
# site-report form so they can log progress + weather while it's
|
# 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
|
# 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.')
|
messages.success(request, f'{len(form.expanded_pairs())} absence(s) logged.')
|
||||||
return redirect('absence_list')
|
return redirect('absence_list')
|
||||||
else:
|
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) ===
|
# === 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 →
|
# Mirrors the pattern in attendance_log(): build a dict of team_id →
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user