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.
This commit is contained in:
Konrad du Plessis 2026-05-14 19:22:06 +02:00
parent f146af0e35
commit bf6f0a5c74
4 changed files with 164 additions and 1 deletions

View File

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

View File

@ -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')},
},
),
]

View File

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

View File

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