From 32972276b59a44402539519de5c0148d06cd1634 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Thu, 14 May 2026 22:03:04 +0200 Subject: [PATCH] feat(absences): add optional project FK on Absence Migration 0015 adds Project FK (SET_NULL, nullable) to Absence. When is_paid=True, the auto-Bonus PayrollAdjustment inherits the project for cost-attribution. Form + admin + list + edit + log templates expose the field. List view filter now uses absence.project_id directly (was indirect via worker__work_logs). 5 new tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- core/admin.py | 12 +- core/forms.py | 55 ++++++- core/migrations/0015_absence_project.py | 19 +++ core/models.py | 14 ++ core/templates/core/absences/edit.html | 6 + core/templates/core/absences/list.html | 7 +- core/templates/core/absences/log.html | 15 ++ core/templates/core/absences/log_confirm.html | 7 +- core/tests.py | 136 ++++++++++++++++++ core/views.py | 72 +++++++++- 10 files changed, 325 insertions(+), 18 deletions(-) create mode 100644 core/migrations/0015_absence_project.py diff --git a/core/admin.py b/core/admin.py index c20a78b..66e717a 100644 --- a/core/admin.py +++ b/core/admin.py @@ -88,10 +88,16 @@ class WorkerWarningAdmin(admin.ModelAdmin): @admin.register(Absence) class AbsenceAdmin(admin.ModelAdmin): - list_display = ('worker', 'date', 'reason', 'is_paid', 'logged_by', 'created_at') - list_filter = ('reason', 'is_paid', 'date') + # `project` shown alongside reason/is_paid so the admin index reads as + # "worker — project — reason — paid" at a glance. + list_display = ('worker', 'project', 'date', 'reason', 'is_paid', 'logged_by', 'created_at') + # `project` filter sits next to reason — handy for "which workers were + # absent on Solar Farm Alpha last month". + list_filter = ('reason', 'is_paid', 'project', 'date') search_fields = ('worker__name', 'worker__id_number', 'notes') - raw_id_fields = ('worker', 'logged_by', 'payroll_adjustment') + # `project` is a small set normally but raw_id keeps the form fast + # even if it grows. Same treatment as the other FKs. + raw_id_fields = ('worker', 'logged_by', 'payroll_adjustment', 'project') readonly_fields = ('created_at', 'updated_at') date_hierarchy = 'date' diff --git a/core/forms.py b/core/forms.py index d7febf7..f7492e1 100644 --- a/core/forms.py +++ b/core/forms.py @@ -668,9 +668,12 @@ class AbsenceLogForm(forms.ModelForm): class Meta: model = Absence - fields = ['date', 'reason', 'is_paid', 'notes'] + # `project` slots in between date and reason — it's part of the + # "what happened" header, not a per-row notes detail. Optional. + fields = ['date', 'project', 'reason', 'is_paid', 'notes'] widgets = { 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'project': forms.Select(attrs={'class': 'form-select'}), 'reason': forms.Select(attrs={'class': 'form-select'}), 'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}), @@ -679,8 +682,13 @@ class AbsenceLogForm(forms.ModelForm): def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) self.user = user - # Supervisor scope: limit team + workers querysets to the user's - # reach. Admins (staff/superuser) keep the default "all active" lists. + # Project is optional — admins can leave it blank for non-project + # absences (e.g. Annual Leave). When set + is_paid=True, the auto- + # created Bonus PayrollAdjustment will inherit it for cost-attribution. + self.fields['project'].required = False + # Supervisor scope: limit team + workers + project querysets to + # the user's reach. Admins (staff/superuser) keep the default + # "all active" lists. if user is not None and not (user.is_staff or user.is_superuser): self.fields['team'].queryset = ( Team.objects.filter(active=True, supervisor=user) @@ -695,6 +703,14 @@ class AbsenceLogForm(forms.ModelForm): teams__active=True, ).distinct() ) + # Project dropdown — only projects this supervisor is assigned to. + # Mirrors the AttendanceLogForm supervisor scoping pattern. + self.fields['project'].queryset = Project.objects.filter( + active=True, supervisors=user, + ) + else: + # Admins see every active project. + self.fields['project'].queryset = Project.objects.filter(active=True) def clean(self): cleaned = super().clean() @@ -787,13 +803,29 @@ class AbsenceQuickForm(forms.ModelForm): class Meta: model = Absence - fields = ['reason', 'is_paid', 'notes'] + # `project` is optional — the modal may be opened from a worker row + # that already has a current project context (e.g. quick-mark from + # /attendance/log/), in which case the view can pre-fill it. + fields = ['project', 'reason', 'is_paid', 'notes'] widgets = { + 'project': forms.Select(attrs={'class': 'form-select'}), 'reason': forms.Select(attrs={'class': 'form-select'}), 'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'notes': forms.Textarea(attrs={'rows': 2, 'class': 'form-control'}), } + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + self.fields['project'].required = False + # Supervisor scope: project dropdown only shows their assigned projects. + # Admin / staff sees every active project. + if user is not None and not (user.is_staff or user.is_superuser): + self.fields['project'].queryset = Project.objects.filter( + active=True, supervisors=user, + ) + else: + self.fields['project'].queryset = Project.objects.filter(active=True) + class AbsenceEditForm(forms.ModelForm): """Edit one existing Absence. Lets admin correct worker/date as well @@ -802,10 +834,13 @@ class AbsenceEditForm(forms.ModelForm): class Meta: model = Absence - fields = ['worker', 'date', 'reason', 'is_paid', 'notes'] + # `project` is editable — admins can add or change the project link + # after the fact. Optional (matches the model's blank=True, null=True). + fields = ['worker', 'date', 'project', 'reason', 'is_paid', 'notes'] widgets = { 'worker': forms.Select(attrs={'class': 'form-select'}), 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'project': forms.Select(attrs={'class': 'form-select'}), 'reason': forms.Select(attrs={'class': 'form-select'}), 'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}), @@ -815,16 +850,24 @@ class AbsenceEditForm(forms.ModelForm): super().__init__(*args, **kwargs) # Default: every active worker. Admins (staff/superuser) keep this list. self.fields['worker'].queryset = Worker.objects.filter(active=True) + # Project is optional — leave blank for non-project absences. + self.fields['project'].required = False # Supervisor scope: when a non-admin opens the edit form, the worker # dropdown is restricted to workers on their own active supervised # teams. Prevents a supervisor from silently re-assigning an absence - # to a worker they don't supervise. + # to a worker they don't supervise. Project dropdown also scoped + # to supervisor's assigned projects. if user is not None and not (user.is_staff or user.is_superuser): self.fields['worker'].queryset = Worker.objects.filter( active=True, teams__supervisor=user, teams__active=True, ).distinct() + self.fields['project'].queryset = Project.objects.filter( + active=True, supervisors=user, + ) + else: + self.fields['project'].queryset = Project.objects.filter(active=True) def clean(self): cleaned = super().clean() diff --git a/core/migrations/0015_absence_project.py b/core/migrations/0015_absence_project.py new file mode 100644 index 0000000..37db385 --- /dev/null +++ b/core/migrations/0015_absence_project.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.7 on 2026-05-14 19:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0014_add_absence'), + ] + + operations = [ + migrations.AddField( + model_name='absence', + name='project', + field=models.ForeignKey(blank=True, help_text='Which project was this worker absent from? (optional)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='absences', to='core.project'), + ), + ] diff --git a/core/models.py b/core/models.py index 025e984..0d5be01 100644 --- a/core/models.py +++ b/core/models.py @@ -485,6 +485,20 @@ class Absence(models.Model): worker = models.ForeignKey( Worker, related_name='absences', on_delete=models.CASCADE, ) + # === PROJECT LINK (optional) === + # Records which project the worker was absent FROM that day. Optional + # because not every absence is project-specific (e.g. "Annual Leave" + # might not be tied to a project). When set AND is_paid=True, the + # auto-created Bonus PayrollAdjustment inherits this project for + # cost-attribution. SET_NULL on delete so we keep the absence record + # (HR audit trail) even if the project is later removed. + project = models.ForeignKey( + 'Project', + on_delete=models.SET_NULL, + null=True, blank=True, + related_name='absences', + help_text='Which project was this worker absent from? (optional)', + ) date = models.DateField(default=timezone.now) reason = models.CharField(max_length=20, choices=REASON_CHOICES) notes = models.TextField( diff --git a/core/templates/core/absences/edit.html b/core/templates/core/absences/edit.html index 10d9046..ed364e5 100644 --- a/core/templates/core/absences/edit.html +++ b/core/templates/core/absences/edit.html @@ -49,6 +49,12 @@ form. {{ form.date }} {{ form.date.errors }} +
+ + {{ form.project }} + {{ form.project.errors }} + Which project was the worker absent from? +
{{ form.reason }} diff --git a/core/templates/core/absences/list.html b/core/templates/core/absences/list.html index 7e25a1b..fe51a76 100644 --- a/core/templates/core/absences/list.html +++ b/core/templates/core/absences/list.html @@ -120,6 +120,7 @@ has an inline delete form. CSV export button only shows for admin. Date Worker + Project Reason Paid? Logged by @@ -132,6 +133,9 @@ has an inline delete form. CSV export button only shows for admin. {{ a.date|date:"d M Y" }} {{ a.worker.name }} + {# Project column — shows the direct Absence.project FK, #} + {# or an em-dash for non-project absences. #} + {{ a.project.name|default:"—" }} {{ a.get_reason_display }} {% if a.is_paid %} @@ -156,7 +160,8 @@ has an inline delete form. CSV export button only shows for admin. {% empty %} - No absences match the filters. + {# Colspan bumped from 7 → 8 to cover the new Project column. #} + No absences match the filters. {% endfor %} diff --git a/core/templates/core/absences/log.html b/core/templates/core/absences/log.html index f683c32..d4931eb 100644 --- a/core/templates/core/absences/log.html +++ b/core/templates/core/absences/log.html @@ -72,6 +72,21 @@ them from the WorkLog).
+ {# === PROJECT (optional) === #} + {# Slots above Reason — when set + Paid ticked, the auto-Bonus #} + {# inherits this project so paid-absence cost shows up against #} + {# the right project on payroll dashboards. #} +
+
+ + {{ form.project }} + {{ form.project.errors }} + Which project was the worker absent from? Leave blank for non-project absences. +
+
+ +
+ {# === REASON + PAID FLAG === #}
diff --git a/core/templates/core/absences/log_confirm.html b/core/templates/core/absences/log_confirm.html index 8b412a5..7c228d0 100644 --- a/core/templates/core/absences/log_confirm.html +++ b/core/templates/core/absences/log_confirm.html @@ -62,7 +62,12 @@ WorkLog intact, so partial-day cases work too.

{{ absence_count }} absence(s) will be created:

-

Reason: {{ reason }}. Paid: {% if is_paid %}Yes{% else %}No{% endif %}.

+

+ Reason: {{ reason }}. + Paid: {% if is_paid %}Yes{% else %}No{% endif %}. + {# Project line is conditional — older flows / non-project absences leave it blank. #} + {% if project_name %}Project: {{ project_name }}.{% endif %} +

← Back to form diff --git a/core/tests.py b/core/tests.py index e9b8c91..9627f4f 100644 --- a/core/tests.py +++ b/core/tests.py @@ -2435,3 +2435,139 @@ class AbsenceExportCSVTests(TestCase): self.client.force_login(self.sup) resp = self.client.get('/absences/export/') self.assertEqual(resp.status_code, 403) + + +# ============================================================================= +# === ABSENCE PROJECT FK TESTS (Round B) === +# Tests for migration 0015_absence_project — adds an optional Project FK +# to Absence so paid absences can be cost-attributed to the right project, +# and so admin can filter "which workers were absent on Solar Farm X?". +# ============================================================================= +class AbsenceProjectTests(TestCase): + """Round B — Absence.project FK + project propagation to Bonus adjustment.""" + + @classmethod + def setUpTestData(cls): + # Admin user for the form-submit tests. + cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True) + # Worker with a known daily rate (6000 / 20 = 300). + cls.worker = Worker.objects.create( + name='W', id_number='1', monthly_salary=Decimal('6000'), + ) + # Project the absence will be linked to. + cls.project = Project.objects.create(name='Solar Farm Alpha') + # Team membership so worker is in the supervisor's reach if needed + # (not strictly required for these tests, but keeps the setup + # consistent with the rest of the absence-tests block above). + cls.team = Team.objects.create(name='T', supervisor=cls.admin) + cls.team.workers.add(cls.worker) + + def test_project_on_absence_model_nullable(self): + """Absence.project is optional — can be None or a real project.""" + # Default: project is null. + a = Absence.objects.create(worker=self.worker, reason='sick') + self.assertIsNone(a.project) + # Setting it works. + a.project = self.project + a.save() + a.refresh_from_db() + self.assertEqual(a.project, self.project) + + def test_paid_absence_with_project_propagates_to_bonus(self): + """When is_paid=True, the auto-Bonus inherits absence.project for cost attribution.""" + from core.views import _sync_absence_payroll_adjustment + a = Absence.objects.create( + worker=self.worker, reason='sick', is_paid=True, project=self.project, + ) + _sync_absence_payroll_adjustment(a) + a.refresh_from_db() + # The Bonus adjustment carries the same project as the absence. + self.assertEqual(a.payroll_adjustment.project, self.project) + # And the adjustment is the right type at the right amount. + self.assertEqual(a.payroll_adjustment.type, 'Bonus') + self.assertEqual(a.payroll_adjustment.amount, Decimal('300.00')) + + def test_log_form_accepts_project(self): + """POST /absences/log/ with a project ID creates the Absence with that FK set.""" + self.client.force_login(self.admin) + resp = self.client.post('/absences/log/', data={ + 'date': '2026-05-14', + 'reason': 'sick', + 'project': self.project.id, + 'workers': [self.worker.id], + }) + # After successful save the view redirects to the list. + self.assertEqual(resp.status_code, 302) + # Exactly one absence — and it carries the project we picked. + a = Absence.objects.first() + self.assertIsNotNone(a) + self.assertEqual(a.project, self.project) + + def test_edit_form_can_change_project(self): + """Edit form can add or change the project on an existing absence.""" + self.client.force_login(self.admin) + # Start with no project link. + a = Absence.objects.create(worker=self.worker, date=_date(2026, 5, 14), reason='sick') + self.assertIsNone(a.project) + # Edit it — pick the project. + resp = self.client.post(f'/absences/{a.id}/edit/', data={ + 'worker': self.worker.id, + 'date': '2026-05-14', + 'project': self.project.id, + 'reason': 'sick', + 'notes': '', + }) + # Redirects to list on success. + self.assertEqual(resp.status_code, 302) + a.refresh_from_db() + self.assertEqual(a.project, self.project) + + def test_list_filter_by_project(self): + """?project=X filters absences by Absence.project_id directly (not via work_logs).""" + self.client.force_login(self.admin) + other_project = Project.objects.create(name='Other') + # Two absences, different projects. + Absence.objects.create( + worker=self.worker, date=_date(2026, 5, 1), reason='sick', + project=self.project, + ) + Absence.objects.create( + worker=self.worker, date=_date(2026, 5, 2), reason='annual', + project=other_project, + ) + # Filtering by self.project should show only the sick (matching) + # row and exclude the annual one. We check by looking at the + # 'page' object on the context — the rendered HTML also has + # badge CSS classes inline in