Reported: when creating a new team or project from the friendly UI (/teams/new/ or /projects/new/), the Supervisor dropdown only lists is_staff / is_superuser accounts. Users who should be eligible to supervise (e.g. eendman, supervisor_smoke) are invisible in the picker even though they are active. Root cause: core.forms._supervisor_user_queryset filtered to is_active=True AND (is_staff OR is_superuser OR groups__name='Work Logger') That was strictly more restrictive than the app's own permission helper is_supervisor(user) in views.py, which grants supervisor powers to ANYONE assigned to a team/project (via the team.supervisor FK or project.supervisors M2M), regardless of group membership. On Konrad's dev DB that excluded 2 of 6 active users from the picker (one in a custom group, one in no group) even though both were valid supervisor candidates by the permission model. Fix: Queryset now returns every active user. The act of assigning a user to a team/project is what confers supervisor-ness downstream, so the picker no longer needs a pre-registered allow-list. Inactive users (is_active=False) remain excluded — the one hard guardrail. Docstring rewritten to explain the new behavior and why. Stale comment in TeamForm.__init__ updated to match (the old comment still described the pre-fix Work-Logger-group requirement). Tests: 4 new regression tests in SupervisorPickerQuerysetTests: - regular active user is selectable (the core bug) - user in an unrelated group is selectable - inactive user is still excluded (guardrail) - admin is still selectable (no regression for prior use case) All 28 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
461 lines
20 KiB
Python
461 lines
20 KiB
Python
# === 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,
|
|
)
|
|
|
|
|
|
# === 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
|