From e8ba2c6745358472a3f11c832c81ee0a752c81fa Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Wed, 22 Apr 2026 22:38:53 +0200 Subject: [PATCH] Add _team_project_activity helper + 4 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chapter IV pivot backend: for each (team, project) pair in the given work-logs queryset, counts distinct work-log dates. Returns columns (projects), rows (teams with cell dict), column totals, and grand total ready for direct template rendering. Logs with NULL team or NULL project are excluded (can't pivot on NULL). Teams/projects with zero activity don't appear as rows/columns — keeps the pivot tight. Tests cover shape, cell counts, row+column+grand totals, and zero-activity team omission. Co-Authored-By: Claude Opus 4.7 (1M context) --- core/tests.py | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++ core/views.py | 65 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/core/tests.py b/core/tests.py index 4aeabc8..1b734cb 100644 --- a/core/tests.py +++ b/core/tests.py @@ -594,3 +594,77 @@ class CurrentOutstandingInScopeTests(TestCase): self.assertEqual(len(result['by_project']), 2) amounts = [row['amount'] for row in result['by_project']] self.assertNotIn(Decimal('500.00'), amounts) + + +class TeamProjectActivityTests(TestCase): + """Chapter IV pivot: rows=team, columns=project, cell=distinct log dates.""" + + def setUp(self): + self.admin = User.objects.create_user(username='a-tpa', 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) + w = Worker.objects.create(name='W', id_number='W1', monthly_salary=Decimal('4000')) + + # T1 works 3 distinct dates on P1 + for d in (1, 2, 3): + log = WorkLog.objects.create( + date=datetime.date(2026, 3, d), project=self.p1, team=self.t1, + supervisor=self.admin, + ) + log.workers.add(w) + + # T2 works 2 distinct dates on P1 and 1 on P2 + for d in (4, 5): + log = WorkLog.objects.create( + date=datetime.date(2026, 3, d), project=self.p1, team=self.t2, + supervisor=self.admin, + ) + log.workers.add(w) + log = WorkLog.objects.create( + date=datetime.date(2026, 3, 6), project=self.p2, team=self.t2, + supervisor=self.admin, + ) + log.workers.add(w) + + self.logs_qs = WorkLog.objects.filter( + date__gte=datetime.date(2026, 3, 1), + date__lte=datetime.date(2026, 3, 31), + ) + + def test_pivot_shape(self): + from core.views import _team_project_activity + r = _team_project_activity(self.logs_qs) + # 2 columns (P1, P2), 2 rows (T1, T2) + self.assertEqual(len(r['columns']), 2) + self.assertEqual(len(r['rows']), 2) + + def test_cell_counts(self): + from core.views import _team_project_activity + r = _team_project_activity(self.logs_qs) + rows = {row['team_name']: row for row in r['rows']} + # T1 has 3 days on P1, 0 on P2 + self.assertEqual(rows['T1']['cells_by_project_id'][self.p1.id], 3) + self.assertEqual(rows['T1']['cells_by_project_id'].get(self.p2.id, 0), 0) + # T2 has 2 days on P1, 1 on P2 + self.assertEqual(rows['T2']['cells_by_project_id'][self.p1.id], 2) + self.assertEqual(rows['T2']['cells_by_project_id'][self.p2.id], 1) + + def test_row_and_column_totals(self): + from core.views import _team_project_activity + r = _team_project_activity(self.logs_qs) + rows = {row['team_name']: row for row in r['rows']} + self.assertEqual(rows['T1']['row_total'], 3) + self.assertEqual(rows['T2']['row_total'], 3) + self.assertEqual(r['col_totals'][self.p1.id], 5) + self.assertEqual(r['col_totals'][self.p2.id], 1) + self.assertEqual(r['grand_total'], 6) + + def test_team_with_no_logs_omitted(self): + """Team with zero logs in the period should not appear as a row.""" + from core.views import _team_project_activity + Team.objects.create(name='GhostTeam', supervisor=self.admin) + r = _team_project_activity(self.logs_qs) + team_names = [row['team_name'] for row in r['rows']] + self.assertNotIn('GhostTeam', team_names) diff --git a/core/views.py b/core/views.py index 7fbc9e8..a21bc3b 100644 --- a/core/views.py +++ b/core/views.py @@ -279,6 +279,71 @@ def _current_outstanding_in_scope(project_ids=None, team_ids=None): } +# ============================================================================= +# === TEAM × PROJECT ACTIVITY PIVOT === +# Chapter IV of the executive report: "how many days did each team work +# on each project in this period?" Cell value = COUNT(DISTINCT work_log.date). +# Logs with no team (team IS NULL) are excluded — the pivot is meaningless +# without a team axis. +# ============================================================================= + +def _team_project_activity(work_logs_qs): + """Return pivot data for team × project activity within a work-logs queryset. + + Plain-English: for each team-project pair represented in the given + queryset, counts the number of distinct calendar dates the team worked + on that project. Rows and columns include only teams/projects that + actually appeared (zero-activity teams/projects aren't shown). + """ + # Narrow to logs that have both a team and a project (we can't pivot + # on NULL axes; also filters out the "No Project" ghost rows). + qs = work_logs_qs.filter(team__isnull=False, project__isnull=False) + + # Aggregate: (team_id, project_id) -> distinct dates + rows_data = qs.values( + 'team_id', 'team__name', 'project_id', 'project__name' + ).annotate(days=Count('date', distinct=True)).order_by('team__name') + + # Build column list (unique projects, ordered by name) + columns_seen = {} + for r in rows_data: + columns_seen.setdefault(r['project_id'], r['project__name']) + columns = [ + {'id': pid, 'name': pname} + for pid, pname in sorted(columns_seen.items(), key=lambda kv: kv[1]) + ] + + # Build rows: team_id -> cells_by_project_id dict + rows_by_team = {} # team_id -> {'team_id', 'team_name', 'cells_by_project_id', 'row_total'} + col_totals = {col['id']: 0 for col in columns} + grand_total = 0 + + for r in rows_data: + tid = r['team_id'] + pid = r['project_id'] + days = r['days'] + row = rows_by_team.setdefault(tid, { + 'team_id': tid, + 'team_name': r['team__name'], + 'cells_by_project_id': {}, + 'row_total': 0, + }) + row['cells_by_project_id'][pid] = days + row['row_total'] += days + col_totals[pid] += days + grand_total += days + + # Ordered rows list (by team name) + rows = sorted(rows_by_team.values(), key=lambda r: r['team_name']) + + return { + 'columns': columns, + 'rows': rows, + 'col_totals': col_totals, + 'grand_total': grand_total, + } + + # === HOME DASHBOARD === # The main page users see after logging in. Shows different content # depending on whether the user is an admin or supervisor.