diff --git a/core/tests.py b/core/tests.py index d7add91..b199117 100644 --- a/core/tests.py +++ b/core/tests.py @@ -3432,3 +3432,71 @@ class ManagerSalariedAttendanceExclusionTests(TestCase): ids = m.get(self.team.id) or m.get(str(self.team.id)) or [] self.assertIn(self.daily.id, ids) self.assertNotIn(self.mgr.id, ids) + + # === Point 4: attendance GET cost-estimate map (worker_rates) === + # The admin attendance form renders a {worker_id: daily_rate} map for + # the live cost estimator. A fixed-salary manager must NOT appear in it + # (they never go on a WorkLog, so they have no daily attendance cost). + # This is a regression GUARD — point 4 was already fixed in 65df9f8, so + # this test is expected to pass immediately; it locks the behaviour in. + def test_attendance_cost_rates_exclude_fixed(self): + self.client.force_login(self.admin) + resp = self.client.get(reverse('attendance_log')) + self.assertEqual(resp.status_code, 200) + # Assert on the actual context map (precise — not a whole-page string + # scan that could collide with the id appearing elsewhere). + worker_rates = resp.context['worker_rates_json'] + self.assertIn(str(self.daily.id), worker_rates) + self.assertNotIn(str(self.mgr.id), worker_rates) + + # === Fix 1: the two inline tw_map re-render branches === + # attendance_log POST has two error/re-render branches that used to build + # the team->workers map INLINE without excluding pay_type='fixed'. POST a + # form that is VALID but yields zero loggable dates (single Saturday with + # the Saturday box unchecked) to hit the "no valid dates" re-render branch, + # then assert the rendered team_workers_json excludes the manager. + def test_no_valid_dates_rerender_team_map_excludes_fixed(self): + import json as _json + self.client.force_login(self.admin) + project = Project.objects.create(name='MSA Proj') + # 2026-05-16 is a Saturday. With include_saturday unchecked and no + # end_date, dates_to_log is empty -> "no valid dates" re-render. + resp = self.client.post(reverse('attendance_log'), { + 'date': '2026-05-16', + 'project': project.id, + 'team': self.team.id, + 'workers': [self.daily.id], + 'overtime_amount': '0.00', + }) + self.assertEqual(resp.status_code, 200) + tw_map = _json.loads(resp.context['team_workers_json']) + ids = tw_map.get(str(self.team.id)) or tw_map.get(self.team.id) or [] + self.assertIn(self.daily.id, ids) + self.assertNotIn(self.mgr.id, ids) + + # === Fix 1: the conflict-warning re-render branch === + # The second inline tw_map builder lives in the conflict-warning branch. + # Pre-create a WorkLog so the same worker+date collides, forcing the + # conflict re-render, then assert the manager is excluded there too. + def test_conflict_rerender_team_map_excludes_fixed(self): + import json as _json + self.client.force_login(self.admin) + project = Project.objects.create(name='MSA Proj 2') + # 2026-05-18 is a Monday (a valid loggable weekday). + existing = WorkLog.objects.create( + date=datetime.date(2026, 5, 18), project=project) + existing.workers.add(self.daily) + resp = self.client.post(reverse('attendance_log'), { + 'date': '2026-05-18', + 'project': project.id, + 'team': self.team.id, + 'workers': [self.daily.id], + 'overtime_amount': '0.00', + }) + self.assertEqual(resp.status_code, 200) + # Confirm we actually hit the conflict branch (it sets `conflicts`). + self.assertTrue(resp.context.get('conflicts')) + tw_map = _json.loads(resp.context['team_workers_json']) + ids = tw_map.get(str(self.team.id)) or tw_map.get(self.team.id) or [] + self.assertIn(self.daily.id, ids) + self.assertNotIn(self.mgr.id, ids) diff --git a/core/views.py b/core/views.py index b838bac..1b2d7ef 100644 --- a/core/views.py +++ b/core/views.py @@ -637,10 +637,11 @@ def attendance_log(request): if not dates_to_log: messages.warning(request, 'No valid dates in the selected range.') - # Still need team_workers_json for the JS even on error re-render - tw_map = {} - for t in Team.objects.filter(active=True).prefetch_related('workers'): - tw_map[t.id] = list(t.workers.filter(active=True).values_list('id', flat=True)) + # Still need team_workers_json for the JS even on error + # re-render. Use the shared helper so this branch applies the + # SAME admin/supervisor scoping AND manager (pay_type='fixed') + # exclusion as the GET path — no duplicated, drift-prone loop. + tw_map = _build_team_workers_map(user) return render(request, 'core/attendance_log.html', { 'form': form, 'is_admin': is_admin(user), @@ -669,10 +670,11 @@ def attendance_log(request): conflict_action = request.POST.get('conflict_action', '') if conflicts and not conflict_action: # Show the conflict warning — let user choose Skip or Overwrite - # Still need team_workers_json for the JS even on conflict re-render - tw_map = {} - for t in Team.objects.filter(active=True).prefetch_related('workers'): - tw_map[t.id] = list(t.workers.filter(active=True).values_list('id', flat=True)) + # Still need team_workers_json for the JS even on conflict + # re-render. Use the shared helper so this branch applies the + # SAME admin/supervisor scoping AND manager (pay_type='fixed') + # exclusion as the GET path — no duplicated, drift-prone loop. + tw_map = _build_team_workers_map(user) # Pass the selected worker IDs explicitly for the conflict # re-submission forms. We can't use form.data.workers in the