Add _team_project_activity helper + 4 tests

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) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-22 22:38:53 +02:00
parent ccc44a8d51
commit e8ba2c6745
2 changed files with 139 additions and 0 deletions

View File

@ -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)

View File

@ -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.