feat(absences): AbsenceLogForm + AbsenceQuickForm + AbsenceEditForm

Three forms covering the three entry points: standalone date-range form
(/absences/log/), quick-action modal (/attendance/log/), and edit one
existing record. Log form expands (worker, date) pairs respecting
Sat/Sun toggles, validates uniqueness, surfaces WorkLog conflicts as a
non-blocking warning via conflicting_worklogs(). 6 tests.
This commit is contained in:
Konrad du Plessis 2026-05-14 19:53:24 +02:00
parent 90c0e57659
commit 8f2d3e9dfe
2 changed files with 407 additions and 1 deletions

View File

@ -14,7 +14,7 @@ from .models import (
WorkLog, Project, Team, Worker, PayrollAdjustment,
ExpenseReceipt, ExpenseLineItem,
WorkerCertificate, WorkerWarning,
SiteReport,
SiteReport, Absence,
)
from .site_report_schema import COUNT_METRICS, CHECK_METRICS
@ -605,3 +605,241 @@ class SiteReportForm(forms.ModelForm):
if commit:
instance.save()
return instance
# ====================================================================
# === ABSENCE FORMS ==================================================
# ====================================================================
# Three forms mirror the SiteReport / WorkerWarning patterns:
# - AbsenceLogForm: standalone /absences/log/ with date-range support,
# team filter, worker checkbox list, conflict detection.
# - AbsenceQuickForm: minimal form for the "Mark Absent" modal on
# /attendance/log/ — worker + date come from URL/POST, form only
# asks for reason / paid / notes.
# - AbsenceEditForm: edit one existing absence; can correct
# worker/date as well as the other fields.
#
# IMPORTANT: these forms do NOT persist Absence rows themselves. They
# build cleaned data + helper outputs (expanded_pairs / conflicting_worklogs).
# The view layer (Tasks 4-6) is the single place that creates Absence rows
# and calls _sync_absence_payroll_adjustment(). Keeps form logic
# focused on input validation, not side effects.
class AbsenceLogForm(forms.ModelForm):
"""
Standalone form for /absences/log/. Supports date ranges and
multiple workers per submission. Validates conflicts; the view
consumes expanded_pairs() and conflicting_worklogs() to drive the
confirm/save flow.
"""
# --- Date range extras (mirrors AttendanceLogForm pattern) ---
# These aren't on the Absence model — they're form-only inputs the
# view uses to expand into multiple Absence rows.
end_date = forms.DateField(
required=False,
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
label='End Date',
help_text='Leave blank to log a single day.',
)
include_saturday = forms.BooleanField(
required=False, initial=False, label='Include Saturdays',
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
)
include_sunday = forms.BooleanField(
required=False, initial=False, label='Include Sundays',
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
)
# --- Worker picker ---
# Team narrows the worker list in the UI (JS filter); workers is the
# source of truth for who's being marked absent.
team = forms.ModelChoiceField(
queryset=Team.objects.filter(active=True),
required=False,
widget=forms.Select(attrs={'class': 'form-select'}),
help_text='Optional — narrows the worker list below.',
)
workers = forms.ModelMultipleChoiceField(
queryset=Worker.objects.filter(active=True),
widget=forms.CheckboxSelectMultiple,
)
class Meta:
model = Absence
fields = ['date', 'reason', 'is_paid', 'notes']
widgets = {
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'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'}),
}
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.
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)
)
# Match AttendanceLogForm: only workers on ACTIVE supervised teams
# appear in the picker. Drops workers whose only supervised team is
# inactive — keeps the picker consistent with attendance logging.
self.fields['workers'].queryset = (
Worker.objects.filter(
active=True,
teams__supervisor=user,
teams__active=True,
).distinct()
)
def clean(self):
cleaned = super().clean()
start = cleaned.get('date')
end = cleaned.get('end_date')
# End date must come on/after start date (mirrors AttendanceLogForm).
if end and start and end < start:
self.add_error('end_date', 'End date must be on or after the start date.')
# Cache the expanded (worker, date) tuples so the view doesn't have
# to repeat the weekend-aware date walk.
self._pairs = self._expand_pairs(cleaned)
# Reject if ANY (worker, date) pair already has an Absence row.
# The DB has unique_together on (worker, date) — surface this as a
# friendly form error rather than letting it blow up at save time.
existing = []
for worker, d in self._pairs:
if Absence.objects.filter(worker=worker, date=d).exists():
existing.append(f'{worker.name} on {d:%d %b %Y}')
if existing:
self.add_error(
None,
f'Absence already exists for: {", ".join(existing)}. '
'Edit the existing record instead.'
)
return cleaned
def _expand_pairs(self, cleaned):
"""Build the (worker, date) tuple list from cleaned data, respecting
Sat/Sun toggles. Returns [] if no start date defensive guard so
partial form errors don't crash the conflict check above."""
from datetime import timedelta
workers = cleaned.get('workers') or []
start = cleaned.get('date')
end = cleaned.get('end_date') or start
inc_sat = cleaned.get('include_saturday') or False
inc_sun = cleaned.get('include_sunday') or False
if not start:
return []
# Single-pass date walk — skip Sat/Sun unless toggled on.
days = []
d = start
while d <= end:
wd = d.weekday() # Mon=0, ..., Sat=5, Sun=6
if wd == 5 and not inc_sat:
d += timedelta(days=1)
continue
if wd == 6 and not inc_sun:
d += timedelta(days=1)
continue
days.append(d)
d += timedelta(days=1)
# Cartesian product: every selected worker on every kept day.
return [(w, d) for w in workers for d in days]
def expanded_pairs(self):
"""Return the (worker, date) tuples produced by clean(). Caller must
invoke is_valid() first otherwise this returns []."""
return getattr(self, '_pairs', [])
def conflicting_worklogs(self):
"""Return a list of dicts describing (worker, date) pairs that have
an existing WorkLog. Each dict has: worker_id, worker_name, date,
work_log_id, project_name.
Conflicts are warnings, NOT errors a worker might genuinely have
partial-day work + partial-day absence (e.g. sick leave that started
mid-shift). The view shows these on a confirm screen so the admin
can review before proceeding."""
rows = []
for worker, d in self.expanded_pairs():
for wl in WorkLog.objects.filter(date=d, workers=worker).select_related('project'):
rows.append({
'worker_id': worker.id,
'worker_name': worker.name,
'date': d,
'work_log_id': wl.id,
'project_name': wl.project.name if wl.project else '',
})
return rows
class AbsenceQuickForm(forms.ModelForm):
"""Minimal form for the ✗ Mark Absent modal on /attendance/log/.
Worker and date come from URL/POST context (the row the admin clicked
on), so this form only asks for the three fields still missing:
reason / is_paid / notes."""
class Meta:
model = Absence
fields = ['reason', 'is_paid', 'notes']
widgets = {
'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'}),
}
class AbsenceEditForm(forms.ModelForm):
"""Edit one existing Absence. Lets admin correct worker/date as well
as the other fields (in case the absence was logged against the wrong
person/day)."""
class Meta:
model = Absence
fields = ['worker', 'date', 'reason', 'is_paid', 'notes']
widgets = {
'worker': forms.Select(attrs={'class': 'form-select'}),
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'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'}),
}
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
# Default: every active worker. Admins (staff/superuser) keep this list.
self.fields['worker'].queryset = Worker.objects.filter(active=True)
# 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.
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()
def clean(self):
cleaned = super().clean()
worker = cleaned.get('worker')
d = cleaned.get('date')
if worker and d:
# Uniqueness check, excluding self (this is the edit form).
qs = Absence.objects.filter(worker=worker, date=d)
if self.instance and self.instance.pk:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
# Surface the error on the date field (where the user sees it),
# matching AbsenceLogForm's add_error style instead of raising.
self.add_error(
'date',
f'{worker.name} already has an absence on {d:%d %b %Y}.'
)
return cleaned

View File

@ -1896,3 +1896,171 @@ class AbsenceUserQuerysetTests(TestCase):
from core.views import _absence_user_queryset
qs = _absence_user_queryset(self.outsider)
self.assertEqual(qs.count(), 0)
# =============================================================================
# === ABSENCE FORM TESTS (Task 3) ===
# Tests for the three form classes added in core/forms.py:
# - AbsenceLogForm: standalone /absences/log/ with date-range + multi-worker
# - AbsenceQuickForm: minimal modal form on /attendance/log/
# - AbsenceEditForm: edit one existing absence
# =============================================================================
class AbsenceFormTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.admin = User.objects.create_user(username='admin', is_staff=True)
cls.worker_a = Worker.objects.create(name='WA', id_number='1', monthly_salary=Decimal('6000'))
cls.worker_b = Worker.objects.create(name='WB', id_number='2', monthly_salary=Decimal('6000'))
cls.project = Project.objects.create(name='P1')
cls.team = Team.objects.create(name='T', supervisor=cls.admin)
cls.team.workers.add(cls.worker_a, cls.worker_b)
def test_single_date_submission(self):
from core.forms import AbsenceLogForm
form = AbsenceLogForm(
data={
'date': '2026-05-14',
'reason': 'sick',
'is_paid': False,
'notes': 'flu',
'team': self.team.id,
'workers': [self.worker_a.id],
},
user=self.admin,
)
self.assertTrue(form.is_valid(), msg=form.errors)
# cleaned data has expanded (worker, date) tuples
tuples = form.expanded_pairs()
self.assertEqual(len(tuples), 1)
self.assertEqual(tuples[0], (self.worker_a, _date(2026, 5, 14)))
def test_range_expansion_skips_weekends_by_default(self):
from core.forms import AbsenceLogForm
# Thursday → Monday (5 days, Sat+Sun skipped → 3 days)
form = AbsenceLogForm(
data={
'date': '2026-05-14', # Thursday
'end_date': '2026-05-18', # Monday
'reason': 'annual',
'workers': [self.worker_a.id],
},
user=self.admin,
)
self.assertTrue(form.is_valid())
dates = [d for _w, d in form.expanded_pairs()]
# 14 Thu, 15 Fri, 18 Mon (16 Sat / 17 Sun skipped)
self.assertEqual(dates, [_date(2026, 5, 14), _date(2026, 5, 15), _date(2026, 5, 18)])
def test_range_expansion_with_weekend_toggles(self):
from core.forms import AbsenceLogForm
form = AbsenceLogForm(
data={
'date': '2026-05-14',
'end_date': '2026-05-17', # Thursday → Sunday
'reason': 'sick',
'include_saturday': True,
'include_sunday': True,
'workers': [self.worker_a.id],
},
user=self.admin,
)
self.assertTrue(form.is_valid())
dates = [d for _w, d in form.expanded_pairs()]
self.assertEqual(len(dates), 4) # all 4 days
def test_end_date_before_start_rejected(self):
from core.forms import AbsenceLogForm
form = AbsenceLogForm(
data={
'date': '2026-05-15',
'end_date': '2026-05-10',
'reason': 'sick',
'workers': [self.worker_a.id],
},
user=self.admin,
)
self.assertFalse(form.is_valid())
self.assertIn('end_date', form.errors)
def test_duplicate_absence_rejected(self):
"""If absence(worker, date) already exists, form is invalid."""
from core.forms import AbsenceLogForm
Absence.objects.create(worker=self.worker_a, date=_date(2026, 5, 14), reason='sick')
form = AbsenceLogForm(
data={
'date': '2026-05-14',
'reason': 'family',
'workers': [self.worker_a.id],
},
user=self.admin,
)
self.assertFalse(form.is_valid())
# Error message names the worker
self.assertIn('already', str(form.errors).lower())
def test_worklog_conflict_flagged_not_blocking(self):
"""If worker has a WorkLog on the absence date, form is still valid
but conflicting_worklogs() returns the conflict rows."""
from core.forms import AbsenceLogForm
wl = WorkLog.objects.create(
date=_date(2026, 5, 14), project=self.project, supervisor=self.admin,
)
wl.workers.add(self.worker_a)
form = AbsenceLogForm(
data={
'date': '2026-05-14',
'reason': 'sick',
'workers': [self.worker_a.id],
},
user=self.admin,
)
self.assertTrue(form.is_valid()) # Conflicts are warnings, not errors
conflicts = form.conflicting_worklogs()
self.assertEqual(len(conflicts), 1)
self.assertEqual(conflicts[0]['worker_id'], self.worker_a.id)
self.assertEqual(conflicts[0]['work_log_id'], wl.id)
def test_edit_form_supervisor_scope(self):
"""AbsenceEditForm with user=supervisor only shows workers in
supervisor's active teams. Other-team workers are not in the
dropdown queryset."""
from core.forms import AbsenceEditForm
# Create a supervisor with their own team
sup = User.objects.create_user(username='sup_edit', password='pw')
sup_team = Team.objects.create(name='SupTeam', supervisor=sup)
sup_worker = Worker.objects.create(
name='Sup Worker', id_number='99', monthly_salary=Decimal('6000'),
)
sup_team.workers.add(sup_worker)
# worker_b is on a team supervised by self.admin, not by sup
abs1 = Absence.objects.create(worker=sup_worker, date=_date(2026, 6, 1), reason='sick')
form = AbsenceEditForm(instance=abs1, user=sup)
worker_ids = list(form.fields['worker'].queryset.values_list('id', flat=True))
self.assertIn(sup_worker.id, worker_ids)
self.assertNotIn(self.worker_b.id, worker_ids)
def test_edit_form_uniqueness_uses_date_error(self):
"""AbsenceEditForm.clean() adds the uniqueness error to the 'date'
field (not raise), so it renders next to the field."""
from core.forms import AbsenceEditForm
# Existing absence on (worker_a, 2026-06-15)
abs1 = Absence.objects.create(worker=self.worker_a, date=_date(2026, 6, 15), reason='sick')
# Try to edit a DIFFERENT absence to clash with abs1
abs2 = Absence.objects.create(worker=self.worker_a, date=_date(2026, 6, 20), reason='annual')
form = AbsenceEditForm(
instance=abs2,
data={
'worker': self.worker_a.id,
'date': '2026-06-15', # clash
'reason': 'family',
'notes': '',
},
)
self.assertFalse(form.is_valid())
# Error must be on the 'date' field, not non-field
self.assertIn('date', form.errors)
self.assertIn('already', str(form.errors['date']).lower())