diff --git a/core/tests.py b/core/tests.py index e3429ae..07b6c28 100644 --- a/core/tests.py +++ b/core/tests.py @@ -7,6 +7,7 @@ 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 @@ -156,3 +157,59 @@ class WorkLogPayrollContextTests(TestCase): 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) diff --git a/core/views.py b/core/views.py index dd0e8ab..b25deee 100644 --- a/core/views.py +++ b/core/views.py @@ -17,6 +17,7 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required from django.http import JsonResponse, HttpResponseForbidden, HttpResponse from django.middleware.csrf import get_token +from django.urls import reverse from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.utils.html import strip_tags @@ -815,12 +816,69 @@ def _build_work_log_payroll_context(log): @login_required def work_log_payroll_ajax(request, log_id): - """Return JSON describing the payroll status of a work log.""" - # Stub — implemented in Task 3 + """Return JSON describing the payroll status of a work log. + + Admin-only. The modal's JS builds its DOM from this JSON using + textContent/createElement (matches the worker_lookup_ajax pattern). + """ + # Only admins can see this data (salaries, adjustments, etc.) if not is_admin(request.user): return JsonResponse({'error': 'Not authorized'}, status=403) - get_object_or_404(WorkLog, id=log_id) - return JsonResponse({'stub': True}) + + # Fetch the log with related objects pre-loaded to avoid extra queries + log = get_object_or_404( + WorkLog.objects.select_related('project', 'team', 'supervisor'), + id=log_id, + ) + # Shared helper also used by the full-page view (Task 4) — keeps the + # JSON payload and the HTML view in perfect sync. + ctx = _build_work_log_payroll_context(log) + + # === SERIALIZE FOR JSON === + # JSON can't represent Decimals or dates natively, so we convert: + # - Decimal -> float (JS does math in floats anyway) + # - date -> ISO 8601 string ("2026-04-10") + def _date_iso(d): + return d.strftime('%Y-%m-%d') if d else None + + # One dict per worker row — small, hand-picked fields the modal needs. + worker_rows = [{ + 'worker_id': row['worker'].id, + 'worker_name': row['worker'].name, + 'worker_active': row['worker'].active, + 'status': row['status'], + 'earned': float(row['earned']), + 'payroll_record_id': row['payroll_record'].pk if row['payroll_record'] else None, + 'paid_date': _date_iso(row['paid_date']), + } for row in ctx['worker_rows']] + + # Adjustments linked directly to this work_log (Overtime, etc.). + adjustments = [{ + 'type': adj.type, + 'amount': float(adj.amount), + 'worker_id': adj.worker.id, + 'worker_name': adj.worker.name, + 'payroll_record_id': adj.payroll_record.pk if adj.payroll_record else None, + } for adj in ctx['adjustments']] + + return JsonResponse({ + 'log_id': log.id, + 'date': _date_iso(log.date), + 'project': {'id': log.project.id, 'name': log.project.name} if log.project else None, + 'team': {'id': log.team.id, 'name': log.team.name} if log.team else None, + # get_full_name() returns "" if no first/last, so fall back to username. + 'supervisor': (log.supervisor.get_full_name() or log.supervisor.username) if log.supervisor else None, + 'worker_rows': worker_rows, + 'adjustments': adjustments, + 'total_earned': float(ctx['total_earned']), + 'total_paid': float(ctx['total_paid']), + 'total_outstanding': float(ctx['total_outstanding']), + 'pay_period_start': _date_iso(ctx['pay_period'][0]), + 'pay_period_end': _date_iso(ctx['pay_period'][1]), + 'overtime_needs_pricing': ctx['overtime_needs_pricing'], + # Link to the full-page view (Task 4) for the "Open full page" button. + 'full_page_url': reverse('work_log_payroll_detail', args=[log.id]), + }) @login_required