From 398a5b21abf867e77ea5bb3d8a9726d50f3c8b5e Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Fri, 15 May 2026 00:47:15 +0200 Subject: [PATCH] feat(history): add Team filter to /history/ page (and CSV export) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the team filter just added to /workers/. WorkLog.team is a nullable FK, so the filter accepts: - empty → all logs (default) - digit → logs tagged with that team - 'none' → logs with no team set (ad-hoc attendance) Filter row reflowed to col-md-3 col-lg-2 so all four selects fit on a single row on wide screens; mobile stacks them. CSV export link now passes &team=… through. Supervisors only see teams they supervise in the dropdown. 4 regression tests covering filter narrowing, no-team match, empty=show-all, and filter_params round-trip for the List/Calendar toggle links. --- core/templates/core/work_history.html | 23 +++++++-- core/tests.py | 70 +++++++++++++++++++++++++++ core/views.py | 31 +++++++++++- 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/core/templates/core/work_history.html b/core/templates/core/work_history.html index 03a7c0a..c78ca58 100644 --- a/core/templates/core/work_history.html +++ b/core/templates/core/work_history.html @@ -27,7 +27,7 @@ Calendar - Export CSV @@ -47,7 +47,7 @@ {% endif %} -
+
-
+
-
+ {# === Team filter === #} + {# WorkLog.team is a nullable FK; 'none' filters logs whose team is null. #} +
+ + +
+ +
-
+
diff --git a/core/tests.py b/core/tests.py index 1aa9286..184f706 100644 --- a/core/tests.py +++ b/core/tests.py @@ -2939,3 +2939,73 @@ class WorkerListTeamFilterTests(TestCase): self.assertContains(resp, 'No team assigned') self.assertContains(resp, 'Alpha Team') self.assertContains(resp, 'Bravo Team') + + +class WorkHistoryTeamFilterTests(TestCase): + """The /history/ page accepts ?team= to narrow to logs tagged + with that team, ?team=none for logs with no team set, and empty + for all logs (default).""" + + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user( + username='admin', password='pw', is_staff=True, is_superuser=True, + ) + cls.project = Project.objects.create(name='Solar Farm Alpha') + cls.worker = Worker.objects.create( + name='Alpha Worker', id_number='AW1', monthly_salary=Decimal('6000'), + ) + cls.team_a = Team.objects.create(name='Alpha Crew', supervisor=cls.admin) + cls.team_b = Team.objects.create(name='Bravo Crew', supervisor=cls.admin) + cls.team_a.workers.add(cls.worker) + cls.team_b.workers.add(cls.worker) + + cls.log_a = WorkLog.objects.create( + date=_date(2026, 5, 10), project=cls.project, + team=cls.team_a, supervisor=cls.admin, + ) + cls.log_a.workers.add(cls.worker) + + cls.log_b = WorkLog.objects.create( + date=_date(2026, 5, 11), project=cls.project, + team=cls.team_b, supervisor=cls.admin, + ) + cls.log_b.workers.add(cls.worker) + + # Log with no team — ad-hoc attendance + cls.log_none = WorkLog.objects.create( + date=_date(2026, 5, 12), project=cls.project, + team=None, supervisor=cls.admin, + ) + cls.log_none.workers.add(cls.worker) + + def setUp(self): + self.client.force_login(self.admin) + + def test_team_filter_narrows_to_team(self): + resp = self.client.get(f'/history/?team={self.team_a.id}') + log_ids = [l.id for l in resp.context['logs']] + self.assertIn(self.log_a.id, log_ids) + self.assertNotIn(self.log_b.id, log_ids) + self.assertNotIn(self.log_none.id, log_ids) + + def test_team_filter_none_shows_logs_with_no_team(self): + resp = self.client.get('/history/?team=none') + log_ids = [l.id for l in resp.context['logs']] + self.assertNotIn(self.log_a.id, log_ids) + self.assertNotIn(self.log_b.id, log_ids) + self.assertIn(self.log_none.id, log_ids) + + def test_team_filter_empty_shows_all(self): + resp = self.client.get('/history/') + log_ids = [l.id for l in resp.context['logs']] + self.assertIn(self.log_a.id, log_ids) + self.assertIn(self.log_b.id, log_ids) + self.assertIn(self.log_none.id, log_ids) + + def test_team_filter_propagates_to_filter_params(self): + """selected_team round-trips correctly into filter_params for + the List/Calendar toggle links.""" + resp = self.client.get(f'/history/?team={self.team_a.id}') + self.assertIn(f'team={self.team_a.id}', resp.context['filter_params']) + self.assertEqual(resp.context['selected_team'], str(self.team_a.id)) diff --git a/core/views.py b/core/views.py index 9073159..fa19eaf 100644 --- a/core/views.py +++ b/core/views.py @@ -887,9 +887,12 @@ def work_history(request): # Validate numeric params to prevent 500 errors from bad/malformed URLs. worker_filter = request.GET.get('worker', '') project_filter = request.GET.get('project', '') + team_filter = (request.GET.get('team', '') or '').strip() status_filter = request.GET.get('status', '') - # Validate: worker and project must be numeric IDs (or empty) + # Validate: worker and project must be numeric IDs (or empty). + # team_filter is left as a raw string so we can also accept 'none' + # (meaning "logs with no team assigned"); the digit check happens below. try: worker_filter = str(int(worker_filter)) if worker_filter else '' except (ValueError, TypeError): @@ -908,6 +911,17 @@ def work_history(request): if project_filter: logs = logs.filter(project__id=project_filter) + # === Team filter === + # WorkLog.team is a nullable FK — some logs were created without + # picking a team (e.g. ad-hoc attendance). 'none' is the special + # value matching those. A numeric value matches the team's PK. + if team_filter == 'none': + logs = logs.filter(team__isnull=True) + elif team_filter.isdigit(): + logs = logs.filter(team_id=int(team_filter)) + else: + team_filter = '' # treat garbage input as no filter + if status_filter == 'paid': # "Paid" = has at least one PayrollRecord linked logs = logs.filter(payroll_records__isnull=False).distinct() @@ -916,7 +930,7 @@ def work_history(request): logs = logs.filter(payroll_records__isnull=True) # Track whether any filter is active (for showing feedback in the template) - has_active_filters = bool(worker_filter or project_filter or status_filter) + has_active_filters = bool(worker_filter or project_filter or team_filter or status_filter) # Count filtered results BEFORE adding joins (more efficient SQL) filtered_log_count = logs.count() if has_active_filters else 0 @@ -936,6 +950,7 @@ def work_history(request): if is_admin(user): filter_workers = Worker.objects.filter(active=True).order_by('name') filter_projects = Project.objects.filter(active=True).order_by('name') + filter_teams = Team.objects.filter(active=True).order_by('name') else: supervised_teams = Team.objects.filter(supervisor=user, active=True) filter_workers = Worker.objects.filter( @@ -944,6 +959,8 @@ def work_history(request): filter_projects = Project.objects.filter( active=True, supervisors=user ).order_by('name') + # Supervisors see only the teams they actually supervise in the filter. + filter_teams = supervised_teams.order_by('name') # --- View mode: list or calendar --- view_mode = request.GET.get('view', 'list') @@ -956,6 +973,8 @@ def work_history(request): filter_params += '&worker=' + worker_filter if project_filter: filter_params += '&project=' + project_filter + if team_filter: + filter_params += '&team=' + team_filter if status_filter: filter_params += '&status=' + status_filter @@ -963,8 +982,10 @@ def work_history(request): 'logs': logs, 'filter_workers': filter_workers, 'filter_projects': filter_projects, + 'filter_teams': filter_teams, 'selected_worker': worker_filter, 'selected_project': project_filter, + 'selected_team': team_filter, 'selected_status': status_filter, 'is_admin': is_admin(user), 'view_mode': view_mode, @@ -1297,12 +1318,18 @@ def export_work_log_csv(request): worker_filter = request.GET.get('worker', '') project_filter = request.GET.get('project', '') + team_filter = (request.GET.get('team', '') or '').strip() status_filter = request.GET.get('status', '') if worker_filter: logs = logs.filter(workers__id=worker_filter).distinct() if project_filter: logs = logs.filter(project__id=project_filter) + # Mirror the team-filter semantics from work_history: 'none' = no team. + if team_filter == 'none': + logs = logs.filter(team__isnull=True) + elif team_filter.isdigit(): + logs = logs.filter(team_id=int(team_filter)) if status_filter == 'paid': logs = logs.filter(payroll_records__isnull=False).distinct() elif status_filter == 'unpaid':