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,
|
ExpenseReceipt, ExpenseLineItem,
|
||||||
WorkerCertificate, WorkerWarning,
|
WorkerCertificate, WorkerWarning,
|
||||||
SiteReport,
|
SiteReport,
|
||||||
|
Absence,
|
||||||
)
|
)
|
||||||
|
|
||||||
@admin.register(UserProfile)
|
@admin.register(UserProfile)
|
||||||
@ -85,6 +86,15 @@ class WorkerWarningAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ('worker__name', 'reason', 'description')
|
search_fields = ('worker__name', 'reason', 'description')
|
||||||
date_hierarchy = 'date'
|
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)
|
@admin.register(Team)
|
||||||
class TeamAdmin(admin.ModelAdmin):
|
class TeamAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'supervisor', 'pay_frequency', 'pay_start_date', 'active')
|
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):
|
def __str__(self):
|
||||||
return f'{self.worker.name} — {self.get_severity_display()} ({self.date})'
|
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.test import TestCase
|
||||||
from django.urls import reverse
|
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
|
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}")
|
msg=f"Expected redirect to .../edit/, got {response.url}")
|
||||||
# And exactly one work log was created
|
# And exactly one work log was created
|
||||||
self.assertEqual(WorkLog.objects.count(), 1)
|
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