""" HR models for the Tenhal Multidisciplinary Healthcare Platform. This module handles HR management: - Employee attendance tracking - Work schedules - Holidays """ from django.db import models from django.utils.translation import gettext_lazy as _ from django.core.validators import MinValueValidator, MaxValueValidator from core.models import ( UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin, ) class Attendance(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin): """ Employee attendance records with clock in/out times. """ class Status(models.TextChoices): PRESENT = 'PRESENT', _('Present') LATE = 'LATE', _('Late') ABSENT = 'ABSENT', _('Absent') HALF_DAY = 'HALF_DAY', _('Half Day') LEAVE = 'LEAVE', _('On Leave') employee = models.ForeignKey( 'core.User', on_delete=models.CASCADE, related_name='attendances', verbose_name=_("Employee") ) date = models.DateField( verbose_name=_("Date") ) check_in = models.TimeField( null=True, blank=True, verbose_name=_("Check In") ) check_out = models.TimeField( null=True, blank=True, verbose_name=_("Check Out") ) hours_worked = models.DecimalField( max_digits=4, decimal_places=2, null=True, blank=True, validators=[MinValueValidator(0), MaxValueValidator(24)], verbose_name=_("Hours Worked") ) status = models.CharField( max_length=20, choices=Status.choices, default=Status.PRESENT, verbose_name=_("Status") ) notes = models.TextField( blank=True, verbose_name=_("Notes") ) class Meta: verbose_name = _("Attendance") verbose_name_plural = _("Attendance Records") ordering = ['-date', 'employee'] unique_together = [['employee', 'date', 'tenant']] indexes = [ models.Index(fields=['employee', 'date']), models.Index(fields=['date', 'status']), models.Index(fields=['tenant', 'date']), ] def __str__(self): return f"{self.employee.get_full_name()} - {self.date} - {self.get_status_display()}" def save(self, *args, **kwargs): """Calculate hours worked if both check_in and check_out are present.""" if self.check_in and self.check_out: from datetime import datetime, timedelta # Convert times to datetime for calculation check_in_dt = datetime.combine(self.date, self.check_in) check_out_dt = datetime.combine(self.date, self.check_out) # Handle overnight shifts if check_out_dt < check_in_dt: check_out_dt += timedelta(days=1) # Calculate hours duration = check_out_dt - check_in_dt self.hours_worked = round(duration.total_seconds() / 3600, 2) super().save(*args, **kwargs) class Schedule(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin): """ Employee work schedules by day of week. """ class DayOfWeek(models.TextChoices): MONDAY = 'MON', _('Monday') TUESDAY = 'TUE', _('Tuesday') WEDNESDAY = 'WED', _('Wednesday') THURSDAY = 'THU', _('Thursday') FRIDAY = 'FRI', _('Friday') SATURDAY = 'SAT', _('Saturday') SUNDAY = 'SUN', _('Sunday') employee = models.ForeignKey( 'core.User', on_delete=models.CASCADE, related_name='schedules', verbose_name=_("Employee") ) day_of_week = models.CharField( max_length=3, choices=DayOfWeek.choices, verbose_name=_("Day of Week") ) start_time = models.TimeField( verbose_name=_("Start Time") ) end_time = models.TimeField( verbose_name=_("End Time") ) is_active = models.BooleanField( default=True, verbose_name=_("Is Active") ) class Meta: verbose_name = _("Schedule") verbose_name_plural = _("Schedules") ordering = ['employee', 'day_of_week'] unique_together = [['employee', 'day_of_week', 'tenant']] indexes = [ models.Index(fields=['employee', 'is_active']), models.Index(fields=['day_of_week', 'is_active']), models.Index(fields=['tenant', 'is_active']), ] def __str__(self): return f"{self.employee.get_full_name()} - {self.get_day_of_week_display()} ({self.start_time}-{self.end_time})" @property def duration_hours(self): """Calculate scheduled hours for this shift.""" # Handle case when times are not set yet if not self.start_time or not self.end_time: return None from datetime import datetime, timedelta start_dt = datetime.combine(datetime.today(), self.start_time) end_dt = datetime.combine(datetime.today(), self.end_time) # Handle overnight shifts if end_dt < start_dt: end_dt += timedelta(days=1) duration = end_dt - start_dt return round(duration.total_seconds() / 3600, 2) class Holiday(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin): """ Company holidays and non-working days. """ name = models.CharField( max_length=200, verbose_name=_("Name") ) date = models.DateField( verbose_name=_("Date") ) is_recurring = models.BooleanField( default=False, help_text=_("If true, this holiday repeats annually"), verbose_name=_("Is Recurring") ) description = models.TextField( blank=True, verbose_name=_("Description") ) class Meta: verbose_name = _("Holiday") verbose_name_plural = _("Holidays") ordering = ['date'] indexes = [ models.Index(fields=['date', 'tenant']), models.Index(fields=['is_recurring', 'tenant']), ] def __str__(self): recurring = " (Recurring)" if self.is_recurring else "" return f"{self.name} - {self.date}{recurring}" class LeaveRequest(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin): """ Employee leave requests with approval workflow. """ class LeaveType(models.TextChoices): ANNUAL = 'ANNUAL', _('Annual Leave') SICK = 'SICK', _('Sick Leave') EMERGENCY = 'EMERGENCY', _('Emergency Leave') UNPAID = 'UNPAID', _('Unpaid Leave') MATERNITY = 'MATERNITY', _('Maternity Leave') PATERNITY = 'PATERNITY', _('Paternity Leave') STUDY = 'STUDY', _('Study Leave') OTHER = 'OTHER', _('Other') class Status(models.TextChoices): PENDING = 'PENDING', _('Pending') APPROVED = 'APPROVED', _('Approved') REJECTED = 'REJECTED', _('Rejected') CANCELLED = 'CANCELLED', _('Cancelled') employee = models.ForeignKey( 'core.User', on_delete=models.CASCADE, related_name='leave_requests', verbose_name=_("Employee") ) leave_type = models.CharField( max_length=20, choices=LeaveType.choices, verbose_name=_("Leave Type") ) start_date = models.DateField( verbose_name=_("Start Date") ) end_date = models.DateField( verbose_name=_("End Date") ) days_requested = models.PositiveIntegerField( default=1, verbose_name=_("Days Requested") ) reason = models.TextField( verbose_name=_("Reason") ) status = models.CharField( max_length=20, choices=Status.choices, default=Status.PENDING, verbose_name=_("Status") ) # Approval workflow reviewed_by = models.ForeignKey( 'core.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='reviewed_leave_requests', verbose_name=_("Reviewed By") ) reviewed_at = models.DateTimeField( null=True, blank=True, verbose_name=_("Reviewed At") ) reviewer_comments = models.TextField( blank=True, verbose_name=_("Reviewer Comments") ) # Supporting documents attachment = models.FileField( upload_to='leave_requests/%Y/%m/', null=True, blank=True, verbose_name=_("Attachment"), help_text=_("Medical certificate or supporting document") ) class Meta: verbose_name = _("Leave Request") verbose_name_plural = _("Leave Requests") ordering = ['-created_at'] indexes = [ models.Index(fields=['employee', 'status']), models.Index(fields=['start_date', 'end_date']), models.Index(fields=['tenant', 'status']), ] def __str__(self): return f"{self.employee.get_full_name()} - {self.get_leave_type_display()} ({self.start_date} to {self.end_date})" def save(self, *args, **kwargs): """Calculate days requested if not provided.""" if not self.days_requested and self.start_date and self.end_date: delta = self.end_date - self.start_date self.days_requested = delta.days + 1 super().save(*args, **kwargs) @property def is_pending(self): """Check if leave request is pending.""" return self.status == self.Status.PENDING @property def is_approved(self): """Check if leave request is approved.""" return self.status == self.Status.APPROVED @property def is_active(self): """Check if leave is currently active.""" from django.utils import timezone today = timezone.now().date() return ( self.status == self.Status.APPROVED and self.start_date <= today <= self.end_date ) class LeaveBalance(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin): """ Employee leave balance tracking. """ employee = models.ForeignKey( 'core.User', on_delete=models.CASCADE, related_name='leave_balances', verbose_name=_("Employee") ) year = models.PositiveIntegerField( verbose_name=_("Year") ) leave_type = models.CharField( max_length=20, choices=LeaveRequest.LeaveType.choices, verbose_name=_("Leave Type") ) total_days = models.DecimalField( max_digits=5, decimal_places=1, default=0, verbose_name=_("Total Days Allocated") ) used_days = models.DecimalField( max_digits=5, decimal_places=1, default=0, verbose_name=_("Used Days") ) class Meta: verbose_name = _("Leave Balance") verbose_name_plural = _("Leave Balances") ordering = ['employee', 'year', 'leave_type'] unique_together = [['employee', 'year', 'leave_type', 'tenant']] indexes = [ models.Index(fields=['employee', 'year']), models.Index(fields=['tenant', 'year']), ] def __str__(self): return f"{self.employee.get_full_name()} - {self.get_leave_type_display()} {self.year}" @property def remaining_days(self): """Calculate remaining leave days.""" return self.total_days - self.used_days @property def utilization_percentage(self): """Calculate leave utilization percentage.""" if self.total_days == 0: return 0 return round((self.used_days / self.total_days) * 100, 1)