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
|
<i class="fas fa-calendar-alt me-1"></i> Calendar
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="fas fa-file-csv me-1"></i> Export CSV
|
<i class="fas fa-file-csv me-1"></i> Export CSV
|
||||||
</a>
|
</a>
|
||||||
@ -47,7 +47,7 @@
|
|||||||
<input type="hidden" name="month" value="{{ curr_month }}">
|
<input type="hidden" name="month" value="{{ curr_month }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="col-md-3">
|
<div class="col-md-3 col-lg-2">
|
||||||
<label class="form-label">Worker</label>
|
<label class="form-label">Worker</label>
|
||||||
<select name="worker" class="form-select form-select-sm">
|
<select name="worker" class="form-select form-select-sm">
|
||||||
<option value="">All Workers</option>
|
<option value="">All Workers</option>
|
||||||
@ -57,7 +57,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3">
|
<div class="col-md-3 col-lg-2">
|
||||||
<label class="form-label">Project</label>
|
<label class="form-label">Project</label>
|
||||||
<select name="project" class="form-select form-select-sm">
|
<select name="project" class="form-select form-select-sm">
|
||||||
<option value="">All Projects</option>
|
<option value="">All Projects</option>
|
||||||
@ -67,7 +67,20 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<label class="form-label">Payment Status</label>
|
||||||
<select name="status" class="form-select form-select-sm">
|
<select name="status" class="form-select form-select-sm">
|
||||||
<option value="" {% if not selected_status %}selected{% endif %}>All</option>
|
<option value="" {% if not selected_status %}selected{% endif %}>All</option>
|
||||||
@ -76,7 +89,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<button type="submit" class="btn btn-sm btn-accent">
|
||||||
<i class="fas fa-filter me-1"></i> Filter
|
<i class="fas fa-filter me-1"></i> Filter
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -2939,3 +2939,73 @@ class WorkerListTeamFilterTests(TestCase):
|
|||||||
self.assertContains(resp, 'No team assigned')
|
self.assertContains(resp, 'No team assigned')
|
||||||
self.assertContains(resp, 'Alpha Team')
|
self.assertContains(resp, 'Alpha Team')
|
||||||
self.assertContains(resp, 'Bravo 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.
|
# Validate numeric params to prevent 500 errors from bad/malformed URLs.
|
||||||
worker_filter = request.GET.get('worker', '')
|
worker_filter = request.GET.get('worker', '')
|
||||||
project_filter = request.GET.get('project', '')
|
project_filter = request.GET.get('project', '')
|
||||||
|
team_filter = (request.GET.get('team', '') or '').strip()
|
||||||
status_filter = request.GET.get('status', '')
|
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:
|
try:
|
||||||
worker_filter = str(int(worker_filter)) if worker_filter else ''
|
worker_filter = str(int(worker_filter)) if worker_filter else ''
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
@ -908,6 +911,17 @@ def work_history(request):
|
|||||||
if project_filter:
|
if project_filter:
|
||||||
logs = logs.filter(project__id=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':
|
if status_filter == 'paid':
|
||||||
# "Paid" = has at least one PayrollRecord linked
|
# "Paid" = has at least one PayrollRecord linked
|
||||||
logs = logs.filter(payroll_records__isnull=False).distinct()
|
logs = logs.filter(payroll_records__isnull=False).distinct()
|
||||||
@ -916,7 +930,7 @@ def work_history(request):
|
|||||||
logs = logs.filter(payroll_records__isnull=True)
|
logs = logs.filter(payroll_records__isnull=True)
|
||||||
|
|
||||||
# Track whether any filter is active (for showing feedback in the template)
|
# 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)
|
# Count filtered results BEFORE adding joins (more efficient SQL)
|
||||||
filtered_log_count = logs.count() if has_active_filters else 0
|
filtered_log_count = logs.count() if has_active_filters else 0
|
||||||
@ -936,6 +950,7 @@ def work_history(request):
|
|||||||
if is_admin(user):
|
if is_admin(user):
|
||||||
filter_workers = Worker.objects.filter(active=True).order_by('name')
|
filter_workers = Worker.objects.filter(active=True).order_by('name')
|
||||||
filter_projects = Project.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:
|
else:
|
||||||
supervised_teams = Team.objects.filter(supervisor=user, active=True)
|
supervised_teams = Team.objects.filter(supervisor=user, active=True)
|
||||||
filter_workers = Worker.objects.filter(
|
filter_workers = Worker.objects.filter(
|
||||||
@ -944,6 +959,8 @@ def work_history(request):
|
|||||||
filter_projects = Project.objects.filter(
|
filter_projects = Project.objects.filter(
|
||||||
active=True, supervisors=user
|
active=True, supervisors=user
|
||||||
).order_by('name')
|
).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: list or calendar ---
|
||||||
view_mode = request.GET.get('view', 'list')
|
view_mode = request.GET.get('view', 'list')
|
||||||
@ -956,6 +973,8 @@ def work_history(request):
|
|||||||
filter_params += '&worker=' + worker_filter
|
filter_params += '&worker=' + worker_filter
|
||||||
if project_filter:
|
if project_filter:
|
||||||
filter_params += '&project=' + project_filter
|
filter_params += '&project=' + project_filter
|
||||||
|
if team_filter:
|
||||||
|
filter_params += '&team=' + team_filter
|
||||||
if status_filter:
|
if status_filter:
|
||||||
filter_params += '&status=' + status_filter
|
filter_params += '&status=' + status_filter
|
||||||
|
|
||||||
@ -963,8 +982,10 @@ def work_history(request):
|
|||||||
'logs': logs,
|
'logs': logs,
|
||||||
'filter_workers': filter_workers,
|
'filter_workers': filter_workers,
|
||||||
'filter_projects': filter_projects,
|
'filter_projects': filter_projects,
|
||||||
|
'filter_teams': filter_teams,
|
||||||
'selected_worker': worker_filter,
|
'selected_worker': worker_filter,
|
||||||
'selected_project': project_filter,
|
'selected_project': project_filter,
|
||||||
|
'selected_team': team_filter,
|
||||||
'selected_status': status_filter,
|
'selected_status': status_filter,
|
||||||
'is_admin': is_admin(user),
|
'is_admin': is_admin(user),
|
||||||
'view_mode': view_mode,
|
'view_mode': view_mode,
|
||||||
@ -1297,12 +1318,18 @@ def export_work_log_csv(request):
|
|||||||
|
|
||||||
worker_filter = request.GET.get('worker', '')
|
worker_filter = request.GET.get('worker', '')
|
||||||
project_filter = request.GET.get('project', '')
|
project_filter = request.GET.get('project', '')
|
||||||
|
team_filter = (request.GET.get('team', '') or '').strip()
|
||||||
status_filter = request.GET.get('status', '')
|
status_filter = request.GET.get('status', '')
|
||||||
|
|
||||||
if worker_filter:
|
if worker_filter:
|
||||||
logs = logs.filter(workers__id=worker_filter).distinct()
|
logs = logs.filter(workers__id=worker_filter).distinct()
|
||||||
if project_filter:
|
if project_filter:
|
||||||
logs = logs.filter(project__id=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':
|
if status_filter == 'paid':
|
||||||
logs = logs.filter(payroll_records__isnull=False).distinct()
|
logs = logs.filter(payroll_records__isnull=False).distinct()
|
||||||
elif status_filter == 'unpaid':
|
elif status_filter == 'unpaid':
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user