feat(history): add Team filter to /history/ page (and CSV export)

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.
This commit is contained in:
Konrad du Plessis 2026-05-15 00:47:15 +02:00
parent 4b57cffb77
commit 398a5b21ab
3 changed files with 117 additions and 7 deletions

View File

@ -27,7 +27,7 @@
<i class="fas fa-calendar-alt me-1"></i> Calendar
</a>
</div>
<a href="{% url 'export_work_log_csv' %}?worker={{ selected_worker }}&project={{ selected_project }}&status={{ selected_status }}"
<a href="{% url 'export_work_log_csv' %}?worker={{ selected_worker }}&project={{ selected_project }}&team={{ selected_team }}&status={{ selected_status }}"
class="btn btn-outline-secondary btn-sm">
<i class="fas fa-file-csv me-1"></i> Export CSV
</a>
@ -47,7 +47,7 @@
<input type="hidden" name="month" value="{{ curr_month }}">
{% endif %}
<div class="col-md-3">
<div class="col-md-3 col-lg-2">
<label class="form-label">Worker</label>
<select name="worker" class="form-select form-select-sm">
<option value="">All Workers</option>
@ -57,7 +57,7 @@
</select>
</div>
<div class="col-md-3">
<div class="col-md-3 col-lg-2">
<label class="form-label">Project</label>
<select name="project" class="form-select form-select-sm">
<option value="">All Projects</option>
@ -67,7 +67,20 @@
</select>
</div>
<div class="col-md-3">
{# === Team filter === #}
{# WorkLog.team is a nullable FK; 'none' filters logs whose team is null. #}
<div class="col-md-3 col-lg-2">
<label class="form-label">Team</label>
<select name="team" class="form-select form-select-sm">
<option value="">All Teams</option>
<option value="none" {% if selected_team == 'none' %}selected{% endif %}>— No team —</option>
{% for t in filter_teams %}
<option value="{{ t.id }}" {% if selected_team == t.id|stringformat:"d" %}selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3 col-lg-2">
<label class="form-label">Payment Status</label>
<select name="status" class="form-select form-select-sm">
<option value="" {% if not selected_status %}selected{% endif %}>All</option>
@ -76,7 +89,7 @@
</select>
</div>
<div class="col-md-3 d-flex gap-2">
<div class="col-md-12 col-lg-4 d-flex gap-2 align-items-end">
<button type="submit" class="btn btn-sm btn-accent">
<i class="fas fa-filter me-1"></i> Filter
</button>

View File

@ -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=<id> 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))

View File

@ -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':