diff --git a/core/forms.py b/core/forms.py index d15c677..d7febf7 100644 --- a/core/forms.py +++ b/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 diff --git a/core/tests.py b/core/tests.py index 0ad7770..c86044b 100644 --- a/core/tests.py +++ b/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())