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:
parent
f146af0e35
commit
bf6f0a5c74
@ -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')
|
||||
|
||||
36
core/migrations/0014_add_absence.py
Normal file
36
core/migrations/0014_add_absence.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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}'
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user