""" 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' 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( 'hr.Employee', 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('hr.Employee', 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) 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 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( 'hr.Employee', on_delete=models.SET_NULL, null=True, blank=True, related_name='created_training_certificates' ) signed_by = models.ForeignKey( 'hr.Employee', 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