1912 lines
56 KiB
Python
1912 lines
56 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.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 (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 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
|
||
|
||
|