From bf6f0a5c74d9e7af02eb283e1dc9a231a00c158e Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Thu, 14 May 2026 19:22:06 +0200 Subject: [PATCH] feat(absences): Absence model + migration + admin registration Per-worker dated records with 8 reason choices (Sick/Family/Annual/ Personal-Unpaid/IOD/Suspension/Absconded/Other), is_paid flag, optional OneToOne to PayrollAdjustment for the auto-Bonus path, audit fields. Unique-per-day at DB layer. 4 model tests. --- core/admin.py | 10 +++++ core/migrations/0014_add_absence.py | 36 ++++++++++++++++ core/models.py | 67 +++++++++++++++++++++++++++++ core/tests.py | 52 +++++++++++++++++++++- 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 core/migrations/0014_add_absence.py diff --git a/core/admin.py b/core/admin.py index 7085cca..c20a78b 100644 --- a/core/admin.py +++ b/core/admin.py @@ -5,6 +5,7 @@ from .models import ( ExpenseReceipt, ExpenseLineItem, WorkerCertificate, WorkerWarning, SiteReport, + Absence, ) @admin.register(UserProfile) @@ -85,6 +86,15 @@ class WorkerWarningAdmin(admin.ModelAdmin): search_fields = ('worker__name', 'reason', 'description') date_hierarchy = 'date' +@admin.register(Absence) +class AbsenceAdmin(admin.ModelAdmin): + list_display = ('worker', 'date', 'reason', 'is_paid', 'logged_by', 'created_at') + list_filter = ('reason', 'is_paid', 'date') + search_fields = ('worker__name', 'worker__id_number', 'notes') + raw_id_fields = ('worker', 'logged_by', 'payroll_adjustment') + readonly_fields = ('created_at', 'updated_at') + date_hierarchy = 'date' + @admin.register(Team) class TeamAdmin(admin.ModelAdmin): list_display = ('name', 'supervisor', 'pay_frequency', 'pay_start_date', 'active') diff --git a/core/migrations/0014_add_absence.py b/core/migrations/0014_add_absence.py new file mode 100644 index 0000000..9f5d4bd --- /dev/null +++ b/core/migrations/0014_add_absence.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.7 on 2026-05-14 17:19 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0013_add_site_report'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Absence', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(default=django.utils.timezone.now)), + ('reason', models.CharField(choices=[('sick', 'Sick'), ('family', 'Family Responsibility'), ('annual', 'Annual Leave'), ('unpaid', 'Personal / Unpaid Leave'), ('iod', 'Injury on Duty'), ('suspension', 'Suspension'), ('absconded', 'Absconded'), ('other', 'Other')], max_length=20)), + ('notes', models.TextField(blank=True, help_text='Free-form context (e.g. "flu, doctor\'s note", "bus broke down").')), + ('is_paid', models.BooleanField(default=False, help_text='Tick to pay the worker their daily rate for this day.')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('logged_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='absences_logged', to=settings.AUTH_USER_MODEL)), + ('payroll_adjustment', models.OneToOneField(blank=True, help_text='Auto-created when is_paid=True. Cleared if is_paid is unchecked.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='absence', to='core.payrolladjustment')), + ('worker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absences', to='core.worker')), + ], + options={ + 'ordering': ['-date', '-created_at'], + 'unique_together': {('worker', 'date')}, + }, + ), + ] diff --git a/core/models.py b/core/models.py index 60893e2..025e984 100644 --- a/core/models.py +++ b/core/models.py @@ -454,3 +454,70 @@ class WorkerWarning(models.Model): def __str__(self): return f'{self.worker.name} — {self.get_severity_display()} ({self.date})' + + +class Absence(models.Model): + """Per-worker, dated record of a day NOT worked. The supervisor or + admin captured WHY (sick, annual leave, absconded, etc.) and + optionally whether the day is to be paid at the worker's daily rate. + + When `is_paid` is True the save flow (in views) creates a Bonus + PayrollAdjustment for that worker on that date, linked via the + OneToOneField below. Edit/delete of the Absence propagates the + adjustment (mirrors the Advance Payment -> Loan -> Repayment + cascade pattern in payroll). + """ + + # === ABSENCE REASONS === + # SA labour-relations terminology. The DB stores the snake_case key; + # users see the human label via get_reason_display(). + REASON_CHOICES = [ + ('sick', 'Sick'), + ('family', 'Family Responsibility'), + ('annual', 'Annual Leave'), + ('unpaid', 'Personal / Unpaid Leave'), + ('iod', 'Injury on Duty'), + ('suspension', 'Suspension'), + ('absconded', 'Absconded'), + ('other', 'Other'), + ] + + worker = models.ForeignKey( + Worker, related_name='absences', on_delete=models.CASCADE, + ) + date = models.DateField(default=timezone.now) + reason = models.CharField(max_length=20, choices=REASON_CHOICES) + notes = models.TextField( + blank=True, + help_text='Free-form context (e.g. "flu, doctor\'s note", "bus broke down").', + ) + + # === PAYROLL LINK === + is_paid = models.BooleanField( + default=False, + help_text='Tick to pay the worker their daily rate for this day.', + ) + payroll_adjustment = models.OneToOneField( + 'PayrollAdjustment', + on_delete=models.SET_NULL, + null=True, blank=True, + related_name='absence', + help_text='Auto-created when is_paid=True. Cleared if is_paid is unchecked.', + ) + + # === AUDIT === + logged_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + related_name='absences_logged', + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + # One absence per worker per day — enforced at the DB layer. + unique_together = [('worker', 'date')] + # Newest first on lists. + ordering = ['-date', '-created_at'] + + def __str__(self): + return f'{self.worker.name} — {self.get_reason_display()} — {self.date:%d %b %Y}' diff --git a/core/tests.py b/core/tests.py index 0fff471..924e8e7 100644 --- a/core/tests.py +++ b/core/tests.py @@ -10,7 +10,7 @@ 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, Loan +from core.models import Project, Team, Worker, WorkLog, PayrollRecord, PayrollAdjustment, Loan, Absence from core.views import _build_work_log_payroll_context, _build_report_context @@ -1684,3 +1684,53 @@ class AttendanceLogRedirectsToSiteReportTests(TestCase): msg=f"Expected redirect to .../edit/, got {response.url}") # And exactly one work log was created self.assertEqual(WorkLog.objects.count(), 1) + + +# ==================================================================== +# === Worker Absence — Phase 1: Model layer ========================== +# ==================================================================== + +from datetime import date as _date +from django.db import IntegrityError + + +class AbsenceModelTests(TestCase): + """Per-worker dated absence records. Mirrors WorkerWarning shape.""" + + @classmethod + def setUpTestData(cls): + cls.worker = Worker.objects.create( + name='Joe Mokoena', id_number='8001015800086', + monthly_salary=Decimal('6000.00'), + ) + + def test_defaults(self): + """is_paid defaults to False; payroll_adjustment is null; date defaults to today.""" + a = Absence.objects.create(worker=self.worker, reason='sick') + # Re-fetch from DB so the DateField default (timezone.now) is coerced + # from datetime → date by the DB layer (mirrors WorkerWarning pattern). + a.refresh_from_db() + self.assertFalse(a.is_paid) + self.assertIsNone(a.payroll_adjustment) + self.assertEqual(a.date, _date.today()) + + def test_unique_per_worker_per_day(self): + """Cannot have two absences for the same worker on the same day.""" + d = _date(2026, 5, 14) + Absence.objects.create(worker=self.worker, date=d, reason='sick') + with self.assertRaises(IntegrityError): + Absence.objects.create(worker=self.worker, date=d, reason='family') + + def test_ordering_newest_first(self): + """Default queryset order is -date.""" + Absence.objects.create(worker=self.worker, date=_date(2026, 5, 10), reason='sick') + Absence.objects.create(worker=self.worker, date=_date(2026, 5, 15), reason='annual') + Absence.objects.create(worker=self.worker, date=_date(2026, 5, 12), reason='other') + dates = list(Absence.objects.values_list('date', flat=True)) + self.assertEqual(dates, [_date(2026, 5, 15), _date(2026, 5, 12), _date(2026, 5, 10)]) + + def test_reverse_accessor_on_worker(self): + """worker.absences.all() works (related_name='absences').""" + Absence.objects.create(worker=self.worker, date=_date(2026, 5, 1), reason='sick') + Absence.objects.create(worker=self.worker, date=_date(2026, 5, 2), reason='family') + self.assertEqual(self.worker.absences.count(), 2)