wbhuman_resources 1.58.4__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. wbhuman_resources/__init__.py +1 -0
  2. wbhuman_resources/admin/__init__.py +5 -0
  3. wbhuman_resources/admin/absence.py +113 -0
  4. wbhuman_resources/admin/calendars.py +37 -0
  5. wbhuman_resources/admin/employee.py +109 -0
  6. wbhuman_resources/admin/kpi.py +21 -0
  7. wbhuman_resources/admin/review.py +157 -0
  8. wbhuman_resources/apps.py +23 -0
  9. wbhuman_resources/dynamic_preferences_registry.py +119 -0
  10. wbhuman_resources/factories/__init__.py +38 -0
  11. wbhuman_resources/factories/absence.py +109 -0
  12. wbhuman_resources/factories/calendars.py +60 -0
  13. wbhuman_resources/factories/employee.py +80 -0
  14. wbhuman_resources/factories/kpi.py +155 -0
  15. wbhuman_resources/filters/__init__.py +20 -0
  16. wbhuman_resources/filters/absence.py +109 -0
  17. wbhuman_resources/filters/absence_graphs.py +85 -0
  18. wbhuman_resources/filters/calendars.py +28 -0
  19. wbhuman_resources/filters/employee.py +81 -0
  20. wbhuman_resources/filters/kpi.py +35 -0
  21. wbhuman_resources/filters/review.py +134 -0
  22. wbhuman_resources/filters/signals.py +27 -0
  23. wbhuman_resources/locale/de/LC_MESSAGES/django.mo +0 -0
  24. wbhuman_resources/locale/de/LC_MESSAGES/django.po +2207 -0
  25. wbhuman_resources/locale/de/LC_MESSAGES/django.po.translated +2456 -0
  26. wbhuman_resources/locale/en/LC_MESSAGES/django.mo +0 -0
  27. wbhuman_resources/locale/en/LC_MESSAGES/django.po +2091 -0
  28. wbhuman_resources/locale/fr/LC_MESSAGES/django.mo +0 -0
  29. wbhuman_resources/locale/fr/LC_MESSAGES/django.po +2093 -0
  30. wbhuman_resources/management/__init__.py +23 -0
  31. wbhuman_resources/migrations/0001_initial_squashed_squashed_0015_alter_absencerequest_calendaritem_ptr_and_more.py +949 -0
  32. wbhuman_resources/migrations/0016_alter_employeehumanresource_options.py +20 -0
  33. wbhuman_resources/migrations/0017_absencerequest_crossborder_country_and_more.py +55 -0
  34. wbhuman_resources/migrations/0018_remove_position_group_position_groups.py +32 -0
  35. wbhuman_resources/migrations/0019_alter_absencerequest_options_alter_kpi_options_and_more.py +44 -0
  36. wbhuman_resources/migrations/0020_alter_employeeyearbalance_year_alter_review_year.py +27 -0
  37. wbhuman_resources/migrations/0021_alter_position_color.py +18 -0
  38. wbhuman_resources/migrations/0022_remove_review_editable_mode.py +64 -0
  39. wbhuman_resources/migrations/__init__.py +0 -0
  40. wbhuman_resources/models/__init__.py +23 -0
  41. wbhuman_resources/models/absence.py +903 -0
  42. wbhuman_resources/models/calendars.py +370 -0
  43. wbhuman_resources/models/employee.py +1241 -0
  44. wbhuman_resources/models/kpi.py +199 -0
  45. wbhuman_resources/models/preferences.py +40 -0
  46. wbhuman_resources/models/review.py +982 -0
  47. wbhuman_resources/permissions/__init__.py +0 -0
  48. wbhuman_resources/permissions/backend.py +26 -0
  49. wbhuman_resources/serializers/__init__.py +49 -0
  50. wbhuman_resources/serializers/absence.py +308 -0
  51. wbhuman_resources/serializers/calendars.py +73 -0
  52. wbhuman_resources/serializers/employee.py +267 -0
  53. wbhuman_resources/serializers/kpi.py +80 -0
  54. wbhuman_resources/serializers/review.py +415 -0
  55. wbhuman_resources/signals.py +4 -0
  56. wbhuman_resources/tasks.py +195 -0
  57. wbhuman_resources/templates/review/review_report.html +322 -0
  58. wbhuman_resources/tests/__init__.py +1 -0
  59. wbhuman_resources/tests/conftest.py +96 -0
  60. wbhuman_resources/tests/models/__init__.py +0 -0
  61. wbhuman_resources/tests/models/test_absences.py +478 -0
  62. wbhuman_resources/tests/models/test_calendars.py +209 -0
  63. wbhuman_resources/tests/models/test_employees.py +502 -0
  64. wbhuman_resources/tests/models/test_review.py +103 -0
  65. wbhuman_resources/tests/models/test_utils.py +110 -0
  66. wbhuman_resources/tests/signals.py +108 -0
  67. wbhuman_resources/tests/test_permission.py +64 -0
  68. wbhuman_resources/tests/test_tasks.py +74 -0
  69. wbhuman_resources/urls.py +221 -0
  70. wbhuman_resources/utils.py +43 -0
  71. wbhuman_resources/viewsets/__init__.py +61 -0
  72. wbhuman_resources/viewsets/absence.py +312 -0
  73. wbhuman_resources/viewsets/absence_charts.py +328 -0
  74. wbhuman_resources/viewsets/buttons/__init__.py +7 -0
  75. wbhuman_resources/viewsets/buttons/absence.py +32 -0
  76. wbhuman_resources/viewsets/buttons/employee.py +44 -0
  77. wbhuman_resources/viewsets/buttons/kpis.py +16 -0
  78. wbhuman_resources/viewsets/buttons/review.py +195 -0
  79. wbhuman_resources/viewsets/calendars.py +103 -0
  80. wbhuman_resources/viewsets/display/__init__.py +39 -0
  81. wbhuman_resources/viewsets/display/absence.py +334 -0
  82. wbhuman_resources/viewsets/display/calendars.py +83 -0
  83. wbhuman_resources/viewsets/display/employee.py +254 -0
  84. wbhuman_resources/viewsets/display/kpis.py +92 -0
  85. wbhuman_resources/viewsets/display/review.py +429 -0
  86. wbhuman_resources/viewsets/employee.py +210 -0
  87. wbhuman_resources/viewsets/endpoints/__init__.py +42 -0
  88. wbhuman_resources/viewsets/endpoints/absence.py +57 -0
  89. wbhuman_resources/viewsets/endpoints/calendars.py +18 -0
  90. wbhuman_resources/viewsets/endpoints/employee.py +51 -0
  91. wbhuman_resources/viewsets/endpoints/kpis.py +53 -0
  92. wbhuman_resources/viewsets/endpoints/review.py +191 -0
  93. wbhuman_resources/viewsets/kpi.py +280 -0
  94. wbhuman_resources/viewsets/menu/__init__.py +22 -0
  95. wbhuman_resources/viewsets/menu/absence.py +50 -0
  96. wbhuman_resources/viewsets/menu/administration.py +15 -0
  97. wbhuman_resources/viewsets/menu/calendars.py +33 -0
  98. wbhuman_resources/viewsets/menu/employee.py +44 -0
  99. wbhuman_resources/viewsets/menu/kpis.py +18 -0
  100. wbhuman_resources/viewsets/menu/review.py +97 -0
  101. wbhuman_resources/viewsets/mixins.py +14 -0
  102. wbhuman_resources/viewsets/review.py +837 -0
  103. wbhuman_resources/viewsets/titles/__init__.py +18 -0
  104. wbhuman_resources/viewsets/titles/absence.py +30 -0
  105. wbhuman_resources/viewsets/titles/employee.py +18 -0
  106. wbhuman_resources/viewsets/titles/kpis.py +15 -0
  107. wbhuman_resources/viewsets/titles/review.py +62 -0
  108. wbhuman_resources/viewsets/utils.py +28 -0
  109. wbhuman_resources-1.58.4.dist-info/METADATA +8 -0
  110. wbhuman_resources-1.58.4.dist-info/RECORD +111 -0
  111. wbhuman_resources-1.58.4.dist-info/WHEEL +5 -0
@@ -0,0 +1,1241 @@
1
+ import math
2
+ import operator
3
+ from datetime import date, datetime, timedelta
4
+ from functools import reduce
5
+ from typing import Generator, List, Optional, Tuple, Type, TypeVar
6
+
7
+ import pandas as pd
8
+ from celery import shared_task
9
+ from colorfield.fields import ColorField
10
+ from django.contrib import admin
11
+ from django.contrib.auth import get_user_model
12
+ from django.contrib.auth.models import Group, Permission
13
+ from django.contrib.contenttypes.models import ContentType
14
+ from django.core.validators import MaxValueValidator, MinValueValidator
15
+ from django.db import models
16
+ from django.db.models import (
17
+ Case,
18
+ Count,
19
+ Exists,
20
+ ExpressionWrapper,
21
+ F,
22
+ OuterRef,
23
+ Q,
24
+ QuerySet,
25
+ Subquery,
26
+ Sum,
27
+ Value,
28
+ When,
29
+ )
30
+ from django.db.models.fields import FloatField
31
+ from django.db.models.functions import Ceil, Coalesce
32
+ from django.db.models.signals import m2m_changed, post_delete, post_save
33
+ from django.db.utils import ProgrammingError
34
+ from django.dispatch import receiver
35
+ from django.utils.timezone import make_naive
36
+ from django.utils.translation import gettext
37
+ from django.utils.translation import gettext_lazy as _
38
+ from dynamic_preferences.registries import global_preferences_registry
39
+ from mptt.models import MPTTModel, TreeForeignKey
40
+ from psycopg.types.range import TimestamptzRange
41
+ from wbcore.contrib.directory.models import (
42
+ Company,
43
+ EmployerEmployeeRelationship,
44
+ Person,
45
+ )
46
+ from wbcore.contrib.directory.models import Position as CRMPosition
47
+ from wbcore.contrib.directory.signals import deactivate_profile
48
+ from wbcore.contrib.notifications.dispatch import send_notification
49
+ from wbcore.contrib.notifications.utils import create_notification_type
50
+ from wbcore.models import WBModel
51
+ from wbcore.models.fields import YearField
52
+ from wbcore.utils.models import ComplexToStringMixin
53
+
54
+ from wbhuman_resources.signals import add_employee_activity_to_daily_brief
55
+
56
+ from .absence import AbsenceRequest, AbsenceRequestPeriods
57
+ from .calendars import DayOff, DayOffCalendar, DefaultDailyPeriod
58
+ from .preferences import (
59
+ default_vacation_days_per_year,
60
+ get_is_external_considered_as_internal,
61
+ get_main_company,
62
+ get_previous_year_balance_expiration_date,
63
+ long_vacation_number_of_days,
64
+ )
65
+
66
+ User = get_user_model()
67
+
68
+
69
+ class ActiveEmployeeManager(models.Manager):
70
+ """Custom Manager for filtering directly Active Employees. Exclude objects without reverse related field user_account and profile"""
71
+
72
+ def __init__(self, only_internal=False, **kwargs):
73
+ self.only_internal = only_internal
74
+ super().__init__(**kwargs)
75
+
76
+ def get_queryset(self) -> "models.QuerySet[EmployeeHumanResource]":
77
+ contract_type_condition = (
78
+ Q(contract_type=EmployeeHumanResource.ContractType.INTERNAL)
79
+ if (self.only_internal and not get_is_external_considered_as_internal())
80
+ else Q(contract_type__isnull=False)
81
+ )
82
+ return (
83
+ super()
84
+ .get_queryset()
85
+ .filter(
86
+ Q(is_active=True)
87
+ & Q(profile__isnull=False)
88
+ & Q(profile__user_account__isnull=False)
89
+ & contract_type_condition
90
+ )
91
+ )
92
+
93
+
94
+ SelfEmployeeHumanResource = TypeVar("SelfEmployeeHumanResource", bound="EmployeeHumanResource")
95
+
96
+
97
+ class EmployeeHumanResource(ComplexToStringMixin, WBModel):
98
+ """
99
+ Stores a single Employee entry, related to :model:`directory.Person`.
100
+ """
101
+
102
+ class ContractType(models.TextChoices):
103
+ INTERNAL = "INTERNAL", _("Internal")
104
+ EXTERNAL = "EXTERNAL", _("External")
105
+
106
+ class ExtraDaysBalanceFrequency(models.TextChoices):
107
+ MONTHLY = "MONTHLY", _("Monthly")
108
+ YEARLY = "YEARLY", _("Yearly")
109
+
110
+ def get_pandas_frequency(self) -> str:
111
+ """
112
+ Return the pandas frequency representation of this frequency
113
+ """
114
+ if self.value == self.MONTHLY:
115
+ return "M"
116
+ if self.value == self.YEARLY:
117
+ return "Y"
118
+
119
+ def get_date_range(self, _d: datetime) -> tuple[datetime, datetime]:
120
+ """
121
+ Return a tuple of datetime range representing this frequency
122
+ """
123
+ if self.value == self.MONTHLY:
124
+ return ((_d - pd.tseries.offsets.MonthBegin(1)).to_pydatetime(), _d)
125
+ if self.value == self.YEARLY:
126
+ return (
127
+ (_d - pd.tseries.offsets.YearEnd(1)).to_pydatetime() + timedelta(days=1),
128
+ (_d + pd.tseries.offsets.YearBegin(1)).to_pydatetime() - timedelta(days=1),
129
+ )
130
+
131
+ def get_yearly_periods_count(self) -> int:
132
+ """
133
+ Return the number of yearly periods defined by this frequency
134
+ """
135
+ if self.value == self.MONTHLY:
136
+ return 12
137
+ if self.value == self.YEARLY:
138
+ return 1
139
+
140
+ def get_period_index(self, _d: datetime) -> int:
141
+ """
142
+ Get the period index
143
+ """
144
+ if self.value == self.MONTHLY:
145
+ return _d.month
146
+ if self.value == self.YEARLY:
147
+ return 1
148
+
149
+ profile = models.OneToOneField(
150
+ "directory.Person",
151
+ related_name="human_resources",
152
+ on_delete=models.CASCADE,
153
+ verbose_name=_("Employee"),
154
+ help_text=_("The CRM profile related to this employee"),
155
+ )
156
+ is_active = models.BooleanField(
157
+ verbose_name=_("Is active"),
158
+ help_text=_(
159
+ "If false, the employee will be considered as not active but his absence requests will be preserved"
160
+ ),
161
+ default=True,
162
+ )
163
+ extra_days_frequency = models.CharField(
164
+ max_length=16,
165
+ default=ExtraDaysBalanceFrequency.YEARLY,
166
+ choices=ExtraDaysBalanceFrequency.choices,
167
+ verbose_name=_("Extra Days Frequency"),
168
+ help_text=_(
169
+ "The frequency at which an additional number of vacation days is enabled for this employee (defaults to yearly)"
170
+ ),
171
+ )
172
+
173
+ occupancy_rate = models.FloatField(
174
+ verbose_name=_("Occupation Rate"),
175
+ help_text=_("The occupation rate in percent, 100% being employed fulltime"),
176
+ default=1,
177
+ )
178
+
179
+ contract_type = models.CharField(
180
+ max_length=16,
181
+ default=ContractType.INTERNAL,
182
+ choices=ContractType.choices,
183
+ verbose_name=_("Employee Type"),
184
+ help_text=_(
185
+ "If Internal, the employee is considered a full-time employee and thus has employee access to the system."
186
+ ),
187
+ )
188
+ position = models.ForeignKey(
189
+ "wbhuman_resources.Position",
190
+ related_name="employees",
191
+ on_delete=models.SET_NULL,
192
+ verbose_name=_("Position"),
193
+ limit_choices_to=models.Q(height=0),
194
+ null=True,
195
+ blank=True,
196
+ help_text=_("The position this employee belongs to"),
197
+ )
198
+ direct_manager = models.ForeignKey(
199
+ "directory.Person",
200
+ null=True,
201
+ blank=True,
202
+ on_delete=models.SET_NULL,
203
+ verbose_name=_("Direct Manager"),
204
+ related_name="managed_employees",
205
+ )
206
+ calendar = models.ForeignKey(
207
+ to="wbhuman_resources.DayOffCalendar",
208
+ related_name="employees",
209
+ on_delete=models.PROTECT,
210
+ )
211
+ weekly_off_periods = models.ManyToManyField(
212
+ to="wbhuman_resources.DefaultDailyPeriod",
213
+ through="wbhuman_resources.EmployeeWeeklyOffPeriods",
214
+ through_fields=("employee", "period"),
215
+ related_name="employees_off",
216
+ verbose_name=_("Weekly off periods"),
217
+ )
218
+ objects = models.Manager()
219
+ active_internal_employees = ActiveEmployeeManager(only_internal=True)
220
+ active_employees = ActiveEmployeeManager()
221
+
222
+ enrollment_at = models.DateField(verbose_name=_("Enrolled at"))
223
+ disenrollment_at = models.DateField(verbose_name="Disenroll at", blank=True, null=True)
224
+
225
+ class Meta:
226
+ verbose_name = _("Employee Human Resource")
227
+ verbose_name_plural = _("Employee Human Resources")
228
+ permissions = [("administrate_employeehumanresource", "Can administrate Employee Human Resource")]
229
+ notification_types = [
230
+ create_notification_type(
231
+ code="wbhuman_resources.employeehumanresource.deactivate",
232
+ title="Deactivate Employee Notification",
233
+ help_text="Notify the requester when an employee has been successfully deactivated",
234
+ ),
235
+ create_notification_type(
236
+ code="wbhuman_resources.employeehumanresource.vacation",
237
+ title="Vacation Notification",
238
+ help_text="Notifies you when there are Vacation days that you still have to take",
239
+ ),
240
+ ]
241
+
242
+ def unassign_position_groups(self):
243
+ """
244
+ Un-assign the employee to the belonged human resource groups
245
+ """
246
+ if (user := getattr(self.profile, "user_account", None)) and self.position:
247
+ for position in self.position.get_ancestors(include_self=True):
248
+ for group in position.groups.all():
249
+ user.groups.remove(group)
250
+
251
+ def assign_position_groups(self):
252
+ """
253
+ Assign the employee to the position permission group
254
+ """
255
+ if (user := getattr(self.profile, "user_account", None)) and self.position:
256
+ for position in self.position.get_ancestors(include_self=True):
257
+ for group in position.groups.difference(user.groups.all()):
258
+ user.groups.add(group)
259
+
260
+ def deactivate(
261
+ self, substitute: Optional[models.Model] = None, disenrollment_date: date | None = None
262
+ ) -> List[str]:
263
+ """
264
+ Utility method to deactivate/disenroll an employee
265
+
266
+ Trigger a signal "deactivate_profile" that every module can implement in order to define module level business logic for employee disenrollment.
267
+
268
+ Args:
269
+ substitute:
270
+ disenrollment_date:
271
+
272
+ Returns:
273
+ A list of message feedback received from the potential receivers
274
+ """
275
+ if not disenrollment_date:
276
+ disenrollment_date = date.today()
277
+ # send signal to deactivate employee
278
+ self.disenrollment_at = disenrollment_date
279
+ res = deactivate_profile.send(self.__class__, instance=self.profile, substitute_profile=substitute)
280
+
281
+ self.is_active = False
282
+ self.save()
283
+ self.profile.user_account.is_active = False
284
+ self.profile.user_account.save()
285
+ self.assign_vacation_allowance_from_range(
286
+ self.enrollment_at, self.disenrollment_at
287
+ ) # recompute and close possible open balance
288
+ try:
289
+ main_company = Company.objects.get(id=get_main_company())
290
+ self.profile.employers.remove(main_company)
291
+ except Company.DoesNotExist:
292
+ pass
293
+ return [msg for _, msg in res]
294
+
295
+ def get_managed_employees(self, include_self: bool | None = True) -> "QuerySet[SelfEmployeeHumanResource]":
296
+ """
297
+ Returns all the direct managed employees, from the current position and all its descendants
298
+ Args:
299
+ include_self: Set to False if the returned queryset needs to exclude the employee. Default to False
300
+
301
+ Returns:
302
+ A queryset of EmployeeHumanResources
303
+ """
304
+ if (user := self.profile.user_account) and user.has_perm("wbhuman_resources.administrate_absencerequest"):
305
+ qs = EmployeeHumanResource.active_employees.all()
306
+ else:
307
+ conditions = [Q(direct_manager=self.profile), Q(id=self.id)] + [
308
+ Q(position__in=position.get_descendants(include_self=True))
309
+ for position in self.profile.managed_positions.all()
310
+ ]
311
+ qs = EmployeeHumanResource.active_employees.filter(reduce(operator.or_, conditions))
312
+ if not include_self:
313
+ qs = qs.exclude(id=self.id)
314
+ return qs.distinct()
315
+
316
+ def get_managers(self, only_direct_manager: bool = False) -> Generator[SelfEmployeeHumanResource, None, None]:
317
+ """
318
+ Returns the direct manager of this employee and the potential global manager. Prioritize the direct manager, and if not available, the next position manager in the company hierarchy
319
+
320
+ Returns:
321
+ A generator yielding managers
322
+ """
323
+ if direct_manager := self.direct_manager:
324
+ yield direct_manager
325
+ elif main_position := self.position:
326
+ if (
327
+ next_position_with_manager := main_position.get_ancestors(ascending=True, include_self=True)
328
+ .filter(manager__isnull=False)
329
+ .first()
330
+ ):
331
+ yield next_position_with_manager.manager
332
+ if not only_direct_manager:
333
+ global_manager_permission = Permission.objects.get(
334
+ codename="administrate_employeehumanresource", content_type=ContentType.objects.get_for_model(self)
335
+ )
336
+ for global_manager_user in global_manager_permission.user_set.filter(profile__isnull=False):
337
+ yield global_manager_user.profile
338
+
339
+ def is_manager_of(self, administree: SelfEmployeeHumanResource, include_self: bool = False) -> bool:
340
+ """
341
+ Return true if self is the manager of administree
342
+ Args:
343
+ administree: The employee to check hierarchy against
344
+
345
+ Returns:
346
+ A boolean
347
+ """
348
+ return self.get_managed_employees(include_self=include_self).filter(id=administree.id).exists()
349
+
350
+ def compute_str(self) -> str:
351
+ return f"{self.profile.first_name} {self.profile.last_name}"
352
+
353
+ def __str__(self) -> str:
354
+ return self.computed_str
355
+
356
+ def save(self, *args, **kwargs):
357
+ if not self.calendar:
358
+ self.calendar = global_preferences_registry.manager()["wbhuman_resources__employee_default_calendar"]
359
+ if not self.enrollment_at:
360
+ self.enrollment_at = date.today()
361
+ super().save(*args, **kwargs)
362
+
363
+ def extract_workable_periods(
364
+ self, start: datetime, end: datetime, count: int = None
365
+ ) -> Generator[Tuple[datetime, datetime, DefaultDailyPeriod], None, None]:
366
+ """
367
+ Utility function that returns the day off for an employee, including the calendar day off and the possible weekly day off
368
+ Args:
369
+ start_date: lower bound range
370
+ end_date: upper bound range
371
+ count: if specified, will stop the iteration at the specified index
372
+ Returns:
373
+ a list of datetime range representing each a period where the employee if off
374
+ """
375
+ cursor = start.date()
376
+ index = 0
377
+ while cursor <= end.date() and (not count or index < count):
378
+ for period in self.calendar.default_periods.order_by("lower_time"):
379
+ lower_datetime = period.get_lower_datetime(cursor)
380
+ upper_datetime = period.get_upper_datetime(cursor)
381
+ if (
382
+ lower_datetime >= start
383
+ and upper_datetime <= end
384
+ and not EmployeeWeeklyOffPeriods.objects.filter(
385
+ weekday=cursor.weekday(), period=period, employee=self
386
+ ).exists()
387
+ and not self.calendar.days_off.filter(date=cursor, count_as_holiday=True).exists()
388
+ ):
389
+ yield cursor, period
390
+ index += 1
391
+ cursor += timedelta(days=1)
392
+
393
+ def assign_vacation_allowance_from_range(self, from_date: date, to_date: date):
394
+ """
395
+ Assign the proper monthly allowance from the given range if it doesn't yet exist.
396
+
397
+ Assign partial allowance if the date range don't span a full period. Check also if the given range don't over span the enrollment and disenrollment employee's date
398
+
399
+ Args:
400
+ from_date: start date range
401
+ to_date: end date range
402
+ """
403
+ from_date = max(self.enrollment_at, from_date)
404
+ if self.disenrollment_at:
405
+ to_date = min(self.disenrollment_at, to_date)
406
+ periods = self.calendar.default_periods.order_by("lower_time")
407
+ if periods.exists():
408
+ # Convert to datetime
409
+ from_datetime = periods.first().get_lower_datetime(from_date)
410
+ to_datetime = periods.last().get_upper_datetime(to_date)
411
+
412
+ frequency = self.ExtraDaysBalanceFrequency[self.extra_days_frequency]
413
+
414
+ period_base_allowance = (
415
+ default_vacation_days_per_year()
416
+ * self.calendar.get_daily_hours()
417
+ / frequency.get_yearly_periods_count()
418
+ )
419
+ for _d in pd.date_range(from_datetime, to_datetime, freq=frequency.get_pandas_frequency()):
420
+ current_year_balance = self.get_or_create_balance(_d.year)[0]
421
+ [start_period, end_period] = frequency.get_date_range(_d)
422
+ total_workable_periods_count = len(list(self.extract_workable_periods(start_period, end_period)))
423
+ actual_workable_periods_count = len(
424
+ list(self.extract_workable_periods(max(start_period, from_datetime), min(end_period, to_datetime)))
425
+ )
426
+ if actual_workable_periods_count and total_workable_periods_count:
427
+ BalanceHourlyAllowance.objects.get_or_create(
428
+ balance=current_year_balance,
429
+ period_index=frequency.get_period_index(_d),
430
+ defaults={
431
+ "hourly_allowance": period_base_allowance
432
+ * actual_workable_periods_count
433
+ / total_workable_periods_count
434
+ },
435
+ )
436
+
437
+ def get_or_create_balance(self, year: int) -> tuple["EmployeeYearBalance", bool]:
438
+ """
439
+ Wrapper around get_or_create
440
+
441
+ Args:
442
+ year: lookup year argument
443
+
444
+ Returns:
445
+ A tuple of EmployeeYearBalance and bool. Boolean is true if the balance was actually created.
446
+ """
447
+ return EmployeeYearBalance.objects.get_or_create(employee=self, year=year)
448
+
449
+ @classmethod
450
+ def get_endpoint_basename(cls) -> str:
451
+ return "wbhuman_resources:employee"
452
+
453
+ @classmethod
454
+ def get_representation_endpoint(cls) -> str:
455
+ return "wbhuman_resources:employeehumanresourcerepresentation-list"
456
+
457
+ @classmethod
458
+ def get_representation_value_key(cls) -> str:
459
+ return "id"
460
+
461
+ @classmethod
462
+ def get_representation_label_key(cls) -> str:
463
+ return "{{ computed_str }}"
464
+
465
+ @classmethod
466
+ def annotated_queryset(
467
+ cls, qs: QuerySet[SelfEmployeeHumanResource], end_of_month: date
468
+ ) -> QuerySet[SelfEmployeeHumanResource]:
469
+ """
470
+ Utility classmethod to annotate the employee queryset with a set of usage and balance statistics variables
471
+
472
+ Args:
473
+ qs: The queryset to annotate
474
+ end_of_month: Date at which the absence periods will be exclude from the balance usage
475
+
476
+ Returns:
477
+ The annotated queryset
478
+ """
479
+ long_vacation_days = long_vacation_number_of_days()
480
+ vacation_days_per_year = default_vacation_days_per_year()
481
+ return qs.annotate(
482
+ x1=Case(
483
+ When(
484
+ extra_days_frequency=EmployeeHumanResource.ExtraDaysBalanceFrequency.YEARLY.name,
485
+ then=F("occupancy_rate") * vacation_days_per_year * 2,
486
+ ),
487
+ default=F("occupancy_rate") * vacation_days_per_year / 6,
488
+ output_field=FloatField(),
489
+ ),
490
+ extra_days_per_period=Ceil("x1") / 2,
491
+ daily_hours=Coalesce(
492
+ Subquery(
493
+ DefaultDailyPeriod.objects.filter(calendar=OuterRef("calendar"))
494
+ .values("calendar")
495
+ .annotate(s=Sum("total_hours"))
496
+ .values("s")[:1]
497
+ ),
498
+ 0.0,
499
+ ),
500
+ available_vacation_balance_previous_year=Coalesce(
501
+ Subquery(
502
+ EmployeeYearBalance.objects.filter(employee=OuterRef("pk"), year=end_of_month.year - 1).values(
503
+ "actual_total_vacation_hourly_balance_in_days"
504
+ )[:1]
505
+ ),
506
+ 0.0,
507
+ ),
508
+ available_vacation_balance_current_year=Coalesce(
509
+ Subquery(
510
+ EmployeeYearBalance.objects.filter(employee=OuterRef("pk"), year=end_of_month.year).values(
511
+ "actual_total_vacation_hourly_balance_in_days"
512
+ )[:1]
513
+ ),
514
+ 0.0,
515
+ ),
516
+ available_vacation_balance_next_year=Coalesce(
517
+ Subquery(
518
+ EmployeeYearBalance.objects.filter(employee=OuterRef("pk"), year=end_of_month.year + 1).values(
519
+ "actual_total_vacation_hourly_balance_in_days"
520
+ )[:1]
521
+ ),
522
+ 0.0,
523
+ ),
524
+ long_vacation_in_hours=Value(long_vacation_days) * F("daily_hours") * F("occupancy_rate"),
525
+ took_long_vacations=Exists(
526
+ AbsenceRequestPeriods.objects.filter(
527
+ consecutive_hours_count__gte=OuterRef("long_vacation_in_hours"),
528
+ request__employee__id=OuterRef("pk"),
529
+ request__status=AbsenceRequest.Status.APPROVED,
530
+ request__type__is_timeoff=True,
531
+ date__year=end_of_month.year,
532
+ )
533
+ ),
534
+ )
535
+
536
+ @classmethod
537
+ def get_administrators(cls) -> QuerySet[User]:
538
+ """
539
+ Utility classmethod that returns the HR module administrators
540
+
541
+ Returns:
542
+ The administrator user accounts (as queryset)
543
+ """
544
+ return (
545
+ get_user_model()
546
+ .objects.filter(is_active=True, profile__isnull=False)
547
+ .filter(
548
+ Q(groups__permissions__codename="administrate_absencerequest")
549
+ | Q(user_permissions__codename="administrate_absencerequest")
550
+ )
551
+ .distinct()
552
+ )
553
+
554
+ @classmethod
555
+ def is_administrator(cls, user: "User") -> bool:
556
+ """
557
+ Check if the given user account has administrator privilege
558
+
559
+ Args:
560
+ user: User to check
561
+
562
+ Returns:
563
+ True if user is an administrator
564
+ """
565
+ return cls.get_administrators().filter(id=user.id).exists() or (user.is_superuser and user.is_active)
566
+
567
+ @classmethod
568
+ def get_employee_absence_periods_df(
569
+ cls,
570
+ calendar: "DayOffCalendar",
571
+ start: date,
572
+ end: date,
573
+ employees: QuerySet[SelfEmployeeHumanResource],
574
+ only_employee_with_absence_periods: bool = False,
575
+ ) -> pd.DataFrame:
576
+ """
577
+ Utility function that gets the subsequent absence dataframe from absence periods, employee weekly off period and days off and concat
578
+ a unique dataframe containing all the employees non working periods.
579
+
580
+ Args:
581
+ calendar: The calendar to use as base calendar
582
+ start: The start filter
583
+ end: The end filter
584
+ employees: A queryset of employees to get the off periods from
585
+ only_employee_with_absence_periods: True if we want to clean rows from employees without at least one absence request during that date range
586
+
587
+ Returns:
588
+ A dataframe whose columns are [employee, employee_repr, period, start, end, date, type, status]
589
+ """
590
+ periods_map = dict()
591
+
592
+ def _get_timespan(period_id, val_date):
593
+ if period_id not in periods_map:
594
+ periods_map[period_id] = DefaultDailyPeriod.objects.get(id=period_id)
595
+ period = periods_map[period_id]
596
+ return (
597
+ make_naive(period.get_lower_datetime(val_date), timezone=calendar.timezone),
598
+ make_naive(period.get_upper_datetime(val_date), timezone=calendar.timezone),
599
+ )
600
+
601
+ df_periods = AbsenceRequestPeriods.get_periods_as_df(start, end, employee__in=employees)
602
+ if only_employee_with_absence_periods:
603
+ employees = employees.filter(id__in=df_periods.employee.unique())
604
+ df_employee_weekly_off_periods = EmployeeWeeklyOffPeriods.get_employee_weekly_periods_df(
605
+ start, end, employee__in=employees
606
+ )
607
+ df_day_offs = calendar.get_day_off_per_employee_df(start, end, employees)
608
+
609
+ df = pd.concat(
610
+ [df_periods, df_employee_weekly_off_periods, df_day_offs],
611
+ axis=0,
612
+ ignore_index=True,
613
+ )
614
+
615
+ df[["start", "end"]] = (
616
+ df[["period", "date"]].apply(lambda x: _get_timespan(x["period"], x["date"]), axis=1).apply(pd.Series)
617
+ )
618
+ df["start"] = pd.to_datetime(df["start"])
619
+ df["end"] = pd.to_datetime(df["end"])
620
+ df["employee_repr"] = df.employee.map(dict(employees.values_list("id", "computed_str")))
621
+ return df
622
+
623
+ @classmethod
624
+ def get_end_of_month_employee_balance_report_df(
625
+ cls, active_employees: QuerySet[SelfEmployeeHumanResource], end_of_month: date, convert_in_days: bool = True
626
+ ) -> pd.DataFrame:
627
+ """
628
+ A utility function that generate a statistics vacation usage dataframe for a list of employees.
629
+
630
+ Args:
631
+ active_employees: A queryset of employees
632
+ end_of_month: The last date for periods to be used in the report
633
+ convert_in_days: True if the resulting statistics needs to be converted into days from hours
634
+
635
+ Returns:
636
+ A dataframe with columns ["Employee", "Total Balance", "End of Month Usage", "Available Balance End of Month"
637
+ """
638
+
639
+ final_columns_name_mapping = {
640
+ "employee": "Employee",
641
+ "total_balance": "Total Balance",
642
+ "current_year_usage": "End of Month Usage",
643
+ "eod_remaining_absence_days": "Available Balance End of Month",
644
+ }
645
+
646
+ df_balances_current_year = (
647
+ pd.DataFrame(
648
+ EmployeeYearBalance.objects.get_queryset_at_date(to_date=end_of_month, today=end_of_month)
649
+ .filter(year=end_of_month.year, employee__in=active_employees)
650
+ .values("employee", "_balance", "_daily_hours")
651
+ )
652
+ .set_index("employee")
653
+ .rename(columns={"_balance": "current_year_available_balance"})
654
+ )
655
+ df_balances_previous_year = (
656
+ pd.DataFrame(
657
+ EmployeeYearBalance.objects.get_queryset_at_date(to_date=end_of_month, today=end_of_month)
658
+ .filter(year=end_of_month.year - 1, employee__in=active_employees)
659
+ .values("employee", "_total_vacation_hourly_balance")
660
+ )
661
+ .set_index("employee")
662
+ .rename(columns={"_total_vacation_hourly_balance": "previous_year_available_balance"})
663
+ )
664
+
665
+ df_usage_current_year = (
666
+ pd.DataFrame(
667
+ AbsenceRequestPeriods.vacation_objects.filter(
668
+ employee__in=active_employees, date__year=end_of_month.year, date__lte=end_of_month
669
+ ).values("employee", "_total_hours")
670
+ )
671
+ .groupby("employee")
672
+ .sum()
673
+ .rename(columns={"_total_hours": "current_year_usage"})
674
+ )
675
+ df_usage_previous_year = (
676
+ pd.DataFrame(
677
+ EmployeeYearBalance.objects.get_queryset_at_date(
678
+ from_date=date(end_of_month.year, 1, 1), to_date=end_of_month, today=end_of_month
679
+ )
680
+ .filter(year=end_of_month.year - 1, employee__in=active_employees)
681
+ .values("employee", "_total_vacation_hourly_usage")
682
+ )
683
+ .set_index("employee")
684
+ .rename(columns={"_total_vacation_hourly_usage": "previous_year_usage"})
685
+ )
686
+
687
+ df = pd.concat(
688
+ [df_balances_current_year, df_balances_previous_year, df_usage_current_year, df_usage_previous_year],
689
+ axis=1,
690
+ ).fillna(0)
691
+
692
+ df["total_balance"] = (
693
+ df.current_year_available_balance + df.previous_year_available_balance + df.previous_year_usage
694
+ )
695
+ df["eod_remaining_absence_days"] = df.total_balance - df.current_year_usage
696
+ if convert_in_days:
697
+ df[["total_balance", "current_year_usage", "eod_remaining_absence_days"]] = df[
698
+ ["total_balance", "current_year_usage", "eod_remaining_absence_days"]
699
+ ].divide(df["_daily_hours"], axis=0)
700
+ df = df[df["total_balance"] != 0].reset_index()
701
+ df.employee = df.employee.map(dict(active_employees.values_list("id", "computed_str")))
702
+
703
+ df = df.rename(columns=final_columns_name_mapping)
704
+ return df.drop(columns=df.columns.difference(final_columns_name_mapping.values()))
705
+
706
+
707
+ class EmployeeYearBalanceDefaultManager(models.Manager):
708
+ def _annotate_queryset(
709
+ self, qs, from_date: date | None = None, to_date: date | None = None, today: date | None = None
710
+ ):
711
+ """
712
+ Intermediary private method to annotate the balance queryset with the usage variables
713
+
714
+ Args:
715
+ qs: Balance queryset
716
+ from_date: Optional, All periods before this date will be excluded from the usage
717
+ to_date: Optional, All periods after this date will be excluded from the usage
718
+ today: Optional, Date at which the statisticis needs to be considered. Usefull particularly to define if the previous year balance available is usable.
719
+
720
+ Returns:
721
+ The annotated queryset
722
+ """
723
+ if not today:
724
+ today = date.today()
725
+ try:
726
+ latest_date_for_last_year_vacation_availibility = get_previous_year_balance_expiration_date(today.year)
727
+ base_periods = AbsenceRequestPeriods.vacation_objects.filter(balance=OuterRef("pk"))
728
+ base_mandatory_days_off = DayOff.objects.filter(
729
+ calendar=OuterRef("employee__calendar"), date__year=OuterRef("year"), count_as_holiday=False
730
+ )
731
+ if to_date:
732
+ base_periods = base_periods.filter(date__lte=to_date)
733
+ base_mandatory_days_off = base_mandatory_days_off.filter(date__lte=to_date)
734
+
735
+ if from_date:
736
+ base_periods = base_periods.filter(date__gte=from_date)
737
+ base_mandatory_days_off = base_mandatory_days_off.filter(date__gte=from_date)
738
+ return qs.annotate(
739
+ _given_balance=ExpressionWrapper(
740
+ Coalesce(
741
+ Subquery(
742
+ BalanceHourlyAllowance.objects.filter(balance=OuterRef("pk"))
743
+ .values("balance")
744
+ .annotate(s=Sum("hourly_allowance"))
745
+ .values("s")[:1]
746
+ ),
747
+ 0,
748
+ ),
749
+ output_field=FloatField(),
750
+ ),
751
+ _balance=F("extra_balance") + Ceil("_given_balance"),
752
+ _daily_hours=Coalesce(
753
+ Subquery(
754
+ DefaultDailyPeriod.objects.filter(calendar=OuterRef("employee__calendar"))
755
+ .values("calendar")
756
+ .annotate(s=Sum("total_hours"))
757
+ .values("s")[:1]
758
+ ),
759
+ 0.0,
760
+ ),
761
+ _number_mandatory_days_off_in_days=ExpressionWrapper(
762
+ Coalesce(Subquery(base_mandatory_days_off.annotate(c=Count("date__year")).values("c")[:1]), 0.0),
763
+ output_field=FloatField(),
764
+ ),
765
+ _number_mandatory_days_off=F("_number_mandatory_days_off_in_days") * F("_daily_hours"),
766
+ _total_vacation_hourly_usage=Coalesce(
767
+ Subquery(base_periods.values("balance").annotate(s=Sum("_total_hours")).values("s")[:1]),
768
+ 0.0,
769
+ ),
770
+ _total_vacation_hourly_balance=F("_balance")
771
+ - F("_total_vacation_hourly_usage")
772
+ - F("_number_mandatory_days_off"),
773
+ today=Value(today),
774
+ actual_total_vacation_hourly_balance=Case(
775
+ When(
776
+ Q(year__lt=today.year) & Q(today__gte=latest_date_for_last_year_vacation_availibility),
777
+ then=0.0,
778
+ ),
779
+ default=F("_total_vacation_hourly_balance"),
780
+ ),
781
+ _balance_in_days=Case(When(_daily_hours=0, then=None), default=F("_balance") / F("_daily_hours")),
782
+ _total_vacation_hourly_usage_in_days=Case(
783
+ When(_daily_hours=0, then=None), default=F("_total_vacation_hourly_usage") / F("_daily_hours")
784
+ ),
785
+ _total_vacation_hourly_balance_in_days=Case(
786
+ When(_daily_hours=0, then=None), default=F("_total_vacation_hourly_balance") / F("_daily_hours")
787
+ ),
788
+ actual_total_vacation_hourly_balance_in_days=Case(
789
+ When(_daily_hours=0, then=None),
790
+ default=F("actual_total_vacation_hourly_balance") / F("_daily_hours"),
791
+ ),
792
+ )
793
+ except ProgrammingError:
794
+ return qs
795
+
796
+ def get_queryset(self):
797
+ """
798
+ Default Manager queryset
799
+ """
800
+ return self._annotate_queryset(super().get_queryset())
801
+
802
+ def get_queryset_at_date(
803
+ self, from_date: date | None = None, to_date: date | None = None, today: date | None = None
804
+ ):
805
+ """
806
+ Default Manager queryset
807
+ """
808
+ return self._annotate_queryset(super().get_queryset(), from_date=from_date, to_date=to_date, today=today)
809
+
810
+
811
+ class BalanceHourlyAllowance(models.Model):
812
+ balance = models.ForeignKey(
813
+ "wbhuman_resources.EmployeeYearBalance",
814
+ on_delete=models.CASCADE,
815
+ verbose_name="Balance",
816
+ related_name="monthly_allowances",
817
+ )
818
+ period_index = models.PositiveIntegerField()
819
+ hourly_allowance = models.FloatField()
820
+
821
+ class Meta:
822
+ verbose_name = _("Monthly Allowance")
823
+ verbose_name_plural = _("Monthly Allowance")
824
+ unique_together = ("balance", "period_index")
825
+ indexes = [
826
+ models.Index(fields=["balance"]),
827
+ models.Index(fields=["balance", "period_index"]),
828
+ ]
829
+
830
+ def __str__(self) -> str:
831
+ return f"{self.balance} - {self.period_index} Period Index: {self.hourly_allowance}"
832
+
833
+
834
+ class EmployeeYearBalance(ComplexToStringMixin):
835
+ employee = models.ForeignKey(
836
+ EmployeeHumanResource,
837
+ related_name="balances",
838
+ on_delete=models.CASCADE,
839
+ verbose_name=_("Employee"),
840
+ help_text=_("The employee having that year balance"),
841
+ )
842
+ year = YearField(verbose_name=_("Year"))
843
+ extra_balance = models.FloatField(
844
+ default=0,
845
+ verbose_name=_("Extra Balance (in hours)"),
846
+ help_text=_("The yearly extra balance (in hours)"),
847
+ )
848
+
849
+ objects = EmployeeYearBalanceDefaultManager()
850
+
851
+ def compute_str(self):
852
+ return "Balance {}: {}".format(self.year, self.employee)
853
+
854
+ class Meta:
855
+ verbose_name = _("Employee Year Balance")
856
+ verbose_name_plural = _("Employee Year Balances")
857
+ unique_together = ("employee", "year")
858
+ indexes = [
859
+ models.Index(fields=["employee", "year"]),
860
+ ]
861
+
862
+ @property
863
+ @admin.display(description="Yearly allowance (in hours)")
864
+ def balance(self) -> float:
865
+ given_balance = self.monthly_allowances.aggregate(s=Sum("hourly_allowance"))["s"] or 0.0
866
+ return getattr(self, "_balance", math.ceil(given_balance) + self.extra_balance)
867
+
868
+ @property
869
+ @admin.display(description="Daily hours")
870
+ def daily_hours(self) -> float:
871
+ return getattr(self, "_daily_hours", self.employee.calendar.get_daily_hours())
872
+
873
+ @property
874
+ @admin.display(description="Mandatory days off (In days)")
875
+ def number_mandatory_days_off_in_days(self) -> float:
876
+ return getattr(
877
+ self,
878
+ "_number_mandatory_days_off",
879
+ self.employee.calendar.days_off.filter(date__year=self.year, count_as_holiday=False).count(),
880
+ )
881
+
882
+ @property
883
+ @admin.display(description="Mandatory Hours off")
884
+ def number_mandatory_days_off(self) -> float:
885
+ return getattr(
886
+ self, "_number_mandatory_days_off_in_days", self.number_mandatory_days_off_in_days * self.daily_hours
887
+ )
888
+
889
+ @property
890
+ @admin.display(description="Total vacation hourly usage")
891
+ def total_vacation_hourly_usage(self) -> float:
892
+ return getattr(
893
+ self,
894
+ "_total_vacation_hourly_usage",
895
+ self.periods.filter(
896
+ request__type__is_vacation=True, request__status=AbsenceRequest.Status.APPROVED
897
+ ).aggregate(s=Sum("_total_hours"))["s"]
898
+ or 0.0,
899
+ )
900
+
901
+ @property
902
+ @admin.display(description="Total available yearly balance")
903
+ def total_vacation_hourly_balance(self) -> float:
904
+ return getattr(
905
+ self,
906
+ "_total_vacation_hourly_balance",
907
+ self.balance - self.total_vacation_hourly_usage - self.number_mandatory_days_off,
908
+ )
909
+
910
+ @property
911
+ @admin.display(description="Yearly allowance (in days)")
912
+ def balance_in_days(self) -> float:
913
+ return getattr(self, "_balance_in_days", self.balance / self.daily_hours)
914
+
915
+ @property
916
+ @admin.display(description="Total vacation hourly usage (in days)")
917
+ def total_vacation_hourly_usage_in_days(self) -> float:
918
+ return getattr(
919
+ self, "_total_vacation_hourly_usage_in_days", self.total_vacation_hourly_usage / self.daily_hours
920
+ )
921
+
922
+ @property
923
+ @admin.display(description="Total available yearly balance (in days)")
924
+ def total_vacation_hourly_balance_in_days(self) -> float:
925
+ return getattr(
926
+ self, "_total_vacation_hourly_balance_in_days", self.total_vacation_hourly_balance / self.daily_hours
927
+ )
928
+
929
+ @classmethod
930
+ def get_representation_value_key(cls) -> str:
931
+ return "id"
932
+
933
+ @classmethod
934
+ def get_representation_endpoint(cls) -> str:
935
+ return "wbhuman_resources:employeeyearbalancerepresentation-list"
936
+
937
+
938
+ class EmployeeWeeklyOffPeriods(ComplexToStringMixin):
939
+ employee = models.ForeignKey(
940
+ "wbhuman_resources.EmployeeHumanResource",
941
+ related_name="default_periods_relationships",
942
+ on_delete=models.CASCADE,
943
+ verbose_name=_("Employee"),
944
+ )
945
+
946
+ period = models.ForeignKey(
947
+ "wbhuman_resources.DefaultDailyPeriod",
948
+ related_name="employees_relationships",
949
+ on_delete=models.CASCADE,
950
+ verbose_name=_("Off Period"),
951
+ )
952
+ weekday = models.PositiveIntegerField(
953
+ validators=[MinValueValidator(0), MaxValueValidator(6)]
954
+ ) # Valid weekday from python convention (e.g. Monday is 0)
955
+
956
+ def compute_str(self) -> str:
957
+ return "{} off on the {} in {}".format(
958
+ self.employee.profile.full_name, self.period.title, date(1, 1, self.weekday + 1).strftime("%A")
959
+ )
960
+
961
+ def get_timespan(self, val_date: date) -> TimestamptzRange:
962
+ """
963
+ Get the combined datetime range for this employee weekly off period given a date
964
+
965
+ Args:
966
+ val_date: A date
967
+
968
+ Returns:
969
+ The datetime range spanning this periods on that valuation date
970
+ """
971
+ return TimestamptzRange(
972
+ lower=self.period.get_lower_datetime(val_date), upper=self.period.get_upper_datetime(val_date)
973
+ )
974
+
975
+ class Meta:
976
+ verbose_name = _("Employee Weekly off Period")
977
+ verbose_name_plural = _("Employee Weekly off Periods")
978
+ unique_together = ("employee", "period", "weekday")
979
+ indexes = [
980
+ models.Index(fields=["employee", "period", "weekday"]),
981
+ ]
982
+
983
+ @classmethod
984
+ def get_representation_endpoint(cls) -> str:
985
+ return "wbhuman_resources:employeeweeklyoffperiodrepresentation-list"
986
+
987
+ @classmethod
988
+ def get_representation_value_key(cls) -> str:
989
+ return "id"
990
+
991
+ @classmethod
992
+ def get_employee_weekly_periods_df(cls, start: date, end: date, **extra_filter_kwargs) -> pd.DataFrame:
993
+ """
994
+ This utility function provides a way to duplicate the employee weekly off periods into a timeserie of absence request whose type is "Day Off" and status is "APPROVED"
995
+ Args:
996
+ start: The beginning of the time period
997
+ end: The end of the time period
998
+ **extra_filter_kwargs: extra filter argument as dictionary to filter down the list of Employee weekly periods (usually to filter out employees)
999
+
1000
+ Returns:
1001
+ A dataframe whose columns is ["status", "type", "employee", "period", "date"]
1002
+ """
1003
+ df_periods_off = pd.DataFrame(
1004
+ EmployeeWeeklyOffPeriods.objects.filter(**extra_filter_kwargs).values("employee", "period", "weekday"),
1005
+ columns=["employee", "period", "weekday"],
1006
+ )
1007
+ df_periods_off = df_periods_off.merge(
1008
+ pd.date_range(start, end, freq="W").to_series(name="sunday"), how="cross"
1009
+ )
1010
+ df_periods_off["date"] = df_periods_off["sunday"] - pd.TimedeltaIndex(6 - df_periods_off["weekday"], unit="D")
1011
+ df_periods_off.date = df_periods_off.date.dt.date
1012
+ del df_periods_off["weekday"]
1013
+ del df_periods_off["sunday"]
1014
+ df_periods_off["status"] = AbsenceRequest.Status.APPROVED.value
1015
+ df_periods_off["type"] = "Day Off"
1016
+ return df_periods_off
1017
+
1018
+
1019
+ class Position(ComplexToStringMixin, WBModel, MPTTModel):
1020
+ color = ColorField(default="#FF0000")
1021
+ name = models.CharField(max_length=256)
1022
+ parent = TreeForeignKey(
1023
+ "wbhuman_resources.Position",
1024
+ related_name="children",
1025
+ null=True,
1026
+ blank=True,
1027
+ on_delete=models.CASCADE,
1028
+ verbose_name=_("Parent Positions"),
1029
+ )
1030
+ groups = models.ManyToManyField(Group, related_name="human_resources_positions", blank=True)
1031
+ height = models.IntegerField(default=0)
1032
+ manager = models.ForeignKey(
1033
+ "directory.Person",
1034
+ null=True,
1035
+ blank=True,
1036
+ on_delete=models.SET_NULL,
1037
+ verbose_name=_("Department Manager"),
1038
+ related_name="managed_positions",
1039
+ )
1040
+
1041
+ class Meta:
1042
+ verbose_name = _("Position")
1043
+ verbose_name_plural = _("Positions")
1044
+
1045
+ def compute_str(self) -> str:
1046
+ if self.parent and self.parent.computed_str:
1047
+ return f"{self.name} ({self.parent.computed_str})"
1048
+ return f"{self.name}"
1049
+
1050
+ def save(self, *args, **kwargs):
1051
+ self.computed_str = self.compute_str()
1052
+ super().save(*args, **kwargs)
1053
+
1054
+ def get_employees(self) -> QuerySet[EmployeeHumanResource]:
1055
+ """
1056
+ Get the queryset of employees that are within this position hierarchy
1057
+
1058
+ Returns:
1059
+ A queryset of employees
1060
+ """
1061
+ return EmployeeHumanResource.objects.filter(
1062
+ is_active=True, position__in=self.get_descendants(include_self=True)
1063
+ )
1064
+
1065
+ # @classmethod
1066
+ # def to_graph(cls):
1067
+ # nr_vertices = 25
1068
+ # v_label = list(map(str, range(nr_vertices)))
1069
+ # G = Graph.Tree(nr_vertices, 2) # 2 stands for children number
1070
+ # lay = G.layout('rt')
1071
+ #
1072
+ # position = {k: lay[k] for k in range(nr_vertices)}
1073
+ # Y = [lay[k][1] for k in range(nr_vertices)]
1074
+ # M = max(Y)
1075
+ #
1076
+ # es = EdgeSeq(G) # sequence of edges
1077
+ # E = [e.tuple for e in G.es] # list of edges
1078
+ #
1079
+ # L = len(position)
1080
+ # Xn = [position[k][0] for k in range(L)]
1081
+ # Yn = [2 * M - position[k][1] for k in range(L)]
1082
+ # Xe = []
1083
+ # Ye = []
1084
+ # for edge in E:
1085
+ # Xe += [position[edge[0]][0], position[edge[1]][0], None]
1086
+ # Ye += [2 * M - position[edge[0]][1], 2 * M - position[edge[1]][1], None]
1087
+ #
1088
+ # labels = v_label
1089
+ @classmethod
1090
+ def get_endpoint_basename(cls) -> str:
1091
+ return "wbhuman_resources:position"
1092
+
1093
+ @classmethod
1094
+ def get_representation_endpoint(cls) -> str:
1095
+ return "wbhuman_resources:positionrepresentation-list"
1096
+
1097
+ @classmethod
1098
+ def get_representation_value_key(cls) -> str:
1099
+ return "id"
1100
+
1101
+ @classmethod
1102
+ def get_representation_label_key(cls) -> str:
1103
+ return "{{ computed_str }}"
1104
+
1105
+
1106
+ @receiver(post_save, sender=EmployeeHumanResource)
1107
+ def post_save_employee(sender, instance, created, **kwargs):
1108
+ """
1109
+ Post save signal, Ensure that employee is among the employees of the CRM company profile upon save
1110
+ """
1111
+ if created:
1112
+ if not instance.weekly_off_periods.exists():
1113
+ # Default in creating the weekend default period off
1114
+ for default_period in instance.calendar.default_periods.all():
1115
+ EmployeeWeeklyOffPeriods.objects.get_or_create(
1116
+ employee=instance, period=default_period, weekday=5
1117
+ ) # Saturday
1118
+ EmployeeWeeklyOffPeriods.objects.get_or_create(
1119
+ employee=instance, period=default_period, weekday=6
1120
+ ) # Sunday
1121
+ [start, end] = EmployeeHumanResource.ExtraDaysBalanceFrequency[instance.extra_days_frequency].get_date_range(
1122
+ instance.enrollment_at
1123
+ )
1124
+ instance.assign_vacation_allowance_from_range(start.date(), end.date())
1125
+
1126
+ try:
1127
+ main_company = Company.objects.get(id=get_main_company())
1128
+ rel = EmployerEmployeeRelationship.objects.get_or_create(
1129
+ employee=instance.profile,
1130
+ employer=main_company,
1131
+ defaults={
1132
+ "primary": True,
1133
+ "position": (
1134
+ CRMPosition.objects.get_or_create(title=instance.position.name)[0]
1135
+ if instance.position
1136
+ else None
1137
+ ),
1138
+ },
1139
+ )[0]
1140
+ rel.primary = True
1141
+ rel.save()
1142
+ except Company.DoesNotExist:
1143
+ pass
1144
+
1145
+ # We assign or unassign the auth.Group position based on the active status
1146
+ if instance.is_active:
1147
+ instance.assign_position_groups()
1148
+ else:
1149
+ instance.unassign_position_groups()
1150
+
1151
+
1152
+ @receiver(post_delete, sender=Position)
1153
+ def post_delete_position(sender, instance, **kwargs):
1154
+ """
1155
+ Post delete Position logic
1156
+ """
1157
+ # We compute the height given the level of this position and its leaf node level
1158
+ if leaf_position := instance.get_family().filter(children__isnull=True).order_by("-level").first():
1159
+ instance.height = leaf_position.level - instance.level
1160
+
1161
+ # We recompute the height for all ancestors if this position is a leaf node
1162
+ if instance.height == 0:
1163
+ for pos in instance.get_ancestors():
1164
+ pos.save()
1165
+
1166
+
1167
+ @receiver(m2m_changed, sender=Position.groups.through)
1168
+ def position_groups_to_employee(
1169
+ sender: Type[Position.groups.through], instance: Position, action: str, pk_set: set[int], **kwargs
1170
+ ):
1171
+ employees = EmployeeHumanResource.objects.filter(
1172
+ profile__isnull=False,
1173
+ profile__user_account__isnull=False,
1174
+ position__in=instance.get_descendants(include_self=True),
1175
+ )
1176
+ groups_to_changed = Group.objects.filter(id__in=pk_set)
1177
+ for employee in employees:
1178
+ user = employee.profile.user_account
1179
+ for group in groups_to_changed:
1180
+ if action == "post_add":
1181
+ if group not in user.groups.all():
1182
+ user.groups.add(group)
1183
+ elif action in ["post_remove", "post_clear"]:
1184
+ user.groups.remove(group)
1185
+
1186
+
1187
+ @shared_task
1188
+ def deactivate_profile_as_task(requester_id: int, employee_id: int, substitute_id: Optional[int] = None):
1189
+ """
1190
+ Call the deactivation method as a async task
1191
+
1192
+ Args:
1193
+ requester_id: The User account id of the user asking for the deactivation
1194
+ employee_id: The deactivated user profile
1195
+ substitute_id: The potential Profile id to replace this employee's resources
1196
+
1197
+ """
1198
+ requester = get_user_model().objects.get(id=requester_id)
1199
+ employee = EmployeeHumanResource.objects.get(id=employee_id)
1200
+ substitute = None
1201
+ if substitute_id:
1202
+ substitute = Person.objects.get(id=substitute_id)
1203
+ messages = employee.deactivate(substitute)
1204
+
1205
+ send_notification(
1206
+ code="wbhuman_resources.employeehumanresource.deactivate",
1207
+ title=gettext("{employee} deactivation ended and was successful").format(
1208
+ employee=employee.profile.computed_str
1209
+ ),
1210
+ body=gettext("The following actions have been done: \n{messages}").format(messages=messages),
1211
+ user=requester,
1212
+ )
1213
+
1214
+
1215
+ @receiver(add_employee_activity_to_daily_brief, sender="directory.Person")
1216
+ def daily_birthday(sender, instance: Person, val_date: date, **kwargs) -> tuple[str, str] | None:
1217
+ """
1218
+ Cron task supposed to be ran every day. Check and notify employee about a colleague's birthday.
1219
+ """
1220
+
1221
+ # If daily brief is a monday, we get the birthday that happen during the weekend as well
1222
+ if val_date.weekday() == 0:
1223
+ sunday = val_date - timedelta(days=1)
1224
+ saturday = val_date - timedelta(days=2)
1225
+ conditions = (
1226
+ (Q(profile__birthday__day=val_date.day) & Q(profile__birthday__month=val_date.month))
1227
+ | (Q(profile__birthday__day=sunday.day) & Q(profile__birthday__month=sunday.month))
1228
+ | (Q(profile__birthday__day=saturday.day) & Q(profile__birthday__month=saturday.month))
1229
+ )
1230
+ else:
1231
+ conditions = Q(profile__birthday__day=val_date.day) & Q(profile__birthday__month=val_date.month)
1232
+ birthday_firstnames = list(
1233
+ EmployeeHumanResource.active_internal_employees.filter(conditions)
1234
+ .exclude(profile=instance)
1235
+ .values_list("profile__first_name", flat=True)
1236
+ )
1237
+ if birthday_firstnames:
1238
+ birthday_firstnames_humanized = f"{', '.join(birthday_firstnames[:-1])} and {birthday_firstnames[-1]}"
1239
+ return "Today Birthdays", _("Today is {}'s birthday, Which them a happy birthday!").format(
1240
+ birthday_firstnames_humanized
1241
+ )