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:
parent
b0aa35661b
commit
5720ca95ad
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user