+
@@ -107,7 +117,7 @@
{% else %}
No workers{% if q %} match "{{ q }}"{% endif %}.
- {% if q or status != 'active' %}
Clear filters{% endif %}
+ {% if q or status != 'active' or team_filter %}
Clear filters{% endif %}
{% endif %}
diff --git a/core/tests.py b/core/tests.py
index 2534c00..1aa9286 100644
--- a/core/tests.py
+++ b/core/tests.py
@@ -2880,3 +2880,62 @@ class AbsenceAdminAndCascadeTests(TestCase):
absence.refresh_from_db()
self.assertFalse(absence.is_paid)
self.assertIsNone(absence.payroll_adjustment)
+
+
+class WorkerListTeamFilterTests(TestCase):
+ """The /workers/ page accepts ?team=
to narrow the list to that
+ team's members, ?team=none for unassigned workers, and ?team= (empty)
+ for the default 'All teams' view."""
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.admin = User.objects.create_user(
+ username='admin', password='pw', is_staff=True, is_superuser=True,
+ )
+ # Two teams with workers and one worker unassigned to any team.
+ cls.team_a = Team.objects.create(name='Alpha Team')
+ cls.team_b = Team.objects.create(name='Bravo Team')
+
+ cls.w_alpha = Worker.objects.create(
+ name='Alice Alpha', id_number='AA1', monthly_salary=Decimal('6000'),
+ )
+ cls.w_bravo = Worker.objects.create(
+ name='Bob Bravo', id_number='BB1', monthly_salary=Decimal('6000'),
+ )
+ cls.w_orphan = Worker.objects.create(
+ name='Otto Orphan', id_number='OO1', monthly_salary=Decimal('6000'),
+ )
+
+ cls.team_a.workers.add(cls.w_alpha)
+ cls.team_b.workers.add(cls.w_bravo)
+ # w_orphan stays unassigned
+
+ def setUp(self):
+ self.client.force_login(self.admin)
+
+ def test_no_filter_shows_all_active(self):
+ resp = self.client.get('/workers/')
+ self.assertContains(resp, 'Alice Alpha')
+ self.assertContains(resp, 'Bob Bravo')
+ self.assertContains(resp, 'Otto Orphan')
+
+ def test_team_filter_narrows_to_team(self):
+ resp = self.client.get(f'/workers/?team={self.team_a.id}')
+ self.assertContains(resp, 'Alice Alpha')
+ self.assertNotContains(resp, 'Bob Bravo')
+ self.assertNotContains(resp, 'Otto Orphan')
+
+ def test_team_filter_none_shows_only_unassigned(self):
+ resp = self.client.get('/workers/?team=none')
+ self.assertNotContains(resp, 'Alice Alpha')
+ self.assertNotContains(resp, 'Bob Bravo')
+ self.assertContains(resp, 'Otto Orphan')
+
+ def test_team_filter_dropdown_lists_active_teams(self):
+ """The Team filter dropdown should render the active teams as
+ options, plus 'All teams' and 'No team assigned'."""
+ resp = self.client.get('/workers/')
+ self.assertContains(resp, 'All teams')
+ self.assertContains(resp, 'No team assigned')
+ self.assertContains(resp, 'Alpha Team')
+ self.assertContains(resp, 'Bravo Team')
diff --git a/core/views.py b/core/views.py
index ea332bd..9073159 100644
--- a/core/views.py
+++ b/core/views.py
@@ -1478,19 +1478,22 @@ def export_workers_csv(request):
@login_required
def worker_list(request):
- """Admin-friendly list of all workers with search + status filter.
+ """Admin-friendly list of all workers with search + status + team filter.
Query params:
?q=search_term — search name / ID number / phone
?status=active — default, only active workers
?status=inactive — only inactive
?status=all — both
+ ?team= — only workers belonging to this team (M2M)
+ ?team=none — workers NOT assigned to any team
"""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
q = (request.GET.get('q') or '').strip()
status = request.GET.get('status') or 'active'
+ team_filter = (request.GET.get('team') or '').strip()
workers = Worker.objects.all()
if status == 'active':
@@ -1504,15 +1507,32 @@ def worker_list(request):
Q(name__icontains=q) | Q(id_number__icontains=q) | Q(phone_number__icontains=q)
)
- # Annotate days worked (distinct WorkLog dates) — shown in the table
+ # === Team filter ===
+ # 'none' is a special value meaning "no team assigned at all".
+ # Any numeric value is treated as a Team primary key and narrows
+ # the list to that team's members via the Team.workers M2M.
+ if team_filter == 'none':
+ workers = workers.filter(teams__isnull=True)
+ elif team_filter.isdigit():
+ workers = workers.filter(teams__id=int(team_filter))
+
+ # Annotate days worked (distinct WorkLog dates) — shown in the table.
+ # `.distinct()` is also needed to avoid duplicate Worker rows when
+ # the team filter joins through the M2M (a worker on multiple teams
+ # would appear twice without it — uncommon but possible).
workers = workers.annotate(
days_worked=Count('work_logs__date', distinct=True),
- ).order_by('name')
+ ).distinct().order_by('name')
+
+ # Build the team dropdown options. Only active teams shown; alphabetical.
+ teams_for_filter = Team.objects.filter(active=True).order_by('name')
context = {
'workers': workers,
'q': q,
'status': status,
+ 'team_filter': team_filter,
+ 'teams_for_filter': teams_for_filter,
'total_count': workers.count(),
}
return render(request, 'core/workers/list.html', context)