Backend: add project_team_pairs_json context for inline-filter cross-filter

Serialises distinct (project_id, team_id) pairs from WorkLog as JSON on
the generate_report context. The upcoming pill-popover JS (Task 4 of the
inline-filters plan) uses this to hide teams that haven't worked on a
selected project (and vice versa) without any extra HTTP round-trips.

Scope: entire history (not the report date range) — cross-filter is about
data possibility, not data shown in this period. Filters out NULL
project or team (can't cross-filter on NULL).

2 tests cover: key is populated with correct pairs; NULL-team logs don't
leak into the pairs list.

No visible behaviour change — template doesn't consume the new key yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-23 09:35:36 +02:00
parent 124b3f61b6
commit 06f2e71d87
2 changed files with 77 additions and 0 deletions

View File

@ -749,3 +749,68 @@ class ReportMultiFilterTests(TestCase):
self.assertEqual(len(ctx['worker_breakdown']), 1)
# All three records are for the same worker, R 100 each = R 300
self.assertEqual(ctx['worker_breakdown'][0]['total_paid'], Decimal('300.00'))
# =============================================================================
# === TESTS FOR INLINE FILTERS (Report Page) ===
# Pill-as-dropdown + cross-filter feature. Most behaviour is template/JS;
# the only backend surface is the project_team_pairs_json context key that
# powers the client-side Team<->Project cross-filter.
# =============================================================================
class InlineFiltersPairsContextTests(TestCase):
"""Report view must serialise distinct (project_id, team_id) pairs for
the pill-popover cross-filter JS."""
def setUp(self):
self.admin = User.objects.create_user(
username='admin-if', password='pass', is_staff=True
)
self.p1 = Project.objects.create(name='P1')
self.p2 = Project.objects.create(name='P2')
self.t1 = Team.objects.create(name='T1', supervisor=self.admin)
self.t2 = Team.objects.create(name='T2', supervisor=self.admin)
self.w = Worker.objects.create(
name='W', id_number='W1', monthly_salary=Decimal('4000')
)
# Log t1 on p1, t2 on p2 — so pairs should be [(p1,t1), (p2,t2)]
for proj, team in [(self.p1, self.t1), (self.p2, self.t2)]:
log = WorkLog.objects.create(
date=datetime.date(2026, 3, 1),
project=proj, team=team, supervisor=self.admin,
)
log.workers.add(self.w)
def test_pairs_context_key_populated(self):
import json as _json
self.client.login(username='admin-if', password='pass')
url = reverse('generate_report')
resp = self.client.get(url + '?from_month=2026-03&to_month=2026-04')
self.assertEqual(resp.status_code, 200)
pairs_json = resp.context['project_team_pairs_json']
pairs = _json.loads(pairs_json)
# Each entry has both project_id and team_id
for p in pairs:
self.assertIn('project_id', p)
self.assertIn('team_id', p)
# Expected pairs (as tuples for set comparison)
pair_set = {(p['project_id'], p['team_id']) for p in pairs}
self.assertIn((self.p1.id, self.t1.id), pair_set)
self.assertIn((self.p2.id, self.t2.id), pair_set)
def test_pairs_excludes_null_project_or_team(self):
"""Logs with null project or null team should not appear in pairs."""
import json as _json
# Add a log with team=None
log = WorkLog.objects.create(
date=datetime.date(2026, 3, 2),
project=self.p1, team=None, supervisor=self.admin,
)
log.workers.add(self.w)
self.client.login(username='admin-if', password='pass')
resp = self.client.get(reverse('generate_report') + '?from_month=2026-03&to_month=2026-04')
pairs = _json.loads(resp.context['project_team_pairs_json'])
# No pair should have team_id=None
self.assertTrue(all(p['team_id'] is not None for p in pairs))

View File

@ -2393,6 +2393,18 @@ def generate_report(request):
return qd.urlencode()
context['query_string_without_project'] = _qs_without('project')
context['query_string_without_team'] = _qs_without('team')
# === Cross-filter source: serialised (project_id, team_id) pairs ===
# The pill-popover JS on the report page uses this to hide teams that
# haven't worked on a selected project, and vice versa. Scope = entire
# history (not this report's date range) — cross-filter is about data
# possibility, not data in this period.
pairs = list(
WorkLog.objects
.filter(project__isnull=False, team__isnull=False)
.values('project_id', 'team_id')
.distinct()
)
context['project_team_pairs_json'] = json.dumps(pairs)
# Pass projects and teams so the "New Report" modal's dropdowns can
# populate (same lists the Dashboard modal uses)
context['projects'] = Project.objects.all().order_by('name')