2824 lines
92 KiB
Python
2824 lines
92 KiB
Python
"""
|
||
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 (shouldn’t 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)
|