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:
parent
90c0e57659
commit
8f2d3e9dfe
240
core/forms.py
240
core/forms.py
@ -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
|
||||
|
||||
168
core/tests.py
168
core/tests.py
@ -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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user