Marwan Alwali 263292f6be update
2025-11-04 00:50:06 +03:00

2824 lines
92 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
HR app models for hospital management system.
Provides staff management, scheduling, performance management, and training tracking.
"""
import uuid
from django.db import models
from django.core.validators import RegexValidator, MinValueValidator, MaxValueValidator
from django.db.models import TextChoices
from django.utils import timezone
from django.conf import settings
from datetime import timedelta, datetime, date, time
from decimal import Decimal
import json
from django.core.exceptions import ValidationError
import re
class Employee(models.Model):
"""
Employee profile for all non-auth user data.
Auto-created via accounts.signals when a User is created.
"""
class MaritalStatus(models.TextChoices):
SINGLE = 'SINGLE', 'Single'
MARRIED = 'MARRIED', 'Married'
DIVORCED = 'DIVORCED', 'Divorced'
WIDOWED = 'WIDOWED', 'Widowed'
SEPARATED = 'SEPARATED', 'Separated'
OTHER = 'OTHER', 'Other'
class EmploymentType(models.TextChoices):
FULL_TIME = 'FULL_TIME', 'Full Time'
PART_TIME = 'PART_TIME', 'Part Time'
CONTRACT = 'CONTRACT', 'Contract'
TEMPORARY = 'TEMPORARY', 'Temporary'
INTERN = 'INTERN', 'Intern'
VOLUNTEER = 'VOLUNTEER', 'Volunteer'
PER_DIEM = 'PER_DIEM', 'Per Diem'
CONSULTANT = 'CONSULTANT', 'Consultant'
class EmploymentStatus(models.TextChoices):
ACTIVE = 'ACTIVE', 'Active'
INACTIVE = 'INACTIVE', 'Inactive'
TERMINATED = 'TERMINATED', 'Terminated'
SUSPENDED = 'SUSPENDED', 'Suspended'
LEAVE = 'LEAVE', 'On Leave'
RETIRED = 'RETIRED', 'Retired'
class Gender(models.TextChoices):
MALE = 'MALE', 'Male'
FEMALE = 'FEMALE', 'Female'
OTHER = 'OTHER', 'Other'
class Role(models.TextChoices):
SUPER_ADMIN = 'SUPER_ADMIN', 'Super Administrator'
ADMIN = 'ADMIN', 'Administrator'
PHYSICIAN = 'PHYSICIAN', 'Physician'
SURGEON = 'SURGEON', 'Surgeon'
NURSE = 'NURSE', 'Nurse'
NURSE_PRACTITIONER = 'NURSE_PRACTITIONER', 'Nurse Practitioner'
PHYSICIAN_ASSISTANT = 'PHYSICIAN_ASSISTANT', 'Physician Assistant'
SURGICAL_TECHNICIAN = 'SURGICAL_TECHNICIAN', 'Surgical Technician'
ANESTHESIOLOGIST = 'ANESTHESIOLOGIST', 'Anesthesiologist'
ANESTHESIOLOGIST_ASSOCIATE = 'ANESTHESIOLOGIST_ASSOCIATE', 'Anesthesiologist Associate'
CLINICAL_NURSE_ASSOCIATE = 'CLINICAL_NURSE_ASSOCIATE', 'Clinical Nurse Associate'
CLINICAL_NURSE_SPECIALIST = 'CLINICAL_NURSE_SPECIALIST', 'Clinical Nurse Specialist'
CLINICAL_NURSE_MANAGER = 'CLINICAL_NURSE_MANAGER', 'Clinical Nurse Manager'
CLINICAL_NURSE_TECHNICIAN = 'CLINICAL_NURSE_TECHNICIAN', 'Clinical Nurse Technician'
CLINICAL_NURSE_COORDINATOR = 'CLINICAL_NURSE_COORDINATOR', 'Clinical Nurse Coordinator'
FELLOW = 'FELLOW', 'Fellow'
INTERN = 'INTERN', 'Intern'
INTERNSHIP = 'INTERNSHIP', 'Internship'
RESIDENT = 'RESIDENT', 'Resident'
WORK_FROM_HOME = 'WORK_FROM_HOME', 'Work from Home'
WORK_FROM_HOME_PART_TIME = 'WORK_FROM_HOME_PART_TIME', 'Work from Home Part-time'
PHARMACIST = 'PHARMACIST', 'Pharmacist'
PHARMACY_TECH = 'PHARMACY_TECH', 'Pharmacy Technician'
LAB_TECH = 'LAB_TECH', 'Laboratory Technician'
RADIOLOGIST = 'RADIOLOGIST', 'Radiologist'
RAD_TECH = 'RAD_TECH', 'Radiology Technician'
RAD_SUPERVISOR = 'RAD_SUPERVISOR', 'Radiology Supervisor'
THERAPIST = 'THERAPIST', 'Therapist'
SOCIAL_WORKER = 'SOCIAL_WORKER', 'Social Worker'
CASE_MANAGER = 'CASE_MANAGER', 'Case Manager'
BILLING_SPECIALIST = 'BILLING_SPECIALIST', 'Billing Specialist'
REGISTRATION = 'REGISTRATION', 'Registration Staff'
SCHEDULER = 'SCHEDULER', 'Scheduler'
MEDICAL_ASSISTANT = 'MEDICAL_ASSISTANT', 'Medical Assistant'
CLERICAL = 'CLERICAL', 'Clerical Staff'
IT_SUPPORT = 'IT_SUPPORT', 'IT Support'
QUALITY_ASSURANCE = 'QUALITY_ASSURANCE', 'Quality Assurance'
COMPLIANCE = 'COMPLIANCE', 'Compliance Officer'
SECURITY = 'SECURITY', 'Security'
MAINTENANCE = 'MAINTENANCE', 'Maintenance'
VOLUNTEER = 'VOLUNTEER', 'Volunteer'
STUDENT = 'STUDENT', 'Student'
RESEARCHER = 'RESEARCHER', 'Researcher'
CONSULTANT = 'CONSULTANT', 'Consultant'
VENDOR = 'VENDOR', 'Vendor'
GUEST = 'GUEST', 'Guest'
class Theme(models.TextChoices):
LIGHT = 'LIGHT', 'Light'
DARK = 'DARK', 'Dark'
AUTO = 'AUTO', 'Auto'
class IdNumberTypes(models.TextChoices):
NATIONAL_ID = 'NATIONAL_ID', 'National ID'
IQAMA = 'IQAMA', 'IQAMA'
PASSPORT = 'PASSPORT', 'Passport'
OTHER = 'OTHER', 'Other'
tenant = models.ForeignKey('core.Tenant',on_delete=models.CASCADE,related_name='employees')
user = models.OneToOneField(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,related_name='employee_profile')
employee_id = models.CharField(max_length=50, unique=True, editable=False)
identification_number = models.CharField(max_length=20, blank=True)
id_type = models.CharField(max_length=20, choices=IdNumberTypes.choices, default=IdNumberTypes.NATIONAL_ID)
first_name = models.CharField(max_length=50, blank=True)
father_name = models.CharField(max_length=100, blank=True, null=True)
grandfather_name = models.CharField(max_length=100, blank=True, null=True)
last_name = models.CharField(max_length=50, blank=True)
e164_ksa_regex = RegexValidator(regex=r'^\+?9665\d{8}$',message='Use E.164 format: +9665XXXXXXXX')
email = models.EmailField(blank=True, null=True)
phone = models.CharField(max_length=16, blank=True, null=True, validators=[e164_ksa_regex])
mobile_phone = models.CharField(max_length=16, blank=True, null=True, validators=[e164_ksa_regex])
address_line_1 = models.CharField(max_length=100, blank=True, null=True)
address_line_2 = models.CharField(max_length=100, blank=True, null=True)
city = models.CharField(max_length=50, blank=True, null=True)
postal_code = models.CharField(max_length=10, blank=True, null=True)
country = models.CharField(max_length=50, blank=True, null=True)
date_of_birth = models.DateField(blank=True, null=True)
gender = models.CharField(max_length=20, choices=Gender.choices, blank=True, null=True)
marital_status = models.CharField(max_length=20, choices=MaritalStatus.choices, blank=True, null=True)
user_timezone = models.CharField(max_length=50, default='Asia/Riyadh')
language = models.CharField(max_length=10, default='ar')
theme = models.CharField(max_length=20, choices=Theme.choices, default=Theme.LIGHT)
role = models.CharField(max_length=50, choices=Role.choices, default=Role.GUEST)
department = models.ForeignKey('hr.Department',on_delete=models.SET_NULL,null=True,blank=True,related_name='employees')
job_title = models.CharField(max_length=100, blank=True, null=True, help_text='Job title')
license_number = models.CharField(max_length=50, blank=True, null=True, help_text='Professional license number')
license_expiry_date = models.DateField(blank=True, null=True, help_text='License expiry date')
license_state = models.CharField(max_length=50, blank=True, null=True, help_text='Issuing state/authority')
dea_number = models.CharField(max_length=20, blank=True, null=True, help_text='DEA number (if applicable)')
npi_number = models.CharField(max_length=10, blank=True, null=True, help_text='NPI (if applicable)')
employment_status = models.CharField(max_length=20,choices=EmploymentStatus.choices,default=EmploymentStatus.ACTIVE)
employment_type = models.CharField(max_length=20, choices=EmploymentType.choices, blank=True, null=True, help_text='Employment type')
hire_date = models.DateField(blank=True, null=True, help_text='Hire date')
termination_date = models.DateField(blank=True, null=True, help_text='Termination date')
supervisor = models.ForeignKey('self',on_delete=models.SET_NULL,null=True,blank=True,related_name='direct_reports')
hourly_rate = models.DecimalField(max_digits=10,decimal_places=2,blank=True,null=True)
standard_hours_per_week = models.DecimalField(max_digits=5,decimal_places=2,default=Decimal('40.00'))
annual_salary = models.DecimalField(max_digits=12,decimal_places=2,blank=True,null=True)
fte_percentage = models.DecimalField(max_digits=5,decimal_places=2,default=Decimal('100.00'),validators=[MinValueValidator(0), MaxValueValidator(100)])
profile_picture = models.ImageField(upload_to='profile_pictures/',blank=True,null=True)
bio = models.TextField(blank=True, null=True, help_text='Professional bio')
emergency_contact_name = models.CharField(max_length=100,blank=True,null=True)
emergency_contact_relationship = models.CharField(max_length=50,blank=True,null=True)
emergency_contact_phone = models.CharField(max_length=20,blank=True,null=True)
notes = models.TextField(blank=True, null=True)
is_verified = models.BooleanField(default=False)
is_approved = models.BooleanField(default=False)
approval_date = models.DateTimeField(blank=True, null=True)
approved_by = models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.SET_NULL,null=True,blank=True,related_name='approved_employees')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.SET_NULL,null=True, blank=True,related_name='created_employees')
class Meta:
db_table = 'hr_employee'
verbose_name = 'Employee'
verbose_name_plural = 'Employees'
ordering = ['last_name', 'first_name']
indexes = [
models.Index(fields=['tenant', 'employee_id']),
models.Index(fields=['tenant', 'role']),
models.Index(fields=['tenant', 'department']),
models.Index(fields=['tenant', 'employment_status']),
]
def __str__(self):
return f"{self.employee_id} - {self.get_full_name()}"
# ---- Convenience ----
def get_full_name(self):
if self.father_name and self.grandfather_name:
return f"{self.first_name} {self.father_name} {self.grandfather_name} {self.last_name}".strip()
return f"{self.first_name} {self.father_name} {self.grandfather_name} {self.last_name}".strip()
@property
def age(self):
"""
Calculate employee's age.
"""
if self.date_of_birth:
today = date.today()
return today.year - self.date_of_birth.year - ((today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day))
return None
@property
def years_of_service(self):
"""
Calculate years of service.
"""
if self.hire_date:
end_date = self.termination_date or date.today()
return (end_date - self.hire_date).days / 365.25
return 0
@property
def is_license_expired(self):
return bool(self.license_expiry_date and self.license_expiry_date < date.today())
def clean(self):
# Ensure tenant alignment: Employee.tenant MUST match User.tenant
if self.user_id and self.tenant_id and self.user.tenant_id != self.tenant_id:
raise ValidationError({'tenant': 'Employee.tenant must match User.tenant.'})
# Dates sanity
if self.termination_date and self.hire_date and self.termination_date < self.hire_date:
raise ValidationError({'termination_date': 'Termination date cannot be before hire date.'})
def save(self, *args, **kwargs):
if not self.employee_id:
year = timezone.now().year
last_employee = (
Employee.objects.filter(
tenant=self.tenant,
employee_id__startswith=f"E{year}"
)
.order_by('-employee_id')
.first()
)
if last_employee and last_employee.employee_id:
# Extract numeric part after the year (E-2025-000123 → 123)
match = re.search(rf"E{year}(\d+)$", last_employee.employee_id)
last_number = int(match.group(1)) if match else 0
else:
last_number = 0
new_number = last_number + 1
self.employee_id = f"E{year}{new_number:06d}"
super().save(*args, **kwargs)
class Department(models.Model):
"""
Department model for organizational structure.
"""
class DepartmentType(models.TextChoices):
CLINICAL = 'CLINICAL', 'Clinical'
ADMINISTRATIVE = 'ADMINISTRATIVE', 'Administrative'
SUPPORT = 'SUPPORT', 'Support'
ANCILLARY = 'ANCILLARY', 'Ancillary'
EXECUTIVE = 'EXECUTIVE', 'Executive'
# Tenant relationship
tenant = models.ForeignKey(
'core.Tenant',
on_delete=models.CASCADE,
related_name='departments',
help_text='Organization tenant'
)
# Department Information
department_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False,
help_text='Unique department identifier'
)
code = models.CharField(
max_length=20,
help_text='Department code (e.g., CARD, EMER, SURG)'
)
name = models.CharField(
max_length=100,
help_text='Department name'
)
description = models.TextField(
blank=True,
null=True,
help_text='Department description'
)
# Department Type
department_type = models.CharField(
max_length=20,
choices=DepartmentType.choices,
help_text='Department type'
)
# Hierarchy
parent_department = models.ForeignKey(
'self',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='sub_departments',
help_text='Parent department'
)
# Management
department_head = models.ForeignKey(
Employee,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='headed_departments',
help_text='Department head'
)
# Contact Information
phone = models.CharField(
max_length=20,
blank=True,
null=True,
help_text='Department phone number'
)
extension = models.CharField(
max_length=10,
blank=True,
null=True,
help_text='Phone extension'
)
email = models.EmailField(
blank=True,
null=True,
help_text='Department email'
)
# Budget Information
annual_budget = models.DecimalField(
max_digits=12,
decimal_places=2,
blank=True,
null=True,
help_text='Annual budget'
)
cost_center = models.CharField(
max_length=20,
blank=True,
null=True,
help_text='Cost center code'
)
authorized_positions = models.PositiveIntegerField(
default=0,
help_text='Number of authorized positions'
)
# Location Information
location = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='Department location'
)
# Operational Information
is_active = models.BooleanField(
default=True,
help_text='Department is active'
)
is_24_hour = models.BooleanField(
default=False,
help_text='Department operates 24 hours'
)
operating_hours = models.JSONField(
default=dict,
blank=True,
help_text='Operating hours by day of week'
)
# Quality and Compliance
accreditation_required = models.BooleanField(
default=False,
help_text='Department requires special accreditation'
)
accreditation_body = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='Accrediting body (e.g., Joint Commission, CAP)'
)
last_inspection_date = models.DateField(
blank=True,
null=True,
help_text='Last inspection date'
)
next_inspection_date = models.DateField(
blank=True,
null=True,
help_text='Next scheduled inspection date'
)
# Notes
notes = models.TextField(
blank=True,
null=True,
help_text='Department notes'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
'hr.Employee',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_hr_departments',
help_text='User who created the department'
)
class Meta:
db_table = 'hr_department'
verbose_name = 'Department'
verbose_name_plural = 'Departments'
ordering = ['name']
indexes = [
models.Index(fields=['tenant', 'department_type']),
models.Index(fields=['code']),
models.Index(fields=['name']),
models.Index(fields=['is_active']),
]
unique_together = ['tenant', 'code']
def __str__(self):
return f"{self.code} - {self.name}"
@property
def full_name(self):
"""Return full department name with parent if applicable"""
if self.parent_department:
return f"{self.parent_department.name} - {self.name}"
return self.name
@property
def employee_count(self):
"""
Get number of employees in department.
"""
return self.employees.filter(employment_status='ACTIVE').count()
@property
def total_fte(self):
"""
Calculate total FTE for department.
"""
return sum(emp.fte_percentage for emp in self.employees.filter(employment_status='ACTIVE')) / 100
@property
def staffing_percentage(self):
"""Calculate current staffing percentage"""
if self.authorized_positions > 0:
return (self.employee_count / self.authorized_positions) * 100
return 0
def get_all_sub_departments(self):
"""Get all sub-departments recursively"""
sub_departments = []
for sub_dept in self.sub_departments.all():
sub_departments.append(sub_dept)
sub_departments.extend(sub_dept.get_all_sub_departments())
return sub_departments
class Schedule(models.Model):
"""
Schedule model for employee work schedules.
"""
class ScheduleType(models.TextChoices):
REGULAR = 'REGULAR', 'Regular'
ROTATING = 'ROTATING', 'Rotating'
FLEXIBLE = 'FLEXIBLE', 'Flexible'
ON_CALL = 'ON_CALL', 'On-Call'
TEMPORARY = 'TEMPORARY', 'Temporary'
# Employee relationship
employee = models.ForeignKey(
Employee,
on_delete=models.CASCADE,
related_name='schedules',
help_text='Employee'
)
# Schedule Information
schedule_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False,
help_text='Unique schedule identifier'
)
name = models.CharField(
max_length=100,
help_text='Schedule name'
)
description = models.TextField(
blank=True,
null=True,
help_text='Schedule description'
)
# Schedule Type
schedule_type = models.CharField(
max_length=20,
choices=ScheduleType.choices,
help_text='Schedule type'
)
# Schedule Dates
effective_date = models.DateField(
help_text='Effective date'
)
end_date = models.DateField(
blank=True,
null=True,
help_text='End date'
)
# Schedule Pattern (JSON)
schedule_pattern = models.JSONField(
default=dict,
help_text='Schedule pattern configuration'
)
# Schedule Status
is_active = models.BooleanField(
default=True,
help_text='Schedule is active'
)
# Approval Information
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='approved_schedules',
help_text='User who approved the schedule'
)
approval_date = models.DateTimeField(
blank=True,
null=True,
help_text='Approval date and time'
)
# Notes
notes = models.TextField(
blank=True,
null=True,
help_text='Schedule notes'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_schedules',
help_text='User who created the schedule'
)
class Meta:
db_table = 'hr_schedule'
verbose_name = 'Schedule'
verbose_name_plural = 'Schedules'
ordering = ['-effective_date']
indexes = [
models.Index(fields=['employee', 'effective_date']),
models.Index(fields=['schedule_type']),
models.Index(fields=['is_active']),
]
def __str__(self):
return f"{self.employee.get_full_name()} - {self.name}"
@property
def tenant(self):
"""
Get tenant from employee.
"""
return self.employee.tenant
@property
def is_current(self):
"""
Check if schedule is currently active.
"""
today = date.today()
if self.end_date:
return self.effective_date <= today <= self.end_date
return self.effective_date <= today
class ScheduleAssignment(models.Model):
"""
Schedule assignment model for specific shift assignments.
"""
class ShiftType(models.TextChoices):
DAY = 'DAY', 'Day Shift'
EVENING = 'EVENING', 'Evening Shift'
NIGHT = 'NIGHT', 'Night Shift'
WEEKEND = 'WEEKEND', 'Weekend Shift'
HOLIDAY = 'HOLIDAY', 'Holiday Shift'
ON_CALL = 'ON_CALL', 'On-Call'
OVERTIME = 'OVERTIME', 'Overtime'
class ShiftStatus(models.TextChoices):
SCHEDULED = 'SCHEDULED', 'Scheduled'
CONFIRMED = 'CONFIRMED', 'Confirmed'
COMPLETED = 'COMPLETED', 'Completed'
CANCELLED = 'CANCELLED', 'Cancelled'
NO_SHOW = 'NO_SHOW', 'No Show'
# Schedule relationship
schedule = models.ForeignKey(
Schedule,
on_delete=models.CASCADE,
related_name='assignments',
help_text='Schedule'
)
# Assignment Information
assignment_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False,
help_text='Unique assignment identifier'
)
# Date and Time
assignment_date = models.DateField(
help_text='Assignment date'
)
start_time = models.TimeField(
help_text='Start time'
)
end_time = models.TimeField(
help_text='End time'
)
# Shift Information
shift_type = models.CharField(
max_length=20,
choices=ShiftType.choices,
help_text='Shift type'
)
# Location Information
department = models.ForeignKey(
Department,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='schedule_assignments',
help_text='Department'
)
location = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='Specific location'
)
# Assignment Status
status = models.CharField(
max_length=20,
choices=ShiftStatus.choices,
default='SCHEDULED',
help_text='Assignment status'
)
# Break Information
break_minutes = models.PositiveIntegerField(
default=0,
help_text='Break time in minutes'
)
lunch_minutes = models.PositiveIntegerField(
default=0,
help_text='Lunch time in minutes'
)
# Notes
notes = models.TextField(
blank=True,
null=True,
help_text='Assignment notes'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'hr_schedule_assignment'
verbose_name = 'Schedule Assignment'
verbose_name_plural = 'Schedule Assignments'
ordering = ['assignment_date', 'start_time']
indexes = [
models.Index(fields=['schedule', 'assignment_date']),
models.Index(fields=['assignment_date']),
models.Index(fields=['status']),
]
def __str__(self):
return f"{self.schedule.employee.get_full_name()} - {self.assignment_date} {self.start_time}-{self.end_time}"
@property
def tenant(self):
"""
Get tenant from schedule.
"""
return self.schedule.tenant
@property
def employee(self):
"""
Get employee from schedule.
"""
return self.schedule.employee
@property
def total_hours(self):
"""
Calculate total hours for assignment.
"""
start_datetime = datetime.combine(self.assignment_date, self.start_time)
end_datetime = datetime.combine(self.assignment_date, self.end_time)
# Handle overnight shifts
if self.end_time < self.start_time:
end_datetime += timedelta(days=1)
total_minutes = (end_datetime - start_datetime).total_seconds() / 60
total_minutes -= (self.break_minutes + self.lunch_minutes)
return total_minutes / 60
class TimeEntry(models.Model):
"""
Time entry model for tracking actual work hours.
"""
class EntryStatus(models.TextChoices):
DRAFT = 'DRAFT', 'Draft'
SUBMITTED = 'SUBMITTED', 'Submitted'
APPROVED = 'APPROVED', 'Approved'
REJECTED = 'REJECTED', 'Rejected'
PAID = 'PAID', 'Paid'
class EntryType(models.TextChoices):
REGULAR = 'REGULAR', 'Regular Time'
OVERTIME = 'OVERTIME', 'Overtime'
HOLIDAY = 'HOLIDAY', 'Holiday'
VACATION = 'VACATION', 'Vacation'
SICK = 'SICK', 'Sick Leave'
PERSONAL = 'PERSONAL', 'Personal Time'
TRAINING = 'TRAINING', 'Training'
# Employee relationship
employee = models.ForeignKey(
Employee,
on_delete=models.CASCADE,
related_name='time_entries',
help_text='Employee'
)
# Time Entry Information
entry_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False,
help_text='Unique time entry identifier'
)
# Date and Time
work_date = models.DateField(
help_text='Work date'
)
clock_in_time = models.DateTimeField(
blank=True,
null=True,
help_text='Clock in time'
)
clock_out_time = models.DateTimeField(
blank=True,
null=True,
help_text='Clock out time'
)
# Break Times
break_start_time = models.DateTimeField(
blank=True,
null=True,
help_text='Break start time'
)
break_end_time = models.DateTimeField(
blank=True,
null=True,
help_text='Break end time'
)
lunch_start_time = models.DateTimeField(
blank=True,
null=True,
help_text='Lunch start time'
)
lunch_end_time = models.DateTimeField(
blank=True,
null=True,
help_text='Lunch end time'
)
# Hours Information
regular_hours = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('0.00'),
help_text='Regular hours worked'
)
overtime_hours = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('0.00'),
help_text='Overtime hours worked'
)
total_hours = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('0.00'),
help_text='Total hours worked'
)
# Entry Type
entry_type = models.CharField(
max_length=20,
choices=EntryType.choices,
default='REGULAR',
help_text='Entry type'
)
# Department and Location
department = models.ForeignKey(
Department,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='time_entries',
help_text='Department'
)
location = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='Work location'
)
# Approval Information
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='approved_time_entries',
help_text='User who approved the time entry'
)
approval_date = models.DateTimeField(
blank=True,
null=True,
help_text='Approval date and time'
)
# Entry Status
status = models.CharField(
max_length=20,
choices=EntryStatus.choices,
default='DRAFT',
help_text='Entry status'
)
# Notes
notes = models.TextField(
blank=True,
null=True,
help_text='Time entry notes'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'hr_time_entry'
verbose_name = 'Time Entry'
verbose_name_plural = 'Time Entries'
ordering = ['-work_date']
indexes = [
models.Index(fields=['employee', 'work_date']),
models.Index(fields=['work_date']),
models.Index(fields=['status']),
models.Index(fields=['entry_type']),
]
def __str__(self):
return f"{self.employee.get_full_name()} - {self.work_date}"
def save(self, *args, **kwargs):
"""
Calculate hours automatically.
"""
if self.clock_in_time and self.clock_out_time:
# Calculate total time worked
total_minutes = (self.clock_out_time - self.clock_in_time).total_seconds() / 60
# Subtract break time
if self.break_start_time and self.break_end_time:
break_minutes = (self.break_end_time - self.break_start_time).total_seconds() / 60
total_minutes -= break_minutes
# Subtract lunch time
if self.lunch_start_time and self.lunch_end_time:
lunch_minutes = (self.lunch_end_time - self.lunch_start_time).total_seconds() / 60
total_minutes -= lunch_minutes
self.total_hours = Decimal(str(total_minutes / 60))
# Calculate regular vs overtime hours (assuming 8 hours is regular)
if self.total_hours <= 8:
self.regular_hours = self.total_hours
self.overtime_hours = Decimal('0.00')
else:
self.regular_hours = Decimal('8.00')
self.overtime_hours = self.total_hours - Decimal('8.00')
super().save(*args, **kwargs)
@property
def tenant(self):
"""
Get tenant from employee.
"""
return self.employee.tenant
@property
def is_approved(self):
"""
Check if time entry is approved.
"""
return self.status == 'APPROVED'
class PerformanceReview(models.Model):
"""
Performance review model for employee evaluations.
"""
class ReviewType(models.TextChoices):
ANNUAL = 'ANNUAL', 'Annual Review'
PROBATIONARY = 'PROBATIONARY', 'Probationary Review'
MID_YEAR = 'MID_YEAR', 'Mid-Year Review'
PROJECT = 'PROJECT', 'Project Review'
DISCIPLINARY = 'DISCIPLINARY', 'Disciplinary Review'
PROMOTION = 'PROMOTION', 'Promotion Review'
OTHER = 'OTHER', 'Other Review'
class ReviewStatus(models.TextChoices):
DRAFT = 'DRAFT', 'Draft'
SUBMITTED = 'SUBMITTED', 'Submitted'
IN_PROGRESS = 'IN_PROGRESS', 'In Progress'
COMPLETED = 'COMPLETED', 'Completed'
ACKNOWLEDGED = 'ACKNOWLEDGED', 'Acknowledged by Employee'
DISPUTED = 'DISPUTED', 'Disputed'
# Employee relationship
employee = models.ForeignKey(
Employee,
on_delete=models.CASCADE,
related_name='performance_reviews',
help_text='Employee being reviewed'
)
# Review Information
review_id = models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text='Unique review identifier'
)
# Review Period
review_period_start = models.DateField(
help_text='Review period start date'
)
review_period_end = models.DateField(
help_text='Review period end date'
)
review_date = models.DateField(
help_text='Review date'
)
# Review Type
review_type = models.CharField(
max_length=20,
choices=ReviewType.choices,
help_text='Review type'
)
# Reviewer Information
reviewer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='conducted_reviews',
help_text='Reviewer'
)
# Overall Rating
overall_rating = models.DecimalField(
max_digits=3,
decimal_places=1,
validators=[MinValueValidator(1), MaxValueValidator(5)],
help_text='Overall rating (1-5)'
)
# Competency Ratings (JSON)
competency_ratings = models.JSONField(
default=dict,
help_text='Individual competency ratings'
)
# Goals and Objectives
goals_achieved = models.TextField(
blank=True,
null=True,
help_text='Goals achieved during review period'
)
goals_not_achieved = models.TextField(
blank=True,
null=True,
help_text='Goals not achieved during review period'
)
future_goals = models.TextField(
blank=True,
null=True,
help_text='Goals for next review period'
)
# Strengths and Areas for Improvement
strengths = models.TextField(
blank=True,
null=True,
help_text='Employee strengths'
)
areas_for_improvement = models.TextField(
blank=True,
null=True,
help_text='Areas for improvement'
)
# Development Plan
development_plan = models.TextField(
blank=True,
null=True,
help_text='Professional development plan'
)
training_recommendations = models.TextField(
blank=True,
null=True,
help_text='Training recommendations'
)
# Employee Comments
employee_comments = models.TextField(
blank=True,
null=True,
help_text='Employee comments'
)
employee_signature_date = models.DateField(
blank=True,
null=True,
help_text='Employee signature date'
)
# Review Status
status = models.CharField(
max_length=20,
choices=ReviewStatus.choices,
default='DRAFT',
help_text='Review status'
)
# Notes
notes = models.TextField(
blank=True,
null=True,
help_text='Additional notes'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'hr_performance_review'
verbose_name = 'Performance Review'
verbose_name_plural = 'Performance Reviews'
ordering = ['-review_date']
indexes = [
models.Index(fields=['employee', 'review_date']),
models.Index(fields=['review_type']),
models.Index(fields=['status']),
models.Index(fields=['overall_rating']),
]
def __str__(self):
return f"{self.employee.get_full_name()} - {self.review_type} ({self.review_date})"
@property
def is_overdue(self):
"""
Check if review is overdue.
"""
if self.status in ['DRAFT', 'IN_PROGRESS']:
return self.review_date < date.today()
return False
class TrainingPrograms(models.Model):
class TrainingType(models.TextChoices):
ORIENTATION = 'ORIENTATION', 'Orientation'
MANDATORY = 'MANDATORY', 'Mandatory Training'
CONTINUING_ED = 'CONTINUING_ED', 'Continuing Education'
CERTIFICATION = 'CERTIFICATION', 'Certification'
SKILLS = 'SKILLS', 'Skills Training'
SAFETY = 'SAFETY', 'Safety Training'
COMPLIANCE = 'COMPLIANCE', 'Compliance Training'
LEADERSHIP = 'LEADERSHIP', 'Leadership Development'
TECHNICAL = 'TECHNICAL', 'Technical Training'
OTHER = 'OTHER', 'Other'
class TrainingStatus(models.TextChoices):
SCHEDULED = 'SCHEDULED', 'Scheduled'
IN_PROGRESS = 'IN_PROGRESS', 'In Progress'
COMPLETED = 'COMPLETED', 'Completed'
CANCELLED = 'CANCELLED', 'Cancelled'
NO_SHOW = 'NO_SHOW', 'No Show'
FAILED = 'FAILED', 'Failed'
tenant = models.ForeignKey('core.Tenant', on_delete=models.CASCADE, related_name='training_programs')
program_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
name = models.CharField(max_length=200)
description = models.TextField(blank=True, null=True)
program_type = models.CharField(max_length=20, choices=TrainingType.choices)
program_provider = models.CharField(max_length=200, blank=True, null=True)
instructor = models.ForeignKey(Employee, on_delete=models.SET_NULL, null=True, blank=True,related_name='instructor_programs')
start_date = models.DateField(blank=True, null=True)
end_date = models.DateField(blank=True, null=True)
duration_hours = models.DecimalField(max_digits=5, decimal_places=2,default=Decimal('0.00'))
cost = models.DecimalField(max_digits=10, decimal_places=2,default=Decimal('0.00'))
is_certified = models.BooleanField(default=False)
validity_days = models.PositiveIntegerField(blank=True, null=True)
notify_before_days = models.PositiveIntegerField(blank=True, null=True)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, blank=True, related_name='created_training_programs'
)
class Meta:
db_table = 'hr_training_program'
ordering = ['name']
unique_together = [('tenant', 'name')]
indexes = [
models.Index(fields=['tenant', 'program_type']),
models.Index(fields=['tenant', 'is_certified']),
]
def clean(self):
if self.start_date and self.end_date and self.end_date < self.start_date:
raise ValidationError(_('Program end_date cannot be before start_date.'))
if self.is_certified and not self.validity_days:
# Not hard error—could be open-ended—but warn as best practice.
pass
def __str__(self):
return f'{self.name} ({self.get_program_type_display()})'
class ProgramModule(models.Model):
"""Optional content structure for a program."""
program = models.ForeignKey(TrainingPrograms, on_delete=models.CASCADE, related_name='modules')
title = models.CharField(max_length=200)
order = models.PositiveIntegerField(default=1)
hours = models.DecimalField(max_digits=5, decimal_places=2,default=Decimal('0.00'))
class Meta:
db_table = 'hr_training_program_module'
ordering = ['program', 'order']
unique_together = [('program', 'order')]
indexes = [models.Index(fields=['program', 'order'])]
def __str__(self):
return f'{self.program.name} · {self.order}. {self.title}'
class ProgramPrerequisite(models.Model):
"""A program may require completion of other program(s)."""
program = models.ForeignKey(
TrainingPrograms, on_delete=models.CASCADE, related_name='prerequisites'
)
required_program = models.ForeignKey(
TrainingPrograms, on_delete=models.CASCADE, related_name='unlocking_programs'
)
class Meta:
db_table = 'hr_training_program_prerequisite'
unique_together = [('program', 'required_program')]
def clean(self):
if self.program_id == self.required_program_id:
raise ValidationError(_('Program cannot require itself.'))
class TrainingSession(models.Model):
"""
A scheduled run of a program (cohort/class).
"""
class TrainingDelivery(models.TextChoices):
IN_PERSON = 'IN_PERSON', 'In Person'
VIRTUAL = 'VIRTUAL', 'Virtual'
HYBRID = 'HYBRID', 'Hybrid'
SELF_PACED = 'SELF_PACED', 'Self Paced'
session_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
program = models.ForeignKey(
TrainingPrograms, on_delete=models.CASCADE, related_name='sessions'
)
title = models.CharField(
max_length=200, blank=True, null=True,
help_text='Optional run title; falls back to program name'
)
instructor = models.ForeignKey(
Employee, on_delete=models.SET_NULL, null=True, blank=True,
related_name='instructed_sessions'
)
delivery_method = models.CharField(max_length=12, choices=TrainingDelivery.choices, default=TrainingDelivery.IN_PERSON)
start_at = models.DateTimeField()
end_at = models.DateTimeField()
location = models.CharField(max_length=200, blank=True, null=True)
capacity = models.PositiveIntegerField(default=0)
cost_override = models.DecimalField(max_digits=10, decimal_places=2,blank=True, null=True)
hours_override = models.DecimalField(max_digits=5, decimal_places=2,blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,null=True, blank=True, related_name='created_training_sessions')
class Meta:
db_table = 'hr_training_session'
ordering = ['-start_at']
verbose_name = 'Training Session'
verbose_name_plural = 'Training Sessions'
def __str__(self):
return self.title or f'{self.program.name} @ {self.start_at:%Y-%m-%d}'
class TrainingRecord(models.Model):
"""
Enrollment/participation record (renamed semantic, kept class name).
Each row = an employee participating in a specific session of a program.
"""
class TrainingStatus(models.TextChoices):
SCHEDULED = 'SCHEDULED', 'Scheduled'
IN_PROGRESS = 'IN_PROGRESS', 'In Progress'
COMPLETED = 'COMPLETED', 'Completed'
CANCELLED = 'CANCELLED', 'Cancelled'
NO_SHOW = 'NO_SHOW', 'No Show'
FAILED = 'FAILED', 'Failed'
WAITLISTED = 'WAITLISTED', 'Waitlisted'
record_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
employee = models.ForeignKey(Employee, on_delete=models.CASCADE, related_name='training_records')
program = models.ForeignKey(TrainingPrograms, on_delete=models.PROTECT, related_name='training_records')
session = models.ForeignKey(TrainingSession, on_delete=models.PROTECT, related_name='enrollments')
enrolled_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(blank=True, null=True)
completion_date = models.DateField(blank=True, null=True)
expiry_date = models.DateField(blank=True, null=True)
status = models.CharField(max_length=20, choices=TrainingStatus.choices, default=TrainingStatus.SCHEDULED)
credits_earned = models.DecimalField(max_digits=5, decimal_places=2,default=Decimal('0.00'))
score = models.DecimalField(max_digits=5, decimal_places=2, blank=True, null=True)
passed = models.BooleanField(default=False)
notes = models.TextField(blank=True, null=True)
cost_paid = models.DecimalField(max_digits=10, decimal_places=2,blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, blank=True, related_name='created_training_records'
)
class Meta:
db_table = 'hr_training_record'
verbose_name = 'Training Enrollment'
verbose_name_plural = 'Training Enrollments'
ordering = ['-enrolled_at']
unique_together = [('employee', 'session')]
def clean(self):
# Prevent enrolling into sessions of a different program (shouldnt happen)
if self.session and self.program and self.session.program_id != self.program_id:
raise ValidationError(_('Session does not belong to the selected program.'))
if self.completion_date and self.status not in ('COMPLETED', 'FAILED'):
raise ValidationError(_('Completion date requires status COMPLETED or FAILED.'))
def __str__(self):
return f'{self.employee}{self.program.name} ({self.get_status_display()})'
# Helper properties
@property
def hours(self):
return self.session.hours_override or self.program.duration_hours
@property
def effective_cost(self):
return self.cost_paid if self.cost_paid is not None else \
(self.session.cost_override if self.session.cost_override is not None
else self.program.cost)
@property
def eligible_for_certificate(self):
return self.status == 'COMPLETED' and self.passed and self.program.is_certified
@property
def completion_percentage(self):
"""
Calculate completion percentage based on training status and progress.
"""
if self.status == 'COMPLETED':
return 100
elif self.status == 'IN_PROGRESS':
# If we have a session with start/end dates, calculate based on time elapsed
if self.session and self.session.start_at and self.session.end_at:
now = timezone.now()
total_duration = (self.session.end_at - self.session.start_at).total_seconds()
if now >= self.session.end_at:
return 100
elif now <= self.session.start_at:
return 0
else:
elapsed = (now - self.session.start_at).total_seconds()
return min(100, max(0, (elapsed / total_duration) * 100))
else:
# Default to 50% for in-progress without specific timing
return 50
elif self.status == 'SCHEDULED':
return 0
elif self.status in ['CANCELLED', 'NO_SHOW', 'FAILED']:
return 0
elif self.status == 'WAITLISTED':
return 0
else:
return 0
class TrainingAttendance(models.Model):
"""
Optional check-in/out per participant per session (or per day if multi-day).
If you want per-day granularity, add a "session_day" field.
"""
class AttendanceStatus(models.TextChoices):
PRESENT = 'PRESENT', 'Present'
LATE = 'LATE', 'Late'
ABSENT = 'ABSENT', 'Absent'
EXCUSED = 'EXCUSED', 'Excused'
enrollment = models.ForeignKey(TrainingRecord, on_delete=models.CASCADE, related_name='attendance')
checked_in_at = models.DateTimeField(blank=True, null=True)
checked_out_at = models.DateTimeField(blank=True, null=True)
status = models.CharField(max_length=10, choices=AttendanceStatus.choices, default=AttendanceStatus.PRESENT)
notes = models.CharField(max_length=255, blank=True, null=True)
class Meta:
db_table = 'hr_training_attendance'
ordering = ['enrollment_id', 'checked_in_at']
indexes = [models.Index(fields=['enrollment'])]
class TrainingAssessment(models.Model):
"""
Optional evaluation (quiz/exam) tied to an enrollment.
"""
enrollment = models.ForeignKey(TrainingRecord, on_delete=models.CASCADE, related_name='assessments')
name = models.CharField(max_length=200)
max_score = models.DecimalField(max_digits=7, decimal_places=2, default=100)
score = models.DecimalField(max_digits=7, decimal_places=2, blank=True, null=True)
passed = models.BooleanField(default=False)
taken_at = models.DateTimeField(blank=True, null=True)
notes = models.TextField(blank=True, null=True)
class Meta:
db_table = 'hr_training_assessment'
ordering = ['-taken_at']
indexes = [models.Index(fields=['enrollment'])]
class TrainingCertificates(models.Model):
"""
Issued certificates on completion.
Usually tied to a program and the enrollment that produced it.
"""
certificate_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
program = models.ForeignKey(TrainingPrograms, on_delete=models.PROTECT, related_name='certificates')
employee = models.ForeignKey(
Employee, on_delete=models.CASCADE, related_name='training_certificates'
)
enrollment = models.OneToOneField(TrainingRecord, on_delete=models.CASCADE, related_name='certificate')
certificate_name = models.CharField(max_length=200)
certificate_number = models.CharField(max_length=50, blank=True, null=True)
certification_body = models.CharField(max_length=200, blank=True, null=True)
issued_date = models.DateField(auto_now_add=True)
expiry_date = models.DateField(blank=True, null=True)
file = models.FileField(upload_to='certificates/', blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, blank=True, related_name='created_training_certificates'
)
signed_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, blank=True, related_name='signed_training_certificates'
)
class Meta:
db_table = 'hr_training_certificate'
verbose_name = 'Training Certificate'
verbose_name_plural = 'Training Certificates'
ordering = ['-issued_date']
unique_together = [('employee', 'program', 'enrollment')]
indexes = [
models.Index(fields=['certificate_number']),
]
def __str__(self):
return f'{self.certificate_name} - {self.employee}'
@property
def is_expired(self):
return bool(self.expiry_date and self.expiry_date < date.today())
@property
def days_to_expiry(self):
return (self.expiry_date - date.today()).days if self.expiry_date else None
@classmethod
def compute_expiry(cls, program: TrainingPrograms, issued_on: date) -> date | None:
if program.is_certified and program.validity_days:
return issued_on + timedelta(days=program.validity_days)
return None
# ============================================================================
# LEAVE MANAGEMENT MODELS
# ============================================================================
class LeaveType(models.Model):
"""
Leave type configuration (Annual, Sick, Maternity, etc.)
"""
class AccrualMethod(models.TextChoices):
MONTHLY = 'MONTHLY', 'Monthly Accrual'
ANNUAL = 'ANNUAL', 'Annual Allocation'
PRORATED = 'PRORATED', 'Pro-rated'
NONE = 'NONE', 'No Accrual'
tenant = models.ForeignKey(
'core.Tenant',
on_delete=models.CASCADE,
related_name='leave_types'
)
leave_type_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False
)
name = models.CharField(max_length=100, help_text='Leave type name')
code = models.CharField(max_length=20, help_text='Short code (e.g., AL, SL, ML)')
description = models.TextField(blank=True, null=True)
# Leave configuration
is_paid = models.BooleanField(default=True, help_text='Is this paid leave?')
requires_approval = models.BooleanField(default=True, help_text='Requires manager approval?')
requires_documentation = models.BooleanField(default=False, help_text='Requires supporting documents?')
# Accrual settings
accrual_method = models.CharField(
max_length=20,
choices=AccrualMethod.choices,
default=AccrualMethod.ANNUAL
)
annual_entitlement = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('0.00'),
help_text='Annual days entitled'
)
max_carry_over = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('0.00'),
help_text='Maximum days that can be carried over'
)
max_consecutive_days = models.PositiveIntegerField(
blank=True,
null=True,
help_text='Maximum consecutive days allowed'
)
min_notice_days = models.PositiveIntegerField(
default=0,
help_text='Minimum notice period in days'
)
# Availability
is_active = models.BooleanField(default=True)
available_for_all = models.BooleanField(
default=True,
help_text='Available for all employees?'
)
# Gender-specific (for maternity/paternity)
gender_specific = models.CharField(
max_length=10,
choices=Employee.Gender.choices,
blank=True,
null=True,
help_text='Restrict to specific gender'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_leave_types'
)
class Meta:
db_table = 'hr_leave_type'
verbose_name = 'Leave Type'
verbose_name_plural = 'Leave Types'
ordering = ['name']
unique_together = [('tenant', 'code')]
indexes = [
models.Index(fields=['tenant', 'is_active']),
models.Index(fields=['code']),
]
def __str__(self):
return f"{self.code} - {self.name}"
class LeaveBalance(models.Model):
"""
Employee leave balance tracking
"""
balance_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False
)
employee = models.ForeignKey(
Employee,
on_delete=models.CASCADE,
related_name='leave_balances'
)
leave_type = models.ForeignKey(
LeaveType,
on_delete=models.CASCADE,
related_name='balances'
)
year = models.PositiveIntegerField(help_text='Calendar year')
# Balance tracking
opening_balance = models.DecimalField(
max_digits=6,
decimal_places=2,
default=Decimal('0.00'),
help_text='Balance brought forward from previous year'
)
accrued = models.DecimalField(
max_digits=6,
decimal_places=2,
default=Decimal('0.00'),
help_text='Days accrued this year'
)
used = models.DecimalField(
max_digits=6,
decimal_places=2,
default=Decimal('0.00'),
help_text='Days used (approved leaves)'
)
pending = models.DecimalField(
max_digits=6,
decimal_places=2,
default=Decimal('0.00'),
help_text='Days pending approval'
)
adjusted = models.DecimalField(
max_digits=6,
decimal_places=2,
default=Decimal('0.00'),
help_text='Manual adjustments'
)
# Metadata
last_accrual_date = models.DateField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'hr_leave_balance'
verbose_name = 'Leave Balance'
verbose_name_plural = 'Leave Balances'
ordering = ['-year', 'employee']
unique_together = [('employee', 'leave_type', 'year')]
indexes = [
models.Index(fields=['employee', 'year']),
models.Index(fields=['leave_type', 'year']),
]
def __str__(self):
return f"{self.employee.get_full_name()} - {self.leave_type.name} ({self.year})"
@property
def available(self):
"""Calculate available balance"""
return self.opening_balance + self.accrued + self.adjusted - self.used - self.pending
@property
def total_entitled(self):
"""Total entitled days"""
return self.opening_balance + self.accrued + self.adjusted
def can_request(self, days):
"""Check if employee can request specified days"""
return self.available >= Decimal(str(days))
class LeaveRequest(models.Model):
"""
Employee leave request
"""
class RequestStatus(models.TextChoices):
DRAFT = 'DRAFT', 'Draft'
PENDING = 'PENDING', 'Pending Approval'
APPROVED = 'APPROVED', 'Approved'
REJECTED = 'REJECTED', 'Rejected'
CANCELLED = 'CANCELLED', 'Cancelled'
WITHDRAWN = 'WITHDRAWN', 'Withdrawn'
class DayType(models.TextChoices):
FULL_DAY = 'FULL_DAY', 'Full Day'
HALF_DAY_AM = 'HALF_DAY_AM', 'Half Day (Morning)'
HALF_DAY_PM = 'HALF_DAY_PM', 'Half Day (Afternoon)'
request_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False
)
employee = models.ForeignKey(
Employee,
on_delete=models.CASCADE,
related_name='leave_requests'
)
leave_type = models.ForeignKey(
LeaveType,
on_delete=models.PROTECT,
related_name='requests'
)
# Leave details
start_date = models.DateField(help_text='Leave start date')
end_date = models.DateField(help_text='Leave end date')
start_day_type = models.CharField(
max_length=15,
choices=DayType.choices,
default=DayType.FULL_DAY
)
end_day_type = models.CharField(
max_length=15,
choices=DayType.choices,
default=DayType.FULL_DAY
)
total_days = models.DecimalField(
max_digits=5,
decimal_places=2,
help_text='Total leave days requested'
)
# Request information
reason = models.TextField(help_text='Reason for leave')
contact_number = models.CharField(
max_length=20,
blank=True,
null=True,
help_text='Contact number during leave'
)
emergency_contact = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='Emergency contact person'
)
# Supporting documents
attachment = models.FileField(
upload_to='leave_attachments/',
blank=True,
null=True,
help_text='Supporting document (medical certificate, etc.)'
)
# Status and workflow
status = models.CharField(
max_length=20,
choices=RequestStatus.choices,
default=RequestStatus.DRAFT
)
submitted_at = models.DateTimeField(blank=True, null=True)
# Approval information
current_approver = models.ForeignKey(
Employee,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='pending_leave_approvals',
help_text='Current approver in the chain'
)
final_approver = models.ForeignKey(
Employee,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='final_approved_leaves',
help_text='Final approver who approved the request'
)
approved_at = models.DateTimeField(blank=True, null=True)
rejected_at = models.DateTimeField(blank=True, null=True)
rejection_reason = models.TextField(blank=True, null=True)
# Cancellation
cancelled_at = models.DateTimeField(blank=True, null=True)
cancelled_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='cancelled_leave_requests'
)
cancellation_reason = models.TextField(blank=True, null=True)
# Metadata
notes = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_leave_requests'
)
class Meta:
db_table = 'hr_leave_request'
verbose_name = 'Leave Request'
verbose_name_plural = 'Leave Requests'
ordering = ['-created_at']
indexes = [
models.Index(fields=['employee', 'status']),
models.Index(fields=['leave_type', 'status']),
models.Index(fields=['start_date', 'end_date']),
models.Index(fields=['current_approver', 'status']),
models.Index(fields=['status']),
]
def __str__(self):
return f"{self.employee.get_full_name()} - {self.leave_type.name} ({self.start_date} to {self.end_date})"
@property
def tenant(self):
return self.employee.tenant
@property
def is_pending(self):
return self.status == 'PENDING'
@property
def is_approved(self):
return self.status == 'APPROVED'
@property
def can_cancel(self):
"""Check if request can be cancelled"""
return self.status in ['PENDING', 'APPROVED'] and self.start_date > date.today()
@property
def can_edit(self):
"""Check if request can be edited"""
return self.status == 'DRAFT'
def calculate_days(self):
"""Calculate total leave days"""
if not self.start_date or not self.end_date:
return Decimal('0.00')
# Calculate business days between dates
total_days = (self.end_date - self.start_date).days + 1
# Adjust for half days
days = Decimal(str(total_days))
if self.start_day_type in ['HALF_DAY_AM', 'HALF_DAY_PM']:
days -= Decimal('0.5')
if self.end_day_type in ['HALF_DAY_AM', 'HALF_DAY_PM'] and self.start_date != self.end_date:
days -= Decimal('0.5')
return days
def clean(self):
"""Validate leave request"""
if self.start_date and self.end_date:
if self.end_date < self.start_date:
raise ValidationError({'end_date': 'End date cannot be before start date.'})
# Check if dates are in the past
if self.start_date < date.today() and not self.pk:
raise ValidationError({'start_date': 'Cannot request leave for past dates.'})
# Check minimum notice period
if self.leave_type and self.leave_type.min_notice_days:
notice_date = date.today() + timedelta(days=self.leave_type.min_notice_days)
if self.start_date < notice_date and not self.pk:
raise ValidationError({
'start_date': f'Minimum {self.leave_type.min_notice_days} days notice required.'
})
# Check maximum consecutive days
if self.leave_type and self.leave_type.max_consecutive_days:
days = self.calculate_days()
if days > self.leave_type.max_consecutive_days:
raise ValidationError({
'end_date': f'Maximum {self.leave_type.max_consecutive_days} consecutive days allowed.'
})
def save(self, *args, **kwargs):
# Calculate total days
if self.start_date and self.end_date:
self.total_days = self.calculate_days()
# Set current approver on submission
if self.status == 'PENDING' and not self.current_approver:
self.current_approver = self.employee.supervisor
super().save(*args, **kwargs)
class LeaveApproval(models.Model):
"""
Leave approval tracking (multi-level approval chain)
"""
class ApprovalAction(models.TextChoices):
PENDING = 'PENDING', 'Pending'
APPROVED = 'APPROVED', 'Approved'
REJECTED = 'REJECTED', 'Rejected'
DELEGATED = 'DELEGATED', 'Delegated'
approval_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False
)
leave_request = models.ForeignKey(
LeaveRequest,
on_delete=models.CASCADE,
related_name='approvals'
)
# Approval level
level = models.PositiveIntegerField(
default=1,
help_text='Approval level (1=Supervisor, 2=Department Head, etc.)'
)
approver = models.ForeignKey(
Employee,
on_delete=models.PROTECT,
related_name='leave_approvals_given'
)
# Delegation
delegated_by = models.ForeignKey(
Employee,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='delegated_leave_approvals',
help_text='Original approver who delegated'
)
# Approval details
action = models.CharField(
max_length=20,
choices=ApprovalAction.choices,
default=ApprovalAction.PENDING
)
comments = models.TextField(blank=True, null=True)
action_date = models.DateTimeField(blank=True, null=True)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'hr_leave_approval'
verbose_name = 'Leave Approval'
verbose_name_plural = 'Leave Approvals'
ordering = ['leave_request', 'level']
indexes = [
models.Index(fields=['leave_request', 'level']),
models.Index(fields=['approver', 'action']),
]
def __str__(self):
return f"Level {self.level} - {self.approver.get_full_name()} - {self.action}"
@property
def is_pending(self):
return self.action == 'PENDING'
@property
def is_delegated_approval(self):
return self.delegated_by is not None
class LeaveDelegate(models.Model):
"""
Temporary delegation of leave approval authority
"""
delegation_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False
)
delegator = models.ForeignKey(
Employee,
on_delete=models.CASCADE,
related_name='leave_delegations_given',
help_text='Person delegating authority'
)
delegate = models.ForeignKey(
Employee,
on_delete=models.CASCADE,
related_name='leave_delegations_received',
help_text='Person receiving delegation'
)
# Delegation period
start_date = models.DateField(help_text='Delegation start date')
end_date = models.DateField(help_text='Delegation end date')
# Delegation scope
reason = models.TextField(help_text='Reason for delegation')
is_active = models.BooleanField(default=True)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_leave_delegations'
)
class Meta:
db_table = 'hr_leave_delegate'
verbose_name = 'Leave Delegation'
verbose_name_plural = 'Leave Delegations'
ordering = ['-start_date']
indexes = [
models.Index(fields=['delegator', 'is_active']),
models.Index(fields=['delegate', 'is_active']),
models.Index(fields=['start_date', 'end_date']),
]
def __str__(self):
return f"{self.delegator.get_full_name()}{self.delegate.get_full_name()} ({self.start_date} to {self.end_date})"
@property
def tenant(self):
return self.delegator.tenant
@property
def is_current(self):
"""Check if delegation is currently active"""
today = date.today()
return self.is_active and self.start_date <= today <= self.end_date
def clean(self):
"""Validate delegation"""
if self.start_date and self.end_date:
if self.end_date < self.start_date:
raise ValidationError({'end_date': 'End date cannot be before start date.'})
if self.delegator == self.delegate:
raise ValidationError({'delegate': 'Cannot delegate to yourself.'})
# Check for overlapping delegations
if self.delegator and self.start_date and self.end_date:
overlapping = LeaveDelegate.objects.filter(
delegator=self.delegator,
is_active=True,
start_date__lte=self.end_date,
end_date__gte=self.start_date
)
if self.pk:
overlapping = overlapping.exclude(pk=self.pk)
if overlapping.exists():
raise ValidationError('Overlapping delegation period exists.')
# ============================================================================
# SALARY & COMPENSATION MANAGEMENT MODELS
# ============================================================================
class SalaryInformation(models.Model):
"""
Employee salary information and payment details.
Tracks current and historical salary data.
"""
class PaymentFrequency(models.TextChoices):
MONTHLY = 'MONTHLY', 'Monthly'
BI_WEEKLY = 'BI_WEEKLY', 'Bi-Weekly'
WEEKLY = 'WEEKLY', 'Weekly'
class Currency(models.TextChoices):
SAR = 'SAR', 'Saudi Riyal'
USD = 'USD', 'US Dollar'
EUR = 'EUR', 'Euro'
GBP = 'GBP', 'British Pound'
# Primary Key
salary_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False,
help_text='Unique salary record identifier'
)
# Employee Relationship
employee = models.ForeignKey(
Employee,
on_delete=models.CASCADE,
related_name='salary_records',
help_text='Employee'
)
# Effective Date
effective_date = models.DateField(
help_text='Date when this salary becomes effective'
)
end_date = models.DateField(
blank=True,
null=True,
help_text='Date when this salary ends (null if current)'
)
# Salary Components
basic_salary = models.DecimalField(
max_digits=12,
decimal_places=2,
help_text='Basic salary amount'
)
housing_allowance = models.DecimalField(
max_digits=12,
decimal_places=2,
default=Decimal('0.00'),
help_text='Housing allowance'
)
transportation_allowance = models.DecimalField(
max_digits=12,
decimal_places=2,
default=Decimal('0.00'),
help_text='Transportation allowance'
)
food_allowance = models.DecimalField(
max_digits=12,
decimal_places=2,
default=Decimal('0.00'),
help_text='Food allowance'
)
other_allowances = models.JSONField(
default=dict,
blank=True,
help_text='Other allowances (flexible structure)'
)
# Total Salary (Calculated)
total_salary = models.DecimalField(
max_digits=12,
decimal_places=2,
help_text='Total salary (calculated)'
)
# Currency and Payment
currency = models.CharField(
max_length=3,
choices=Currency.choices,
default=Currency.SAR,
help_text='Currency code'
)
payment_frequency = models.CharField(
max_length=20,
choices=PaymentFrequency.choices,
default=PaymentFrequency.MONTHLY,
help_text='Payment frequency'
)
# Bank Details
bank_name = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='Bank name'
)
account_number = models.CharField(
max_length=50,
blank=True,
null=True,
help_text='Bank account number'
)
iban = models.CharField(
max_length=34,
blank=True,
null=True,
help_text='IBAN number'
)
swift_code = models.CharField(
max_length=11,
blank=True,
null=True,
help_text='SWIFT/BIC code'
)
# Status
is_active = models.BooleanField(
default=True,
help_text='Is this the current active salary?'
)
# Notes
notes = models.TextField(
blank=True,
null=True,
help_text='Additional notes'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_salary_records'
)
class Meta:
db_table = 'hr_salary_information'
verbose_name = 'Salary Information'
verbose_name_plural = 'Salary Information'
ordering = ['-effective_date']
indexes = [
models.Index(fields=['employee', 'is_active']),
models.Index(fields=['employee', 'effective_date']),
models.Index(fields=['effective_date']),
]
unique_together = [('employee', 'effective_date')]
def __str__(self):
return f"{self.employee.get_full_name()} - {self.total_salary} {self.currency} (Effective: {self.effective_date})"
@property
def tenant(self):
return self.employee.tenant
def calculate_total_salary(self):
"""Calculate total salary from all components"""
total = self.basic_salary + self.housing_allowance + self.transportation_allowance + self.food_allowance
# Add other allowances
if self.other_allowances:
for key, value in self.other_allowances.items():
if isinstance(value, (int, float, Decimal)):
total += Decimal(str(value))
return total
def clean(self):
"""Validate salary information"""
# Ensure effective_date is not in the future for new records
if not self.pk and self.effective_date > date.today():
raise ValidationError({'effective_date': 'Effective date cannot be in the future.'})
# Ensure end_date is after effective_date
if self.end_date and self.end_date < self.effective_date:
raise ValidationError({'end_date': 'End date cannot be before effective date.'})
# Validate IBAN format (basic check)
if self.iban:
iban_clean = self.iban.replace(' ', '').upper()
if not re.match(r'^[A-Z]{2}\d{2}[A-Z0-9]+$', iban_clean):
raise ValidationError({'iban': 'Invalid IBAN format.'})
def save(self, *args, **kwargs):
# Calculate total salary
self.total_salary = self.calculate_total_salary()
# If this is set as active, deactivate other salary records for this employee
if self.is_active:
SalaryInformation.objects.filter(
employee=self.employee,
is_active=True
).exclude(pk=self.pk).update(is_active=False, end_date=self.effective_date)
super().save(*args, **kwargs)
class SalaryAdjustment(models.Model):
"""
Track salary adjustments and changes.
"""
class AdjustmentType(models.TextChoices):
PROMOTION = 'PROMOTION', 'Promotion'
ANNUAL_INCREMENT = 'ANNUAL_INCREMENT', 'Annual Increment'
MERIT_INCREASE = 'MERIT_INCREASE', 'Merit Increase'
COST_OF_LIVING = 'COST_OF_LIVING', 'Cost of Living Adjustment'
MARKET_ADJUSTMENT = 'MARKET_ADJUSTMENT', 'Market Adjustment'
CORRECTION = 'CORRECTION', 'Correction'
DEMOTION = 'DEMOTION', 'Demotion'
OTHER = 'OTHER', 'Other'
# Primary Key
adjustment_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False
)
# Employee
employee = models.ForeignKey(
Employee,
on_delete=models.CASCADE,
related_name='salary_adjustments'
)
# Salary References
previous_salary = models.ForeignKey(
SalaryInformation,
on_delete=models.PROTECT,
related_name='adjustments_from',
help_text='Previous salary record'
)
new_salary = models.ForeignKey(
SalaryInformation,
on_delete=models.PROTECT,
related_name='adjustments_to',
help_text='New salary record'
)
# Adjustment Details
adjustment_type = models.CharField(
max_length=20,
choices=AdjustmentType.choices,
help_text='Type of adjustment'
)
adjustment_reason = models.TextField(
help_text='Detailed reason for adjustment'
)
adjustment_percentage = models.DecimalField(
max_digits=5,
decimal_places=2,
blank=True,
null=True,
help_text='Percentage increase/decrease'
)
adjustment_amount = models.DecimalField(
max_digits=12,
decimal_places=2,
help_text='Absolute amount of change'
)
# Effective Date
effective_date = models.DateField(
help_text='Date when adjustment becomes effective'
)
# Approval
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='approved_salary_adjustments'
)
approval_date = models.DateTimeField(
blank=True,
null=True
)
# Notes
notes = models.TextField(
blank=True,
null=True
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_salary_adjustments'
)
class Meta:
db_table = 'hr_salary_adjustment'
verbose_name = 'Salary Adjustment'
verbose_name_plural = 'Salary Adjustments'
ordering = ['-effective_date']
indexes = [
models.Index(fields=['employee', 'effective_date']),
models.Index(fields=['adjustment_type']),
models.Index(fields=['effective_date']),
]
def __str__(self):
return f"{self.employee.get_full_name()} - {self.get_adjustment_type_display()} ({self.effective_date})"
@property
def tenant(self):
return self.employee.tenant
def save(self, *args, **kwargs):
# Calculate adjustment amount and percentage
if self.previous_salary and self.new_salary:
self.adjustment_amount = self.new_salary.total_salary - self.previous_salary.total_salary
if self.previous_salary.total_salary > 0:
self.adjustment_percentage = (self.adjustment_amount / self.previous_salary.total_salary) * 100
super().save(*args, **kwargs)
# ============================================================================
# DOCUMENT REQUEST MANAGEMENT MODELS
# ============================================================================
class DocumentRequest(models.Model):
"""
Employee document requests (salary certificates, employment letters, etc.)
"""
class DocumentType(models.TextChoices):
SALARY_CERTIFICATE = 'SALARY_CERTIFICATE', 'Salary Certificate'
EMPLOYMENT_CERTIFICATE = 'EMPLOYMENT_CERTIFICATE', 'Employment Certificate'
EXPERIENCE_LETTER = 'EXPERIENCE_LETTER', 'Experience Letter'
TO_WHOM_IT_MAY_CONCERN = 'TO_WHOM_IT_MAY_CONCERN', 'To Whom It May Concern'
BANK_LETTER = 'BANK_LETTER', 'Bank Letter'
EMBASSY_LETTER = 'EMBASSY_LETTER', 'Embassy Letter'
VISA_LETTER = 'VISA_LETTER', 'Visa Support Letter'
CUSTOM = 'CUSTOM', 'Custom Document'
class RequestStatus(models.TextChoices):
DRAFT = 'DRAFT', 'Draft'
PENDING = 'PENDING', 'Pending Review'
IN_PROGRESS = 'IN_PROGRESS', 'In Progress'
READY = 'READY', 'Ready for Pickup/Delivery'
DELIVERED = 'DELIVERED', 'Delivered'
REJECTED = 'REJECTED', 'Rejected'
CANCELLED = 'CANCELLED', 'Cancelled'
class Language(models.TextChoices):
ARABIC = 'AR', 'Arabic'
ENGLISH = 'EN', 'English'
BOTH = 'BOTH', 'Both (Arabic & English)'
class DeliveryMethod(models.TextChoices):
EMAIL = 'EMAIL', 'Email'
PICKUP = 'PICKUP', 'Pickup from HR'
MAIL = 'MAIL', 'Mail/Courier'
PORTAL = 'PORTAL', 'Download from Portal'
# Primary Key
request_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False
)
# Employee
employee = models.ForeignKey(
Employee,
on_delete=models.CASCADE,
related_name='document_requests'
)
# Document Details
document_type = models.CharField(
max_length=30,
choices=DocumentType.choices,
help_text='Type of document requested'
)
custom_document_name = models.CharField(
max_length=200,
blank=True,
null=True,
help_text='Custom document name (if type is CUSTOM)'
)
# Request Details
purpose = models.TextField(
help_text='Purpose/reason for requesting the document'
)
addressee = models.CharField(
max_length=200,
blank=True,
null=True,
help_text='To whom the document should be addressed'
)
# Language and Delivery
language = models.CharField(
max_length=10,
choices=Language.choices,
default=Language.ENGLISH,
help_text='Document language'
)
delivery_method = models.CharField(
max_length=20,
choices=DeliveryMethod.choices,
default=DeliveryMethod.EMAIL,
help_text='Preferred delivery method'
)
delivery_address = models.TextField(
blank=True,
null=True,
help_text='Delivery address (if mail delivery)'
)
delivery_email = models.EmailField(
blank=True,
null=True,
help_text='Email address for delivery (if different from employee email)'
)
# Dates
requested_date = models.DateTimeField(
auto_now_add=True,
help_text='Date and time of request'
)
required_by_date = models.DateField(
blank=True,
null=True,
help_text='Date by which document is needed'
)
# Status
status = models.CharField(
max_length=20,
choices=RequestStatus.choices,
default=RequestStatus.DRAFT,
help_text='Request status'
)
# Processing
processed_by = models.ForeignKey(
Employee,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='processed_document_requests',
help_text='HR staff who processed the request'
)
processed_date = models.DateTimeField(
blank=True,
null=True,
help_text='Date and time when processed'
)
# Generated Document
generated_document = models.FileField(
upload_to='hr/documents/generated/%Y/%m/',
blank=True,
null=True,
help_text='Generated document file (PDF)'
)
document_number = models.CharField(
max_length=50,
blank=True,
null=True,
unique=True,
help_text='Official document number'
)
# Rejection
rejection_reason = models.TextField(
blank=True,
null=True,
help_text='Reason for rejection'
)
# Additional Information
include_salary = models.BooleanField(
default=False,
help_text='Include salary information in document'
)
additional_notes = models.TextField(
blank=True,
null=True,
help_text='Additional notes or special requirements'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_document_requests'
)
class Meta:
db_table = 'hr_document_request'
verbose_name = 'Document Request'
verbose_name_plural = 'Document Requests'
ordering = ['-requested_date']
indexes = [
models.Index(fields=['employee', 'status']),
models.Index(fields=['document_type', 'status']),
models.Index(fields=['status']),
models.Index(fields=['requested_date']),
models.Index(fields=['required_by_date']),
]
def __str__(self):
return f"{self.employee.get_full_name()} - {self.get_document_type_display()} ({self.get_status_display()})"
@property
def tenant(self):
return self.employee.tenant
@property
def is_urgent(self):
"""Check if request is urgent (required within 3 days)"""
if self.required_by_date:
days_until_required = (self.required_by_date - date.today()).days
return days_until_required <= 3
return False
@property
def is_overdue(self):
"""Check if request is overdue"""
if self.required_by_date and self.status not in ['DELIVERED', 'REJECTED', 'CANCELLED']:
return self.required_by_date < date.today()
return False
@property
def can_cancel(self):
"""Check if request can be cancelled"""
return self.status in ['DRAFT', 'PENDING', 'IN_PROGRESS']
def generate_document_number(self):
"""Generate unique document number"""
if not self.document_number:
year = timezone.now().year
# Get last document number for this year
last_doc = DocumentRequest.objects.filter(
document_number__startswith=f'DOC{year}'
).order_by('-document_number').first()
if last_doc and last_doc.document_number:
match = re.search(rf'DOC{year}(\d+)$', last_doc.document_number)
last_number = int(match.group(1)) if match else 0
else:
last_number = 0
new_number = last_number + 1
self.document_number = f'DOC{year}{new_number:06d}'
def clean(self):
"""Validate document request"""
# Validate required_by_date
if self.required_by_date and self.required_by_date < date.today():
raise ValidationError({'required_by_date': 'Required by date cannot be in the past.'})
# Validate custom document name
if self.document_type == 'CUSTOM' and not self.custom_document_name:
raise ValidationError({'custom_document_name': 'Custom document name is required for custom documents.'})
# Validate delivery address for mail delivery
if self.delivery_method == 'MAIL' and not self.delivery_address:
raise ValidationError({'delivery_address': 'Delivery address is required for mail delivery.'})
def save(self, *args, **kwargs):
# Generate document number if status is READY or DELIVERED
if self.status in ['READY', 'DELIVERED'] and not self.document_number:
self.generate_document_number()
super().save(*args, **kwargs)
class DocumentTemplate(models.Model):
"""
Reusable document templates for generating official documents.
"""
# Primary Key
template_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False
)
# Tenant
tenant = models.ForeignKey(
'core.Tenant',
on_delete=models.CASCADE,
related_name='document_templates'
)
# Template Details
name = models.CharField(
max_length=200,
help_text='Template name'
)
description = models.TextField(
blank=True,
null=True,
help_text='Template description'
)
# Document Type
document_type = models.CharField(
max_length=30,
choices=DocumentRequest.DocumentType.choices,
help_text='Type of document this template is for'
)
# Language
language = models.CharField(
max_length=10,
choices=DocumentRequest.Language.choices,
help_text='Template language'
)
# Template Content
template_content = models.TextField(
help_text='Template content with placeholders (HTML supported)'
)
# Header and Footer
header_content = models.TextField(
blank=True,
null=True,
help_text='Header content (letterhead, logo, etc.)'
)
footer_content = models.TextField(
blank=True,
null=True,
help_text='Footer content (signatures, contact info, etc.)'
)
# Placeholders
available_placeholders = models.JSONField(
default=dict,
help_text='Available placeholders and their descriptions'
)
# Settings
is_active = models.BooleanField(
default=True,
help_text='Template is active and available for use'
)
is_default = models.BooleanField(
default=False,
help_text='Default template for this document type and language'
)
requires_approval = models.BooleanField(
default=True,
help_text='Documents generated from this template require approval'
)
# Styling
css_styles = models.TextField(
blank=True,
null=True,
help_text='Custom CSS styles for the template'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_document_templates'
)
class Meta:
db_table = 'hr_document_template'
verbose_name = 'Document Template'
verbose_name_plural = 'Document Templates'
ordering = ['document_type', 'language', 'name']
indexes = [
models.Index(fields=['tenant', 'document_type', 'language']),
models.Index(fields=['is_active', 'is_default']),
]
unique_together = [('tenant', 'document_type', 'language', 'is_default')]
def __str__(self):
return f"{self.name} ({self.get_document_type_display()} - {self.get_language_display()})"
def clean(self):
"""Validate template"""
# Ensure only one default template per document type and language
if self.is_default:
existing_default = DocumentTemplate.objects.filter(
tenant=self.tenant,
document_type=self.document_type,
language=self.language,
is_default=True
)
if self.pk:
existing_default = existing_default.exclude(pk=self.pk)
if existing_default.exists():
raise ValidationError(
'A default template already exists for this document type and language.'
)
def get_default_placeholders(self):
"""Get default placeholders based on document type"""
placeholders = {
'employee_name': 'Employee full name',
'employee_id': 'Employee ID',
'job_title': 'Job title',
'department': 'Department name',
'hire_date': 'Hire date',
'current_date': 'Current date',
'company_name': 'Company/Hospital name',
'company_address': 'Company address',
}
# Add salary-specific placeholders
if self.document_type in ['SALARY_CERTIFICATE', 'BANK_LETTER']:
placeholders.update({
'basic_salary': 'Basic salary',
'housing_allowance': 'Housing allowance',
'transportation_allowance': 'Transportation allowance',
'total_salary': 'Total salary',
'currency': 'Currency',
})
return placeholders
def save(self, *args, **kwargs):
# Set default placeholders if not provided
if not self.available_placeholders:
self.available_placeholders = self.get_default_placeholders()
super().save(*args, **kwargs)