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:
parent
2ae9f34058
commit
32972276b5
@ -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'
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
19
core/migrations/0015_absence_project.py
Normal file
19
core/migrations/0015_absence_project.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@ -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(
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">← Back to form</a>
|
||||
|
||||
136
core/tests.py
136
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 <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')
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user