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,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
|
+
)
|