+ {# === 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':