38686-vm/core/admin.py
Konrad du Plessis a6cf766394 fix(absences): pre-push polish — admin sync + bulk-delete cascade + supervisor menu
Three small fixes from the final review:
- AbsenceAdmin.save_model() now runs _sync_absence_payroll_adjustment
  so toggling is_paid via /admin/ updates the linked Bonus consistently
  with the friendly UI.
- _delete_adjustment_with_cascade clears absence.is_paid when deleting
  a Bonus linked to an Absence — closes the state-drift window after
  bulk-delete from /payroll/?status=adjustments.
- base.html — Resources dropdown 'Absences' entry now shows for
  supervisors as well as staff (was staff-only). View-layer permission
  helpers (_absence_user_queryset, _user_can_log_absences) already
  enforce the real access boundary; this just makes the menu honest.
2 regression tests.
2026-05-14 23:04:12 +02:00

198 lines
8.3 KiB
Python

from django.contrib import admin
from .models import (
UserProfile, Project, Worker, Team, WorkLog,
PayrollRecord, Loan, PayrollAdjustment,
ExpenseReceipt, ExpenseLineItem,
WorkerCertificate, WorkerWarning,
SiteReport,
Absence,
)
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ('user',)
search_fields = ('user__username', 'user__first_name', 'user__last_name')
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ('name', 'active')
list_filter = ('active',)
search_fields = ('name', 'description')
filter_horizontal = ('supervisors',)
# === INLINE ADMINS FOR WORKER ===
# Let admins manage a worker's certifications and warnings directly
# from the Worker change page, without navigating to a separate screen.
class WorkerCertificateInline(admin.TabularInline):
model = WorkerCertificate
extra = 0 # no blank rows by default — admin clicks "Add another" to create
readonly_fields = ('created_at',)
fields = ('cert_type', 'document', 'issued_date', 'valid_until', 'notes', 'created_at')
class WorkerWarningInline(admin.TabularInline):
model = WorkerWarning
extra = 0
readonly_fields = ('created_at',)
fields = ('date', 'severity', 'reason', 'description', 'issued_by', 'document', 'created_at')
@admin.register(Worker)
class WorkerAdmin(admin.ModelAdmin):
list_display = ('name', 'id_number', 'monthly_salary', 'active')
list_filter = ('active', 'has_drivers_license')
search_fields = ('name', 'id_number', 'phone_number')
# Inline sections for certs + warnings appear below the main Worker form
inlines = [WorkerCertificateInline, WorkerWarningInline]
# === FIELDSETS ===
# Organise the worker edit form into clear sections.
# Banking & Tax fields (UIF, Bank, Acc No.) live inside Personal Info
# per product requirement — help_text strings render as hints under
# each field in admin (and as tooltips on the friendly edit page).
fieldsets = (
('Personal Info', {
'fields': ('name', 'id_number', 'phone_number', 'monthly_salary',
'tax_number', 'uif_number',
'bank_name', 'bank_account_number',
'employment_date', 'active', 'notes'),
}),
('Sizing', {
'fields': ('shoe_size', 'overall_top_size', 'pants_size', 'tshirt_size'),
}),
('Documents & License', {
'fields': ('photo', 'id_document',
'has_drivers_license', 'drivers_license', 'drivers_license_code'),
}),
)
# === STANDALONE ADMINS FOR CERTS + WARNINGS ===
# Separate pages for bulk operations across workers — "show me all
# certs expiring this month" or "show me all final warnings".
@admin.register(WorkerCertificate)
class WorkerCertificateAdmin(admin.ModelAdmin):
list_display = ('worker', 'cert_type', 'issued_date', 'valid_until', 'is_expired')
list_filter = ('cert_type',)
search_fields = ('worker__name', 'worker__id_number')
date_hierarchy = 'valid_until'
@admin.register(WorkerWarning)
class WorkerWarningAdmin(admin.ModelAdmin):
list_display = ('worker', 'date', 'severity', 'reason', 'issued_by')
list_filter = ('severity',)
search_fields = ('worker__name', 'reason', 'description')
date_hierarchy = 'date'
@admin.register(Absence)
class AbsenceAdmin(admin.ModelAdmin):
# `project` shown alongside reason/is_paid so the admin index reads as
# "worker — project — reason — paid" at a glance.
list_display = ('worker', 'project', 'date', 'reason', 'is_paid', 'logged_by', 'created_at')
# `project` filter sits next to reason — handy for "which workers were
# absent on Solar Farm Alpha last month".
list_filter = ('reason', 'is_paid', 'project', 'date')
search_fields = ('worker__name', 'worker__id_number', 'notes')
# `project` is a small set normally but raw_id keeps the form fast
# even if it grows. Same treatment as the other FKs.
raw_id_fields = ('worker', 'logged_by', 'payroll_adjustment', 'project')
readonly_fields = ('created_at', 'updated_at')
date_hierarchy = 'date'
# === KEEP PAYROLL ADJUSTMENT IN SYNC WITH is_paid ===
# Without this, toggling `is_paid` via /admin/core/absence/<id>/change/
# saves the row but does NOT create (or delete) the linked Bonus
# PayrollAdjustment — the friendly UI (`absence_edit`) does this
# correctly. Mirror that behavior here so the two paths are
# consistent.
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
# Import the sync helper inside the method so admin.py doesn't
# have to import from views at module load (avoids any chance
# of circular-import surprises during Django startup).
from .views import _sync_absence_payroll_adjustment
try:
_sync_absence_payroll_adjustment(obj)
except ValueError as e:
# The sync helper raises ValueError when the user tries to
# un-pay an Absence whose linked adjustment has already been
# paid (i.e. a PayrollRecord exists). Surface that to the
# admin via the messages framework rather than blowing up.
from django.contrib import messages
messages.error(request, str(e))
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ('name', 'supervisor', 'pay_frequency', 'pay_start_date', 'active')
list_editable = ('pay_frequency', 'pay_start_date')
list_filter = ('active', 'supervisor', 'pay_frequency')
search_fields = ('name',)
filter_horizontal = ('workers',)
@admin.register(WorkLog)
class WorkLogAdmin(admin.ModelAdmin):
list_display = ('date', 'project', 'supervisor', 'overtime_amount')
list_filter = ('date', 'project', 'supervisor')
search_fields = ('project__name', 'notes')
filter_horizontal = ('workers', 'priced_workers')
@admin.register(PayrollRecord)
class PayrollRecordAdmin(admin.ModelAdmin):
list_display = ('worker', 'date', 'amount_paid')
list_filter = ('date', 'worker')
search_fields = ('worker__name',)
filter_horizontal = ('work_logs',)
@admin.register(Loan)
class LoanAdmin(admin.ModelAdmin):
list_display = ('worker', 'principal_amount', 'remaining_balance', 'date', 'active')
list_filter = ('active', 'date', 'worker')
search_fields = ('worker__name', 'reason')
@admin.register(SiteReport)
class SiteReportAdmin(admin.ModelAdmin):
"""Admin view for daily site progress reports.
`metrics` is a JSONField — Django renders it as a textarea blob,
which is fine for low-frequency admin edits. The friendly
/site-report/<id>/edit/ form is the primary entry point.
"""
list_display = ('work_log', 'weather', 'temperature_min', 'temperature_max', 'created_by', 'created_at')
list_filter = ('weather', 'created_at', 'work_log__project')
search_fields = ('notes', 'work_log__project__name', 'work_log__supervisor__username')
raw_id_fields = ('work_log', 'created_by')
readonly_fields = ('created_at', 'updated_at')
@admin.register(PayrollAdjustment)
class PayrollAdjustmentAdmin(admin.ModelAdmin):
list_display = ('worker', 'type_display', 'amount', 'date')
list_filter = ('type', 'date', 'worker')
search_fields = ('worker__name', 'description')
# === Type column uses the short user-facing label ===
@admin.display(description='Type', ordering='type')
def type_display(self, obj):
"""Show the short user-facing label (e.g. "Loan", "Advance")
instead of the raw DB value ("New Loan", "Advance Payment").
Sorting and filtering still work off the underlying `type`
field — this only changes what's printed in the column."""
return obj.get_type_display()
class ExpenseLineItemInline(admin.TabularInline):
model = ExpenseLineItem
extra = 1
@admin.register(ExpenseReceipt)
class ExpenseReceiptAdmin(admin.ModelAdmin):
list_display = ('vendor_name', 'date', 'total_amount', 'user')
list_filter = ('date', 'payment_method', 'vat_type')
search_fields = ('vendor_name', 'description')
inlines = [ExpenseLineItemInline]
@admin.register(ExpenseLineItem)
class ExpenseLineItemAdmin(admin.ModelAdmin):
list_display = ('product_name', 'amount', 'receipt')
search_fields = ('product_name', 'receipt__vendor_name')