fix(report): stop double-encoding project_team_pairs_json for pill cross-filter

Task 1 set context['project_team_pairs_json'] = json.dumps(pairs), then the
template rendered it with |json_script — which also calls json.dumps on the
value. Result was a JSON-encoded string-of-a-string in the <script
id="projectTeamPairs"> tag, so JSON.parse() returned a string (not a list)
and the pill-popover IIFE died on pairs.forEach(...). Symptom: all three
filter pills clickable but unresponsive.

Fix: pass the raw Python list; let |json_script own the serialisation (the
established pattern for team_workers_map_json and the other *_json keys on
the payroll dashboard).

Tests updated to read the raw list from resp.context. Added an end-to-end
regression test that extracts the rendered <script id="projectTeamPairs">
payload and asserts JSON.parse() would return a list (not a string) —
catches any future regression of this class even if the test suite and the
view drift apart.

Verified in the browser: all three pill popovers now open on click and
Choices.js lazy-initialises correctly for projects/teams.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-23 10:20:58 +02:00
parent 6d2c72f6d1
commit 5c4162d2eb
2 changed files with 46 additions and 6 deletions

View File

@ -783,13 +783,14 @@ class InlineFiltersPairsContextTests(TestCase):
log.workers.add(self.w)
def test_pairs_context_key_populated(self):
import json as _json
# The context value is a raw Python list of dicts; Django's
# |json_script filter handles the single JSON serialisation at
# template render time (no double-encoding).
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)
pairs = resp.context['project_team_pairs_json']
# Each entry has both project_id and team_id
for p in pairs:
self.assertIn('project_id', p)
@ -801,7 +802,6 @@ class InlineFiltersPairsContextTests(TestCase):
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),
@ -811,6 +811,39 @@ class InlineFiltersPairsContextTests(TestCase):
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'])
pairs = 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))
def test_pairs_renders_as_valid_json_in_template(self):
"""End-to-end: the rendered HTML must contain a single, valid JSON
array inside the <script id="projectTeamPairs"> tag NOT a
JSON-encoded string (which was the bug that broke all pill
interactions before the context key was changed from
`json.dumps(pairs)` to raw `pairs`)."""
import json as _json
import re
self.client.login(username='admin-if', password='pass')
resp = self.client.get(reverse('generate_report') + '?from_month=2026-03&to_month=2026-04')
html = resp.content.decode('utf-8')
# Extract the JSON payload inside <script id="projectTeamPairs">...</script>
match = re.search(
r'<script id="projectTeamPairs"[^>]*>(.*?)</script>',
html, re.DOTALL
)
self.assertIsNotNone(match, 'projectTeamPairs <script> tag missing')
payload = match.group(1).strip()
# Must parse to a LIST, not a string.
parsed = _json.loads(payload)
self.assertIsInstance(parsed, list,
"Double-encoded JSON regression: browser's JSON.parse "
"would return a string here, killing pairs.forEach() in the "
"pill-popover JS. See 2026-04-23 bugfix.")
# And the list members must be dicts with project_id + team_id
for entry in parsed:
self.assertIsInstance(entry, dict)
self.assertIn('project_id', entry)
self.assertIn('team_id', entry)

View File

@ -2404,7 +2404,14 @@ def generate_report(request):
.values('project_id', 'team_id')
.distinct()
)
context['project_team_pairs_json'] = json.dumps(pairs)
# Pass the raw Python list — Django's |json_script filter does the single,
# XSS-safe JSON serialization at template render time. Calling json.dumps()
# here would double-encode: json_script would re-serialize the already-JSON
# string, producing `"[{\"project_id\": 1, ...}]"` — a quoted string literal
# that JSON.parse() returns as a string, breaking `pairs.forEach(...)` in
# the pill-popover JS. Matches the pattern used for team_workers_map_json
# and other *_json context keys on the payroll dashboard.
context['project_team_pairs_json'] = 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')