38686-vm/core/tests.py
Konrad du Plessis 16d192d5fc Refactor _build_report_context signature to multi-value filters
project_id/team_id become project_ids/team_ids (list[int] or None).
Every internal filter uses the __in lookup; M2M filters use the
id__in subquery pattern documented in CLAUDE.md's Django ORM gotcha.
generate_report and generate_report_pdf switch to request.GET.getlist.
Old URL ?project=1 still works - getlist returns a single-element list.

Return dict gains six hero-KPI keys: current_outstanding, current_as_of,
company_avg_daily, company_avg_monthly, company_working_days,
team_project_activity - ready for the template restructure in Tasks 9-12.

Tests: 3 new multi-filter tests; existing inflation tests updated to the
new kwarg names. 42 total, all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:53:16 +02:00

752 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# === TESTS FOR WORK LOG PAYROLL CROSS-LINK ===
# Covers the _build_work_log_payroll_context helper — the core logic that
# determines, for each worker on a log, whether they were paid for it.
import datetime
from decimal import Decimal
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from core.models import Project, Team, Worker, WorkLog, PayrollRecord, PayrollAdjustment
from core.views import _build_work_log_payroll_context, _build_report_context
class WorkLogPayrollContextTests(TestCase):
"""Tests for the helper that builds the payroll-status view of a work log."""
def setUp(self):
# Minimal scenario: 1 admin, 1 project, 1 team, 3 workers, 1 log.
# Worker A has been paid for the log; Worker B is priced-not-paid;
# Worker C is unpaid.
self.admin = User.objects.create_user(username='admin', is_staff=True)
self.project = Project.objects.create(name='Test Project')
self.team = Team.objects.create(name='Team X', supervisor=self.admin)
self.worker_a = Worker.objects.create(name='Alice', id_number='A1', monthly_salary=Decimal('4000'))
self.worker_b = Worker.objects.create(name='Bob', id_number='B1', monthly_salary=Decimal('4000'))
self.worker_c = Worker.objects.create(name='Carol', id_number='C1', monthly_salary=Decimal('4000'))
self.log = WorkLog.objects.create(
date=datetime.date(2026, 4, 10),
project=self.project,
team=self.team,
supervisor=self.admin,
)
self.log.workers.add(self.worker_a, self.worker_b, self.worker_c)
# Worker A has a PayrollRecord linking them and this log — "Paid".
self.record_a = PayrollRecord.objects.create(
worker=self.worker_a,
amount_paid=Decimal('200.00'),
date=datetime.date(2026, 4, 15),
)
self.record_a.work_logs.add(self.log)
# Worker B appears in priced_workers but has no PayrollRecord — "Priced, not paid".
self.log.priced_workers.add(self.worker_b)
# Worker C has neither — "Unpaid".
def test_returns_log_and_worker_rows(self):
ctx = _build_work_log_payroll_context(self.log)
self.assertEqual(ctx['log'], self.log)
self.assertEqual(len(ctx['worker_rows']), 3)
def test_paid_worker_has_payslip_link(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_a.id)
self.assertEqual(row['status'], 'Paid')
self.assertEqual(row['payroll_record'], self.record_a)
self.assertGreater(row['earned'], 0)
def test_priced_but_unpaid_worker(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_b.id)
self.assertEqual(row['status'], 'Priced, not paid')
self.assertIsNone(row['payroll_record'])
def test_totally_unpaid_worker(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_c.id)
self.assertEqual(row['status'], 'Unpaid')
self.assertIsNone(row['payroll_record'])
def test_totals(self):
ctx = _build_work_log_payroll_context(self.log)
# Paid = Alice's daily_rate (one record exists for this log+worker).
self.assertEqual(ctx['total_paid'], self.worker_a.daily_rate)
# Outstanding = Bob + Carol each at their daily_rate.
expected = self.worker_b.daily_rate + self.worker_c.daily_rate
self.assertEqual(ctx['total_outstanding'], expected)
def test_adjustments_linked_to_log(self):
adj = PayrollAdjustment.objects.create(
worker=self.worker_a,
project=self.project,
type='Overtime',
amount=Decimal('50.00'),
date=datetime.date(2026, 4, 10),
description='Extra hour',
work_log=self.log,
)
ctx = _build_work_log_payroll_context(self.log)
self.assertIn(adj, ctx['adjustments'])
def test_pay_period_absent_if_no_schedule(self):
ctx = _build_work_log_payroll_context(self.log)
self.assertEqual(ctx['pay_period'], (None, None))
def test_pay_period_present_when_schedule_configured(self):
self.team.pay_frequency = 'weekly'
self.team.pay_start_date = datetime.date(2026, 1, 5) # A Monday
self.team.save()
ctx = _build_work_log_payroll_context(self.log)
start, end = ctx['pay_period']
self.assertIsNotNone(start)
self.assertIsNotNone(end)
self.assertLessEqual(start, self.log.date)
self.assertGreaterEqual(end, self.log.date)
def test_overtime_needs_pricing_flag(self):
"""Flag fires when log has OT hours but no priced_workers yet."""
# Start: no OT, no priced workers -> flag False
self.assertFalse(_build_work_log_payroll_context(self.log)['overtime_needs_pricing'])
# Add OT hours but keep priced_workers empty -> flag True
self.log.overtime_amount = Decimal('0.50')
self.log.priced_workers.clear()
self.log.save()
self.assertTrue(_build_work_log_payroll_context(self.log)['overtime_needs_pricing'])
# Price the OT -> flag False again
self.log.priced_workers.add(self.worker_a)
self.assertFalse(_build_work_log_payroll_context(self.log)['overtime_needs_pricing'])
def test_query_count_is_bounded(self):
"""Helper should not issue per-worker queries — guards against N+1."""
# 4 queries: payroll_records, priced_workers, workers.all, adjustments
# (plus whichever assertNumQueries overhead Django's test client adds).
# We assert a tight upper bound; regressions that add per-worker queries
# will push this well above the bound and fail the test.
with self.assertNumQueries(4):
_build_work_log_payroll_context(self.log)
def test_empty_log_returns_zero_totals(self):
"""Log with no workers: helper returns empty rows and zero totals."""
empty_log = WorkLog.objects.create(
date=datetime.date(2026, 4, 11),
project=self.project,
team=self.team,
supervisor=self.admin,
)
ctx = _build_work_log_payroll_context(empty_log)
self.assertEqual(ctx['worker_rows'], [])
self.assertEqual(ctx['total_earned'], Decimal('0.00'))
self.assertEqual(ctx['total_paid'], Decimal('0.00'))
self.assertEqual(ctx['total_outstanding'], Decimal('0.00'))
self.assertEqual(ctx['adjustments'], [])
def test_log_without_team_has_no_pay_period(self):
"""Log whose team was later soft-deleted to NULL still works."""
self.log.team = None
self.log.save()
ctx = _build_work_log_payroll_context(self.log)
self.assertEqual(ctx['pay_period'], (None, None))
# The rest of the context should still populate correctly.
self.assertEqual(len(ctx['worker_rows']), 3)
# === TESTS FOR THE WORK LOG PAYROLL AJAX ENDPOINT ===
# These cover the JSON endpoint that the Task-5 modal will consume.
# The endpoint must: return JSON to admins, forbid supervisors/anons,
# and 404 on unknown logs.
class WorkLogPayrollAjaxTests(TestCase):
"""Tests for the JSON AJAX endpoint that powers the modal."""
def setUp(self):
# One admin, one non-admin supervisor, and a simple log with one worker.
self.admin = User.objects.create_user(
username='admin', password='pass', is_staff=True
)
self.supervisor = User.objects.create_user(
username='sup', password='pass', is_staff=False
)
project = Project.objects.create(name='P')
team = Team.objects.create(name='T', supervisor=self.admin)
worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('4000'))
self.log = WorkLog.objects.create(
date=datetime.date(2026, 4, 10),
project=project, team=team, supervisor=self.admin,
)
self.log.workers.add(worker)
def test_admin_sees_200_json(self):
# Admin hits the endpoint and gets a well-formed JSON body.
self.client.login(username='admin', password='pass')
url = reverse('work_log_payroll_ajax', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data['log_id'], self.log.id)
self.assertEqual(len(data['worker_rows']), 1)
self.assertEqual(data['worker_rows'][0]['status'], 'Unpaid')
def test_supervisor_forbidden(self):
# A non-admin user (even if authenticated) gets 403 JSON.
self.client.login(username='sup', password='pass')
url = reverse('work_log_payroll_ajax', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 403)
def test_anonymous_redirected_to_login(self):
# @login_required intercepts before our view ever runs — 302 to /accounts/login/.
url = reverse('work_log_payroll_ajax', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 302)
def test_missing_log_is_404(self):
# get_object_or_404 returns a 404 if the log_id doesn't exist.
self.client.login(username='admin', password='pass')
resp = self.client.get('/history/99999/payroll/ajax/')
self.assertEqual(resp.status_code, 404)
def test_full_payload_with_paid_worker_null_team_and_ot(self):
"""One scenario exercising: paid worker, null team, OT flag, full URL."""
# Create a paid worker on a separate log with no team (null team edge case)
# and with overtime hours but no priced_workers (unpriced OT flag).
project = Project.objects.create(name='P-full')
paid_worker = Worker.objects.create(
name='Paula', id_number='P1', monthly_salary=Decimal('4000')
)
log = WorkLog.objects.create(
date=datetime.date(2026, 4, 11),
project=project,
team=None, # null team
supervisor=self.admin,
overtime_amount=Decimal('1.50'), # > 0 and no priced_workers -> flag fires
)
log.workers.add(paid_worker)
# Link a PayrollRecord so Paula shows as Paid
record = PayrollRecord.objects.create(
worker=paid_worker,
amount_paid=Decimal('250.00'),
date=datetime.date(2026, 4, 16),
)
record.work_logs.add(log)
# Link an adjustment to the same log
adj = PayrollAdjustment.objects.create(
worker=paid_worker,
project=project,
type='Overtime',
amount=Decimal('75.00'),
date=datetime.date(2026, 4, 11),
description='OT pricing',
work_log=log,
payroll_record=record,
)
self.client.login(username='admin', password='pass')
resp = self.client.get(reverse('work_log_payroll_ajax', args=[log.id]))
self.assertEqual(resp.status_code, 200)
data = resp.json()
# Null team branch
self.assertIsNone(data['team'])
# Project is still present
self.assertEqual(data['project']['name'], 'P-full')
# Paid worker serialization shape
paula_row = next(r for r in data['worker_rows'] if r['worker_name'] == 'Paula')
self.assertEqual(paula_row['status'], 'Paid')
self.assertEqual(paula_row['payroll_record_id'], record.pk)
self.assertEqual(paula_row['paid_date'], '2026-04-16')
# OT flag fires (overtime > 0, no priced_workers)
self.assertTrue(data['overtime_needs_pricing'])
# full_page_url is the reverse of work_log_payroll_detail
self.assertEqual(data['full_page_url'], f'/history/{log.id}/')
# Adjustment serialized with payslip link
self.assertEqual(len(data['adjustments']), 1)
self.assertEqual(data['adjustments'][0]['type'], 'Overtime')
self.assertEqual(data['adjustments'][0]['payroll_record_id'], record.pk)
# === TESTS FOR THE WORK LOG PAYROLL FULL-PAGE VIEW ===
# These cover the HTML page at /history/<id>/ that shares the same context
# builder as the AJAX endpoint. Admin sees a 200 HTML page; supervisor 403.
class WorkLogPayrollDetailTests(TestCase):
"""Tests for the full-page /history/<id>/ view."""
def setUp(self):
self.admin = User.objects.create_user(
username='admin', password='pass', is_staff=True
)
self.supervisor = User.objects.create_user(
username='sup', password='pass', is_staff=False
)
project = Project.objects.create(name='P2')
team = Team.objects.create(name='T2', supervisor=self.admin)
worker = Worker.objects.create(name='Wanda', id_number='X', monthly_salary=Decimal('4000'))
self.log = WorkLog.objects.create(
date=datetime.date(2026, 4, 10),
project=project, team=team, supervisor=self.admin,
)
self.log.workers.add(worker)
def test_admin_gets_full_page(self):
self.client.login(username='admin', password='pass')
url = reverse('work_log_payroll_detail', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'FoxFitt')
self.assertContains(resp, 'History')
self.assertContains(resp, 'Wanda')
def test_supervisor_forbidden(self):
self.client.login(username='sup', password='pass')
url = reverse('work_log_payroll_detail', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 403)
# =============================================================================
# === TESTS FOR PAYROLL REPORT FILTER INFLATION ===
# Regression tests for the M2M double-JOIN bug in _build_report_context.
#
# THE BUG (fixed 2026-04-23):
# Filtering a report by project AND team via chained `.filter(work_logs__field=X)`
# calls produced TWO separate JOIN aliases on the core_payrollrecord_work_logs
# M2M table. Any downstream `.values().annotate(Sum(...))` then aggregated
# across the cartesian product of matching rows, inflating every per-worker
# total_paid and every payments_by_date amount by N² (where N = number of
# matching work logs per record). Total_paid_out itself was correct because
# `.aggregate(Sum(...))` wraps distinct() in a subquery, but the per-row
# values/annotate pattern doesn't get that help.
#
# These tests lock down the fix: with filters applied, the worker-level and
# date-level totals must equal the real payment amount, not a multiplied one.
# =============================================================================
class ReportContextFilterInflationTests(TestCase):
"""Report aggregates must not inflate when project + team filters combine."""
def setUp(self):
self.admin = User.objects.create_user(username='admin-r', is_staff=True)
self.project = Project.objects.create(name='Solar Farm Gamma')
self.team = Team.objects.create(name='Team Gamma', supervisor=self.admin)
self.worker = Worker.objects.create(
name='Test Worker', id_number='TW1', monthly_salary=Decimal('4000')
)
# Worker must be in the team's M2M for the adjustment-summary test
# to find them via worker__teams__id=team_id. In real data this is
# the standard setup — workers belong to a team.
self.team.workers.add(self.worker)
# Three work logs in the range, all in the same project + team.
# This is the minimum setup that reproduces the N² inflation: with
# one payroll record linked to 3 logs, the double-JOIN produces 9 rows.
self.logs = []
for day in (5, 10, 15):
log = WorkLog.objects.create(
date=datetime.date(2026, 3, day),
project=self.project,
team=self.team,
supervisor=self.admin,
)
log.workers.add(self.worker)
self.logs.append(log)
# One payment covering all 3 logs.
self.record = PayrollRecord.objects.create(
worker=self.worker,
amount_paid=Decimal('600.00'),
date=datetime.date(2026, 3, 20),
)
self.record.work_logs.add(*self.logs)
def _ctx(self, project_ids=None, team_ids=None):
return _build_report_context(
datetime.date(2026, 3, 1),
datetime.date(2026, 3, 31),
project_ids=project_ids,
team_ids=team_ids,
)
def test_worker_breakdown_not_inflated_with_project_filter_only(self):
ctx = self._ctx(project_ids=[self.project.id])
self.assertEqual(len(ctx['worker_breakdown']), 1)
# Pre-fix: this was 600 × 3 = 1800 (one JOIN, 3-way inflation).
self.assertEqual(ctx['worker_breakdown'][0]['total_paid'], Decimal('600.00'))
def test_worker_breakdown_not_inflated_with_both_filters(self):
ctx = self._ctx(project_ids=[self.project.id], team_ids=[self.team.id])
self.assertEqual(len(ctx['worker_breakdown']), 1)
# Pre-fix: this was 600 × 3 × 3 = 5400 (two JOINs, 9-way inflation).
self.assertEqual(ctx['worker_breakdown'][0]['total_paid'], Decimal('600.00'))
def test_payments_by_date_not_inflated_with_both_filters(self):
ctx = self._ctx(project_ids=[self.project.id], team_ids=[self.team.id])
payments = list(ctx['payments_by_date'])
self.assertEqual(len(payments), 1)
self.assertEqual(payments[0]['total'], Decimal('600.00'))
def test_total_paid_out_stays_correct_with_both_filters(self):
"""Regression guard: total_paid_out was ALREADY correct pre-fix
because .aggregate() handles distinct() via a subquery. Lock it in
so a future refactor doesn't accidentally reintroduce inflation here."""
ctx = self._ctx(project_ids=[self.project.id], team_ids=[self.team.id])
self.assertEqual(ctx['total_paid_out'], Decimal('600.00'))
def test_adjustment_summary_not_inflated_with_team_filter(self):
"""Adjustments filtered by team go through worker__teams (M2M) — same
bug class. values().annotate(Sum()) would inflate if the worker is in
multiple teams or if the JOIN is chained with other M2M filters."""
PayrollAdjustment.objects.create(
worker=self.worker,
project=self.project,
type='Bonus',
amount=Decimal('100.00'),
date=datetime.date(2026, 3, 10),
description='Test bonus',
)
ctx = self._ctx(project_ids=[self.project.id], team_ids=[self.team.id])
totals = {item['type']: item['total'] for item in ctx['adjustment_totals']}
self.assertEqual(totals.get('Bonus'), Decimal('100.00'))
# =============================================================================
# === TESTS FOR SUPERVISOR PICKER QUERYSET ===
# Regression tests for the TeamForm/ProjectForm supervisor dropdown.
#
# THE BUG (fixed 2026-04-23):
# The picker required either admin flags (is_staff/is_superuser) or "Work Logger"
# group membership. Any active user NOT pre-enrolled in that specific group
# was invisible, even though the app's downstream `is_supervisor()` helper
# grants supervisor powers to anyone assigned to a team/project FK/M2M —
# regardless of group. The picker was strictly more restrictive than the
# permission model, hiding field supervisors from the person trying to assign
# them. Fix: allow any active user; let the act of assignment confer
# supervisor-ness (matching how `is_supervisor` works).
# =============================================================================
class SupervisorPickerQuerysetTests(TestCase):
"""Supervisor picker must surface all active users, not just admins."""
def test_regular_active_user_is_selectable(self):
"""A plain active user (no is_staff, no is_superuser, no Work Logger
group) must appear. Pre-fix: excluded by the Q filter."""
from core.forms import _supervisor_user_queryset
regular = User.objects.create_user(
username='field_supervisor', password='pass',
is_staff=False, is_superuser=False,
)
self.assertIn(regular, _supervisor_user_queryset())
def test_user_in_unrelated_group_is_still_selectable(self):
"""A user in some random group (not 'Work Logger') is still an active
user and must show up. Pre-fix: excluded because the Q filter specifically
checked groups__name='Work Logger'."""
from django.contrib.auth.models import Group
from core.forms import _supervisor_user_queryset
user = User.objects.create_user(username='misc_group_user', password='pass')
group, _ = Group.objects.get_or_create(name='Some Other Group')
user.groups.add(group)
self.assertIn(user, _supervisor_user_queryset())
def test_inactive_user_still_excluded(self):
"""Inactive users must NEVER appear in the picker — even if they used
to be admins or Work Loggers. Active-only is the one hard guardrail."""
from core.forms import _supervisor_user_queryset
inactive = User.objects.create_user(
username='former_employee', password='pass',
is_active=False, is_staff=True,
)
self.assertNotIn(inactive, _supervisor_user_queryset())
def test_admin_still_selectable(self):
"""Defense-in-depth: admins continue to appear (no regression for the
existing use case)."""
from core.forms import _supervisor_user_queryset
admin = User.objects.create_user(
username='an_admin', password='pass', is_staff=True
)
self.assertIn(admin, _supervisor_user_queryset())
# =============================================================================
# === TESTS FOR EXECUTIVE REPORT v2 ===
# Covers the new helpers introduced in the report rebuild (Apr 2026):
# _company_cost_velocity, _current_outstanding_in_scope, _team_project_activity,
# and the multi-filter extension of _build_report_context.
# =============================================================================
class CompanyCostVelocityTests(TestCase):
"""Company-wide avg daily and monthly labour cost (hero KPI card 3 & 4)."""
def test_empty_db_returns_zero(self):
from core.views import _company_cost_velocity
result = _company_cost_velocity()
self.assertEqual(result['avg_daily'], Decimal('0.00'))
self.assertEqual(result['avg_monthly'], Decimal('0.00'))
self.assertEqual(result['working_days'], 0)
def test_known_values(self):
from core.views import _company_cost_velocity
# Setup: 2 workers (daily_rate = 4000/20 = R 200 each), each works 5 distinct dates.
# Lifetime cost = 2 workers * 5 days * R 200 = R 2000. Working days = 5.
# Avg daily = 2000 / 5 = R 400.
# Avg monthly = 400 * 30.44 = R 12,176.
admin = User.objects.create_user(username='admin-cv', is_staff=True)
project = Project.objects.create(name='P')
w1 = Worker.objects.create(name='W1', id_number='W1', monthly_salary=Decimal('4000'))
w2 = Worker.objects.create(name='W2', id_number='W2', monthly_salary=Decimal('4000'))
for d in range(1, 6): # 5 distinct dates
log = WorkLog.objects.create(
date=datetime.date(2026, 3, d),
project=project, supervisor=admin,
)
log.workers.add(w1, w2)
result = _company_cost_velocity()
self.assertEqual(result['working_days'], 5)
self.assertEqual(result['avg_daily'], Decimal('400.00'))
# Tolerance: ±1 cent for the 30.44 multiplication
self.assertAlmostEqual(
float(result['avg_monthly']), 12176.00, delta=0.01
)
def test_duplicate_dates_not_double_counted(self):
"""Two workers working the same date = 1 distinct date, not 2."""
from core.views import _company_cost_velocity
admin = User.objects.create_user(username='admin-cv2', is_staff=True)
project = Project.objects.create(name='P2')
w1 = Worker.objects.create(name='X', id_number='X1', monthly_salary=Decimal('4000'))
w2 = Worker.objects.create(name='Y', id_number='Y1', monthly_salary=Decimal('4000'))
log = WorkLog.objects.create(
date=datetime.date(2026, 3, 1), project=project, supervisor=admin,
)
log.workers.add(w1, w2)
result = _company_cost_velocity()
self.assertEqual(result['working_days'], 1) # not 2
class CurrentOutstandingInScopeTests(TestCase):
"""Hero card 2 — 'Outstanding NOW' with optional filter scope."""
def setUp(self):
self.admin = User.objects.create_user(username='a-out', is_staff=True)
self.p1 = Project.objects.create(name='ProjA')
self.p2 = Project.objects.create(name='ProjB')
self.t1 = Team.objects.create(name='TeamA', supervisor=self.admin)
self.w = Worker.objects.create(
name='Wkr', id_number='W1', monthly_salary=Decimal('4000')
)
self.t1.workers.add(self.w)
# Unpaid log on project 1
log1 = WorkLog.objects.create(
date=datetime.date(2026, 3, 1),
project=self.p1, team=self.t1, supervisor=self.admin,
)
log1.workers.add(self.w)
# Unpaid log on project 2
log2 = WorkLog.objects.create(
date=datetime.date(2026, 3, 2),
project=self.p2, team=self.t1, supervisor=self.admin,
)
log2.workers.add(self.w)
def test_no_filters_includes_all_projects(self):
from core.views import _current_outstanding_in_scope
result = _current_outstanding_in_scope()
# daily_rate = 4000/20 = 200; 2 unpaid logs * 200 = 400
self.assertEqual(result['total'], Decimal('400.00'))
self.assertEqual(len(result['by_project']), 2)
def test_project_filter_scopes_total(self):
from core.views import _current_outstanding_in_scope
result = _current_outstanding_in_scope(project_ids=[self.p1.id])
self.assertEqual(result['total'], Decimal('200.00'))
self.assertEqual(len(result['by_project']), 1)
self.assertEqual(result['by_project'][0]['name'], 'ProjA')
def test_team_filter_scopes_total(self):
"""Team filter on work logs + worker__teams on adjustments."""
from core.views import _current_outstanding_in_scope
# Adjustment on a worker not in t1
other_worker = Worker.objects.create(
name='Other', id_number='O1', monthly_salary=Decimal('4000')
)
PayrollAdjustment.objects.create(
worker=other_worker, project=self.p1, type='Bonus',
amount=Decimal('500.00'), date=datetime.date(2026, 3, 3),
)
# With team filter, only self.w's logs appear — R 400 total
result = _current_outstanding_in_scope(team_ids=[self.t1.id])
self.assertEqual(result['total'], Decimal('400.00'))
# The R500 bonus on other_worker must NOT appear in by_project because
# that worker isn't in t1 — the team scope excludes them entirely.
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)
class ChapterOneEnrichmentTests(TestCase):
"""Chapter I — All Time Projects gains working_days and avg_per_working_day."""
def test_alltime_projects_includes_working_days_and_avg(self):
from core.views import _build_report_context
admin = User.objects.create_user(username='c1', is_staff=True)
proj = Project.objects.create(name='C1', start_date=datetime.date(2026, 1, 1))
w = Worker.objects.create(name='W', id_number='W1', monthly_salary=Decimal('4000'))
# 4 distinct dates, 1 worker each; daily_rate=200; total = R 800; working_days=4; avg=200
for d in (1, 2, 3, 4):
log = WorkLog.objects.create(
date=datetime.date(2026, 3, d), project=proj, supervisor=admin,
)
log.workers.add(w)
ctx = _build_report_context(
datetime.date(2026, 1, 1), datetime.date(2026, 12, 31),
)
by_name = {p['project']: p for p in ctx['alltime_projects']}
self.assertIn('C1', by_name)
self.assertEqual(by_name['C1']['working_days'], 4)
self.assertEqual(by_name['C1']['avg_per_working_day'], Decimal('200.00'))
self.assertEqual(by_name['C1']['start_date'], datetime.date(2026, 1, 1))
# =============================================================================
# === TESTS FOR MULTI-VALUE FILTER SUPPORT (Task 6) ===
# _build_report_context now accepts project_ids and team_ids lists.
# =============================================================================
class ReportMultiFilterTests(TestCase):
"""Task 6 — multi-value project_ids / team_ids filters."""
def setUp(self):
self.admin = User.objects.create_user(username='mf', is_staff=True)
self.p1 = Project.objects.create(name='P1')
self.p2 = Project.objects.create(name='P2')
self.p3 = Project.objects.create(name='P3')
self.team = Team.objects.create(name='T', supervisor=self.admin)
self.w = Worker.objects.create(
name='W', id_number='W1', monthly_salary=Decimal('4000')
)
self.team.workers.add(self.w)
# One log + one paid record per project
for proj in (self.p1, self.p2, self.p3):
log = WorkLog.objects.create(
date=datetime.date(2026, 3, 1),
project=proj, team=self.team, supervisor=self.admin,
)
log.workers.add(self.w)
rec = PayrollRecord.objects.create(
worker=self.w, amount_paid=Decimal('100.00'),
date=datetime.date(2026, 3, 5),
)
rec.work_logs.add(log)
def _ctx(self, project_ids=None, team_ids=None):
from core.views import _build_report_context
return _build_report_context(
datetime.date(2026, 3, 1), datetime.date(2026, 3, 31),
project_ids=project_ids, team_ids=team_ids,
)
def test_multi_project_union(self):
ctx = self._ctx(project_ids=[self.p1.id, self.p2.id])
# Two projects paid R 100 each = R 200; third excluded
self.assertEqual(ctx['total_paid_out'], Decimal('200.00'))
def test_empty_list_equals_none(self):
ctx_none = self._ctx(project_ids=None)
ctx_empty = self._ctx(project_ids=[])
self.assertEqual(ctx_none['total_paid_out'], ctx_empty['total_paid_out'])
def test_no_inflation_with_multi_project(self):
"""Worker breakdown must not inflate when multiple projects are selected."""
ctx = self._ctx(project_ids=[self.p1.id, self.p2.id, self.p3.id])
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'))