# === FORMS === # Django form classes for the app. # - AttendanceLogForm: daily work log creation with date ranges and conflict detection # - PayrollAdjustmentForm: adding bonuses, deductions, overtime, and loan adjustments # - ExpenseReceiptForm + ExpenseLineItemFormSet: expense receipt creation with dynamic line items # - WorkerForm + WorkerCertificateFormSet + WorkerWarningFormSet: friendly # worker management (alternative to the Django admin) from django import forms from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.forms import inlineformset_factory from .models import ( WorkLog, Project, Team, Worker, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem, WorkerCertificate, WorkerWarning, SiteReport, Absence, ) from .site_report_schema import COUNT_METRICS, CHECK_METRICS # === FILE SIZE VALIDATOR === # Reusable 5 MB ceiling for uploads (photos, IDs, certificates, warning docs). # Keeps the MEDIA_ROOT from being filled with a single accidental 50 MB scan. MAX_UPLOAD_SIZE = 5 * 1024 * 1024 # 5 MB def validate_max_5mb(f): """Raise ValidationError if the uploaded file exceeds 5 MB.""" if f and hasattr(f, 'size') and f.size > MAX_UPLOAD_SIZE: mb = f.size / (1024 * 1024) raise ValidationError( f'File is {mb:.1f} MB — maximum allowed is 5 MB. ' 'Please reduce the file size (e.g. scan at a lower resolution) and try again.' ) class AttendanceLogForm(forms.ModelForm): """ Form for logging daily worker attendance. Extra fields (not on the WorkLog model): - end_date: optional end date for logging multiple days at once - include_saturday: whether to include Saturdays in a date range - include_sunday: whether to include Sundays in a date range The supervisor field is NOT shown on the form — it gets set automatically in the view to whoever is logged in. """ # --- Extra fields for date range logging --- # These aren't on the WorkLog model, they're only used by the form 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'}) ) class Meta: model = WorkLog # Supervisor is NOT included — it gets set in the view automatically fields = ['date', 'project', 'team', 'workers', 'overtime_amount', 'notes'] widgets = { 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), 'project': forms.Select(attrs={'class': 'form-select'}), 'team': forms.Select(attrs={'class': 'form-select'}), 'workers': forms.CheckboxSelectMultiple(attrs={'class': 'form-check-input'}), 'overtime_amount': forms.Select(attrs={'class': 'form-select'}), 'notes': forms.Textarea(attrs={ 'class': 'form-control', 'rows': 3, 'placeholder': 'Any notes about the day...' }), } def __init__(self, *args, **kwargs): # Pop 'user' from kwargs so we can filter based on who's logged in self.user = kwargs.pop('user', None) super().__init__(*args, **kwargs) # --- Supervisor filtering --- # If the user is NOT an admin, they can only see: # - Projects they're assigned to (via project.supervisors M2M) # - Workers in teams they supervise if self.user and not (self.user.is_staff or self.user.is_superuser): # Only show projects this supervisor is assigned to self.fields['project'].queryset = Project.objects.filter( active=True, supervisors=self.user ) # Only show workers from teams this supervisor manages supervised_teams = Team.objects.filter(supervisor=self.user, active=True) self.fields['workers'].queryset = Worker.objects.filter( active=True, teams__in=supervised_teams ).distinct() # Only show teams this supervisor manages self.fields['team'].queryset = supervised_teams else: # Admins see everything self.fields['workers'].queryset = Worker.objects.filter(active=True) self.fields['project'].queryset = Project.objects.filter(active=True) self.fields['team'].queryset = Team.objects.filter(active=True) # Make team optional (it already is on the model, but make the form match) self.fields['team'].required = False # Force start date to be blank — don't pre-fill with today's date. # Django 5.x auto-fills form fields from model defaults (default=timezone.now), # but we want the user to consciously pick a date every time. self.fields['date'].initial = None def clean(self): """Validate the date range makes sense.""" cleaned_data = super().clean() start_date = cleaned_data.get('date') end_date = cleaned_data.get('end_date') if start_date and end_date and end_date < start_date: raise forms.ValidationError('End date cannot be before start date.') return cleaned_data class PayrollAdjustmentForm(forms.ModelForm): """ Form for adding/editing payroll adjustments (bonuses, deductions, etc.). Business rule: A project is required for Overtime, Bonus, Deduction, and Advance Payment types. Loan and Loan Repayment are worker-level (no project). """ class Meta: model = PayrollAdjustment fields = ['type', 'project', 'worker', 'amount', 'date', 'description'] widgets = { 'type': forms.Select(attrs={'class': 'form-select'}), 'project': forms.Select(attrs={'class': 'form-select'}), 'worker': forms.Select(attrs={'class': 'form-select'}), 'amount': forms.NumberInput(attrs={ 'class': 'form-control', 'step': '0.01', 'min': '0.01' }), 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), 'description': forms.Textarea(attrs={ 'class': 'form-control', 'rows': 2, 'placeholder': 'Reason for this adjustment...' }), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['project'].queryset = Project.objects.filter(active=True) self.fields['project'].required = False self.fields['worker'].queryset = Worker.objects.filter(active=True) def clean(self): """Validate that project-required types have a project selected.""" cleaned_data = super().clean() adj_type = cleaned_data.get('type', '') project = cleaned_data.get('project') # These types must have a project — they're tied to specific work project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment') if adj_type in project_required_types and not project: self.add_error('project', 'A project must be selected for this adjustment type.') return cleaned_data # ============================================================================= # === EXPENSE RECEIPT FORM === # Used on the /receipts/create/ page. # The form handles receipt header fields (vendor, date, payment method, VAT type). # Line items are handled separately by the ExpenseLineItemFormSet below. # ============================================================================= class ExpenseReceiptForm(forms.ModelForm): """ Form for the receipt header — vendor, date, payment method, VAT type. Line items (products + amounts) are handled by ExpenseLineItemFormSet. """ class Meta: model = ExpenseReceipt fields = ['date', 'vendor_name', 'description', 'payment_method', 'vat_type'] widgets = { 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), 'vendor_name': forms.TextInput(attrs={ 'class': 'form-control', 'placeholder': 'Vendor Name' }), 'description': forms.Textarea(attrs={ 'class': 'form-control', 'rows': 2, 'placeholder': 'What was purchased and why...' }), 'payment_method': forms.Select(attrs={'class': 'form-select'}), # Radio buttons for VAT type — shown as 3 options side by side 'vat_type': forms.RadioSelect(attrs={'class': 'form-check-input'}), } # === LINE ITEM FORMSET === # A "formset" is a group of identical mini-forms — one per line item. # inlineformset_factory creates it automatically from the parent-child relationship. # - extra=1: start with 1 blank row # - can_delete=True: allows removing rows (checks a hidden DELETE checkbox) ExpenseLineItemFormSet = inlineformset_factory( ExpenseReceipt, # Parent model ExpenseLineItem, # Child model fields=['product_name', 'amount'], extra=1, # Show 1 blank row by default can_delete=True, # Allow deleting rows widgets={ 'product_name': forms.TextInput(attrs={ 'class': 'form-control', 'placeholder': 'Item Name' }), 'amount': forms.NumberInput(attrs={ 'class': 'form-control item-amount', 'step': '0.01', 'placeholder': '0.00' }), } ) # ============================================================= # === WORKER MANAGEMENT FORMS === # ============================================================= class WorkerForm(forms.ModelForm): """Main worker edit form — covers all the flat fields on Worker. Certifications and warnings are handled separately by the formsets below (they have their own rows in their own tables). """ class Meta: model = Worker fields = [ 'name', 'id_number', 'phone_number', 'monthly_salary', 'tax_number', 'uif_number', 'bank_name', 'bank_account_number', 'employment_date', 'active', 'notes', 'shoe_size', 'overall_top_size', 'pants_size', 'tshirt_size', 'photo', 'id_document', 'has_drivers_license', 'drivers_license', 'drivers_license_code', ] widgets = { 'name': forms.TextInput(attrs={'class': 'form-control'}), 'id_number': forms.TextInput(attrs={'class': 'form-control'}), 'phone_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': '+27...'}), 'monthly_salary': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'}), # Banking & Tax 'tax_number': forms.TextInput(attrs={'class': 'form-control'}), 'uif_number': forms.TextInput(attrs={'class': 'form-control'}), 'bank_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. FNB, Standard Bank, Capitec'}), 'bank_account_number': forms.TextInput(attrs={'class': 'form-control'}), 'employment_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), 'active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), 'shoe_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. 9 / 43'}), 'overall_top_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. L'}), 'pants_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. 34'}), 'tshirt_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. L'}), 'photo': forms.ClearableFileInput(attrs={'class': 'form-control'}), 'id_document': forms.ClearableFileInput(attrs={'class': 'form-control'}), 'has_drivers_license': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'drivers_license': forms.ClearableFileInput(attrs={'class': 'form-control'}), 'drivers_license_code': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. EB, C1'}), } def clean_photo(self): f = self.cleaned_data.get('photo') validate_max_5mb(f) return f def clean_id_document(self): f = self.cleaned_data.get('id_document') validate_max_5mb(f) return f def clean_drivers_license(self): f = self.cleaned_data.get('drivers_license') validate_max_5mb(f) return f class WorkerCertificateForm(forms.ModelForm): """Single certificate row. Used inside the formset — not rendered directly.""" class Meta: model = WorkerCertificate fields = ['cert_type', 'document', 'issued_date', 'valid_until', 'notes'] widgets = { 'cert_type': forms.Select(attrs={'class': 'form-select form-select-sm'}), 'document': forms.ClearableFileInput(attrs={'class': 'form-control form-control-sm'}), 'issued_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control form-control-sm'}), 'valid_until': forms.DateInput(attrs={'type': 'date', 'class': 'form-control form-control-sm'}), 'notes': forms.Textarea(attrs={'class': 'form-control form-control-sm', 'rows': 2}), } def clean_document(self): f = self.cleaned_data.get('document') validate_max_5mb(f) return f class WorkerWarningForm(forms.ModelForm): """Single warning row. Used inside the formset — not rendered directly.""" class Meta: model = WorkerWarning fields = ['date', 'severity', 'reason', 'description', 'document'] widgets = { 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control form-control-sm'}), 'severity': forms.Select(attrs={'class': 'form-select form-select-sm'}), 'reason': forms.TextInput(attrs={'class': 'form-control form-control-sm', 'placeholder': 'Short summary'}), 'description': forms.Textarea(attrs={'class': 'form-control form-control-sm', 'rows': 2, 'placeholder': 'Full context...'}), 'document': forms.ClearableFileInput(attrs={'class': 'form-control form-control-sm'}), } def clean_document(self): f = self.cleaned_data.get('document') validate_max_5mb(f) return f # === WORKER CERTIFICATE FORMSET === # extra=0: don't render blank rows by default — user clicks "+ Add" to create. # can_delete: user can tick the delete checkbox to remove a cert on save. WorkerCertificateFormSet = inlineformset_factory( Worker, WorkerCertificate, form=WorkerCertificateForm, extra=0, can_delete=True, ) # === WORKER WARNING FORMSET === WorkerWarningFormSet = inlineformset_factory( Worker, WorkerWarning, form=WorkerWarningForm, extra=0, can_delete=True, ) # ============================================================= # === TEAM & PROJECT MANAGEMENT FORMS === # ============================================================= # Friendly edit forms for Teams and Projects — alternative to Django # admin. Both are simple ModelForms (no inline formsets — these models # only have M2M relationships, handled by standard multi-select widgets). def _supervisor_user_queryset(): """Users eligible to supervise a team or project. Returns ALL active users. Any active user can be picked as a supervisor — the picker doesn't need to pre-filter by group or staff flags because the app's `is_supervisor(user)` helper (in views.py) already grants supervisor permissions to anyone assigned to a team/project FK/M2M, regardless of their group membership. Previously this filter required `is_staff`/`is_superuser` OR membership in a "Work Logger" group, which was strictly more restrictive than the permission model and hid field supervisors from the picker. Fix (2026-04-23): trust the admin making the assignment; any active user can be chosen, and the act of assignment is what confers supervisor powers downstream. Inactive users (`is_active=False`) are still excluded — deactivated accounts should never appear in dropdowns. """ return User.objects.filter(is_active=True).order_by('username') class TeamForm(forms.ModelForm): """Team edit form — covers every Team field plus the `workers` M2M.""" class Meta: model = Team fields = [ 'name', 'supervisor', 'active', 'pay_frequency', 'pay_start_date', 'workers', ] widgets = { 'name': forms.TextInput(attrs={'class': 'form-control'}), 'supervisor': forms.Select(attrs={'class': 'form-select'}), 'active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'pay_frequency': forms.Select(attrs={'class': 'form-select'}), 'pay_start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), # CheckboxSelectMultiple is kinder for small worker lists; the # template groups active/inactive visually via template logic. 'workers': forms.CheckboxSelectMultiple(), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Supervisor dropdown — show any active user. The app's is_supervisor # helper (views.py) grants supervisor powers to whoever is assigned # here, regardless of group or staff flags, so the picker doesn't # need to pre-filter by role. Only is_active=True users appear, so # deactivated accounts are hidden from the dropdown. self.fields['supervisor'].queryset = _supervisor_user_queryset() self.fields['supervisor'].required = False # Include inactive workers too — matches admin parity. The template # badges inactive ones so users can tell at a glance. self.fields['workers'].queryset = Worker.objects.all().order_by('-active', 'name') self.fields['workers'].required = False class ProjectForm(forms.ModelForm): """Project edit form — covers every Project field plus the `supervisors` M2M.""" class Meta: model = Project fields = [ 'name', 'description', 'active', 'start_date', 'end_date', 'supervisors', ] widgets = { 'name': forms.TextInput(attrs={'class': 'form-control'}), 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), 'active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), 'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), 'supervisors': forms.CheckboxSelectMultiple(), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Project supervisors follow the same rule as team supervisors — admins # or Work Loggers are eligible. self.fields['supervisors'].queryset = _supervisor_user_queryset() self.fields['supervisors'].required = False def clean(self): cleaned = super().clean() start = cleaned.get('start_date') end = cleaned.get('end_date') if start and end and end < start: raise ValidationError("End date must be on or after the start date.") return cleaned # ============================================================= # === SITE REPORT FORM === # ============================================================= # What it does: lets the on-site supervisor (or admin) record the # day's progress alongside attendance — weather, temperature, free-form # notes, and a flexible set of count + check metrics whose schema # lives in core/site_report_schema.py. # Why it's structured this way: # The four model fields (weather, temperature_min/max, notes) are # declared as a normal ModelForm. The METRICS portion is built # dynamically in __init__() — one IntegerField per COUNT_METRIC and # one BooleanField per CHECK_METRIC. On save() we serialize those # into the JSON `metrics` blob in the canonical {'counts': {...}, # 'checks': {...}} shape. # # The dynamic-fields approach means: when Konrad adds a new metric # to core/site_report_schema.py, the form picks it up automatically # on next page load. No form code change. No migration. The JSONField # on the model just stores the new key. class SiteReportForm(forms.ModelForm): """Mobile-first form for editing a SiteReport. Use as: `SiteReportForm(data, instance=site_report)` for edits, or `SiteReportForm(data, work_log=work_log)` for new reports — the work_log gets attached in save(commit=True). """ class Meta: model = SiteReport fields = ['weather', 'temperature_min', 'temperature_max', 'notes'] widgets = { # min=-20 / max=60 covers any plausible South African # construction site temp range. inputmode=numeric pulls up # the numeric keypad on phones. 'temperature_min': forms.NumberInput(attrs={ 'min': -20, 'max': 60, 'inputmode': 'numeric', 'placeholder': 'min °C', 'class': 'form-control', }), 'temperature_max': forms.NumberInput(attrs={ 'min': -20, 'max': 60, 'inputmode': 'numeric', 'placeholder': 'max °C', 'class': 'form-control', }), 'notes': forms.Textarea(attrs={ 'rows': 3, 'placeholder': 'What happened on site today (free-form)', 'class': 'form-control', }), } def __init__(self, *args, **kwargs): # Pull `work_log` out of kwargs before super().__init__() — it's # only used during save() to attach a brand-new instance. self._work_log = kwargs.pop('work_log', None) super().__init__(*args, **kwargs) # === Style the weather field as a radio group (rendered as # icon buttons in the template). Bootstrap-friendly classes so # the row of choices wraps well on small screens. self.fields['weather'].widget = forms.RadioSelect(attrs={'class': 'site-report-weather'}) # === Build the dynamic count + check fields === # Counts → IntegerField (min=0). Pre-fill from existing JSON if editing. existing_metrics = (self.instance.metrics or {}) if self.instance.pk else {} existing_counts = existing_metrics.get('counts', {}) or {} existing_checks = existing_metrics.get('checks', {}) or {} for metric in COUNT_METRICS: field_name = f"count_{metric['key']}" self.fields[field_name] = forms.IntegerField( label=metric['label'], min_value=0, required=False, initial=existing_counts.get(metric['key'], None), widget=forms.NumberInput(attrs={ 'min': 0, 'inputmode': 'numeric', 'placeholder': '0', 'class': 'form-control site-report-count', }), ) # Checks → BooleanField. Initial value comes from the JSON; if # the key is absent (older report, schema added later), default # to False. for metric in CHECK_METRICS: field_name = f"check_{metric['key']}" self.fields[field_name] = forms.BooleanField( label=metric['label'], required=False, initial=bool(existing_checks.get(metric['key'], False)), widget=forms.CheckboxInput(attrs={ 'class': 'form-check-input site-report-check', }), ) def clean(self): cleaned = super().clean() # Validate temp range: if BOTH are set, min ≤ max. # If only one is set, that's fine (supervisor only had a # thermometer reading at midday, say). tmin = cleaned.get('temperature_min') tmax = cleaned.get('temperature_max') if tmin is not None and tmax is not None and tmin > tmax: raise ValidationError( "Min temperature can't be higher than max temperature. " "Did you swap them?" ) return cleaned def save(self, commit=True): # First do the usual ModelForm save (without commit — we want # to build the metrics dict first, set work_log if needed, THEN # hit the DB once). instance = super().save(commit=False) # Attach the parent WorkLog on first save (kwargs path). if self._work_log is not None: instance.work_log = self._work_log # Build the metrics JSON blob from the dynamic fields. Use the # schema lists as the source of truth — that way removing a # metric from the schema also removes it from new reports. counts = {} for metric in COUNT_METRICS: field_name = f"count_{metric['key']}" value = self.cleaned_data.get(field_name) # Treat blank as 0 so historic reports + skipped fields # behave consistently when summed. counts[metric['key']] = int(value) if value is not None else 0 checks = {} for metric in CHECK_METRICS: field_name = f"check_{metric['key']}" checks[metric['key']] = bool(self.cleaned_data.get(field_name, False)) instance.metrics = {'counts': counts, 'checks': checks} if commit: instance.save() return instance # ==================================================================== # === ABSENCE FORMS ================================================== # ==================================================================== # Two forms mirror the SiteReport / WorkerWarning patterns: # - AbsenceLogForm: standalone /absences/log/ with date-range support, # team filter, worker checkbox list, conflict detection. # - 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 # `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'}), } def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) self.user = user # 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) ) # 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() ) # 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() 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. PERF: single query for all candidate WorkLogs, then Python-side filter by the actual (worker_id, date) pair set. Previously fired one SELECT per (worker, date) pair (N+1 — 25 queries on a typical 5-worker × 5-day submission). Now: 2 queries total (WorkLog + its workers prefetch) regardless of pair count. """ pairs = self.expanded_pairs() if not pairs: return [] # Build sets used as the outer filter (broad SQL match) AND the # post-filter pair check (narrow Python match). The outer filter # may match WorkLogs that include OTHER workers on those dates, # so we still verify each (worker_id, date) against pair_set. workers = {w for w, _ in pairs} dates = {d for _, d in pairs} pair_set = {(w.id, d) for w, d in pairs} wls = ( WorkLog.objects .filter(date__in=dates, workers__in=workers) .select_related('project') .prefetch_related('workers') .distinct() ) rows = [] for wl in wls: for w in wl.workers.all(): if (w.id, wl.date) in pair_set: rows.append({ 'worker_id': w.id, 'worker_name': w.name, 'date': wl.date, 'work_log_id': wl.id, 'project_name': wl.project.name if wl.project else '—', }) return rows 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 # `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'}), } 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) # 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. 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() 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