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.