From 8c749f3f5292dd14a3262580909c6767ec316fed Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Thu, 14 May 2026 22:17:41 +0200 Subject: [PATCH] feat(absences): 'Submit + Log Absences' button on attendance form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/templates/core/attendance_log.html | 15 ++- core/tests.py | 139 ++++++++++++++++++++++++ core/views.py | 39 ++++++- 3 files changed, 190 insertions(+), 3 deletions(-) diff --git a/core/templates/core/attendance_log.html b/core/templates/core/attendance_log.html index dbf4101..b8c1003 100644 --- a/core/templates/core/attendance_log.html +++ b/core/templates/core/attendance_log.html @@ -36,6 +36,7 @@
{% 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' %} @@ -51,6 +52,7 @@
{% 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' %} @@ -164,10 +166,19 @@
{# --- Submit --- #} -
- +
diff --git a/core/tests.py b/core/tests.py index 9627f4f..ddfd5ad 100644 --- a/core/tests.py +++ b/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) diff --git a/core/views.py b/core/views.py index a878270..ae9538a 100644 --- a/core/views.py +++ b/core/views.py @@ -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 →