Add minimal section headers to all core/ files and CLAUDE.md coding style

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-02-20 00:07:26 +02:00
parent a79cb0b435
commit 370b0e9815
8 changed files with 130 additions and 30 deletions

View File

@ -1,5 +1,11 @@
# Fox Fitt LabourPay
## Coding Style
- Always add clear section header comments using the format: # === SECTION NAME ===
- Add plain English comments explaining what complex logic does
- The project owner is not a programmer — comments should be understandable by a non-technical person
- When creating or editing code, maintain the existing comment structure
## Project Overview
Django payroll management system for Fox Fitt Construction, a civil works contractor specializing in solar farm foundation installations. Manages field worker attendance, payroll processing, employee loans, and business expenses for solar farm projects.

View File

@ -5,14 +5,14 @@ from django.db import models
from .models import Worker, Project, Team, WorkLog, UserProfile
# Inline UserProfile on User admin
# === USER ADMIN ===
class UserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
verbose_name_plural = 'Profile'
# Customise the User admin to show groups prominently
class UserAdmin(BaseUserAdmin):
inlines = [UserProfileInline]
list_display = ('username', 'first_name', 'last_name', 'is_staff', 'get_groups')
@ -42,11 +42,12 @@ class UserAdmin(BaseUserAdmin):
get_groups.short_description = 'Groups'
# Re-register User with our custom admin
admin.site.unregister(User)
admin.site.register(User, UserAdmin)
# === WORKER ADMIN ===
@admin.register(Worker)
class WorkerAdmin(admin.ModelAdmin):
list_display = ('name', 'id_no', 'phone_no', 'monthly_salary', 'date_of_employment', 'projects_worked_on_count')
@ -54,6 +55,8 @@ class WorkerAdmin(admin.ModelAdmin):
readonly_fields = ('projects_worked_on_count',)
# === PROJECT ADMIN ===
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ('name', 'get_supervisors', 'is_active', 'created_at')
@ -74,6 +77,8 @@ class ProjectAdmin(admin.ModelAdmin):
return super().formfield_for_manytomany(db_field, request, **kwargs)
# === TEAM ADMIN ===
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ('name', 'supervisor', 'worker_count', 'is_active', 'created_at')
@ -94,6 +99,8 @@ class TeamAdmin(admin.ModelAdmin):
return super().formfield_for_foreignkey(db_field, request, **kwargs)
# === WORK LOG ADMIN ===
@admin.register(WorkLog)
class WorkLogAdmin(admin.ModelAdmin):
list_display = ('date', 'project', 'supervisor')

View File

@ -1,6 +1,9 @@
import os
import time
# === TEMPLATE CONTEXT PROCESSORS ===
def project_context(request):
"""
Adds project-specific environment variables to the template context globally.

View File

@ -2,6 +2,8 @@ from django import forms
from django.forms import inlineformset_factory
from .models import WorkLog, Project, Worker, Team, ExpenseReceipt, ExpenseLineItem
# === WORK LOG FORM ===
class WorkLogForm(forms.ModelForm):
end_date = forms.DateField(
required=False,
@ -9,20 +11,19 @@ class WorkLogForm(forms.ModelForm):
label="End Date (Optional)"
)
include_saturday = forms.BooleanField(
required=False,
required=False,
label="Include Saturday",
initial=False,
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
include_sunday = forms.BooleanField(
required=False,
required=False,
label="Include Sunday",
initial=False,
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False, empty_label="Select Team", widget=forms.Select(attrs={'class': 'form-control'}))
# Explicitly defining overtime to ensure it's not required and has correct widget
overtime = forms.ChoiceField(
choices=WorkLog.OT_CHOICES,
required=False,
@ -43,30 +44,26 @@ class WorkLogForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Base querysets with active filter
projects_qs = Project.objects.filter(is_active=True)
workers_qs = Worker.objects.filter(is_active=True)
teams_qs = Team.objects.filter(is_active=True)
if user and not user.is_superuser:
# Filter projects and workers based on user assignment
self.fields['project'].queryset = projects_qs.filter(supervisors=user)
# For workers, we might want to show workers from teams supervised by the user
# OR just all active workers if that's the business rule.
# The previous code filtered workers by managed teams. Let's keep that logic but respecting is_active.
managed_teams = user.managed_teams.all()
worker_ids = managed_teams.values_list('workers__id', flat=True).distinct()
self.fields['workers'].queryset = workers_qs.filter(id__in=worker_ids)
# Filter teams
self.fields['team'].queryset = teams_qs.filter(supervisor=user)
else:
self.fields['project'].queryset = projects_qs
self.fields['workers'].queryset = workers_qs
self.fields['team'].queryset = teams_qs
# === EXPENSE RECEIPT FORM ===
class ExpenseReceiptForm(forms.ModelForm):
class Meta:
model = ExpenseReceipt
@ -79,6 +76,8 @@ class ExpenseReceiptForm(forms.ModelForm):
'vat_type': forms.RadioSelect(attrs={'class': 'form-check-input'}),
}
# === EXPENSE LINE ITEM FORMSET ===
ExpenseLineItemFormSet = inlineformset_factory(
ExpenseReceipt, ExpenseLineItem,
fields=['product', 'amount'],
@ -88,4 +87,4 @@ ExpenseLineItemFormSet = inlineformset_factory(
'product': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Item Name'}),
'amount': forms.NumberInput(attrs={'class': 'form-control item-amount', 'step': '0.01'}),
}
)
)

View File

@ -4,6 +4,9 @@ from decimal import Decimal
from django.contrib.auth.models import User
from django.utils import timezone
# === USER PROFILE ===
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
@ -14,6 +17,9 @@ class UserProfile(models.Model):
def __str__(self):
return f"{self.user.username}'s profile"
# === PROJECT ===
class Project(models.Model):
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
@ -28,18 +34,20 @@ class Project(models.Model):
def __str__(self):
return self.name
# === WORKER ===
class Worker(models.Model):
name = models.CharField(max_length=200)
id_no = models.CharField(max_length=50, unique=True, verbose_name="ID Number")
phone_no = models.CharField(max_length=20, verbose_name="Phone Number")
monthly_salary = models.DecimalField(max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal('0.00'))])
# New fields
photo = models.ImageField(upload_to='workers/photos/', blank=True, null=True)
id_photo = models.ImageField(upload_to='workers/ids/', blank=True, null=True, verbose_name="ID Document")
date_of_employment = models.DateField(default=timezone.now)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
is_active = models.BooleanField(default=True)
@ -53,12 +61,14 @@ class Worker(models.Model):
@property
def projects_worked_on_count(self):
"""Returns the number of distinct projects this worker has worked on."""
return self.work_logs.values('project').distinct().count()
def __str__(self):
return self.name
# === TEAM ===
class Team(models.Model):
name = models.CharField(max_length=200)
workers = models.ManyToManyField(Worker, related_name='teams')
@ -73,6 +83,9 @@ class Team(models.Model):
def __str__(self):
return self.name
# === WORK LOG ===
class WorkLog(models.Model):
OT_CHOICES = [
(Decimal('0'), 'None'),
@ -88,7 +101,7 @@ class WorkLog(models.Model):
workers = models.ManyToManyField(Worker, related_name='work_logs')
supervisor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
notes = models.TextField(blank=True)
overtime = models.DecimalField(max_digits=3, decimal_places=2, default=0, choices=OT_CHOICES)
overtime_paid_to = models.ManyToManyField(Worker, blank=True, related_name='overtime_paid_logs')
@ -99,6 +112,9 @@ class WorkLog(models.Model):
def __str__(self):
return f"{self.date} - {self.project.name}"
# === PAYROLL RECORD ===
class PayrollRecord(models.Model):
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='payroll_records')
date = models.DateField(default=timezone.now)
@ -113,6 +129,9 @@ class PayrollRecord(models.Model):
def __str__(self):
return f"Payment to {self.worker.name} on {self.date}"
# === LOAN ===
class Loan(models.Model):
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='loans')
amount = models.DecimalField(max_digits=10, decimal_places=2, help_text="Principal amount borrowed")
@ -133,6 +152,9 @@ class Loan(models.Model):
def __str__(self):
return f"Loan for {self.worker.name} - R{self.amount}"
# === PAYROLL ADJUSTMENT ===
class PayrollAdjustment(models.Model):
ADJUSTMENT_TYPES = [
('BONUS', 'Bonus'),
@ -142,13 +164,12 @@ class PayrollAdjustment(models.Model):
('LOAN', 'New Loan'),
('ADVANCE', 'Advance Payment'),
]
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='adjustments')
payroll_record = models.ForeignKey(PayrollRecord, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments')
loan = models.ForeignKey(Loan, on_delete=models.SET_NULL, null=True, blank=True, related_name='repayments')
# Link back to WorkLog to track Project/Team context for Overtime
work_log = models.ForeignKey(WorkLog, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments')
amount = models.DecimalField(max_digits=10, decimal_places=2, help_text="Positive adds to pay, negative subtracts (except for Loan Repayment which is auto-handled)")
date = models.DateField(default=timezone.now)
description = models.CharField(max_length=255)
@ -161,6 +182,9 @@ class PayrollAdjustment(models.Model):
def __str__(self):
return f"{self.get_type_display()} - {self.amount} for {self.worker.name}"
# === EXPENSE RECEIPT ===
class ExpenseReceipt(models.Model):
VAT_CHOICES = [
('INCLUDED', 'VAT Included'),
@ -180,12 +204,11 @@ class ExpenseReceipt(models.Model):
description = models.TextField(blank=True)
payment_method = models.CharField(max_length=10, choices=PAYMENT_METHODS, default='CASH')
vat_type = models.CharField(max_length=10, choices=VAT_CHOICES, default='NONE')
# Financials (Stored for record keeping)
subtotal = models.DecimalField(max_digits=12, decimal_places=2, default=0)
vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0)
total_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
@ -195,6 +218,9 @@ class ExpenseReceipt(models.Model):
def __str__(self):
return f"Receipt from {self.vendor} - {self.date}"
# === EXPENSE LINE ITEM ===
class ExpenseLineItem(models.Model):
receipt = models.ForeignKey(ExpenseReceipt, on_delete=models.CASCADE, related_name='items')
product = models.CharField(max_length=255, verbose_name="Product/Item")

View File

@ -20,21 +20,34 @@ from .views import (
)
urlpatterns = [
# === HOME & ATTENDANCE ===
path("", home, name="home"),
path("log-attendance/", log_attendance, name="log_attendance"),
# === WORK LOGS ===
path("work-logs/", work_log_list, name="work_log_list"),
path("work-logs/export/", export_work_log_csv, name="export_work_log_csv"),
# === RESOURCE MANAGEMENT ===
path("manage-resources/", manage_resources, name="manage_resources"),
path("manage-resources/toggle/<str:model_type>/<int:pk>/", toggle_resource_status, name="toggle_resource_status"),
# === PAYROLL ===
path("payroll/", payroll_dashboard, name="payroll_dashboard"),
path("payroll/pay/<int:worker_id>/", process_payment, name="process_payment"),
path("payroll/preview/<int:worker_id>/", preview_payslip, name="preview_payslip"),
path("payroll/price-overtime/", price_overtime, name="price_overtime"),
path("payroll/payslip/<int:pk>/", payslip_detail, name="payslip_detail"),
# === LOANS ===
path("loans/", loan_list, name="loan_list"),
path("loans/add/", add_loan, name="add_loan"),
# === ADJUSTMENTS ===
path("payroll/adjustment/add/", add_adjustment, name="add_adjustment"),
path("payroll/adjustment/<int:pk>/edit/", edit_adjustment, name="edit_adjustment"),
path("payroll/adjustment/<int:pk>/delete/", delete_adjustment, name="delete_adjustment"),
# === EXPENSES ===
path("receipts/create/", create_receipt, name="create_receipt"),
]
]

View File

@ -4,6 +4,9 @@ from xhtml2pdf import pisa
from django.conf import settings
import os
# === PDF GENERATION ===
def render_to_pdf(template_src, context_dict={}):
template = get_template(template_src)
html = template.render(context_dict)

View File

@ -20,11 +20,15 @@ from datetime import timedelta
from decimal import Decimal
from core.utils import render_to_pdf
# Adjustment types that ADD to pay
# === CONSTANTS ===
ADDITIVE_TYPES = ['BONUS', 'OVERTIME', 'LOAN']
# Adjustment types that SUBTRACT from pay
DEDUCTIVE_TYPES = ['DEDUCTION', 'LOAN_REPAYMENT', 'ADVANCE']
# === PERMISSION HELPERS ===
def is_admin(user):
"""Check if user has admin-level access (staff, superuser, or in Admin group)."""
return user.is_staff or user.is_superuser
@ -39,6 +43,9 @@ def is_staff_or_supervisor(user):
"""Check if user has at least supervisor-level access."""
return is_admin(user) or is_supervisor(user)
# === HOME DASHBOARD ===
@login_required
def home(request):
"""Render the landing screen with dashboard stats."""
@ -158,6 +165,9 @@ def home(request):
}
return render(request, "core/index.html", context)
# === LOG ATTENDANCE ===
@login_required
def log_attendance(request):
# Build team workers map for frontend JS (needed for both GET and POST if re-rendering)
@ -307,6 +317,9 @@ def log_attendance(request):
return render(request, 'core/log_attendance.html', context)
# === WORK LOG LIST ===
@login_required
def work_log_list(request):
"""View work log history and payroll adjustments with advanced filtering."""
@ -547,6 +560,9 @@ def work_log_list(request):
return render(request, 'core/work_log_list.html', context)
# === EXPORT WORK LOG CSV ===
@login_required
def export_work_log_csv(request):
"""Export filtered work logs and adjustments to CSV."""
@ -672,6 +688,9 @@ def export_work_log_csv(request):
return response
# === RESOURCE MANAGEMENT ===
@login_required
def manage_resources(request):
"""Redirect to dashboard which now includes manage resources."""
@ -705,6 +724,9 @@ def toggle_resource_status(request, model_type, pk):
return redirect('home')
# === PAYROLL DASHBOARD ===
@login_required
def payroll_dashboard(request):
"""Dashboard for payroll management with filtering."""
@ -924,6 +946,9 @@ def payroll_dashboard(request):
}
return render(request, 'core/payroll_dashboard.html', context)
# === PROCESS PAYMENT ===
@login_required
def process_payment(request, worker_id):
"""Process payment for a worker, mark logs as paid, link adjustments, and email receipt."""
@ -1019,6 +1044,8 @@ def process_payment(request, worker_id):
return redirect('payroll_dashboard')
# === PREVIEW PAYSLIP ===
@login_required
def preview_payslip(request, worker_id):
"""Return payslip preview data as JSON (no DB changes, no email)."""
@ -1062,6 +1089,9 @@ def preview_payslip(request, worker_id):
'net_pay': total_amount,
})
# === PRICE OVERTIME ===
@login_required
def price_overtime(request):
"""Create OVERTIME PayrollAdjustments from unpriced overtime logs."""
@ -1106,6 +1136,9 @@ def price_overtime(request):
messages.success(request, f"Created {created} overtime adjustment(s).")
return redirect('payroll_dashboard')
# === PAYSLIP DETAIL ===
@login_required
def payslip_detail(request, pk):
"""Show details of a payslip (Payment Record)."""
@ -1133,6 +1166,9 @@ def payslip_detail(request, pk):
}
return render(request, 'core/payslip.html', context)
# === LOANS ===
@login_required
def loan_list(request):
"""Redirect to payroll dashboard loans tab."""
@ -1162,6 +1198,9 @@ def add_loan(request):
return redirect('/payroll/?status=loans')
# === PAYROLL ADJUSTMENTS ===
@login_required
def add_adjustment(request):
"""Add a payroll adjustment (Bonus, Overtime, Deduction, Repayment) for one or more workers."""
@ -1351,6 +1390,7 @@ def edit_adjustment(request, pk):
messages.success(request, f"Updated {adj.get_type_display()} for {adj.worker.name}.")
return redirect('payroll_dashboard')
@login_required
def delete_adjustment(request, pk):
"""Delete an unpaid payroll adjustment. Admin only, POST only."""
@ -1394,6 +1434,9 @@ def delete_adjustment(request, pk):
messages.success(request, f"Deleted {type_display} for {worker_name}.")
return redirect('payroll_dashboard')
# === EXPENSE RECEIPTS ===
@login_required
def create_receipt(request):
"""Create a new expense receipt and email it."""