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) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-05-14 22:03:04 +02:00
parent 2ae9f34058
commit 32972276b5
10 changed files with 325 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -49,6 +49,12 @@ form.
{{ form.date }}
{{ form.date.errors }}
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="{{ form.project.id_for_label }}">Project (optional)</label>
{{ form.project }}
{{ form.project.errors }}
<small class="text-muted">Which project was the worker absent from?</small>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="{{ form.reason.id_for_label }}">Reason</label>
{{ form.reason }}

View File

@ -120,6 +120,7 @@ has an inline delete form. CSV export button only shows for admin.
<tr>
<th>Date</th>
<th>Worker</th>
<th>Project</th>
<th>Reason</th>
<th>Paid?</th>
<th>Logged by</th>
@ -132,6 +133,9 @@ has an inline delete form. CSV export button only shows for admin.
<tr>
<td>{{ a.date|date:"d M Y" }}</td>
<td>{{ a.worker.name }}</td>
{# Project column — shows the direct Absence.project FK, #}
{# or an em-dash for non-project absences. #}
<td class="text-muted">{{ a.project.name|default:"—" }}</td>
<td><span class="badge badge-absence-{{ a.reason }}">{{ a.get_reason_display }}</span></td>
<td>
{% if a.is_paid %}
@ -156,7 +160,8 @@ has an inline delete form. CSV export button only shows for admin.
</td>
</tr>
{% empty %}
<tr><td colspan="7" class="text-center text-muted py-4">No absences match the filters.</td></tr>
{# Colspan bumped from 7 → 8 to cover the new Project column. #}
<tr><td colspan="8" class="text-center text-muted py-4">No absences match the filters.</td></tr>
{% endfor %}
</tbody>
</table>

View File

@ -72,6 +72,21 @@ them from the WorkLog).
<hr class="my-3">
{# === 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. #}
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="{{ form.project.id_for_label }}">Project (optional)</label>
{{ form.project }}
{{ form.project.errors }}
<small class="text-muted">Which project was the worker absent from? Leave blank for non-project absences.</small>
</div>
</div>
<hr class="my-3">
{# === REASON + PAID FLAG === #}
<div class="row g-3">
<div class="col-12 col-md-6">

View File

@ -62,7 +62,12 @@ WorkLog intact, so partial-day cases work too.
<hr>
<p class="mb-2"><strong>{{ absence_count }} absence(s) will be created:</strong></p>
<p>Reason: <strong>{{ reason }}</strong>. Paid: <strong>{% if is_paid %}Yes{% else %}No{% endif %}</strong>.</p>
<p>
Reason: <strong>{{ reason }}</strong>.
Paid: <strong>{% if is_paid %}Yes{% else %}No{% endif %}</strong>.
{# Project line is conditional — older flows / non-project absences leave it blank. #}
{% if project_name %}Project: <strong>{{ project_name }}</strong>.{% endif %}
</p>
<div class="d-flex justify-content-end gap-2 mt-3">
<a href="{% url 'absence_log' %}" class="btn btn-outline-secondary">&larr; Back to form</a>

View File

@ -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 <style>, so we can't substring-match
# on the class names alone (they appear in the style block even
# when there are no rows).
resp = self.client.get(f'/absences/?project={self.project.id}')
self.assertEqual(resp.status_code, 200)
page = resp.context['page']
# Only one row in the page (the sick one), and it belongs to
# `self.project` — proves the FK filter narrowed to direct matches.
rows = list(page.object_list)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].reason, 'sick')
self.assertEqual(rows[0].project, self.project)
def test_log_form_paid_absence_propagates_project_to_bonus(self):
"""Full chain: POST /absences/log/ with is_paid+project → Bonus has project.
Regression guard: someone refactoring _create_absences_atomic to forget
threading `project` through the helper would silently lose the project
link on the auto-created adjustment. This test exercises the entire
form view helper adjustment chain."""
self.client.force_login(self.admin)
self.client.post('/absences/log/', data={
'date': '2026-05-14',
'reason': 'sick',
'is_paid': 'on',
'project': self.project.id,
'workers': [self.worker.id],
})
a = Absence.objects.first()
self.assertIsNotNone(a)
self.assertEqual(a.project, self.project)
self.assertIsNotNone(a.payroll_adjustment)
self.assertEqual(a.payroll_adjustment.project, self.project)
self.assertEqual(a.payroll_adjustment.type, 'Bonus')

View File

@ -4384,11 +4384,16 @@ def _sync_absence_payroll_adjustment(absence):
return adj
# Create a fresh Bonus adjustment at the worker's daily rate.
# Propagate the absence's project (if any) onto the adjustment so
# paid-absence costs show up against the right project on the
# payroll dashboard / project reports. PayrollAdjustment.project
# is already SET_NULL+nullable; passing None is fine.
new_adj = PayrollAdjustment.objects.create(
worker=absence.worker,
type='Bonus', # DB value (Title Case) — see CLAUDE.md naming-drift section
amount=absence.worker.daily_rate,
date=absence.date,
project=absence.project, # cost-attribute the bonus to the same project
description=f'Paid {absence.get_reason_display().lower()} — auto-created from Absence #{absence.id}',
)
absence.payroll_adjustment = new_adj
@ -5229,6 +5234,10 @@ def absence_log(request):
if request.method == 'POST':
form = AbsenceLogForm(request.POST, user=request.user)
if form.is_valid():
# Project is optional — store its ID (or None) into session as
# plain JSON-safe data, and pass the resolved Project object
# to the atomic helper.
project_obj = form.cleaned_data.get('project')
conflicts = form.conflicting_worklogs()
if conflicts:
# Stash + redirect to confirm page. We serialize dates as
@ -5239,6 +5248,9 @@ def absence_log(request):
'reason': form.cleaned_data['reason'],
'is_paid': form.cleaned_data.get('is_paid') or False,
'notes': form.cleaned_data.get('notes') or '',
# Stash the project ID (not the whole object — JSON-safe).
# Resolved back to a Project in absence_log_confirm POST.
'project_id': project_obj.id if project_obj else None,
'conflicts': [
{**c, 'date': c['date'].isoformat()} for c in conflicts
],
@ -5252,6 +5264,7 @@ def absence_log(request):
notes=form.cleaned_data.get('notes') or '',
user=request.user,
worklog_removals=[],
project=project_obj,
)
messages.success(request, f'{len(form.expanded_pairs())} absence(s) logged.')
return redirect('absence_list')
@ -5341,6 +5354,17 @@ def absence_log_confirm(request):
except ValueError:
# Malformed key — skip silently rather than 500
pass
# Re-resolve the project FK from the stashed ID. If the project
# was deleted between form submit and confirm POST, silently drop
# it (the absence stays valid, just without the project link).
project_id = pending.get('project_id')
project_obj = None
if project_id:
try:
project_obj = Project.objects.get(id=project_id)
except Project.DoesNotExist:
project_obj = None # silently drop if deleted mid-flow
_create_absences_atomic(
pairs=pairs,
reason=pending['reason'],
@ -5348,6 +5372,7 @@ def absence_log_confirm(request):
notes=pending['notes'],
user=request.user,
worklog_removals=removals,
project=project_obj,
)
# Clear the session blob now that the write succeeded — refreshing
# the list page should not re-trigger anything.
@ -5356,22 +5381,39 @@ def absence_log_confirm(request):
return redirect('absence_list')
# GET — render warning page using the stashed conflict list.
# Look up project name for display only — the actual project FK is
# re-resolved from session.project_id on POST. None when project_id
# is missing/deleted.
project_name = None
project_id = pending.get('project_id')
if project_id:
try:
project_name = Project.objects.get(id=project_id).name
except Project.DoesNotExist:
project_name = None
return render(request, 'core/absences/log_confirm.html', {
'conflicts': pending['conflicts'],
'reason': pending['reason'],
'is_paid': pending['is_paid'],
'absence_count': len(pending['pairs']),
'project_name': project_name,
})
def _create_absences_atomic(pairs, reason, is_paid, notes, user, worklog_removals):
def _create_absences_atomic(pairs, reason, is_paid, notes, user, worklog_removals, project=None):
"""Atomically: (1) remove flagged workers from WorkLogs, (2) create
Absence rows, (3) sync payroll adjustments via the existing helper.
The whole thing runs in a single transaction.atomic() block if any
step fails the entire batch rolls back, so we never end up with
half-committed state (e.g. workers removed from WorkLogs but no
absences created, or absences created without their adjustments)."""
absences created, or absences created without their adjustments).
`project` (optional) is passed straight through onto every Absence
row created in this batch. When paid, the sync helper then
propagates it onto the auto-created Bonus PayrollAdjustment for
cost-attribution. None is fine non-project absences just get a
null project FK."""
with transaction.atomic():
# Step 1: detach flagged workers from their conflicting WorkLogs.
# If a WorkLog was deleted between form submit and confirm POST,
@ -5388,6 +5430,7 @@ def _create_absences_atomic(pairs, reason, is_paid, notes, user, worklog_removal
a = Absence.objects.create(
worker=worker, date=d, reason=reason,
is_paid=is_paid, notes=notes, logged_by=user,
project=project,
)
_sync_absence_payroll_adjustment(a)
@ -5415,8 +5458,10 @@ def absence_list(request):
# Base queryset — scoped to what this user is allowed to see, with
# select_related on the FKs we render in the table to avoid N+1s.
# `project` is now a direct FK on Absence (since 0015_absence_project),
# so include it in select_related to render the column in one query.
qs = _absence_user_queryset(user).select_related(
'worker', 'logged_by', 'payroll_adjustment'
'worker', 'logged_by', 'payroll_adjustment', 'project'
)
# === FILTERS ===
@ -5443,7 +5488,15 @@ def absence_list(request):
if team_id and team_id.isdigit():
qs = qs.filter(worker__teams__id=team_id).distinct()
if project_id and project_id.isdigit():
qs = qs.filter(worker__work_logs__project_id=project_id).distinct()
# Direct FK filter — was previously a transitive join via
# worker__work_logs__project_id, which was a workaround for not
# having Absence.project. Now that the FK exists, filter on it
# directly: matches absences explicitly linked to this project.
# (An absence whose worker happens to have worked on the project
# before but with a NULL absence.project will no longer appear —
# which is the correct behaviour: filter by what the absence
# says, not by adjacent activity.)
qs = qs.filter(project_id=project_id)
if reasons:
qs = qs.filter(reason__in=reasons)
# parse_date() returns None for malformed input (e.g. "not-a-date")
@ -5602,7 +5655,7 @@ def absence_export_csv(request):
if not is_admin(request.user):
return HttpResponseForbidden('Admin access required.')
qs = _absence_user_queryset(request.user).select_related('worker', 'logged_by')
qs = _absence_user_queryset(request.user).select_related('worker', 'logged_by', 'project')
# ===========================================================
# FILTER BLOCK — DUPLICATED from absence_list above.
@ -5628,7 +5681,10 @@ def absence_export_csv(request):
if team_id and team_id.isdigit():
qs = qs.filter(worker__teams__id=team_id).distinct()
if project_id and project_id.isdigit():
qs = qs.filter(worker__work_logs__project_id=project_id).distinct()
# Direct FK filter — was previously worker__work_logs__project_id
# (a workaround for not having Absence.project). Now uses the
# direct FK. Mirrors absence_list above for filter parity.
qs = qs.filter(project_id=project_id)
if reasons:
qs = qs.filter(reason__in=reasons)
if date_from:
@ -5647,11 +5703,13 @@ def absence_export_csv(request):
resp = HttpResponse(content_type='text/csv')
resp['Content-Disposition'] = 'attachment; filename="absences.csv"'
writer = csv.writer(resp)
writer.writerow(['Worker', 'Date', 'Reason', 'Paid', 'Notes', 'Logged By'])
# Project column added — mirrors the on-screen list table.
writer.writerow(['Worker', 'Date', 'Project', 'Reason', 'Paid', 'Notes', 'Logged By'])
for a in qs:
writer.writerow([
a.worker.name,
a.date.isoformat(),
a.project.name if a.project else '',
a.get_reason_display(),
'Yes' if a.is_paid else 'No',
a.notes,