Marwan Alwali 23158e9fbf update
2025-09-08 03:00:23 +03:00

1912 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""
HR app models for hospital management system.
Provides staff management, scheduling, performance management, and training tracking.
"""
import uuid
from django.db import models
from django.core.validators import RegexValidator, MinValueValidator, MaxValueValidator
from django.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
class Employee(models.Model):
"""
Employee model for hospital staff management.
"""
GENDER_CHOICES = [
('MALE', 'Male'),
('FEMALE', 'Female'),
('OTHER', 'Other'),
('UNKNOWN', 'Unknown'),
]
MARITAL_STATUS_CHOICES = [
('SINGLE', 'Single'),
('MARRIED', 'Married'),
('DIVORCED', 'Divorced'),
('WIDOWED', 'Widowed'),
('SEPARATED', 'Separated'),
('OTHER', 'Other'),
]
EMPLOYMENT_TYPE_CHOICES = [
('FULL_TIME', 'Full Time'),
('PART_TIME', 'Part Time'),
('CONTRACT', 'Contract'),
('TEMPORARY', 'Temporary'),
('INTERN', 'Intern'),
('VOLUNTEER', 'Volunteer'),
('PER_DIEM', 'Per Diem'),
('CONSULTANT', 'Consultant'),
]
EMPLOYMENT_STATUS_CHOICES = [
('ACTIVE', 'Active'),
('INACTIVE', 'Inactive'),
('TERMINATED', 'Terminated'),
('SUSPENDED', 'Suspended'),
('LEAVE', 'On Leave'),
('RETIRED', 'Retired'),
]
# Tenant relationship
tenant = models.ForeignKey(
'core.Tenant',
on_delete=models.CASCADE,
related_name='employees',
help_text='Organization tenant'
)
# User relationship (optional - for employees who have system access)
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='employee_profile',
help_text='Associated user account'
)
# Employee Information
employee_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False,
help_text='Unique employee identifier'
)
employee_number = models.CharField(
max_length=20,
help_text='Employee number'
)
# Personal Information
first_name = models.CharField(
max_length=50,
help_text='First name'
)
last_name = models.CharField(
max_length=50,
help_text='Last name'
)
middle_name = models.CharField(
max_length=50,
blank=True,
null=True,
help_text='Middle name'
)
preferred_name = models.CharField(
max_length=50,
blank=True,
null=True,
help_text='Preferred name'
)
# Contact Information
email = models.EmailField(
blank=True,
null=True,
help_text='Email address'
)
phone = models.CharField(
max_length=20,
blank=True,
null=True,
help_text='Phone number'
)
mobile_phone = models.CharField(
max_length=20,
blank=True,
null=True,
help_text='Mobile phone number'
)
# Address Information
address_line_1 = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='Address line 1'
)
address_line_2 = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='Address line 2'
)
city = models.CharField(
max_length=50,
blank=True,
null=True,
help_text='City'
)
state = models.CharField(
max_length=50,
blank=True,
null=True,
help_text='State/Province'
)
postal_code = models.CharField(
max_length=20,
blank=True,
null=True,
help_text='Postal/ZIP code'
)
country = models.CharField(
max_length=50,
blank=True,
null=True,
help_text='Country'
)
national_id = models.CharField(
max_length=10,
blank=True,
null=True,
unique=True,
help_text='National ID'
)
# Personal Details
date_of_birth = models.DateField(
blank=True,
null=True,
help_text='Date of birth'
)
gender = models.CharField(
max_length=10,
choices=GENDER_CHOICES,
blank=True,
null=True,
help_text='Gender'
)
marital_status = models.CharField(
max_length=20,
choices=MARITAL_STATUS_CHOICES,
blank=True,
null=True,
help_text='Marital status'
)
# Employment Information
department = models.ForeignKey(
'Department',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='employees',
help_text='Department'
)
job_title = models.CharField(
max_length=100,
help_text='Job title'
)
employment_type = models.CharField(
max_length=20,
choices=EMPLOYMENT_TYPE_CHOICES,
help_text='Employment type'
)
employment_status = models.CharField(
max_length=20,
choices=EMPLOYMENT_STATUS_CHOICES,
default='ACTIVE',
help_text='Employment status'
)
# Employment Dates
hire_date = models.DateField(
help_text='Hire date'
)
termination_date = models.DateField(
blank=True,
null=True,
help_text='Termination date'
)
# Supervisor Information
supervisor = models.ForeignKey(
'self',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='direct_reports',
help_text='Direct supervisor'
)
# Work Schedule Information
standard_hours_per_week = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('40.00'),
help_text='Standard hours per week'
)
fte_percentage = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('100.00'),
validators=[MinValueValidator(0), MaxValueValidator(100)],
help_text='FTE percentage'
)
# Compensation Information
hourly_rate = models.DecimalField(
max_digits=10,
decimal_places=2,
blank=True,
null=True,
help_text='Hourly rate'
)
annual_salary = models.DecimalField(
max_digits=12,
decimal_places=2,
blank=True,
null=True,
help_text='Annual salary'
)
# Professional Information
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'
)
certifications = models.JSONField(
default=list,
help_text='Professional certifications'
)
# Emergency Contact
emergency_contact_name = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='Emergency contact name'
)
emergency_contact_relationship = models.CharField(
max_length=50,
blank=True,
null=True,
help_text='Emergency contact relationship'
)
emergency_contact_phone = models.CharField(
max_length=20,
blank=True,
null=True,
help_text='Emergency contact phone'
)
# Notes
notes = models.TextField(
blank=True,
null=True,
help_text='Employee 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_employees',
help_text='User who created the employee record'
)
class Meta:
db_table = 'hr_employee'
verbose_name = 'Employee'
verbose_name_plural = 'Employees'
ordering = ['last_name', 'first_name']
indexes = [
models.Index(fields=['tenant', 'employment_status']),
models.Index(fields=['employee_number']),
models.Index(fields=['last_name', 'first_name']),
models.Index(fields=['department']),
models.Index(fields=['hire_date']),
]
unique_together = ['tenant', 'employee_number']
def __str__(self):
return f"{self.employee_number} - {self.get_full_name()}"
def get_full_name(self):
"""
Get employee's full name.
"""
if self.middle_name:
return f"{self.first_name} {self.middle_name} {self.last_name}"
return f"{self.first_name} {self.last_name}"
def get_display_name(self):
"""
Get employee's display name (preferred name if available).
"""
if self.preferred_name:
return f"{self.preferred_name} {self.last_name}"
return self.get_full_name()
@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):
"""
Check if professional license is expired.
"""
if self.license_expiry_date:
return self.license_expiry_date < date.today()
return False
@property
def full_address(self):
"""
Get full address.
"""
parts = [
self.address_line_1,
self.address_line_2,
f"{self.city}, {self.state} {self.postal_code}",
self.country
]
return "\n".join([part for part in parts if part])
class Department(models.Model):
"""
Department model for organizational structure.
"""
DEPARTMENT_TYPE_CHOICES = [
('CLINICAL', 'Clinical'),
('ADMINISTRATIVE', 'Administrative'),
('SUPPORT', 'Support'),
('ANCILLARY', 'Ancillary'),
('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'
)
department_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=DEPARTMENT_TYPE_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(
settings.AUTH_USER_MODEL,
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=['department_code']),
models.Index(fields=['name']),
models.Index(fields=['is_active']),
]
unique_together = ['tenant', 'department_code']
def __str__(self):
return f"{self.department_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.
"""
SCHEDULE_TYPE_CHOICES = [
('REGULAR', 'Regular Schedule'),
('ROTATING', 'Rotating Schedule'),
('FLEXIBLE', 'Flexible Schedule'),
('ON_CALL', 'On-Call Schedule'),
('TEMPORARY', 'Temporary Schedule'),
]
# 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=SCHEDULE_TYPE_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.
"""
SHIFT_TYPE_CHOICES = [
('DAY', 'Day Shift'),
('EVENING', 'Evening Shift'),
('NIGHT', 'Night Shift'),
('WEEKEND', 'Weekend Shift'),
('HOLIDAY', 'Holiday Shift'),
('ON_CALL', 'On-Call'),
('OVERTIME', 'Overtime'),
]
STATUS_CHOICES = [
('SCHEDULED', 'Scheduled'),
('CONFIRMED', 'Confirmed'),
('COMPLETED', 'Completed'),
('CANCELLED', 'Cancelled'),
('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=SHIFT_TYPE_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=STATUS_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.
"""
ENTRY_TYPE_CHOICES = [
('REGULAR', 'Regular Time'),
('OVERTIME', 'Overtime'),
('HOLIDAY', 'Holiday'),
('VACATION', 'Vacation'),
('SICK', 'Sick Leave'),
('PERSONAL', 'Personal Time'),
('BEREAVEMENT', 'Bereavement'),
('JURY_DUTY', 'Jury Duty'),
('TRAINING', 'Training'),
]
STATUS_CHOICES = [
('DRAFT', 'Draft'),
('SUBMITTED', 'Submitted'),
('APPROVED', 'Approved'),
('REJECTED', 'Rejected'),
('PAID', 'Paid'),
]
# 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=ENTRY_TYPE_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=STATUS_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.
"""
REVIEW_TYPE_CHOICES = [
('ANNUAL', 'Annual Review'),
('PROBATIONARY', 'Probationary Review'),
('MID_YEAR', 'Mid-Year Review'),
('PROJECT', 'Project Review'),
('DISCIPLINARY', 'Disciplinary Review'),
('PROMOTION', 'Promotion Review'),
]
STATUS_CHOICES = [
('DRAFT', 'Draft'),
('IN_PROGRESS', 'In Progress'),
('COMPLETED', 'Completed'),
('ACKNOWLEDGED', 'Acknowledged by Employee'),
('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,
unique=True,
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=REVIEW_TYPE_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=STATUS_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 tenant(self):
"""
Get tenant from employee.
"""
return self.employee.tenant
@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 TrainingRecord(models.Model):
"""
Training record model for employee training and certifications.
"""
TRAINING_TYPE_CHOICES = [
('ORIENTATION', 'Orientation'),
('MANDATORY', 'Mandatory Training'),
('CONTINUING_ED', 'Continuing Education'),
('CERTIFICATION', 'Certification'),
('SKILLS', 'Skills Training'),
('SAFETY', 'Safety Training'),
('COMPLIANCE', 'Compliance Training'),
('LEADERSHIP', 'Leadership Development'),
('TECHNICAL', 'Technical Training'),
('OTHER', 'Other'),
]
STATUS_CHOICES = [
('SCHEDULED', 'Scheduled'),
('IN_PROGRESS', 'In Progress'),
('COMPLETED', 'Completed'),
('CANCELLED', 'Cancelled'),
('NO_SHOW', 'No Show'),
('FAILED', 'Failed'),
]
# Employee relationship
employee = models.ForeignKey(
Employee,
on_delete=models.CASCADE,
related_name='training_records',
help_text='Employee'
)
# Training Information
record_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False,
help_text='Unique training record identifier'
)
training_name = models.CharField(
max_length=200,
help_text='Training name'
)
training_description = models.TextField(
blank=True,
null=True,
help_text='Training description'
)
# Training Type
training_type = models.CharField(
max_length=20,
choices=TRAINING_TYPE_CHOICES,
help_text='Training type'
)
# Training Provider
training_provider = models.CharField(
max_length=200,
blank=True,
null=True,
help_text='Training provider'
)
instructor = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='Instructor name'
)
# Training Dates
training_date = models.DateField(
help_text='Training date'
)
completion_date = models.DateField(
blank=True,
null=True,
help_text='Completion date'
)
expiry_date = models.DateField(
blank=True,
null=True,
help_text='Certification expiry date'
)
# Training Details
duration_hours = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('0.00'),
help_text='Training duration in hours'
)
credits_earned = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('0.00'),
help_text='Credits earned'
)
# Training Status
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='SCHEDULED',
help_text='Training status'
)
# Results
score = models.DecimalField(
max_digits=5,
decimal_places=2,
blank=True,
null=True,
help_text='Training score/grade'
)
passed = models.BooleanField(
default=False,
help_text='Training passed'
)
is_certified = models.BooleanField(
default=False,
help_text='Training is certified'
)
# Certification Information
certificate_number = models.CharField(
max_length=50,
blank=True,
null=True,
help_text='Certificate number'
)
certification_body = models.CharField(
max_length=200,
blank=True,
null=True,
help_text='Certification body'
)
# Cost Information
training_cost = models.DecimalField(
max_digits=10,
decimal_places=2,
default=Decimal('0.00'),
help_text='Training cost'
)
# Notes
notes = models.TextField(
blank=True,
null=True,
help_text='Training 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_training_records',
help_text='User who created the training record'
)
class Meta:
db_table = 'hr_training_record'
verbose_name = 'Training Record'
verbose_name_plural = 'Training Records'
ordering = ['-training_date']
indexes = [
models.Index(fields=['employee', 'training_date']),
models.Index(fields=['training_type']),
models.Index(fields=['status']),
models.Index(fields=['expiry_date']),
]
def __str__(self):
return f"{self.employee.get_full_name()} - {self.training_name}"
@property
def tenant(self):
"""
Get tenant from employee.
"""
return self.employee.tenant
@property
def is_expired(self):
"""
Check if certification is expired.
"""
if self.expiry_date:
return self.expiry_date < date.today()
return False
@property
def days_to_expiry(self):
"""
Calculate days to expiry.
"""
if self.expiry_date:
return (self.expiry_date - date.today()).days
return None
@property
def is_due_for_renewal(self):
"""
Check if certification is due for renewal (within 30 days).
"""
if self.expiry_date:
return (self.expiry_date - date.today()).days <= 30
return False
# class TrainingPrograms(models.Model):
# PROGRAM_TYPE_CHOICES = [
# ('ORIENTATION', 'Orientation'),
# ('MANDATORY', 'Mandatory Training'),
# ('CONTINUING_ED', 'Continuing Education'),
# ('CERTIFICATION', 'Certification'),
# ('SKILLS', 'Skills Training'),
# ('SAFETY', 'Safety Training'),
# ('COMPLIANCE', 'Compliance Training'),
# ('LEADERSHIP', 'Leadership Development'),
# ('TECHNICAL', 'Technical Training'),
# ('OTHER', 'Other'),
# ]
#
# # Multi-tenancy
# 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=PROGRAM_TYPE_CHOICES)
#
# # Provider/Instructor at the program (defaults; sessions can override)
# program_provider = models.CharField(max_length=200, blank=True, null=True)
# default_instructor = models.ForeignKey(
# Employee, on_delete=models.SET_NULL, null=True, blank=True,
# related_name='default_instructor_programs',
# help_text='Default instructor; sessions may override'
# )
#
# # Optional “program window” (e.g., for long initiatives)
# start_date = models.DateField(help_text='Program start date', blank=True, null=True)
# end_date = models.DateField(help_text='Program end date', 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)
#
# # Renewal/expiry policy (applies if is_certified)
# validity_days = models.PositiveIntegerField(
# blank=True, null=True,
# help_text='Days certificate is valid from completion (e.g., 365).'
# )
# notify_before_days = models.PositiveIntegerField(
# blank=True, null=True,
# help_text='Days before expiry to flag for renewal.'
# )
#
# # 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).
# """
# DELIVERY_CHOICES = [
# ('IN_PERSON', 'In-person'),
# ('VIRTUAL', 'Virtual'),
# ('HYBRID', 'Hybrid'),
# ('SELF_PACED', 'Self-paced'),
# ]
#
# tenant = models.ForeignKey(
# Tenant, on_delete=models.CASCADE, related_name='training_sessions'
# )
# 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=DELIVERY_CHOICES, default='IN_PERSON')
#
# # Schedule
# start_at = models.DateTimeField()
# end_at = models.DateTimeField()
# location = models.CharField(max_length=200, blank=True, null=True)
# capacity = models.PositiveIntegerField(default=0)
#
# # Overrides
# 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)
#
# # Metadata
# 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']
# indexes = [
# models.Index(fields=['tenant', 'start_at']),
# models.Index(fields=['tenant', 'program']),
# ]
# constraints = [
# models.CheckConstraint(
# check=models.Q(end_at__gt=models.F('start_at')),
# name='session_end_after_start'
# ),
# ]
#
# 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.
# """
# STATUS_CHOICES = [
# ('SCHEDULED', 'Scheduled'),
# ('IN_PROGRESS', 'In Progress'),
# ('COMPLETED', 'Completed'),
# ('CANCELLED', 'Cancelled'),
# ('NO_SHOW', 'No Show'),
# ('FAILED', 'Failed'),
# ('WAITLISTED', 'Waitlisted'),
# ]
#
# tenant = models.ForeignKey(
# Tenant, on_delete=models.CASCADE, related_name='training_records'
# )
# record_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
#
# # Core links
# 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',
# help_text='The specific run the employee is enrolled in.'
# )
#
# # Timeline
# enrolled_at = models.DateTimeField(auto_now_add=True)
# started_at = models.DateTimeField(blank=True, null=True)
# completion_date = models.DateField(blank=True, null=True)
#
# # Outcomes
# status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='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/Cost
# 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')]
# indexes = [
# models.Index(fields=['tenant', 'employee']),
# models.Index(fields=['tenant', 'program']),
# models.Index(fields=['tenant', 'session']),
# models.Index(fields=['tenant', 'status']),
# models.Index(fields=['tenant', 'completion_date']),
# ]
#
# def clean(self):
# # Tenancy alignment
# if self.program and self.tenant_id != self.program.tenant_id:
# raise ValidationError(_('Tenant mismatch between record and program.'))
# if self.session and self.tenant_id != self.session.tenant_id:
# raise ValidationError(_('Tenant mismatch between record and session.'))
# if self.employee and self.tenant_id != self.employee.tenant_id:
# raise ValidationError(_('Tenant mismatch between record and employee.'))
#
# # Prevent enrolling into sessions of a different program (shouldnt happen)
# if self.session and self.program and self.session.program_id != self.program_id:
# raise ValidationError(_('Session does not belong to the selected program.'))
#
# if self.completion_date and self.status not in ('COMPLETED', 'FAILED'):
# raise ValidationError(_('Completion date requires status COMPLETED or FAILED.'))
#
# def __str__(self):
# return f'{self.employee} → {self.program.name} ({self.get_status_display()})'
#
# # Helper properties
# @property
# def hours(self):
# return self.session.hours_override or self.program.duration_hours
#
# @property
# def effective_cost(self):
# return self.cost_paid if self.cost_paid is not None else \
# (self.session.cost_override if self.session.cost_override is not None
# else self.program.cost)
#
# @property
# def eligible_for_certificate(self):
# return self.status == 'COMPLETED' and self.passed and self.program.is_certified
#
#
# class SessionAttendance(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.
# """
# ATTENDANCE_STATUS = [
# ('PRESENT', 'Present'),
# ('LATE', 'Late'),
# ('ABSENT', 'Absent'),
# ('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=ATTENDANCE_STATUS, default='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 SessionAssessment(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)
#
# 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.
# """
# tenant = models.ForeignKey(
# Tenant, on_delete=models.CASCADE, related_name='training_certificates'
# )
# 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',
# help_text='The enrollment that generated this 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)
# created_by = models.ForeignKey(
# settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
# null=True, blank=True, related_name='created_training_certificates'
# )
#
# class Meta:
# db_table = 'hr_training_certificate'
# ordering = ['-issued_date']
# unique_together = [('employee', 'program', 'enrollment')]
# indexes = [
# models.Index(fields=['tenant', 'employee']),
# models.Index(fields=['tenant', 'program']),
# models.Index(fields=['tenant', 'expiry_date']),
# models.Index(fields=['certificate_number']),
# ]
#
# def clean(self):
# # tenancy alignment
# if self.program and self.tenant_id != self.program.tenant_id:
# raise ValidationError(_('Tenant mismatch between certificate and program.'))
# if self.employee and self.tenant_id != self.employee.tenant_id:
# raise ValidationError(_('Tenant mismatch between certificate and employee.'))
# if self.enrollment and self.tenant_id != self.enrollment.tenant_id:
# raise ValidationError(_('Tenant mismatch between certificate and enrollment.'))
# if self.enrollment and self.enrollment.program_id != self.program_id:
# raise ValidationError(_('Enrollment does not belong to this program.'))
#
# 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