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.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
from core.models import Project, Team, Worker, WorkLog, PayrollRecord, PayrollAdjustment from core.models import Project, Team, Worker, WorkLog, PayrollRecord, PayrollAdjustment
from core.views import _build_work_log_payroll_context from core.views import _build_work_log_payroll_context
@ -156,3 +157,59 @@ class WorkLogPayrollContextTests(TestCase):
self.assertEqual(ctx['pay_period'], (None, None)) self.assertEqual(ctx['pay_period'], (None, None))
# The rest of the context should still populate correctly. # The rest of the context should still populate correctly.
self.assertEqual(len(ctx['worker_rows']), 3) 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.contrib.auth.decorators import login_required
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
from django.middleware.csrf import get_token from django.middleware.csrf import get_token
from django.urls import reverse
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.html import strip_tags from django.utils.html import strip_tags
@ -815,12 +816,69 @@ def _build_work_log_payroll_context(log):
@login_required @login_required
def work_log_payroll_ajax(request, log_id): def work_log_payroll_ajax(request, log_id):
"""Return JSON describing the payroll status of a work log.""" """Return JSON describing the payroll status of a work log.
# Stub — implemented in Task 3
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): if not is_admin(request.user):
return JsonResponse({'error': 'Not authorized'}, status=403) 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 @login_required