AJAX endpoint returns JSON payload for work log payroll modal

work_log_payroll_ajax serializes the helper's output to JSON with
floats (not Decimals), ISO dates, and payroll_record/worker IDs for
client-side link construction. Admin-only; supervisor = 403, anon =
302, unknown log = 404. Matches the worker_lookup_ajax pattern.

Added 4 view-level tests (total 16 passing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-22 14:57:16 +02:00
parent b0aa35661b
commit 5720ca95ad
2 changed files with 119 additions and 4 deletions

View File

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

View File

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