import datetime from django.db import models from django.contrib.auth.models import User from django.utils import timezone from decimal import Decimal from django.db.models.signals import post_save from django.dispatch import receiver class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') # Add any extra profile fields if needed in the future def __str__(self): return self.user.username @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): if created: UserProfile.objects.get_or_create(user=instance) @receiver(post_save, sender=User) def save_user_profile(sender, instance, **kwargs): if hasattr(instance, 'profile'): instance.profile.save() class Project(models.Model): name = models.CharField(max_length=200) description = models.TextField(blank=True) supervisors = models.ManyToManyField(User, related_name='assigned_projects') active = models.BooleanField(default=True) start_date = models.DateField(blank=True, null=True) end_date = models.DateField(blank=True, null=True) def __str__(self): return self.name class Worker(models.Model): name = models.CharField(max_length=200) id_number = models.CharField(max_length=50, unique=True) phone_number = models.CharField(max_length=20, blank=True) monthly_salary = models.DecimalField(max_digits=10, decimal_places=2) # === BANKING & TAX === # Payroll-related identifiers. Shown in the "Personal Info" fieldset in # Django admin and the "Personal & Pay" section of the friendly edit form. # verbose_name becomes the form label; help_text becomes the tooltip # (friendly page) or the admin's under-field hint. tax_number = models.CharField( 'Tax No', max_length=50, blank=True, help_text='Registered Tax Number', ) uif_number = models.CharField( 'UIF', max_length=50, blank=True, help_text='Unemployment Insurance Fund number', ) bank_name = models.CharField( 'Bank', max_length=100, blank=True, help_text='Account at which Institution', ) bank_account_number = models.CharField( 'Acc No.', max_length=50, blank=True, help_text='Bank account number', ) photo = models.ImageField(upload_to='workers/photos/', blank=True, null=True) id_document = models.FileField(upload_to='workers/documents/', blank=True, null=True) employment_date = models.DateField(default=timezone.now) notes = models.TextField(blank=True) active = models.BooleanField(default=True) # === SIZING === # Clothing and boot sizes for PPE (personal protective equipment) ordering shoe_size = models.CharField(max_length=20, blank=True) overall_top_size = models.CharField(max_length=10, blank=True) pants_size = models.CharField(max_length=20, blank=True) tshirt_size = models.CharField(max_length=10, blank=True) # === DRIVERS LICENSE === # Track which workers have a valid drivers license and store a scanned copy has_drivers_license = models.BooleanField(default=False) drivers_license = models.FileField(upload_to='workers/documents/', blank=True, null=True) drivers_license_code = models.CharField( 'Code', max_length=20, blank=True, help_text='Drivers License Code (e.g. A, B, C, EB, EC)', ) @property def daily_rate(self): # monthly salary divided by 20 working days return (self.monthly_salary / Decimal('20.00')).quantize(Decimal('0.01')) def __str__(self): return self.name class Team(models.Model): # === PAY FREQUENCY CHOICES === # Used for the team's recurring pay schedule (weekly, fortnightly, or monthly) PAY_FREQUENCY_CHOICES = [ ('weekly', 'Weekly'), ('fortnightly', 'Fortnightly'), ('monthly', 'Monthly'), ] name = models.CharField(max_length=200) workers = models.ManyToManyField(Worker, related_name='teams') supervisor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='supervised_teams') active = models.BooleanField(default=True) # === PAY SCHEDULE === # These two fields define when the team gets paid. # pay_start_date is the anchor — the first day of the very first pay period. # pay_frequency determines the length of each recurring period. # Both are optional — teams without a schedule work as before. pay_frequency = models.CharField(max_length=15, choices=PAY_FREQUENCY_CHOICES, blank=True, default='') pay_start_date = models.DateField(blank=True, null=True, help_text='Anchor date for first pay period') def __str__(self): return self.name class WorkLog(models.Model): OVERTIME_CHOICES = [ (Decimal('0.00'), 'None'), (Decimal('0.25'), '1/4 Day'), (Decimal('0.50'), '1/2 Day'), (Decimal('0.75'), '3/4 Day'), (Decimal('1.00'), 'Full Day'), ] date = models.DateField(default=timezone.now) project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='work_logs') team = models.ForeignKey(Team, on_delete=models.SET_NULL, null=True, blank=True, related_name='work_logs') workers = models.ManyToManyField(Worker, related_name='work_logs') supervisor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='work_logs_created') notes = models.TextField(blank=True) overtime_amount = models.DecimalField(max_digits=3, decimal_places=2, choices=OVERTIME_CHOICES, default=Decimal('0.00')) priced_workers = models.ManyToManyField(Worker, related_name='priced_overtime_logs', blank=True) @property def display_amount(self): """Total daily cost for all workers on this log (sum of daily_rate). Works efficiently with prefetch_related('workers').""" return sum(w.daily_rate for w in self.workers.all()) def __str__(self): return f"{self.date} - {self.project.name}" class PayrollRecord(models.Model): worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='payroll_records') date = models.DateField(default=timezone.now) amount_paid = models.DecimalField(max_digits=10, decimal_places=2) work_logs = models.ManyToManyField(WorkLog, related_name='payroll_records') def __str__(self): return f"{self.worker.name} - {self.date}" class SiteReport(models.Model): """One-per-WorkLog optional report capturing what was DONE on site that day: weather, temperature, free-form notes, and a flexible `metrics` JSON blob (counts + booleans). Why this is separate from WorkLog (1:1 with optional reverse link) rather than fields on WorkLog itself: * WorkLog is a payroll record — the source of "who worked + got paid for what." Bloating it with operational metrics couples two unrelated concerns. * Reports are optional. A WorkLog without a SiteReport is a completely valid historic row; the supervisor just didn't fill in progress data that day. * The 1:1 reverse accessor `work_log.site_report` raises DoesNotExist on absence — wrap with hasattr() / try-except in templates and views, OR use `WorkLog.objects.filter( site_report__isnull=False)` to query "logs WITH a report." Why `metrics` is a JSONField: Konrad's metric set evolves over time (new construction phases, different project types). A JSON blob lets us add/remove fields via a one-line edit to core/site_report_schema.py — no DB migration, no risk to historic data. Old reports without a new key just render as 0 or unchecked. The blob has two top-level keys: `counts` (dict of key->non-negative-int) and `checks` (dict of key->bool). Schema lives in core/site_report_schema.py. """ # Common, universally-applicable weather descriptors. Stored as the # canonical short string; rendered via get_weather_display(). # Empty string = "not recorded" (the form leaves the radio blank). WEATHER_CHOICES = [ ('', '—'), ('sunny', 'Sunny'), ('cloudy', 'Cloudy / Overcast'), ('rain', 'Rain'), ('storm', 'Storm'), ('hot', 'Hot'), ('cold', 'Cold'), ('windy', 'Windy'), ] work_log = models.OneToOneField( WorkLog, on_delete=models.CASCADE, related_name='site_report', help_text='The WorkLog this site report belongs to (1:1).', ) weather = models.CharField( max_length=20, choices=WEATHER_CHOICES, blank=True, help_text='Dominant weather of the working day.', ) # Two integers (Celsius). Both optional — supervisor can fill in # only one, or neither. We use IntegerField rather than DecimalField # because a phone keyboard whole-degree entry is faster + thermometers # at construction sites typically aren't precise to a decimal anyway. temperature_min = models.IntegerField( null=True, blank=True, help_text='Coldest temperature seen during the working day, °C.', ) temperature_max = models.IntegerField( null=True, blank=True, help_text='Hottest temperature seen during the working day, °C.', ) notes = models.TextField( blank=True, help_text='Free-form prose. What happened, what was tricky, who came on site, etc.', ) # JSONField is supported on MySQL 5.7+ and SQLite 3.9+ (via Django's # native JSONField since 3.1). The default `default=dict` ensures # `report.metrics` is never None — always at least an empty dict — # which simplifies the form-rendering and detail-rendering code. metrics = models.JSONField( default=dict, blank=True, help_text='Per-day operational metrics. Schema in core/site_report_schema.py.', ) # Who filled it in. Usually the supervisor of the parent WorkLog. # SET_NULL so deactivating an old supervisor's User account doesn't # cascade-delete their historic reports. created_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='site_reports_created', ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-work_log__date', '-created_at'] def __str__(self): return f"Site Report — {self.work_log}" class Loan(models.Model): # === LOAN TYPE === # 'loan' = traditional loan (created via "New Loan") # 'advance' = salary advance (created via "Advance Payment") # Both work the same way (tracked balance, repayments) but are # labelled differently on payslips and in the Loans tab. LOAN_TYPE_CHOICES = [ ('loan', 'Loan'), ('advance', 'Advance'), ] worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='loans') loan_type = models.CharField(max_length=10, choices=LOAN_TYPE_CHOICES, default='loan') principal_amount = models.DecimalField(max_digits=10, decimal_places=2) remaining_balance = models.DecimalField(max_digits=10, decimal_places=2) date = models.DateField(default=timezone.now) reason = models.TextField(blank=True) active = models.BooleanField(default=True) def save(self, *args, **kwargs): if not self.pk: self.remaining_balance = self.principal_amount super().save(*args, **kwargs) def __str__(self): label = 'Advance' if self.loan_type == 'advance' else 'Loan' return f"{self.worker.name} - {label} - {self.date}" class PayrollAdjustment(models.Model): # === PayrollAdjustment TYPE_CHOICES - canonical DB value | display label === # Path A rename (24 Apr 2026): DB values are PRESERVED as-is. Only the # second tuple element (the human label) changes for three types, so # users see shorter labels in tables while every historic row, formula, # constant, test fixture, CSS class, and data-attribute KEEP WORKING # UNCHANGED because they all key off the DB value on the left. # See CLAUDE.md "UI-vs-DB naming drift" section for the full rule. TYPE_CHOICES = [ ('Bonus', 'Bonus'), ('Overtime', 'Overtime'), ('Deduction', 'Deduction'), ('Loan Repayment', 'Loan Repayment'), ('New Loan', 'Loan'), ('Advance Payment', 'Advance'), ('Advance Repayment', 'Advance Repaid'), ] 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') work_log = models.ForeignKey(WorkLog, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments_by_work_log') project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments_by_project') amount = models.DecimalField(max_digits=10, decimal_places=2) date = models.DateField(default=timezone.now) description = models.TextField(blank=True) type = models.CharField(max_length=50, choices=TYPE_CHOICES) def __str__(self): return f"{self.worker.name} - {self.type} - {self.amount}" class ExpenseReceipt(models.Model): METHOD_CHOICES = [ ('Cash', 'Cash'), ('Card', 'Card'), ('EFT', 'EFT'), ('Other', 'Other'), ] VAT_CHOICES = [ ('Included', 'Included'), ('Excluded', 'Excluded'), ('None', 'None'), ] user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='expense_receipts') date = models.DateField(default=timezone.now) vendor_name = models.CharField(max_length=200) description = models.TextField(blank=True) payment_method = models.CharField(max_length=20, choices=METHOD_CHOICES) vat_type = models.CharField(max_length=20, choices=VAT_CHOICES, default='None') subtotal = models.DecimalField(max_digits=12, decimal_places=2) vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00')) total_amount = models.DecimalField(max_digits=12, decimal_places=2) def __str__(self): return f"{self.vendor_name} - {self.date}" class ExpenseLineItem(models.Model): receipt = models.ForeignKey(ExpenseReceipt, on_delete=models.CASCADE, related_name='line_items') product_name = models.CharField(max_length=200) amount = models.DecimalField(max_digits=12, decimal_places=2) def __str__(self): return self.product_name # ============================================================= # === WORKER CERTIFICATIONS === # ============================================================= # Each row means "this worker currently holds this certificate". # Delete the row to record that they no longer hold it. # Use valid_until to track when the cert expires — certs without a # valid_until date are treated as non-expiring (e.g. a completed # skills course with no expiry). class WorkerCertificate(models.Model): # === CERT TYPES === # Fixed list for now; add more entries here when new cert types # become relevant (e.g. scaffolding, electrical, confined spaces). CERT_TYPES = [ ('skills', 'Skills Certificate'), ('pdp', 'PDP (Professional Driving Permit)'), ('first_aid', 'First Aid'), ('medical', 'Medical'), ('work_at_height', 'Work at Height'), ] worker = models.ForeignKey( Worker, related_name='certificates', on_delete=models.CASCADE, ) cert_type = models.CharField(max_length=30, choices=CERT_TYPES) document = models.FileField( upload_to='workers/certificates/', blank=True, null=True, help_text='Scan or photo of the certificate', ) issued_date = models.DateField(blank=True, null=True) valid_until = models.DateField( blank=True, null=True, help_text='Expiry date — leave blank if the cert does not expire', ) notes = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: # One row per (worker, cert_type) — no duplicate cert types per worker constraints = [ models.UniqueConstraint( fields=['worker', 'cert_type'], name='unique_cert_per_worker', ), ] ordering = ['worker', 'cert_type'] def __str__(self): return f'{self.worker.name} — {self.get_cert_type_display()}' @property def is_expired(self): """True if the certificate's valid_until date is in the past.""" if not self.valid_until: return False return self.valid_until < timezone.now().date() @property def expires_soon(self): """True if the cert expires within the next 30 days (but not yet expired).""" if not self.valid_until: return False today = timezone.now().date() return today <= self.valid_until <= today + datetime.timedelta(days=30) # ============================================================= # === WORKER WARNINGS / DISCIPLINARY === # ============================================================= # A disciplinary record per worker. Severity escalates: Verbal → # Written → Final. Keep all historical warnings for audit purposes; # don't delete rows. If a warning was issued in error, update the # reason/description to note that rather than removing it. class WorkerWarning(models.Model): # === SEVERITY LEVELS === # Standard South African labour-relations escalation order. SEVERITY_CHOICES = [ ('verbal', 'Verbal Warning'), ('written', 'Written Warning'), ('final', 'Final Warning'), ] worker = models.ForeignKey( Worker, related_name='warnings', on_delete=models.CASCADE, ) date = models.DateField(default=timezone.now) severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES) reason = models.CharField( max_length=200, help_text='Short summary — e.g. "Repeated lateness"', ) description = models.TextField( blank=True, help_text='Full context of what happened', ) issued_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='warnings_issued', ) document = models.FileField( upload_to='workers/warnings/', blank=True, null=True, help_text='Signed warning form (optional)', ) created_at = models.DateTimeField(auto_now_add=True) class Meta: # Newest warnings first — that's what the UI will show at the top ordering = ['-date'] def __str__(self): return f'{self.worker.name} — {self.get_severity_display()} ({self.date})' class Absence(models.Model): """Per-worker, dated record of a day NOT worked. The supervisor or admin captured WHY (sick, annual leave, absconded, etc.) and optionally whether the day is to be paid at the worker's daily rate. When `is_paid` is True the save flow (in views) creates a Bonus PayrollAdjustment for that worker on that date, linked via the OneToOneField below. Edit/delete of the Absence propagates the adjustment (mirrors the Advance Payment -> Loan -> Repayment cascade pattern in payroll). """ # === ABSENCE REASONS === # SA labour-relations terminology. The DB stores the snake_case key; # users see the human label via get_reason_display(). REASON_CHOICES = [ ('sick', 'Sick'), ('family', 'Family Responsibility'), ('annual', 'Annual Leave'), ('unpaid', 'Personal / Unpaid Leave'), ('iod', 'Injury on Duty'), ('suspension', 'Suspension'), ('absconded', 'Absconded'), ('other', 'Other'), ] worker = models.ForeignKey( Worker, related_name='absences', on_delete=models.CASCADE, ) # === PROJECT LINK (optional) === # Records which project the worker was absent FROM that day. Optional # because not every absence is project-specific (e.g. "Annual Leave" # might not be tied to a project). When set AND is_paid=True, the # auto-created Bonus PayrollAdjustment inherits this project for # cost-attribution. SET_NULL on delete so we keep the absence record # (HR audit trail) even if the project is later removed. project = models.ForeignKey( 'Project', on_delete=models.SET_NULL, null=True, blank=True, related_name='absences', help_text='Which project was this worker absent from? (optional)', ) date = models.DateField(default=timezone.now) reason = models.CharField(max_length=20, choices=REASON_CHOICES) notes = models.TextField( blank=True, help_text='Free-form context (e.g. "flu, doctor\'s note", "bus broke down").', ) # === PAYROLL LINK === is_paid = models.BooleanField( default=False, help_text='Tick to pay the worker their daily rate for this day.', ) payroll_adjustment = models.OneToOneField( 'PayrollAdjustment', on_delete=models.SET_NULL, null=True, blank=True, related_name='absence', help_text='Auto-created when is_paid=True. Cleared if is_paid is unchecked.', ) # === AUDIT === logged_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='absences_logged', ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: # One absence per worker per day — enforced at the DB layer. unique_together = [('worker', 'date')] # Newest first on lists. ordering = ['-date', '-created_at'] def __str__(self): return f'{self.worker.name} — {self.get_reason_display()} — {self.date:%d %b %Y}'