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:
parent
4b57cffb77
commit
398a5b21ab
@ -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>
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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':
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user