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) @admin.register(Absence)
class AbsenceAdmin(admin.ModelAdmin): class AbsenceAdmin(admin.ModelAdmin):
list_display = ('worker', 'date', 'reason', 'is_paid', 'logged_by', 'created_at') # `project` shown alongside reason/is_paid so the admin index reads as
list_filter = ('reason', 'is_paid', 'date') # "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') 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') readonly_fields = ('created_at', 'updated_at')
date_hierarchy = 'date' date_hierarchy = 'date'

View File

@ -668,9 +668,12 @@ class AbsenceLogForm(forms.ModelForm):
class Meta: class Meta:
model = Absence 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 = { widgets = {
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'project': forms.Select(attrs={'class': 'form-select'}),
'reason': forms.Select(attrs={'class': 'form-select'}), 'reason': forms.Select(attrs={'class': 'form-select'}),
'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}), 'notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}),
@ -679,8 +682,13 @@ class AbsenceLogForm(forms.ModelForm):
def __init__(self, *args, user=None, **kwargs): def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.user = user self.user = user
# Supervisor scope: limit team + workers querysets to the user's # Project is optional — admins can leave it blank for non-project
# reach. Admins (staff/superuser) keep the default "all active" lists. # 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): if user is not None and not (user.is_staff or user.is_superuser):
self.fields['team'].queryset = ( self.fields['team'].queryset = (
Team.objects.filter(active=True, supervisor=user) Team.objects.filter(active=True, supervisor=user)
@ -695,6 +703,14 @@ class AbsenceLogForm(forms.ModelForm):
teams__active=True, teams__active=True,
).distinct() ).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): def clean(self):
cleaned = super().clean() cleaned = super().clean()
@ -787,13 +803,29 @@ class AbsenceQuickForm(forms.ModelForm):
class Meta: class Meta:
model = Absence 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 = { widgets = {
'project': forms.Select(attrs={'class': 'form-select'}),
'reason': forms.Select(attrs={'class': 'form-select'}), 'reason': forms.Select(attrs={'class': 'form-select'}),
'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'notes': forms.Textarea(attrs={'rows': 2, 'class': 'form-control'}), '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): class AbsenceEditForm(forms.ModelForm):
"""Edit one existing Absence. Lets admin correct worker/date as well """Edit one existing Absence. Lets admin correct worker/date as well
@ -802,10 +834,13 @@ class AbsenceEditForm(forms.ModelForm):
class Meta: class Meta:
model = Absence 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 = { widgets = {
'worker': forms.Select(attrs={'class': 'form-select'}), 'worker': forms.Select(attrs={'class': 'form-select'}),
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'project': forms.Select(attrs={'class': 'form-select'}),
'reason': forms.Select(attrs={'class': 'form-select'}), 'reason': forms.Select(attrs={'class': 'form-select'}),
'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}), 'notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}),
@ -815,16 +850,24 @@ class AbsenceEditForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Default: every active worker. Admins (staff/superuser) keep this list. # Default: every active worker. Admins (staff/superuser) keep this list.
self.fields['worker'].queryset = Worker.objects.filter(active=True) 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 # Supervisor scope: when a non-admin opens the edit form, the worker
# dropdown is restricted to workers on their own active supervised # dropdown is restricted to workers on their own active supervised
# teams. Prevents a supervisor from silently re-assigning an absence # 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): if user is not None and not (user.is_staff or user.is_superuser):
self.fields['worker'].queryset = Worker.objects.filter( self.fields['worker'].queryset = Worker.objects.filter(
active=True, active=True,
teams__supervisor=user, teams__supervisor=user,
teams__active=True, teams__active=True,
).distinct() ).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): def clean(self):
cleaned = super().clean() 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 = models.ForeignKey(
Worker, related_name='absences', on_delete=models.CASCADE, 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) date = models.DateField(default=timezone.now)
reason = models.CharField(max_length=20, choices=REASON_CHOICES) reason = models.CharField(max_length=20, choices=REASON_CHOICES)
notes = models.TextField( notes = models.TextField(

View File

@ -49,6 +49,12 @@ form.
{{ form.date }} {{ form.date }}
{{ form.date.errors }} {{ form.date.errors }}
</div> </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"> <div class="col-12 col-md-6">
<label class="form-label" for="{{ form.reason.id_for_label }}">Reason</label> <label class="form-label" for="{{ form.reason.id_for_label }}">Reason</label>
{{ form.reason }} {{ form.reason }}

View File

@ -120,6 +120,7 @@ has an inline delete form. CSV export button only shows for admin.
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Worker</th> <th>Worker</th>
<th>Project</th>
<th>Reason</th> <th>Reason</th>
<th>Paid?</th> <th>Paid?</th>
<th>Logged by</th> <th>Logged by</th>
@ -132,6 +133,9 @@ has an inline delete form. CSV export button only shows for admin.
<tr> <tr>
<td>{{ a.date|date:"d M Y" }}</td> <td>{{ a.date|date:"d M Y" }}</td>
<td>{{ a.worker.name }}</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><span class="badge badge-absence-{{ a.reason }}">{{ a.get_reason_display }}</span></td>
<td> <td>
{% if a.is_paid %} {% if a.is_paid %}
@ -156,7 +160,8 @@ has an inline delete form. CSV export button only shows for admin.
</td> </td>
</tr> </tr>
{% empty %} {% 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 %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@ -72,6 +72,21 @@ them from the WorkLog).
<hr class="my-3"> <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 === #} {# === REASON + PAID FLAG === #}
<div class="row g-3"> <div class="row g-3">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">

View File

@ -62,7 +62,12 @@ WorkLog intact, so partial-day cases work too.
<hr> <hr>
<p class="mb-2"><strong>{{ absence_count }} absence(s) will be created:</strong></p> <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"> <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> <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) self.client.force_login(self.sup)
resp = self.client.get('/absences/export/') resp = self.client.get('/absences/export/')
self.assertEqual(resp.status_code, 403) 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 return adj
# Create a fresh Bonus adjustment at the worker's daily rate. # 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( new_adj = PayrollAdjustment.objects.create(
worker=absence.worker, worker=absence.worker,
type='Bonus', # DB value (Title Case) — see CLAUDE.md naming-drift section type='Bonus', # DB value (Title Case) — see CLAUDE.md naming-drift section
amount=absence.worker.daily_rate, amount=absence.worker.daily_rate,
date=absence.date, 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}', description=f'Paid {absence.get_reason_display().lower()} — auto-created from Absence #{absence.id}',
) )
absence.payroll_adjustment = new_adj absence.payroll_adjustment = new_adj
@ -5229,6 +5234,10 @@ def absence_log(request):
if request.method == 'POST': if request.method == 'POST':
form = AbsenceLogForm(request.POST, user=request.user) form = AbsenceLogForm(request.POST, user=request.user)
if form.is_valid(): 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() conflicts = form.conflicting_worklogs()
if conflicts: if conflicts:
# Stash + redirect to confirm page. We serialize dates as # Stash + redirect to confirm page. We serialize dates as
@ -5239,6 +5248,9 @@ def absence_log(request):
'reason': form.cleaned_data['reason'], 'reason': form.cleaned_data['reason'],
'is_paid': form.cleaned_data.get('is_paid') or False, 'is_paid': form.cleaned_data.get('is_paid') or False,
'notes': form.cleaned_data.get('notes') or '', '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': [ 'conflicts': [
{**c, 'date': c['date'].isoformat()} for c in 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 '', notes=form.cleaned_data.get('notes') or '',
user=request.user, user=request.user,
worklog_removals=[], worklog_removals=[],
project=project_obj,
) )
messages.success(request, f'{len(form.expanded_pairs())} absence(s) logged.') messages.success(request, f'{len(form.expanded_pairs())} absence(s) logged.')
return redirect('absence_list') return redirect('absence_list')
@ -5341,6 +5354,17 @@ def absence_log_confirm(request):
except ValueError: except ValueError:
# Malformed key — skip silently rather than 500 # Malformed key — skip silently rather than 500
pass 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( _create_absences_atomic(
pairs=pairs, pairs=pairs,
reason=pending['reason'], reason=pending['reason'],
@ -5348,6 +5372,7 @@ def absence_log_confirm(request):
notes=pending['notes'], notes=pending['notes'],
user=request.user, user=request.user,
worklog_removals=removals, worklog_removals=removals,
project=project_obj,
) )
# Clear the session blob now that the write succeeded — refreshing # Clear the session blob now that the write succeeded — refreshing
# the list page should not re-trigger anything. # the list page should not re-trigger anything.
@ -5356,22 +5381,39 @@ def absence_log_confirm(request):
return redirect('absence_list') return redirect('absence_list')
# GET — render warning page using the stashed conflict 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', { return render(request, 'core/absences/log_confirm.html', {
'conflicts': pending['conflicts'], 'conflicts': pending['conflicts'],
'reason': pending['reason'], 'reason': pending['reason'],
'is_paid': pending['is_paid'], 'is_paid': pending['is_paid'],
'absence_count': len(pending['pairs']), '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 """Atomically: (1) remove flagged workers from WorkLogs, (2) create
Absence rows, (3) sync payroll adjustments via the existing helper. Absence rows, (3) sync payroll adjustments via the existing helper.
The whole thing runs in a single transaction.atomic() block if any 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 step fails the entire batch rolls back, so we never end up with
half-committed state (e.g. workers removed from WorkLogs but no 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(): with transaction.atomic():
# Step 1: detach flagged workers from their conflicting WorkLogs. # Step 1: detach flagged workers from their conflicting WorkLogs.
# If a WorkLog was deleted between form submit and confirm POST, # 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( a = Absence.objects.create(
worker=worker, date=d, reason=reason, worker=worker, date=d, reason=reason,
is_paid=is_paid, notes=notes, logged_by=user, is_paid=is_paid, notes=notes, logged_by=user,
project=project,
) )
_sync_absence_payroll_adjustment(a) _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 # 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. # 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( qs = _absence_user_queryset(user).select_related(
'worker', 'logged_by', 'payroll_adjustment' 'worker', 'logged_by', 'payroll_adjustment', 'project'
) )
# === FILTERS === # === FILTERS ===
@ -5443,7 +5488,15 @@ def absence_list(request):
if team_id and team_id.isdigit(): if team_id and team_id.isdigit():
qs = qs.filter(worker__teams__id=team_id).distinct() qs = qs.filter(worker__teams__id=team_id).distinct()
if project_id and project_id.isdigit(): 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: if reasons:
qs = qs.filter(reason__in=reasons) qs = qs.filter(reason__in=reasons)
# parse_date() returns None for malformed input (e.g. "not-a-date") # 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): if not is_admin(request.user):
return HttpResponseForbidden('Admin access required.') 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. # FILTER BLOCK — DUPLICATED from absence_list above.
@ -5628,7 +5681,10 @@ def absence_export_csv(request):
if team_id and team_id.isdigit(): if team_id and team_id.isdigit():
qs = qs.filter(worker__teams__id=team_id).distinct() qs = qs.filter(worker__teams__id=team_id).distinct()
if project_id and project_id.isdigit(): 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: if reasons:
qs = qs.filter(reason__in=reasons) qs = qs.filter(reason__in=reasons)
if date_from: if date_from:
@ -5647,11 +5703,13 @@ def absence_export_csv(request):
resp = HttpResponse(content_type='text/csv') resp = HttpResponse(content_type='text/csv')
resp['Content-Disposition'] = 'attachment; filename="absences.csv"' resp['Content-Disposition'] = 'attachment; filename="absences.csv"'
writer = csv.writer(resp) 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: for a in qs:
writer.writerow([ writer.writerow([
a.worker.name, a.worker.name,
a.date.isoformat(), a.date.isoformat(),
a.project.name if a.project else '',
a.get_reason_display(), a.get_reason_display(),
'Yes' if a.is_paid else 'No', 'Yes' if a.is_paid else 'No',
a.notes, a.notes,