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,
|
WorkLog, Project, Team, Worker, PayrollAdjustment,
|
||||||
ExpenseReceipt, ExpenseLineItem,
|
ExpenseReceipt, ExpenseLineItem,
|
||||||
WorkerCertificate, WorkerWarning,
|
WorkerCertificate, WorkerWarning,
|
||||||
SiteReport,
|
SiteReport, Absence,
|
||||||
)
|
)
|
||||||
from .site_report_schema import COUNT_METRICS, CHECK_METRICS
|
from .site_report_schema import COUNT_METRICS, CHECK_METRICS
|
||||||
|
|
||||||
@ -605,3 +605,241 @@ class SiteReportForm(forms.ModelForm):
|
|||||||
if commit:
|
if commit:
|
||||||
instance.save()
|
instance.save()
|
||||||
return instance
|
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
|
from core.views import _absence_user_queryset
|
||||||
qs = _absence_user_queryset(self.outsider)
|
qs = _absence_user_queryset(self.outsider)
|
||||||
self.assertEqual(qs.count(), 0)
|
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