1480 lines
50 KiB
Python
1480 lines
50 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
|