Migration 0015 adds Project FK (SET_NULL, nullable) to Absence. When is_paid=True, the auto-Bonus PayrollAdjustment inherits the project for cost-attribution. Form + admin + list + edit + log templates expose the field. List view filter now uses absence.project_id directly (was indirect via worker__work_logs). 5 new tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
538 lines
22 KiB
Python
538 lines
22 KiB
Python
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}'
|