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,903 @@
1
+ from datetime import timedelta
2
+
3
+ import pandas as pd
4
+ from django.contrib import admin
5
+ from django.contrib.auth import get_user_model
6
+ from django.contrib.auth.models import Group
7
+ from django.contrib.postgres.constraints import ExclusionConstraint
8
+ from django.contrib.postgres.fields import DateTimeRangeField, RangeOperators
9
+ from django.core.exceptions import ValidationError
10
+ from django.db import models
11
+ from django.db.models.functions import Coalesce
12
+ from django.db.models.signals import post_save
13
+ from django.db.utils import ProgrammingError
14
+ from django.dispatch import receiver
15
+ from django.utils import timezone
16
+ from django.utils.functional import cached_property
17
+ from django.utils.translation import gettext, pgettext_lazy
18
+ from django.utils.translation import gettext_lazy as _
19
+ from django_fsm import FSMField, transition
20
+ from pandas._libs.tslibs.offsets import BDay
21
+ from psycopg.types.range import TimestamptzRange
22
+ from wbcore.contrib.agenda.models import CalendarItem
23
+ from wbcore.contrib.geography.models import Geography
24
+ from wbcore.contrib.icons import WBIcon
25
+ from wbcore.contrib.notifications.dispatch import send_notification
26
+ from wbcore.contrib.notifications.utils import create_notification_type
27
+ from wbcore.enums import RequestType
28
+ from wbcore.metadata.configs.buttons import ActionButton, ButtonDefaultColor
29
+ from wbcore.metadata.configs.display.instance_display.shortcuts import (
30
+ create_simple_display,
31
+ )
32
+ from wbcore.models import WBModel
33
+ from wbcore.utils.models import CalendarItemTypeMixin
34
+
35
+ from .calendars import DayOff, DefaultDailyPeriod
36
+ from .preferences import get_previous_year_balance_expiration_date
37
+
38
+ User = get_user_model()
39
+
40
+
41
+ def can_cancel_request(instance: "AbsenceRequest", user: "User") -> bool:
42
+ """
43
+ Check if the given user has cancel ability on the given request
44
+
45
+ Args:
46
+ instance: The request
47
+ user: User to check permission
48
+
49
+ Returns:
50
+ True if the user got the right to cancel this request
51
+ """
52
+ if instance.period.upper < timezone.now():
53
+ return False
54
+ return (
55
+ user.profile == instance.employee.profile
56
+ or user.has_perm("wbhuman_resources.administrate_absencerequest")
57
+ or user.profile.human_resources.is_manager_of(instance.employee)
58
+ )
59
+
60
+
61
+ def can_validate_or_deny_request(instance: "AbsenceRequest", user: "User") -> bool:
62
+ """
63
+ Check if the given user can validate or deny the given request
64
+
65
+ Args:
66
+ instance: The request
67
+ user: User to check permission
68
+
69
+ Returns:
70
+ True if the user got the right to validate or deny this request
71
+ """
72
+ if user.has_perm("wbhuman_resources.administrate_absencerequest"):
73
+ return True
74
+ elif (
75
+ user.profile
76
+ and (employee := getattr(user.profile, "human_resources", None))
77
+ and (requester := instance.employee)
78
+ ):
79
+ return employee.is_manager_of(requester)
80
+ return False
81
+
82
+
83
+ class AbsenceRequestType(CalendarItemTypeMixin, WBModel):
84
+ title = models.CharField(max_length=255, verbose_name=_("Title"))
85
+
86
+ is_vacation = models.BooleanField(
87
+ default=False,
88
+ verbose_name=_("Vacation"),
89
+ help_text=_("If true, the days will be counted towards the employee's vacation balance"),
90
+ )
91
+ is_timeoff = models.BooleanField(
92
+ default=False, verbose_name=_("Time-Off"), help_text=_("If true, the employee is considered as not working")
93
+ )
94
+ is_extensible = models.BooleanField(
95
+ default=False,
96
+ verbose_name=_("Extensible"),
97
+ help_text=_("If true, allow the associated request to be extended"),
98
+ )
99
+ auto_approve = models.BooleanField(default=False, verbose_name=_("Auto Approve"))
100
+ days_in_advance = models.PositiveIntegerField(default=0, verbose_name=_("Days In Advance"))
101
+
102
+ is_country_necessary = models.BooleanField(default=False, verbose_name=_("Is country necessary"))
103
+ crossborder_countries = models.ManyToManyField(
104
+ to="geography.Geography",
105
+ limit_choices_to={"level": 1},
106
+ blank=True,
107
+ verbose_name=_("Countries"),
108
+ help_text=_("List of countries where crossborder activity is allowed"),
109
+ )
110
+ extra_notify_groups = models.ManyToManyField(
111
+ to=Group, blank=True, related_name="notified_absence_request_types", verbose_name=_("Extra Notify Groups")
112
+ )
113
+
114
+ def validate_country(self, country):
115
+ if self.is_country_necessary:
116
+ if not country:
117
+ raise ValueError(_("A country is necessary for this absence request type"))
118
+ elif not self.crossborder_countries.filter(id=country.id).exists():
119
+ raise ValueError(
120
+ _(
121
+ "You are not allowed to have crossborder activities in the specified country for the specified absence request type"
122
+ )
123
+ )
124
+ return True
125
+
126
+ class Meta:
127
+ verbose_name = _("Absence Request Type")
128
+ verbose_name_plural = _("Absence Request Types")
129
+
130
+ def __str__(self):
131
+ return self.title
132
+
133
+ @classmethod
134
+ def get_choices(cls) -> list[tuple[int, str]]:
135
+ """
136
+ Utility method that returns all possible absence request type choices as a text choices datastructure with id as name and title as label
137
+
138
+ We expect runtime error at runtime on non-initialized database that will be caught and an empty list will be returned in that case.
139
+
140
+ Returns:
141
+ A list of tuple choices
142
+ """
143
+ try:
144
+ return [(absence_type.id, absence_type.title) for absence_type in cls.objects.all()]
145
+ except (RuntimeError, ProgrammingError):
146
+ return []
147
+
148
+ @classmethod
149
+ def get_endpoint_basename(cls) -> str:
150
+ return "wbhuman_resources:absencerequesttype"
151
+
152
+ @classmethod
153
+ def get_representation_endpoint(cls) -> str:
154
+ return "wbhuman_resources:absencerequesttyperepresentation-list"
155
+
156
+ @classmethod
157
+ def get_representation_value_key(cls) -> str:
158
+ return "id"
159
+
160
+ @classmethod
161
+ def get_representation_label_key(cls) -> str:
162
+ return "{{title}}"
163
+
164
+
165
+ class AbsenceRequestManager(models.Manager):
166
+ def get_queryset(self) -> "models.QuerySet[AbsenceRequest]":
167
+ """
168
+ Default manager that provide a set of annotated variables for convenience
169
+ """
170
+ return (
171
+ super()
172
+ .get_queryset()
173
+ .annotate(
174
+ daily_hours=Coalesce(
175
+ models.Subquery(
176
+ DefaultDailyPeriod.objects.filter(calendar=models.OuterRef("employee__calendar"))
177
+ .values("calendar")
178
+ .annotate(s=models.Sum("total_hours"))
179
+ .values("s")[:1]
180
+ ),
181
+ 0.0,
182
+ ),
183
+ _total_hours=Coalesce(
184
+ models.Subquery(
185
+ AbsenceRequestPeriods.objects.filter(request=models.OuterRef("pk"))
186
+ .values("request")
187
+ .annotate(s=models.Sum("_total_hours"))
188
+ .values("s")[:1]
189
+ ),
190
+ 0.0,
191
+ ),
192
+ _total_vacation_hours=Coalesce(
193
+ models.Subquery(
194
+ AbsenceRequestPeriods.vacation_objects.filter(request=models.OuterRef("pk"))
195
+ .values("request")
196
+ .annotate(s=models.Sum("_total_hours"))
197
+ .values("s")[:1]
198
+ ),
199
+ 0.0,
200
+ ),
201
+ _total_hours_in_days=models.F("_total_hours") / models.F("daily_hours"),
202
+ _total_vacation_hours_in_days=models.F("_total_vacation_hours") / models.F("daily_hours"),
203
+ )
204
+ )
205
+
206
+
207
+ class AbsenceRequest(CalendarItem):
208
+ """
209
+ Stores a single Absence Request entry, related to :model:`wbhuman_resources.EmployeeHumanResource` and :model:`wbcrm.Activity`.
210
+ """
211
+
212
+ class Status(models.TextChoices):
213
+ DRAFT = "DRAFT", _("Draft")
214
+ PENDING = "PENDING", _("Pending")
215
+ APPROVED = "APPROVED", _("Approved")
216
+ DENIED = "DENIED", _("Denied")
217
+ CANCELLED = "CANCELLED", _("Cancelled")
218
+
219
+ status = FSMField(
220
+ default=Status.DRAFT,
221
+ choices=Status.choices,
222
+ verbose_name=_("Status"),
223
+ help_text=_("The request status (defaults to draft)"),
224
+ )
225
+
226
+ type = models.ForeignKey(
227
+ to="wbhuman_resources.AbsenceRequestType",
228
+ related_name="request",
229
+ verbose_name=_("Type"),
230
+ on_delete=models.PROTECT,
231
+ )
232
+
233
+ employee = models.ForeignKey(
234
+ "wbhuman_resources.EmployeeHumanResource",
235
+ related_name="requests",
236
+ on_delete=models.CASCADE,
237
+ verbose_name=_("Employee"),
238
+ help_text=_("The employee requesting the absence"),
239
+ )
240
+
241
+ notes = models.TextField(
242
+ null=True, blank=True, verbose_name=_("Extra Notes"), help_text=_("A note to the HR administrator")
243
+ )
244
+ reason = models.TextField(
245
+ null=True, blank=True, verbose_name=_("Reason"), help_text=_("The HR's response to this absence request")
246
+ )
247
+ created = models.DateTimeField(
248
+ auto_now_add=True, verbose_name=_("Created"), help_text=_("The request creation time")
249
+ )
250
+
251
+ attachment = models.FileField(
252
+ null=True,
253
+ blank=True,
254
+ max_length=256,
255
+ verbose_name=_("Attachment"),
256
+ upload_to="human_resources/absence_request/attachments",
257
+ help_text=_("Upload a file to document this absence request (e.g. medical certificate)"),
258
+ )
259
+
260
+ crossborder_country = models.ForeignKey(
261
+ to="geography.Geography",
262
+ null=True,
263
+ blank=True,
264
+ related_name="absence_requests",
265
+ on_delete=models.PROTECT,
266
+ verbose_name=_("Crossborder Country"),
267
+ help_text=_("The country where this absence request will be held."),
268
+ limit_choices_to={"level": 1},
269
+ )
270
+
271
+ @transition(
272
+ field=status,
273
+ source=[Status.DRAFT],
274
+ target=Status.PENDING,
275
+ permission=lambda instance, user: user.profile == instance.employee.profile
276
+ or user.has_perm("wbhuman_resources.administrate_absencerequest"),
277
+ custom={
278
+ "_transition_button": ActionButton(
279
+ method=RequestType.PATCH,
280
+ color=ButtonDefaultColor.WARNING,
281
+ identifiers=("wbhuman_resources:absencerequest",),
282
+ icon=WBIcon.SEND.icon,
283
+ key="submit",
284
+ label=_("Submit"),
285
+ action_label=_("Submitting"),
286
+ description_fields=_("<p>Are you sure you want to submit this request?</p>"),
287
+ instance_display=create_simple_display([["notes"]]),
288
+ )
289
+ },
290
+ )
291
+ def submit(self, **kwargs):
292
+ pass
293
+
294
+ def post_submit(self, **kwargs):
295
+ msg = gettext(
296
+ "<p>{employee} has submitted a {type} request for {hours} hours from {lower} to {upper}.</p>"
297
+ ).format(
298
+ employee=str(self.employee),
299
+ type=str(self.type),
300
+ hours=self.total_hours,
301
+ lower=self.period.lower.strftime("%Y-%m-%d %H:%M:%S"),
302
+ upper=self.period.upper.strftime("%Y-%m-%d %H:%M:%S"),
303
+ )
304
+ if self.crossborder_country:
305
+ msg += gettext("</br><p><b>Country:</b></p><i>{0}</i>").format(str(self.crossborder_country))
306
+ if self.notes and self.notes != "<p></p>" and self.notes != "null":
307
+ msg += gettext("</br><p><b>Employee's Note:</b></p><i>{notes}</i>").format(notes=self.notes)
308
+ title = gettext("New {type} Request").format(type=str(self.type))
309
+ self.notify(title, msg, to_requester=False, to_manager=True)
310
+
311
+ def can_submit(self):
312
+ errors = dict()
313
+ try:
314
+ self.type.validate_country(self.crossborder_country)
315
+ except ValueError as e:
316
+ errors["crossborder_country"] = e.args[0]
317
+ return errors
318
+
319
+ @transition(
320
+ field=status,
321
+ source=[Status.PENDING],
322
+ target=Status.APPROVED,
323
+ on_error="failed",
324
+ permission=lambda instance, user: can_validate_or_deny_request(instance, user),
325
+ custom={
326
+ "_transition_button": ActionButton(
327
+ method=RequestType.PATCH,
328
+ identifiers=("wbhuman_resources:absencerequest",),
329
+ icon=WBIcon.APPROVE.icon,
330
+ color=ButtonDefaultColor.SUCCESS,
331
+ key="approve",
332
+ label=_("Approve"),
333
+ action_label=_("Approval"),
334
+ description_fields=_("<p>Are you sure you want to approve this request?</p>"),
335
+ )
336
+ },
337
+ )
338
+ def approve(self, **kwargs):
339
+ pass
340
+
341
+ def post_approve(self, **kwargs):
342
+ msg = gettext("<p>Your {type} request from {start_date} to {end_date} has been approved.</p>").format(
343
+ type=str(self.type),
344
+ start_date=self.period.lower.strftime("%d.%m.%Y"),
345
+ end_date=self.period.upper.strftime("%d.%m.%Y"),
346
+ )
347
+ title = gettext("Absence request approved")
348
+ self.notify(title, msg, to_requester=True)
349
+
350
+ @transition(
351
+ field=status,
352
+ source=[Status.PENDING],
353
+ target=Status.DENIED,
354
+ permission=lambda instance, user: can_validate_or_deny_request(instance, user),
355
+ custom={
356
+ "_transition_button": ActionButton(
357
+ method=RequestType.PATCH,
358
+ identifiers=("wbhuman_resources:absencerequest",),
359
+ icon=WBIcon.DENY.icon,
360
+ color=ButtonDefaultColor.ERROR,
361
+ key="deny",
362
+ label=_("Deny"),
363
+ action_label=_("Denial"),
364
+ description_fields=_("<p>Are you sure you want to deny this request?</p>"),
365
+ instance_display=create_simple_display([["reason"]]),
366
+ )
367
+ },
368
+ )
369
+ def deny(self, **kwargs):
370
+ pass
371
+
372
+ def post_deny(self, **kwargs):
373
+ if self.type.is_vacation:
374
+ msg = gettext("<p>Your absence request from {start_date} to {end_date} has been denied.</p>").format(
375
+ start_date=self.period.lower.strftime("%d.%m.%Y"), end_date=self.period.upper.strftime("%d.%m.%Y")
376
+ )
377
+ if self.reason and self.reason not in ["<p></p>", ""]:
378
+ msg += gettext("</br><p><b>HR's reason:</b></p><i>{reason}</i>").format(reason=self.reason)
379
+ title = gettext("Absence request denied")
380
+ self.notify(title, msg, to_requester=True)
381
+
382
+ @transition(
383
+ field=status,
384
+ source=[Status.PENDING],
385
+ target=Status.DRAFT,
386
+ permission=lambda instance, user: user.profile == instance.employee.profile
387
+ or user.has_perm("wbhuman_resources.administrate_absencerequest"),
388
+ custom={
389
+ "_transition_button": ActionButton(
390
+ method=RequestType.PATCH,
391
+ identifiers=("wbhuman_resources:absencerequest",),
392
+ color=ButtonDefaultColor.WARNING,
393
+ icon=WBIcon.EDIT.icon,
394
+ key="backtodraft",
395
+ label=_("Back to Draft"),
396
+ action_label=_("Back to Draft"),
397
+ description_fields=_("<p>Are you sure you want to put this request back to draft?</p>"),
398
+ )
399
+ },
400
+ )
401
+ def backtodraft(self, **kwargs):
402
+ pass
403
+
404
+ @transition(
405
+ field=status,
406
+ source=[Status.APPROVED],
407
+ target=Status.CANCELLED,
408
+ on_error="failed",
409
+ permission=can_cancel_request,
410
+ custom={
411
+ "_transition_button": ActionButton(
412
+ method=RequestType.PATCH,
413
+ identifiers=("wbhuman_resources:absencerequest",),
414
+ icon=WBIcon.REJECT.icon,
415
+ color=ButtonDefaultColor.ERROR,
416
+ key="cancel",
417
+ label=pgettext_lazy("Transition button", "Cancel"),
418
+ action_label=_("Cancellation"),
419
+ description_fields=_("<p>Are you sure you want to cancel this request?</p>"),
420
+ )
421
+ },
422
+ )
423
+ def cancel(self, **kwargs):
424
+ pass
425
+
426
+ def post_cancel(self, **kwargs):
427
+ msg = gettext("{employee} has cancelled a {type} request from {start_date} to {end_date}.").format(
428
+ employee=str(self.employee),
429
+ type=str(self.type),
430
+ start_date=self.period.lower.strftime("%d.%m.%Y"),
431
+ end_date=self.period.upper.strftime("%d.%m.%Y"),
432
+ )
433
+ self.periods.all().delete()
434
+ title = gettext("Cancelled {type} Request").format(type=str(self.type))
435
+ self.notify(title, msg, to_manager=True)
436
+
437
+ objects = AbsenceRequestManager()
438
+
439
+ class Meta:
440
+ verbose_name = _("Absence Request")
441
+ verbose_name_plural = _("Absence Requests")
442
+ permissions = [("administrate_absencerequest", "Can Administrate Absence Requests")]
443
+
444
+ @property
445
+ @admin.display(description="Total hours")
446
+ def total_hours(self) -> float:
447
+ return getattr(self, "_total_hours", self.periods.aggregate(s=models.Sum("_total_hours"))["s"] or 0.0)
448
+
449
+ @property
450
+ @admin.display(description="Total Vacation hours")
451
+ def total_vacation_hours(self) -> float:
452
+ return getattr(
453
+ self,
454
+ "_total_vacation_hours",
455
+ self.total_hours if self.type.is_vacation and self.status == AbsenceRequest.Status.APPROVED else 0.0,
456
+ )
457
+
458
+ @property
459
+ @admin.display(description="Total hours (in days)")
460
+ def total_hours_in_days(self) -> float:
461
+ return getattr(self, "_total_hours_in_days", self.total_hours / self.employee.calendar.get_daily_hours())
462
+
463
+ @property
464
+ @admin.display(description="Total Vacation hours (in days)")
465
+ def total_vacation_hours_in_days(self) -> float:
466
+ return getattr(
467
+ self, "_total_vacation_hours_in_days", self.total_vacation_hours / self.employee.calendar.get_daily_hours()
468
+ )
469
+
470
+ @property
471
+ def next_extensible_period(self) -> TimestamptzRange | None:
472
+ upper_date = (self.period.upper + BDay(1)).to_pydatetime()
473
+ while self.employee.calendar.days_off.filter(date=upper_date.date(), count_as_holiday=True).exists():
474
+ upper_date += timedelta(days=1)
475
+ if not AbsenceRequestPeriods.objects.filter(employee=self.employee, date=upper_date).exists():
476
+ return TimestamptzRange(lower=self.period.lower, upper=upper_date)
477
+
478
+ def get_color(self) -> str:
479
+ return self.type.color
480
+
481
+ def get_icon(self) -> str:
482
+ return self.type.icon
483
+
484
+ def __str__(self) -> str:
485
+ return f"{str(self.employee)} [{self.period.lower:%Y-%m-%d %H:%M}-{self.period.upper:%Y-%m-%d %H:%M}] ({self.Status[self.status].label})"
486
+
487
+ def clean(self):
488
+ if not self.period or not self.period.lower or not self.period.upper:
489
+ raise ValidationError("Period needs to be set with nonnull bound")
490
+ super().clean()
491
+
492
+ def save(self, *args, **kwargs):
493
+ self.title = f"{str(self.type)}: {str(self.employee)}"
494
+ self.full_clean()
495
+ if self.id and self.periods.count() > 1:
496
+ self.all_day = True
497
+ self.is_cancelled = self.status == self.Status.CANCELLED
498
+ self.period = self.employee.calendar.normalize_period(
499
+ self.period
500
+ ) # We normalize the period to be sure it respect the default calendar working periods
501
+ super().save(*args, **kwargs)
502
+
503
+ def delete(self, **kwargs):
504
+ super().delete(no_deletion=False)
505
+
506
+ @property
507
+ def periods_timespan(self) -> TimestamptzRange:
508
+ """
509
+ Property to returns the timespan datetime range originating from the attached periods
510
+
511
+ Returns:
512
+ A datetime range. None if no period are already attached to this request
513
+ """
514
+ periods = self.periods.order_by("timespan__startswith")
515
+ if periods.exists():
516
+ return TimestamptzRange(lower=periods.first().timespan.lower, upper=periods.last().timespan.upper)
517
+
518
+ def notify(self, title: str, msg: str, to_requester: bool = True, to_manager: bool = False):
519
+ """
520
+ Get a message and a title and create the proper Notification object for the user administrating the HR module
521
+
522
+ Args:
523
+ title (str): The Notification title
524
+ msg (str): The Notification message
525
+ """
526
+ users = []
527
+ if to_requester and (requester_account := getattr(self.employee.profile, "user_account", None)):
528
+ users.append(requester_account)
529
+ if to_manager:
530
+ for manager in self.employee.get_managers():
531
+ if manager_account := getattr(manager, "user_account", None):
532
+ users.append(manager_account)
533
+ for extra_group in self.type.extra_notify_groups.all():
534
+ for extra_notify_user in extra_group.user_set.all():
535
+ users.append(extra_notify_user)
536
+ for user in users:
537
+ if user.is_active:
538
+ send_notification(
539
+ code="wbhuman_resources.absencerequest.notify",
540
+ title=title,
541
+ body=msg,
542
+ user=user,
543
+ reverse_name="wbhuman_resources:absencerequest-detail",
544
+ reverse_args=[self.id],
545
+ )
546
+
547
+ def is_deletable_for_user(self, user: "User") -> bool:
548
+ """
549
+ Check if the given user has the permission to delete a request
550
+
551
+ Args:
552
+ user: Checked user
553
+
554
+ Returns:
555
+ True if the request can be deleted by the user
556
+ """
557
+ return self.is_deletable and (
558
+ (self.status == AbsenceRequest.Status.PENDING and self.period.lower > timezone.now())
559
+ or self.status == AbsenceRequest.Status.DRAFT
560
+ or user.has_perm("wbhuman_resources.administrate_absencerequest")
561
+ )
562
+
563
+ @classmethod
564
+ def get_endpoint_basename(cls) -> str:
565
+ return "wbhuman_resources:absencerequest"
566
+
567
+ @classmethod
568
+ def get_representation_value_key(cls) -> str:
569
+ return "id"
570
+
571
+ @classmethod
572
+ def get_representation_label_key(cls) -> str:
573
+ return "{{employee}}"
574
+
575
+
576
+ class AbsenceRequestPeriodDefaultManager(models.Manager):
577
+ def get_queryset(self) -> "models.QuerySet[AbsenceRequestPeriods]":
578
+ """
579
+ Default manager
580
+ """
581
+ return super().get_queryset().annotate(_total_hours=models.F("default_period__total_hours"))
582
+
583
+
584
+ class AbsenceRequestPeriodVacationManager(models.Manager):
585
+ def get_queryset(self) -> "models.QuerySet[AbsenceRequestPeriods]":
586
+ """
587
+ Default manager
588
+ """
589
+ return (
590
+ super()
591
+ .get_queryset()
592
+ .filter(request__type__is_vacation=True, request__status=AbsenceRequest.Status.APPROVED)
593
+ .annotate(_total_hours=models.F("default_period__total_hours"))
594
+ )
595
+
596
+
597
+ class AbsenceRequestPeriods(models.Model):
598
+ request = models.ForeignKey(
599
+ "wbhuman_resources.AbsenceRequest",
600
+ related_name="periods",
601
+ on_delete=models.CASCADE,
602
+ verbose_name=_("Request"),
603
+ help_text=_("The associated request"),
604
+ )
605
+ employee = models.ForeignKey(
606
+ "wbhuman_resources.EmployeeHumanResource",
607
+ related_name="periods",
608
+ on_delete=models.CASCADE,
609
+ verbose_name=_("Employee"),
610
+ help_text=_("The Requester"),
611
+ ) # Not supposed to be set dynamically. Use as a replicate to ensure database constraint
612
+ default_period = models.ForeignKey(
613
+ "wbhuman_resources.DefaultDailyPeriod",
614
+ related_name="periods",
615
+ on_delete=models.PROTECT,
616
+ verbose_name=_("Period"),
617
+ help_text=_("The associated period"),
618
+ )
619
+ date = models.DateField()
620
+
621
+ timespan = DateTimeRangeField(
622
+ verbose_name=_("Timespan"),
623
+ ) # Expected to be read only
624
+
625
+ balance = models.ForeignKey(
626
+ "wbhuman_resources.EmployeeYearBalance",
627
+ related_name="periods",
628
+ on_delete=models.PROTECT,
629
+ blank=True,
630
+ null=True,
631
+ verbose_name=_("Balance"),
632
+ help_text=_("For which balance this absence will count towards"),
633
+ )
634
+ consecutive_hours_count = models.IntegerField(
635
+ default=0,
636
+ verbose_name=_("Consecutive Absence Hours count"),
637
+ help_text=_("The number of consecutive hours this absence request period spans"),
638
+ )
639
+
640
+ objects = AbsenceRequestPeriodDefaultManager()
641
+ vacation_objects = AbsenceRequestPeriodVacationManager()
642
+
643
+ class Meta:
644
+ verbose_name = _("Absence Request Period")
645
+ verbose_name_plural = _("Absence Request Periods")
646
+ unique_together = ("employee", "default_period", "date")
647
+ indexes = [
648
+ models.Index(fields=["employee", "default_period", "date"]),
649
+ ]
650
+ constraints = [
651
+ ExclusionConstraint(
652
+ name="exclude_overlapping_periods",
653
+ expressions=[
654
+ ("timespan", RangeOperators.OVERLAPS),
655
+ ("employee", RangeOperators.EQUAL),
656
+ ],
657
+ ),
658
+ ]
659
+
660
+ notification_types = [
661
+ create_notification_type(
662
+ code="wbhuman_resources.absencerequest.notify",
663
+ title="Absence Notification",
664
+ help_text="Sends a notification when you can approve an absence request",
665
+ )
666
+ ]
667
+
668
+ def __str__(self) -> str:
669
+ return f"{self.request.employee.computed_str}: {self.timespan.lower:%Y-%m-%d %H:%M:%S}-{self.timespan.upper:%Y-%m-%d %H:%M:%S} ({self.total_hours})"
670
+
671
+ @cached_property
672
+ def total_hours(self) -> float:
673
+ """
674
+ A property holding the number of hours this period has. Get it from the attached default period
675
+ """
676
+ return getattr(self, "_total_hours", self.default_period.total_hours)
677
+
678
+ @property
679
+ def previous_period(self):
680
+ """
681
+ Return the previous approved period if it exists from this period's employee
682
+ """
683
+ return (
684
+ AbsenceRequestPeriods.objects.exclude(
685
+ request__status=AbsenceRequest.Status.CANCELLED,
686
+ )
687
+ .filter(
688
+ employee=self.employee,
689
+ timespan__startswith__lt=self.timespan.lower,
690
+ )
691
+ .order_by("-timespan__startswith")
692
+ .first()
693
+ )
694
+
695
+ @classmethod
696
+ def get_periods_as_df(cls, start: date, end: date, **extra_filter_kwargs) -> pd.DataFrame:
697
+ """
698
+ Utility function that reshape the periods for a given calendar and a list of employees among a certain date range
699
+
700
+ Args:
701
+ start: Start filter
702
+ end: End filter
703
+ extra_filter_kwargs: keyword argument that can be passed down to filter out employees
704
+
705
+ Returns:
706
+ A dataframe whose columns are [employee, period, date, type, status]
707
+ """
708
+ periods = cls.objects.filter(
709
+ models.Q(date__gte=start),
710
+ models.Q(date__lte=end),
711
+ models.Q(_total_hours__gt=0)
712
+ & models.Q(
713
+ request__status__in=[
714
+ AbsenceRequest.Status.APPROVED.name,
715
+ AbsenceRequest.Status.PENDING.name,
716
+ AbsenceRequest.Status.DRAFT.name,
717
+ ]
718
+ ),
719
+ ).filter(**extra_filter_kwargs)
720
+ df_periods = pd.DataFrame(
721
+ periods.values(
722
+ "employee",
723
+ "request__type__title",
724
+ "request__status",
725
+ "default_period",
726
+ "date",
727
+ ),
728
+ columns=["employee", "request__type__title", "request__status", "default_period", "date"],
729
+ ).rename(
730
+ columns={
731
+ "employee": "employee",
732
+ "request__type__title": "type",
733
+ "request__status": "status",
734
+ "default_period": "period",
735
+ "date": "date",
736
+ }
737
+ )
738
+ return df_periods
739
+
740
+ def assign_balance(self, check_date_availability: bool = True):
741
+ """
742
+ Utility function that returns what balance this request will be counted for
743
+
744
+ Args:
745
+ check_date_availability: If False, we bypass wether this request can be used toward the previous year balance from the prereference and only check if the previous year balance can accomotate the period's total hours
746
+ """
747
+
748
+ if self.request.type.is_vacation:
749
+ previous_balances_with_positive_allowance = self.employee.balances.filter(
750
+ _total_vacation_hourly_balance__gte=self.total_hours, year__lt=self.date.year
751
+ )
752
+ year = (
753
+ previous_balances_with_positive_allowance.earliest("year").year
754
+ if previous_balances_with_positive_allowance.exists()
755
+ else self.date.year
756
+ )
757
+
758
+ # we loop to try to find, in order, the first balance to accomodate our request since the request's year to the max year
759
+ while year <= self.date.year + 1 and self.balance is None:
760
+ if possible_balance := self.employee.balances.filter(year=year).first():
761
+ if (
762
+ possible_balance.balance > 0
763
+ and possible_balance.total_vacation_hourly_balance >= self.total_hours
764
+ and get_previous_year_balance_expiration_date(possible_balance.year) > self.date
765
+ ):
766
+ self.balance = possible_balance
767
+ self.save()
768
+ year += 1
769
+ if self.balance is None:
770
+ # we make sure that the max year is always the last seeded balance year + 1
771
+ seeded_balances = self.employee.balances.filter(_given_balance__gt=0)
772
+ if seeded_balances.exists():
773
+ self.balance = self.employee.get_or_create_balance(seeded_balances.latest("year").year + 1)[0]
774
+
775
+ def get_consecutive_hours_count(self) -> float:
776
+ """
777
+ This subroutines aims to find the latest valid request days and get its consecutive days balance in order to
778
+ compute this request day consecutive days balance.
779
+ """
780
+ consecutive_hours = self.total_hours
781
+ if (
782
+ (previous_period := self.previous_period)
783
+ and previous_period.request.type.is_timeoff
784
+ and not list(
785
+ self.employee.extract_workable_periods(previous_period.timespan.upper, self.timespan.lower, count=1)
786
+ )
787
+ ):
788
+ consecutive_hours += previous_period.consecutive_hours_count
789
+ return consecutive_hours
790
+
791
+ def save(self, *args, **kwargs):
792
+ self.timespan = TimestamptzRange(
793
+ lower=self.default_period.get_lower_datetime(self.date),
794
+ upper=self.default_period.get_upper_datetime(self.date),
795
+ )
796
+ self.employee = self.request.employee
797
+ self.consecutive_hours_count = self.get_consecutive_hours_count()
798
+ super().save(*args, **kwargs)
799
+
800
+ @classmethod
801
+ def get_representation_value_key(cls) -> str:
802
+ return "id"
803
+
804
+ @classmethod
805
+ def get_representation_label_key(cls) -> str:
806
+ return "{{request}}{{timespan}}{{_total_hours}}"
807
+
808
+
809
+ @receiver(post_save, sender=AbsenceRequest)
810
+ def post_save_absence_request(sender, instance, created, **kwargs):
811
+ """
812
+ Post save signal
813
+ * Auto approve request if there are not absence request.
814
+ * Compute and Create one AbsenceRequestPeriods if the request holds within the same year or two if the request spans multiple years.
815
+ AbsenceRequestPeriods stores the total number of working days for a certain period of time.
816
+ """
817
+
818
+ if instance.type.auto_approve and instance.status == AbsenceRequest.Status.DRAFT:
819
+ instance.submit()
820
+ AbsenceRequest.objects.filter(id=instance.id).update(status=AbsenceRequest.Status.APPROVED)
821
+
822
+ if employee := instance.employee:
823
+ instance.entities.set([employee.profile])
824
+
825
+ # We make sure that the periods are deleted on cancellation
826
+ if instance.status == AbsenceRequest.Status.CANCELLED:
827
+ instance.periods.all().delete()
828
+
829
+ # Create for every workable period a Absence request period object for this request
830
+ existing_periods = instance.periods
831
+ for period_date, default_period in instance.employee.extract_workable_periods(
832
+ instance.period.lower, instance.period.upper
833
+ ):
834
+ period, created = AbsenceRequestPeriods.objects.get_or_create(
835
+ default_period=default_period, employee=instance.employee, date=period_date, defaults={"request": instance}
836
+ )
837
+ existing_periods = existing_periods.exclude(id=period.id)
838
+ existing_periods.all().delete()
839
+ update_kwargs = {"all_day": (instance.periods.count() > 1)}
840
+ if (new_period := instance.periods_timespan) and new_period != instance.period:
841
+ update_kwargs["period"] = new_period
842
+ AbsenceRequest.objects.filter(id=instance.id).update(**update_kwargs)
843
+
844
+ if instance.status == AbsenceRequest.Status.APPROVED and instance.type.is_vacation:
845
+ for period in instance.periods.filter(balance__isnull=True).order_by("timespan__startswith"):
846
+ period.assign_balance()
847
+
848
+
849
+ # TODO find an optimal way to recompute consecutive hours count on posterieurs periods save
850
+ # @shared_task
851
+ # def save_future_employee_periods_as_task(request_id: int, from_datetime: datetime):
852
+ # request = AbsenceRequest.objects.get(id=request_id)
853
+ # if (new_period := request.timespan) and new_period != request.period:
854
+ # AbsenceRequest.objects.filter(id=request.id).update(period=new_period)
855
+ # for period in AbsenceRequestPeriods.objects.filter(
856
+ # employee=request.employee, timespan__startswith__gt=from_datetime
857
+ # ).order_by("timespan__startswith"):
858
+ # # If the consecutive hours is equals to a normal working day, we assume this is the beginning of a new continious serie of periods
859
+ # if period.consecutive_hours_count == period.default_period.calendar.get_daily_hours():
860
+ # break
861
+ # period.save()
862
+
863
+
864
+ # @receiver(post_delete, sender=AbsenceRequestPeriods)
865
+ # @receiver(post_save, sender=AbsenceRequestPeriods)
866
+ # def receiver_absence_request_periods(sender, instance, **kwargs):
867
+ # if kwargs.get("created", False) or not hasattr(kwargs, "created"):
868
+ # save_future_employee_periods_as_task.delay(instance.request.id, instance.timespan.upper)
869
+
870
+
871
+ @receiver(post_save, sender=DayOff)
872
+ def post_save_dayoff(sender, instance, created, **kwargs):
873
+ """
874
+ Post save signal, Ensure that when a day off is created, all absence request periods are recomputed
875
+ """
876
+ if created:
877
+ for period in AbsenceRequestPeriods.objects.filter(date=instance.date):
878
+ period.request.save()
879
+
880
+
881
+ @receiver(post_save, sender=AbsenceRequestType)
882
+ def post_save_absence_request_type(sender, instance: AbsenceRequestType, created: bool, raw: bool, **kwargs):
883
+ """
884
+ Post save signal for absence request type
885
+ """
886
+ # Ensure that when a absence request where country validation is necessary, all countries are added to the allowed list by default
887
+ if created and instance.is_country_necessary:
888
+ instance.crossborder_countries.set(Geography.countries.all())
889
+
890
+ if not raw:
891
+ # We need to trigger all requests' save methods to update their color and icon
892
+ for request in instance.request.all():
893
+ request.save()
894
+
895
+
896
+ @receiver(post_save, sender=Geography)
897
+ def post_country_creation(sender, instance, created, **kwargs):
898
+ """
899
+ Post save signal, Ensure that when a country is created, it is added by default to all absence request where country validation is necessary
900
+ """
901
+ if created and instance.level == 1:
902
+ for absence_request_type in AbsenceRequestType.objects.filter(is_country_necessary=True):
903
+ absence_request_type.crossborder_countries.add(instance)