diff --git a/.env.dist b/.env.dist index 8684675c0..4a81c9751 100644 --- a/.env.dist +++ b/.env.dist @@ -39,4 +39,4 @@ DB_PORT=5432 # Microsoft SQL Server: ``mssql://`` # PyODBC: ``pyodbc://`` # Amazon Redshift: ``redshift://`` -# LDAP: ``ldap://`` +# LDAP: ``ldap://`` \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 61ef1fd91..8483e624d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -36,4 +36,4 @@ services: retries: 5 volumes: - horilla-data: + horilla-data: \ No newline at end of file diff --git a/employee/forms.py b/employee/forms.py index 9f851c9a5..23d20d8d1 100644 --- a/employee/forms.py +++ b/employee/forms.py @@ -285,6 +285,7 @@ class Meta: "email", "mobile", "date_joining", + "anniversary_date", "contract_end_date", "tags", "basic_salary", @@ -295,6 +296,7 @@ class Meta: widgets = { "date_joining": DateInput(attrs={"type": "date"}), "contract_end_date": DateInput(attrs={"type": "date"}), + "anniversary_date": DateInput(attrs={"type": "date", "class": "oh-input w-100", "placeholder": _("Select Anniversary Date"),}), } def __init__(self, *args, disable=False, **kwargs): diff --git a/employee/models.py b/employee/models.py index 0ce216d71..d66d10d85 100644 --- a/employee/models.py +++ b/employee/models.py @@ -638,6 +638,12 @@ class EmployeeWorkInformation(models.Model): ) additional_info = models.JSONField(null=True, blank=True) experience = models.FloatField(null=True, blank=True, default=0) + anniversary_date = models.DateField( + null=True, + blank=True, + verbose_name=_("Anniversary Date"), + help_text=_("Date used for anniversary-based leave resets") + ) history = HorillaAuditLog( related_name="history_set", bases=[ @@ -684,6 +690,25 @@ def experience_calculator(self): self.save() return self + def clean(self): + super().clean() + if self.anniversary_date: + # Ensure anniversary date isn't in the future + if self.anniversary_date > timezone.now().date(): + raise ValidationError({ + 'anniversary_date': _('Anniversary date cannot be in the future') + }) + + # Check if employee has anniversary-based leave types + has_anniversary_leaves = self.employee_id.available_leave.filter( + leave_type_id__reset_based='anniversary' + ).exists() + + if has_anniversary_leaves and not self.anniversary_date: + raise ValidationError({ + 'anniversary_date': _('Anniversary date is required for employees with anniversary-based leave types') + }) + class EmployeeBankDetails(HorillaModel): """ diff --git a/employee/templates/employee/profile/work_info.html b/employee/templates/employee/profile/work_info.html index 236f0abac..27ef13c28 100644 --- a/employee/templates/employee/profile/work_info.html +++ b/employee/templates/employee/profile/work_info.html @@ -99,7 +99,13 @@ - +
+
+
+ + {{work_form.anniversary_date}} +
+
diff --git a/employee/templates/tabs/personal_tab.html b/employee/templates/tabs/personal_tab.html index 9d46ddca8..28d4e74aa 100644 --- a/employee/templates/tabs/personal_tab.html +++ b/employee/templates/tabs/personal_tab.html @@ -264,6 +264,20 @@ class="oh-profile__info-value dateformat_changer">{{employee.employee_work_info.date_joining|default:_("None")}} +
  • + + + {% trans "Anniversary Date" %} + + {% if employee.employee_work_info.anniversary_date %} + {{ employee.employee_work_info.anniversary_date }} + {% else %} + {% trans "None" %} + {% endif %} + +
  • +
  • @@ -273,9 +287,13 @@ {% if employee.employee_work_info.tags.all %} {% for i in employee.employee_work_info.tags.all %} {% with tag_width=i.title|length %} - {{ i.title }} + + {{ i.title }} + {% endwith %} {% endfor %} {% else %} diff --git a/entrypoint.sh b/entrypoint.sh old mode 100644 new mode 100755 index d063e8bbf..c744ee402 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -5,4 +5,4 @@ python3 manage.py makemigrations python3 manage.py migrate python3 manage.py collectstatic --noinput python3 manage.py createhorillauser --first_name admin --last_name admin --username admin --password admin --email admin@example.com --phone 1234567890 -gunicorn --bind 0.0.0.0:8000 horilla.wsgi:application +gunicorn --bind 0.0.0.0:8000 horilla.wsgi:application \ No newline at end of file diff --git a/horilla/settings.py b/horilla/settings.py index 93958c92a..05fde70d5 100755 --- a/horilla/settings.py +++ b/horilla/settings.py @@ -222,14 +222,14 @@ LANGUAGE_CODE = "en-us" +# Timezone settings TIME_ZONE = env("TIME_ZONE", default="Asia/Kolkata") +USE_TZ = True USE_I18N = True USE_L10N = True -USE_TZ = True - # Production settings if not DEBUG: SECURE_BROWSER_XSS_FILTER = True diff --git a/leave/forms.py b/leave/forms.py index ef6abfc60..6c8b698f9 100644 --- a/leave/forms.py +++ b/leave/forms.py @@ -201,6 +201,17 @@ def clean(self): cleaned_data["reset_month"] = "1" cleaned_data["reset_day"] = "1" + reset = cleaned_data.get('reset') + reset_based = cleaned_data.get('reset_based') + reset_month = cleaned_data.get('reset_month') + reset_day = cleaned_data.get('reset_day') + + if reset and reset_based != 'anniversary': + if not reset_month: + raise ValidationError(_('Reset month is required when reset is enabled')) + if not reset_day: + raise ValidationError(_('Reset day is required when reset is enabled')) + return cleaned_data def save(self, *args, **kwargs): @@ -388,7 +399,7 @@ def clean(self): if f"{today.month}-{today.year}" in unique_dates: unique_dates.remove(f"{today.strftime('%m')}-{today.year}") - forcated_days = available_leave.forcasted_leaves(start_date) + forcasted_days = available_leave.forcasted_leaves(start_date) total_leave_days = ( available_leave.leave_type_id.carryforward_max if available_leave.leave_type_id.carryforward_type @@ -401,7 +412,7 @@ def clean(self): and available_leave.carryforward_days ): total_leave_days = total_leave_days - available_leave.carryforward_days - total_leave_days += forcated_days + total_leave_days += forcasted_days if not effective_requested_days <= total_leave_days: raise forms.ValidationError(_("Employee doesn't have enough leave days..")) @@ -516,7 +527,7 @@ def clean(self): if f"{today.month}-{today.year}" in unique_dates: unique_dates.remove(f"{today.strftime('%m')}-{today.year}") - forcated_days = available_leave.forcasted_leaves(start_date) + forcasted_days = available_leave.forcasted_leaves(start_date) total_leave_days = ( available_leave.leave_type_id.carryforward_max if available_leave.leave_type_id.carryforward_type @@ -529,7 +540,7 @@ def clean(self): and available_leave.carryforward_days ): total_leave_days = total_leave_days - available_leave.carryforward_days - total_leave_days += forcated_days + total_leave_days += forcasted_days if not effective_requested_days <= total_leave_days: raise forms.ValidationError(_("Employee doesn't have enough leave days..")) diff --git a/leave/models.py b/leave/models.py index 67fa0e593..63826be4d 100644 --- a/leave/models.py +++ b/leave/models.py @@ -61,6 +61,7 @@ ("yearly", _("Yearly")), ("monthly", _("Monthly")), ("weekly", _("Weekly")), + ("anniversary", _("Anniversary")), ] MONTHS = [ ("1", _("Jan")), @@ -209,6 +210,22 @@ class LeaveType(HorillaModel): company_id = models.ForeignKey( Company, null=True, editable=False, on_delete=models.PROTECT ) + carryforward = models.BooleanField( + default=False, + verbose_name=_("Allow Carryforward"), + help_text=_("Enable to allow carrying forward unused leaves") + ) + carryforward_period = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_("Carryforward Period (Years)"), + help_text=_("Number of years before carried forward leaves expire") + ) + carryforward_expire_date = models.DateField( + null=True, + blank=True, + verbose_name=_("Carryforward Expiry Date") + ) objects = HorillaCompanyManager(related_company_field="company_id") class Meta: @@ -226,7 +243,10 @@ def get_avatar(self): url = self.icon.url return url - def leave_type_next_reset_date(self): + def leave_type_next_reset_date(self, employee=None): + """ + Method to get the next reset date for leave type + """ today = datetime.now().date() if not self.reset: @@ -239,15 +259,33 @@ def get_reset_day(month, day): else int(day) ) - if self.reset_based == "yearly": - month, day = int(self.reset_month), get_reset_day( - int(self.reset_month), self.reset_day - ) - reset_date = datetime( - today.year + (datetime(today.year, month, day).date() < today), - month, - day, - ).date() + if self.reset_based == "anniversary": + if not employee: + return None # Can't calculate anniversary without employee + + work_info = employee.employee_work_info + if not work_info or not work_info.anniversary_date: + return None + + # Calculate next anniversary date + anniversary_date = work_info.anniversary_date + next_anniversary = anniversary_date.replace(year=today.year) + if next_anniversary < today: + next_anniversary = next_anniversary.replace(year=today.year + 1) + return next_anniversary + + elif self.reset_based == "yearly": + try: + month = int(self.reset_month) + day = get_reset_day(month, self.reset_day) + reset_date = datetime( + today.year + (datetime(today.year, month, day).date() < today), + month, + day, + ).date() + return reset_date + except (ValueError, TypeError): + return None elif self.reset_based == "monthly": month = today.month @@ -272,15 +310,18 @@ def get_reset_day(month, day): return reset_date def set_expired_date(self, assigned_date): - period = self.carryforward_expire_in - if self.carryforward_expire_period == "day": - expired_date = assigned_date + relativedelta(days=period) - elif self.carryforward_expire_period == "month": - expired_date = assigned_date + relativedelta(months=period) - else: + """ + Method to set the expiry date for carried forward leaves + """ + if not assigned_date or not self.carryforward_period: + return None + + try: + period = int(self.carryforward_period) expired_date = assigned_date + relativedelta(years=period) - - return expired_date + return expired_date + except (ValueError, TypeError): + return None def clean(self, *args, **kwargs): if self.is_compensatory_leave: @@ -410,60 +451,64 @@ def update_carryforward(self): # Setting the reset date for carryforward leaves - def set_reset_date(self, assigned_date, available_leave): - if available_leave.leave_type_id.reset_based == "monthly": - reset_day = available_leave.leave_type_id.reset_day - if reset_day == "last day": - temp_date = assigned_date + relativedelta(months=0, day=31) - if assigned_date < temp_date: - reset_date = temp_date - else: - reset_date = assigned_date + relativedelta(months=1, day=31) + def set_reset_date(self, available_leave): + """ + Method to set the reset date for leave allocation + """ + today = datetime.now().date() - else: - temp_date = assigned_date + relativedelta(months=0, day=int(reset_day)) - if assigned_date < temp_date: - reset_date = temp_date - else: - reset_date = assigned_date + relativedelta( - months=1, day=int(reset_day) - ) + # Check if the leave type has reset enabled + if not self.leave_type_id.reset: + return None - elif available_leave.leave_type_id.reset_based == "weekly": - temp = 7 - ( - assigned_date.isoweekday() - - int(available_leave.leave_type_id.reset_weekend) - - 1 + def get_reset_day(month, day): + return ( + calendar.monthrange(today.year, month)[1] + if day == "last day" + else int(day) ) - if temp != 7: - reset_date = assigned_date + relativedelta(days=(temp % 7)) - else: - reset_date = assigned_date + relativedelta(days=7) - else: - reset_month = int(available_leave.leave_type_id.reset_month) - reset_day = available_leave.leave_type_id.reset_day - if reset_day == "last day": - temp_date = assigned_date + relativedelta( - years=0, month=reset_month, day=31 - ) - if assigned_date < temp_date: - reset_date = temp_date - else: - reset_date = assigned_date + relativedelta( - years=1, month=reset_month, day=31 - ) - else: - temp_date = assigned_date + relativedelta( - years=0, month=reset_month, day=int(reset_day) - ) - if assigned_date < temp_date: - reset_date = temp_date - else: - # nth_day = int(reset_day) - reset_date = assigned_date + relativedelta( - years=1, month=reset_month, day=int(reset_day) - ) + if self.leave_type_id.reset_based == "anniversary": + work_info = self.employee_id.employee_work_info + if not work_info or not work_info.anniversary_date: + return None + + anniversary_date = work_info.anniversary_date + next_anniversary = anniversary_date.replace(year=today.year) + if next_anniversary < today: + next_anniversary = next_anniversary.replace(year=today.year + 1) + return next_anniversary + + elif self.leave_type_id.reset_based == "yearly": + month, day = int(self.leave_type_id.reset_month), get_reset_day( + int(self.leave_type_id.reset_month), self.leave_type_id.reset_day + ) + reset_date = datetime( + today.year + (datetime(today.year, month, day).date() < today), + month, + day, + ).date() + return reset_date + + elif self.leave_type_id.reset_based == "monthly": + month = today.month + reset_date = datetime( + today.year, month, get_reset_day(month, self.reset_day) + ).date() + if reset_date < today: + month = (month % 12) + 1 + year = today.year + (month == 1) + reset_date = datetime( + year, month, get_reset_day(month, self.reset_day) + ).date() + + elif self.leave_type_id.reset_based == "weekly": + target_weekday = WEEK_DAYS[self.reset_day] + days_until_reset = (target_weekday - today.weekday()) % 7 or 7 + reset_date = today + timedelta(days=days_until_reset) + + else: + reset_date = None return reset_date @@ -491,23 +536,31 @@ def set_expired_date(self, available_leave, assigned_date): return expired_date def save(self, *args, **kwargs): - # if self.assigned_date == datetime.now().date() or self.assigned_date.date() == datetime.now().date(): - if self.reset_date is None: - # Check whether the reset is enabled - if self.leave_type_id.reset: - reset_date = self.set_reset_date( - assigned_date=self.assigned_date, available_leave=self - ) + if not self.id: + # Set the reset date to the employee's anniversary date if the leave type is anniversary-based + if self.leave_type_id.reset_based == "anniversary": + work_info = self.employee_id.employee_work_info + if work_info and work_info.anniversary_date: + self.reset_date = work_info.anniversary_date + + if self.leave_type_id.reset: + today = datetime.now().date() + reset_date = self.set_reset_date(self) + + if reset_date and reset_date <= today: + # Reset the available days + self.available_days = self.leave_type_id.total_days self.reset_date = reset_date - # assigning expire date - if self.leave_type_id.carryforward_type == "carryforward expire": - expiry_date = self.assigned_date - if self.leave_type_id.carryforward_expire_date: - expiry_date = self.leave_type_id.carryforward_expire_date - self.expired_date = expiry_date - - self.total_leave_days = max(self.available_days + self.carryforward_days, 0) - self.carryforward_days = max(self.carryforward_days, 0) + + # Calculate next reset date + if self.leave_type_id.reset_based == "anniversary": + work_info = self.employee_id.employee_work_info + if work_info and work_info.anniversary_date: + next_year = reset_date.year + 1 + self.reset_date = work_info.anniversary_date.replace(year=next_year) + else: + self.reset_date = self.set_reset_date(self) + super().save(*args, **kwargs) diff --git a/leave/scheduler.py b/leave/scheduler.py index dbc1933ae..b87026fbe 100644 --- a/leave/scheduler.py +++ b/leave/scheduler.py @@ -9,27 +9,91 @@ today = datetime.now() +def update_anniversary_reset_dates(): + """ + Updates reset dates for anniversary-based leave types when anniversary dates change + """ + from leave.models import AvailableLeave + from employee.models import EmployeeWorkInformation + + today_date = today.date() + + # Get all employees with anniversary-based leaves + available_leaves = AvailableLeave.objects.filter( + leave_type_id__reset_based='anniversary', + employee_id__employee_work_info__isnull=False + ).select_related('employee_id__employee_work_info') + + for available_leave in available_leaves: + work_info = available_leave.employee_id.employee_work_info + if work_info and work_info.anniversary_date: + # Calculate next anniversary date + next_anniversary = work_info.anniversary_date.replace(year=today_date.year) + if next_anniversary < today_date: + next_anniversary = next_anniversary.replace(year=today_date.year + 1) + + # Update reset date if it's different + if available_leave.reset_date != next_anniversary: + available_leave.reset_date = next_anniversary + available_leave.save() + + def leave_reset(): - from leave.models import LeaveType + from leave.models import LeaveType, AvailableLeave + from employee.models import EmployeeWorkInformation today_date = today.date() + + # Handle anniversary-based resets + anniversary_leaves = AvailableLeave.objects.filter( + leave_type_id__reset_based='anniversary', + reset_date__lte=today_date + ) + + for available_leave in anniversary_leaves: + # Reset available days + available_leave.available_days = available_leave.leave_type_id.total_days + + # Calculate next reset date + work_info = available_leave.employee_id.employee_work_info + if work_info and work_info.anniversary_date: + next_year = today_date.year + 1 + available_leave.reset_date = work_info.anniversary_date.replace(year=next_year) + available_leave.save() + leave_types = LeaveType.objects.filter(reset=True) # Looping through filtered leave types with reset is true for leave_type in leave_types: + # Skip if carryforward is not enabled + if not leave_type.carryforward: + continue + + expire_date = leave_type.set_expired_date(today_date) + if expire_date: + leave_type.carryforward_expire_date = expire_date + leave_type.save() + # Looping through all available leaves available_leaves = leave_type.employee_available_leave.all() for available_leave in available_leaves: - reset_date = available_leave.reset_date + reset_date = None expired_date = available_leave.expired_date + + if leave_type.reset_based == "anniversary": + work_info = available_leave.employee_id.employee_work_info + if work_info and work_info.anniversary_date: + anniversary_date = work_info.anniversary_date + next_anniversary = anniversary_date.replace(year=today_date.year) + # Check if the next anniversary is today + if next_anniversary == today_date: + reset_date = next_anniversary + if reset_date == today_date: available_leave.update_carryforward() - # new_reset_date = available_leave.set_reset_date(assigned_date=today_date,available_leave = available_leave) - new_reset_date = available_leave.set_reset_date( - assigned_date=today_date, available_leave=available_leave - ) - available_leave.reset_date = new_reset_date + available_leave.reset_date = reset_date available_leave.save() + if expired_date and expired_date <= today_date: new_expired_date = available_leave.set_expired_date( available_leave=available_leave, assigned_date=today_date @@ -37,15 +101,6 @@ def leave_reset(): available_leave.expired_date = new_expired_date available_leave.save() - if ( - leave_type.carryforward_expire_date - and leave_type.carryforward_expire_date <= today_date - ): - leave_type.carryforward_expire_date = leave_type.set_expired_date( - today_date - ) - leave_type.save() - if not any( cmd in sys.argv @@ -56,5 +111,7 @@ def leave_reset(): """ scheduler = BackgroundScheduler() scheduler.add_job(leave_reset, "interval", seconds=20) + # Add the new job to run every hour + scheduler.add_job(update_anniversary_reset_dates, "interval", hours=1) scheduler.start() diff --git a/leave/templates/leave/leave_request/employee_available_leave_count.html b/leave/templates/leave/leave_request/employee_available_leave_count.html index a673868f8..85e40922f 100644 --- a/leave/templates/leave/leave_request/employee_available_leave_count.html +++ b/leave/templates/leave/leave_request/employee_available_leave_count.html @@ -42,7 +42,7 @@ display: flex; "> {% trans "Available Leaves" %} : {{ total_leave_days }} - {% if forcated_days and forcated_days > 0 %} + {% if forcasted_days and forcasted_days > 0 %} {% endif %} diff --git a/leave/views.py b/leave/views.py index be18fd421..66f90a86e 100644 --- a/leave/views.py +++ b/leave/views.py @@ -1241,10 +1241,7 @@ def leave_assign_one(request, obj_id): available_days=leave_type.total_days, ) if leave.reset_date is None: - if leave_type.reset: - leave.reset_date = leave.set_reset_date( - assigned_date=leave.assigned_date, available_leave=leave - ) + leave.reset_date = leave.set_reset_date(leave) if leave_type.carryforward_type == "carryforward expire": if not expiry_date: @@ -3788,16 +3785,8 @@ def user_request_select_filter(request): def employee_available_leave_count(request): leave_type_id = request.GET.get("leave_type_id") start_date = request.GET.get("start_date") - try: - start_date_format = datetime.strptime(start_date, "%Y-%m-%d").date() - except: - leave_type_id = None - hx_target = request.META.get("HTTP_HX_TARGET", None) - employee_id = ( - request.GET.getlist("employee_id")[0] - if request.GET.getlist("employee_id") - else None - ) + employee_id = request.GET.getlist("employee_id")[0] if request.GET.getlist("employee_id") else None + available_leave = ( AvailableLeave.objects.filter( leave_type_id=leave_type_id, employee_id=employee_id @@ -3805,38 +3794,19 @@ def employee_available_leave_count(request): if leave_type_id and employee_id else None ) - total_leave_days = available_leave.total_leave_days if available_leave else 0 - forcated_days = 0 - if ( - available_leave - and available_leave.leave_type_id.leave_type_next_reset_date() - and available_leave - and start_date_format - >= available_leave.leave_type_id.leave_type_next_reset_date() - ): - forcated_days = available_leave.forcasted_leaves(start_date) - total_leave_days = ( - available_leave.leave_type_id.carryforward_max - if available_leave.leave_type_id.carryforward_type - in ["carryforward", "carryforward expire"] - and available_leave.leave_type_id.carryforward_max < total_leave_days - else total_leave_days - ) - if available_leave.leave_type_id.carryforward_type == "no carryforward": - total_leave_days = 0 - total_leave_days += forcated_days + total_leave_days = 0 + if available_leave: + total_leave_days = available_leave.available_days + available_leave.carryforward_days - context = { - "hx_target": hx_target, - "leave_type_id": leave_type_id, + # Additional logic for calculating forcated_days and updating total_leave_days + # ... + + return render(request, "leave/leave_request/employee_available_leave_count.html", { "available_leave": available_leave, "total_leave_days": total_leave_days, - "forcated_days": forcated_days, - } - return render( - request, "leave/leave_request/employee_available_leave_count.html", context - ) + # other context variables... + }) @login_required @@ -4110,7 +4080,7 @@ def create_allocationrequest_comment(request, leave_id): verb_ar="تلقى طلب تخصيص الإجازة الخاص بك تعليقًا.", verb_de="Ihr Antrag auf Urlaubszuweisung hat einen Kommentar erhalten.", verb_es="Tu solicitud de asignación de permisos ha recibido un comentario.", - verb_fr="Votre demande d'allocation de congé a reçu un commentaire.", + verb_fr=f"La demande d'allocation de congé de {leave.employee_id} a reçu un commentaire.", redirect=reverse("leave-allocation-request-view") + f"?id={leave.id}", icon="chatbox-ellipses", @@ -4910,9 +4880,9 @@ def create_compensatory_leave_comment(request, comp_leave_id): recipient=rec, verb="Your compensatory leave request has received a comment.", verb_ar="تلقى طلب إجازة العوض الخاص بك تعليقًا.", - verb_de="Ihr Antrag auf Freizeitausgleich hat einen Kommentar erhalten.", + verb_de=f"Ihr Antrag auf Freizeitausgleich hat einen Kommentar erhalten.", verb_es="Su solicitud de permiso compensatorio ha recibido un comentario.", - verb_fr="Votre demande de congé compensatoire a reçu un commentaire.", + verb_fr=f"Votre demande de congé compensatoire a reçu un commentaire.", redirect=reverse("view-compensatory-leave") + f"?id={comp_leave.id}", icon="chatbox-ellipses", @@ -5198,3 +5168,4 @@ def leave_allocation_approve(request): # "current_date":date.today(), }, ) +