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.
- wbhuman_resources/__init__.py +1 -0
- wbhuman_resources/admin/__init__.py +5 -0
- wbhuman_resources/admin/absence.py +113 -0
- wbhuman_resources/admin/calendars.py +37 -0
- wbhuman_resources/admin/employee.py +109 -0
- wbhuman_resources/admin/kpi.py +21 -0
- wbhuman_resources/admin/review.py +157 -0
- wbhuman_resources/apps.py +23 -0
- wbhuman_resources/dynamic_preferences_registry.py +119 -0
- wbhuman_resources/factories/__init__.py +38 -0
- wbhuman_resources/factories/absence.py +109 -0
- wbhuman_resources/factories/calendars.py +60 -0
- wbhuman_resources/factories/employee.py +80 -0
- wbhuman_resources/factories/kpi.py +155 -0
- wbhuman_resources/filters/__init__.py +20 -0
- wbhuman_resources/filters/absence.py +109 -0
- wbhuman_resources/filters/absence_graphs.py +85 -0
- wbhuman_resources/filters/calendars.py +28 -0
- wbhuman_resources/filters/employee.py +81 -0
- wbhuman_resources/filters/kpi.py +35 -0
- wbhuman_resources/filters/review.py +134 -0
- wbhuman_resources/filters/signals.py +27 -0
- wbhuman_resources/locale/de/LC_MESSAGES/django.mo +0 -0
- wbhuman_resources/locale/de/LC_MESSAGES/django.po +2207 -0
- wbhuman_resources/locale/de/LC_MESSAGES/django.po.translated +2456 -0
- wbhuman_resources/locale/en/LC_MESSAGES/django.mo +0 -0
- wbhuman_resources/locale/en/LC_MESSAGES/django.po +2091 -0
- wbhuman_resources/locale/fr/LC_MESSAGES/django.mo +0 -0
- wbhuman_resources/locale/fr/LC_MESSAGES/django.po +2093 -0
- wbhuman_resources/management/__init__.py +23 -0
- wbhuman_resources/migrations/0001_initial_squashed_squashed_0015_alter_absencerequest_calendaritem_ptr_and_more.py +949 -0
- wbhuman_resources/migrations/0016_alter_employeehumanresource_options.py +20 -0
- wbhuman_resources/migrations/0017_absencerequest_crossborder_country_and_more.py +55 -0
- wbhuman_resources/migrations/0018_remove_position_group_position_groups.py +32 -0
- wbhuman_resources/migrations/0019_alter_absencerequest_options_alter_kpi_options_and_more.py +44 -0
- wbhuman_resources/migrations/0020_alter_employeeyearbalance_year_alter_review_year.py +27 -0
- wbhuman_resources/migrations/0021_alter_position_color.py +18 -0
- wbhuman_resources/migrations/0022_remove_review_editable_mode.py +64 -0
- wbhuman_resources/migrations/__init__.py +0 -0
- wbhuman_resources/models/__init__.py +23 -0
- wbhuman_resources/models/absence.py +903 -0
- wbhuman_resources/models/calendars.py +370 -0
- wbhuman_resources/models/employee.py +1241 -0
- wbhuman_resources/models/kpi.py +199 -0
- wbhuman_resources/models/preferences.py +40 -0
- wbhuman_resources/models/review.py +982 -0
- wbhuman_resources/permissions/__init__.py +0 -0
- wbhuman_resources/permissions/backend.py +26 -0
- wbhuman_resources/serializers/__init__.py +49 -0
- wbhuman_resources/serializers/absence.py +308 -0
- wbhuman_resources/serializers/calendars.py +73 -0
- wbhuman_resources/serializers/employee.py +267 -0
- wbhuman_resources/serializers/kpi.py +80 -0
- wbhuman_resources/serializers/review.py +415 -0
- wbhuman_resources/signals.py +4 -0
- wbhuman_resources/tasks.py +195 -0
- wbhuman_resources/templates/review/review_report.html +322 -0
- wbhuman_resources/tests/__init__.py +1 -0
- wbhuman_resources/tests/conftest.py +96 -0
- wbhuman_resources/tests/models/__init__.py +0 -0
- wbhuman_resources/tests/models/test_absences.py +478 -0
- wbhuman_resources/tests/models/test_calendars.py +209 -0
- wbhuman_resources/tests/models/test_employees.py +502 -0
- wbhuman_resources/tests/models/test_review.py +103 -0
- wbhuman_resources/tests/models/test_utils.py +110 -0
- wbhuman_resources/tests/signals.py +108 -0
- wbhuman_resources/tests/test_permission.py +64 -0
- wbhuman_resources/tests/test_tasks.py +74 -0
- wbhuman_resources/urls.py +221 -0
- wbhuman_resources/utils.py +43 -0
- wbhuman_resources/viewsets/__init__.py +61 -0
- wbhuman_resources/viewsets/absence.py +312 -0
- wbhuman_resources/viewsets/absence_charts.py +328 -0
- wbhuman_resources/viewsets/buttons/__init__.py +7 -0
- wbhuman_resources/viewsets/buttons/absence.py +32 -0
- wbhuman_resources/viewsets/buttons/employee.py +44 -0
- wbhuman_resources/viewsets/buttons/kpis.py +16 -0
- wbhuman_resources/viewsets/buttons/review.py +195 -0
- wbhuman_resources/viewsets/calendars.py +103 -0
- wbhuman_resources/viewsets/display/__init__.py +39 -0
- wbhuman_resources/viewsets/display/absence.py +334 -0
- wbhuman_resources/viewsets/display/calendars.py +83 -0
- wbhuman_resources/viewsets/display/employee.py +254 -0
- wbhuman_resources/viewsets/display/kpis.py +92 -0
- wbhuman_resources/viewsets/display/review.py +429 -0
- wbhuman_resources/viewsets/employee.py +210 -0
- wbhuman_resources/viewsets/endpoints/__init__.py +42 -0
- wbhuman_resources/viewsets/endpoints/absence.py +57 -0
- wbhuman_resources/viewsets/endpoints/calendars.py +18 -0
- wbhuman_resources/viewsets/endpoints/employee.py +51 -0
- wbhuman_resources/viewsets/endpoints/kpis.py +53 -0
- wbhuman_resources/viewsets/endpoints/review.py +191 -0
- wbhuman_resources/viewsets/kpi.py +280 -0
- wbhuman_resources/viewsets/menu/__init__.py +22 -0
- wbhuman_resources/viewsets/menu/absence.py +50 -0
- wbhuman_resources/viewsets/menu/administration.py +15 -0
- wbhuman_resources/viewsets/menu/calendars.py +33 -0
- wbhuman_resources/viewsets/menu/employee.py +44 -0
- wbhuman_resources/viewsets/menu/kpis.py +18 -0
- wbhuman_resources/viewsets/menu/review.py +97 -0
- wbhuman_resources/viewsets/mixins.py +14 -0
- wbhuman_resources/viewsets/review.py +837 -0
- wbhuman_resources/viewsets/titles/__init__.py +18 -0
- wbhuman_resources/viewsets/titles/absence.py +30 -0
- wbhuman_resources/viewsets/titles/employee.py +18 -0
- wbhuman_resources/viewsets/titles/kpis.py +15 -0
- wbhuman_resources/viewsets/titles/review.py +62 -0
- wbhuman_resources/viewsets/utils.py +28 -0
- wbhuman_resources-1.58.4.dist-info/METADATA +8 -0
- wbhuman_resources-1.58.4.dist-info/RECORD +111 -0
- 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)
|