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,312 @@
|
|
|
1
|
+
from django.contrib.messages import info, warning
|
|
2
|
+
from django.db.models import Case, CharField, F, Q, Sum, Value, When
|
|
3
|
+
from django.db.models.functions import Concat, Extract
|
|
4
|
+
from django.shortcuts import get_object_or_404
|
|
5
|
+
from django.utils import timezone
|
|
6
|
+
from django.utils.functional import cached_property
|
|
7
|
+
from django.utils.translation import gettext
|
|
8
|
+
from rest_framework import filters
|
|
9
|
+
from rest_framework.decorators import action
|
|
10
|
+
from rest_framework.response import Response
|
|
11
|
+
from wbcore import viewsets
|
|
12
|
+
from wbcore.contrib.agenda.models import CalendarItem
|
|
13
|
+
from wbcore.contrib.icons import WBIcon
|
|
14
|
+
from wbcore.filters import DjangoFilterBackend
|
|
15
|
+
from wbcore.utils.strings import format_number
|
|
16
|
+
|
|
17
|
+
from wbhuman_resources.filters import (
|
|
18
|
+
AbsenceRequestEmployeeHumanResourceFilterSet,
|
|
19
|
+
AbsenceRequestFilter,
|
|
20
|
+
AbsenceTypeCountEmployeeModelFilterSet,
|
|
21
|
+
)
|
|
22
|
+
from wbhuman_resources.models import (
|
|
23
|
+
AbsenceRequest,
|
|
24
|
+
AbsenceRequestPeriods,
|
|
25
|
+
AbsenceRequestType,
|
|
26
|
+
EmployeeHumanResource,
|
|
27
|
+
)
|
|
28
|
+
from wbhuman_resources.serializers import (
|
|
29
|
+
AbsenceRequestCrossBorderCountryModelSerializer,
|
|
30
|
+
AbsenceRequestModelSerializer,
|
|
31
|
+
AbsenceRequestPeriodsModelSerializer,
|
|
32
|
+
AbsenceRequestTypeModelSerializer,
|
|
33
|
+
AbsenceRequestTypeRepresentationSerializer,
|
|
34
|
+
EmployeeAbsenceDaysModelSerializer,
|
|
35
|
+
)
|
|
36
|
+
from wbhuman_resources.viewsets.buttons import AbsenceRequestButtonConfig
|
|
37
|
+
from wbhuman_resources.viewsets.display import (
|
|
38
|
+
AbsenceRequestCrossBorderCountryDisplayConfig,
|
|
39
|
+
AbsenceRequestDisplayConfig,
|
|
40
|
+
AbsenceRequestEmployeeHumanResourceDisplayConfig,
|
|
41
|
+
AbsenceRequestPeriodsAbsenceRequestDisplayConfig,
|
|
42
|
+
AbsenceRequestTypeDisplayConfig,
|
|
43
|
+
AbsenceTypeCountEmployeeDisplayConfig,
|
|
44
|
+
)
|
|
45
|
+
from wbhuman_resources.viewsets.endpoints import (
|
|
46
|
+
AbsenceRequestCrossBorderCountryEndpointConfig,
|
|
47
|
+
AbsenceRequestEmployeeHumanResourceEndpointConfig,
|
|
48
|
+
AbsenceRequestEndpointConfig,
|
|
49
|
+
AbsenceRequestPeriodsAbsenceRequestEndpointConfig,
|
|
50
|
+
AbsenceTypeCountEmployeeEndpointConfig,
|
|
51
|
+
)
|
|
52
|
+
from wbhuman_resources.viewsets.titles import (
|
|
53
|
+
AbsenceRequestEmployeeBalanceTitleConfig,
|
|
54
|
+
AbsenceTypeCountEmployeeTitleConfig,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
from ..models.absence import can_validate_or_deny_request
|
|
58
|
+
from .mixins import EmployeeViewMixin
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AbsenceRequestTypeRepresentationViewSet(viewsets.RepresentationViewSet):
|
|
62
|
+
IDENTIFIER = "wbhuman_resources:absencerequesttyperepresentation"
|
|
63
|
+
queryset = AbsenceRequestType.objects.all()
|
|
64
|
+
serializer_class = AbsenceRequestTypeRepresentationSerializer
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AbsenceRequestCrossBorderCountryModelViewSet(viewsets.ModelViewSet):
|
|
68
|
+
queryset = AbsenceRequestType.crossborder_countries.through.objects.all()
|
|
69
|
+
serializer_class = AbsenceRequestCrossBorderCountryModelSerializer
|
|
70
|
+
ordering = ordering_fields = search_fields = ["geography_repr"]
|
|
71
|
+
|
|
72
|
+
endpoint_config_class = AbsenceRequestCrossBorderCountryEndpointConfig
|
|
73
|
+
display_config_class = AbsenceRequestCrossBorderCountryDisplayConfig
|
|
74
|
+
|
|
75
|
+
def get_queryset(self):
|
|
76
|
+
return (
|
|
77
|
+
super()
|
|
78
|
+
.get_queryset()
|
|
79
|
+
.filter(absencerequesttype=self.kwargs["absencerequesttype_id"])
|
|
80
|
+
.annotate(geography_repr=F("geography__name"))
|
|
81
|
+
.select_related("geography")
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AbsenceRequestModelViewSet(EmployeeViewMixin, viewsets.ModelViewSet):
|
|
86
|
+
queryset = AbsenceRequest.objects.all()
|
|
87
|
+
serializer_class = AbsenceRequestModelSerializer
|
|
88
|
+
|
|
89
|
+
ordering_fields = [
|
|
90
|
+
"employee__profile__computed_str",
|
|
91
|
+
"period__startswith",
|
|
92
|
+
"created",
|
|
93
|
+
"_total_hours_in_days",
|
|
94
|
+
"_total_vacation_hours_in_days",
|
|
95
|
+
]
|
|
96
|
+
ordering = ["-period__startswith"]
|
|
97
|
+
search_fields = ["employee__profile__computed_str", "notes", "reason"]
|
|
98
|
+
|
|
99
|
+
filterset_class = AbsenceRequestFilter
|
|
100
|
+
|
|
101
|
+
display_config_class = AbsenceRequestDisplayConfig
|
|
102
|
+
endpoint_config_class = AbsenceRequestEndpointConfig
|
|
103
|
+
button_config_class = AbsenceRequestButtonConfig
|
|
104
|
+
|
|
105
|
+
@cached_property
|
|
106
|
+
def can_administrate(self) -> bool:
|
|
107
|
+
if "pk" in self.kwargs and (obj := self.get_object()):
|
|
108
|
+
return can_validate_or_deny_request(obj, self.request.user)
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
def add_messages(
|
|
112
|
+
self,
|
|
113
|
+
request,
|
|
114
|
+
queryset=None,
|
|
115
|
+
paginated_queryset=None,
|
|
116
|
+
instance=None,
|
|
117
|
+
initial=False,
|
|
118
|
+
):
|
|
119
|
+
if instance:
|
|
120
|
+
if instance.status != AbsenceRequest.Status.CANCELLED:
|
|
121
|
+
qs = CalendarItem.objects.filter(
|
|
122
|
+
is_cancelled=False, period__overlap=instance.period, entities=instance.employee.profile
|
|
123
|
+
).exclude(id=instance.id)
|
|
124
|
+
activities_title = qs.values_list("title", flat=True)
|
|
125
|
+
if len(activities_title) > 0:
|
|
126
|
+
message = gettext("<p>During this absence, you already have these events:</p><ul>")
|
|
127
|
+
for activity_title in activities_title:
|
|
128
|
+
message += f"<li>{activity_title}</li>"
|
|
129
|
+
message += "</ul>"
|
|
130
|
+
warning(request, message)
|
|
131
|
+
if instance.type.is_vacation:
|
|
132
|
+
if instance.status in [
|
|
133
|
+
AbsenceRequest.Status.DRAFT,
|
|
134
|
+
AbsenceRequest.Status.PENDING,
|
|
135
|
+
]:
|
|
136
|
+
other_pending_hours = (
|
|
137
|
+
AbsenceRequestPeriods.objects.exclude(request__id=instance.id)
|
|
138
|
+
.filter(
|
|
139
|
+
request__status__in=[AbsenceRequest.Status.PENDING, AbsenceRequest.Status.DRAFT],
|
|
140
|
+
request__type__is_vacation=True,
|
|
141
|
+
employee=instance.employee,
|
|
142
|
+
)
|
|
143
|
+
.aggregate(s=Sum("_total_hours"))["s"]
|
|
144
|
+
or 0.0
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
current_balance = instance.employee.get_or_create_balance(instance.period.lower.year)[0]
|
|
148
|
+
available_hourly_balance = current_balance.total_vacation_hourly_balance - instance.total_hours
|
|
149
|
+
available_hourly_balance_in_days = (
|
|
150
|
+
available_hourly_balance / instance.employee.calendar.get_daily_hours()
|
|
151
|
+
)
|
|
152
|
+
message = gettext(
|
|
153
|
+
"After this request, you will have {} days ({} hours) left for the balance {}</b>"
|
|
154
|
+
).format(available_hourly_balance_in_days, available_hourly_balance, current_balance.year)
|
|
155
|
+
if other_pending_hours > 0:
|
|
156
|
+
message += gettext(
|
|
157
|
+
" (not including <b>{pending_hours}</b> hours from other pending/draft absence requests)"
|
|
158
|
+
).format(pending_hours=other_pending_hours)
|
|
159
|
+
if available_hourly_balance < 0:
|
|
160
|
+
warning(request, message, extra_tags="auto_close=0")
|
|
161
|
+
else:
|
|
162
|
+
info(request, message)
|
|
163
|
+
day_offs = instance.employee.calendar.days_off.filter(
|
|
164
|
+
date__gte=instance.period.lower.date(), date__lte=instance.period.upper.date()
|
|
165
|
+
)
|
|
166
|
+
if day_offs:
|
|
167
|
+
day_offs_messages = [
|
|
168
|
+
gettext("{holiday} not counted ({title})").format(
|
|
169
|
+
holiday=holiday.date.strftime("%d.%m.%Y"), title=holiday.title
|
|
170
|
+
)
|
|
171
|
+
for holiday in day_offs
|
|
172
|
+
]
|
|
173
|
+
info(request, ", ".join(day_offs_messages))
|
|
174
|
+
|
|
175
|
+
def get_queryset(self):
|
|
176
|
+
qs = AbsenceRequest.objects.none()
|
|
177
|
+
if self.is_administrator:
|
|
178
|
+
qs = super().get_queryset()
|
|
179
|
+
elif employee := getattr(self.request.user.profile, "human_resources", None):
|
|
180
|
+
qs = super().get_queryset().filter(employee__in=employee.get_managed_employees())
|
|
181
|
+
when_statements = []
|
|
182
|
+
for type in AbsenceRequestType.objects.all():
|
|
183
|
+
try:
|
|
184
|
+
when_statements.append(When(type=type, then=Value(WBIcon[type.icon].icon)))
|
|
185
|
+
except KeyError:
|
|
186
|
+
when_statements.append(When(type=type, then=Value(type.icon)))
|
|
187
|
+
|
|
188
|
+
qs = qs.annotate(
|
|
189
|
+
department=F("employee__position__id"), type_icon=Case(*when_statements, default=Value(None))
|
|
190
|
+
).select_related(
|
|
191
|
+
"type",
|
|
192
|
+
"employee",
|
|
193
|
+
)
|
|
194
|
+
return qs
|
|
195
|
+
|
|
196
|
+
def get_aggregates(self, queryset, paginated_queryset):
|
|
197
|
+
current_year = timezone.now().year
|
|
198
|
+
|
|
199
|
+
qs = AbsenceRequestPeriods.objects.filter(request__in=queryset, date__year=current_year)
|
|
200
|
+
|
|
201
|
+
qs_vacation = qs.filter(
|
|
202
|
+
Q(request__type__is_vacation=True) & Q(request__status=AbsenceRequest.Status.APPROVED.name)
|
|
203
|
+
)
|
|
204
|
+
return {
|
|
205
|
+
"_total_hours_in_days": {
|
|
206
|
+
"Σ": format_number(queryset.aggregate(s=Sum(F("_total_hours_in_days")))["s"]),
|
|
207
|
+
f"Σ {current_year}": format_number(qs.aggregate(s=Sum(F("_total_hours")))["s"]),
|
|
208
|
+
},
|
|
209
|
+
"_total_vacation_hours_in_days": {
|
|
210
|
+
"Σ": format_number(queryset.aggregate(s=Sum(F("_total_vacation_hours_in_days")))["s"]),
|
|
211
|
+
f"Σ {current_year}": format_number(qs_vacation.aggregate(s=Sum(F("_total_hours")))["s"]),
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@action(detail=True, methods=["PATCH"])
|
|
216
|
+
def increaseday(self, request, pk=None):
|
|
217
|
+
absence_request = get_object_or_404(AbsenceRequest, id=pk)
|
|
218
|
+
if absence_request.type.is_extensible and (number_days := int(request.POST.get("number_days", 1))):
|
|
219
|
+
for _ in range(number_days):
|
|
220
|
+
if next_extensible_period := absence_request.next_extensible_period:
|
|
221
|
+
absence_request.period = next_extensible_period
|
|
222
|
+
absence_request.save()
|
|
223
|
+
return Response({"send": True})
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class AbsenceRequestTypeModelViewSet(viewsets.ModelViewSet):
|
|
227
|
+
IDENTIFIER = "wbhuman_resources:absencerequesttype"
|
|
228
|
+
queryset = AbsenceRequestType.objects.all()
|
|
229
|
+
serializer_class = AbsenceRequestTypeModelSerializer
|
|
230
|
+
display_config_class = AbsenceRequestTypeDisplayConfig
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# Employee Subs Viewsets ####
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class AbsenceTypeCountEmployeeModelViewSet(viewsets.ModelViewSet):
|
|
237
|
+
READ_ONLY = True
|
|
238
|
+
queryset = AbsenceRequestPeriods.objects.all()
|
|
239
|
+
serializer_class = EmployeeAbsenceDaysModelSerializer
|
|
240
|
+
|
|
241
|
+
filter_backends = (filters.OrderingFilter, DjangoFilterBackend)
|
|
242
|
+
ordering_fields = ["year", "hours_count", "day_count"]
|
|
243
|
+
ordering = ["-year"]
|
|
244
|
+
|
|
245
|
+
filterset_class = AbsenceTypeCountEmployeeModelFilterSet
|
|
246
|
+
title_config_class = AbsenceTypeCountEmployeeTitleConfig
|
|
247
|
+
display_config_class = AbsenceTypeCountEmployeeDisplayConfig
|
|
248
|
+
endpoint_config_class = AbsenceTypeCountEmployeeEndpointConfig
|
|
249
|
+
|
|
250
|
+
def get_queryset(self):
|
|
251
|
+
employee = get_object_or_404(EmployeeHumanResource, pk=self.kwargs["employee_id"])
|
|
252
|
+
employee_daily_hours = employee.calendar.get_daily_hours()
|
|
253
|
+
qs = (
|
|
254
|
+
AbsenceRequestPeriods.objects.filter(
|
|
255
|
+
request__employee=employee,
|
|
256
|
+
request__status=AbsenceRequest.Status.APPROVED.name,
|
|
257
|
+
)
|
|
258
|
+
.annotate(
|
|
259
|
+
year=Extract("date", "year"),
|
|
260
|
+
absence_type=F("request__type__id"),
|
|
261
|
+
)
|
|
262
|
+
.values("year", "absence_type")
|
|
263
|
+
.annotate(
|
|
264
|
+
hours_count=Sum("_total_hours"),
|
|
265
|
+
days_count=F("hours_count") / Value(employee_daily_hours),
|
|
266
|
+
id=Concat(
|
|
267
|
+
Value(self.kwargs["employee_id"]),
|
|
268
|
+
F("year"),
|
|
269
|
+
Value("."),
|
|
270
|
+
F("absence_type"),
|
|
271
|
+
output_field=CharField(),
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
return qs
|
|
276
|
+
|
|
277
|
+
def get_aggregates(self, queryset, paginated_queryset):
|
|
278
|
+
return {
|
|
279
|
+
"days_count": {
|
|
280
|
+
"Σ": format_number(queryset.aggregate(s=Sum(F("days_count")))["s"], decimal=1),
|
|
281
|
+
},
|
|
282
|
+
"hours_count": {
|
|
283
|
+
"Σ": format_number(queryset.aggregate(s=Sum(F("hours_count")))["s"], decimal=1),
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class AbsenceRequestEmployeeHumanResourceModelViewset(AbsenceRequestModelViewSet):
|
|
289
|
+
title_config_class = AbsenceRequestEmployeeBalanceTitleConfig
|
|
290
|
+
display_config_class = AbsenceRequestEmployeeHumanResourceDisplayConfig
|
|
291
|
+
endpoint_config_class = AbsenceRequestEmployeeHumanResourceEndpointConfig
|
|
292
|
+
filterset_class = AbsenceRequestEmployeeHumanResourceFilterSet
|
|
293
|
+
|
|
294
|
+
ordering = ["-period__startswith"]
|
|
295
|
+
|
|
296
|
+
def get_queryset(self):
|
|
297
|
+
employee = EmployeeHumanResource.objects.get(id=self.kwargs["employee_id"])
|
|
298
|
+
return super().get_queryset().filter(employee=employee)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class AbsenceRequestPeriodsAbsenceRequestModelViewSet(viewsets.ModelViewSet):
|
|
302
|
+
display_config_class = AbsenceRequestPeriodsAbsenceRequestDisplayConfig
|
|
303
|
+
endpoint_config_class = AbsenceRequestPeriodsAbsenceRequestEndpointConfig
|
|
304
|
+
queryset = AbsenceRequestPeriods.objects.all()
|
|
305
|
+
serializer_class = AbsenceRequestPeriodsModelSerializer
|
|
306
|
+
|
|
307
|
+
filter_backends = (filters.OrderingFilter,)
|
|
308
|
+
ordering_fields = ["date"]
|
|
309
|
+
ordering = ["date"]
|
|
310
|
+
|
|
311
|
+
def get_queryset(self):
|
|
312
|
+
return super().get_queryset().filter(request__id=self.kwargs["request_id"])
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
from calendar import day_name
|
|
2
|
+
from datetime import date, time
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import plotly.express as px
|
|
6
|
+
import plotly.graph_objects as go
|
|
7
|
+
from django.db.models import Q
|
|
8
|
+
from django.shortcuts import get_object_or_404
|
|
9
|
+
from django.utils import timezone
|
|
10
|
+
from django.utils.dateparse import parse_date
|
|
11
|
+
from django.utils.translation import gettext as _
|
|
12
|
+
from wbcore import viewsets
|
|
13
|
+
from wbcore.pandas import fields as pf
|
|
14
|
+
from wbcore.pandas.views import PandasAPIViewSet
|
|
15
|
+
from wbcore.utils.date import get_date_interval_from_request
|
|
16
|
+
|
|
17
|
+
from wbhuman_resources.filters import AbsenceRequestPlannerFilter, AbsenceTableFilter
|
|
18
|
+
from wbhuman_resources.models import (
|
|
19
|
+
AbsenceRequest,
|
|
20
|
+
AbsenceRequestPeriods,
|
|
21
|
+
AbsenceRequestType,
|
|
22
|
+
DayOffCalendar,
|
|
23
|
+
EmployeeHumanResource,
|
|
24
|
+
Position,
|
|
25
|
+
)
|
|
26
|
+
from wbhuman_resources.viewsets.display import AbsenceTablePandasDisplayConfig
|
|
27
|
+
from wbhuman_resources.viewsets.endpoints import (
|
|
28
|
+
AbsenceRequestPlannerEndpointConfig,
|
|
29
|
+
AbsenceTablePandasEndpointConfig,
|
|
30
|
+
)
|
|
31
|
+
from wbhuman_resources.viewsets.titles import (
|
|
32
|
+
AbsenceRequestPlannerTitleConfig,
|
|
33
|
+
AbsenceTablePandasTitleConfig,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from .utils import (
|
|
37
|
+
current_day_range,
|
|
38
|
+
current_month_range,
|
|
39
|
+
current_week_range,
|
|
40
|
+
current_year_range,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def update_layoute(fig, start, end):
|
|
45
|
+
fig.layout.xaxis.rangeselector = None
|
|
46
|
+
fig.update_layout(
|
|
47
|
+
updatemenus=[
|
|
48
|
+
dict(
|
|
49
|
+
type="buttons",
|
|
50
|
+
direction="left",
|
|
51
|
+
active=1,
|
|
52
|
+
x=0.57,
|
|
53
|
+
y=1.2,
|
|
54
|
+
buttons=[
|
|
55
|
+
dict(
|
|
56
|
+
label=_("Current Day"),
|
|
57
|
+
method="relayout",
|
|
58
|
+
args=[
|
|
59
|
+
{
|
|
60
|
+
"xaxis.range": current_day_range(),
|
|
61
|
+
"xaxis.dtick": 3600000,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
),
|
|
65
|
+
dict(
|
|
66
|
+
label=_("Current Week"),
|
|
67
|
+
method="relayout",
|
|
68
|
+
args=[
|
|
69
|
+
{
|
|
70
|
+
"xaxis.range": current_week_range(),
|
|
71
|
+
"xaxis.dtick": "D1",
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
),
|
|
75
|
+
dict(
|
|
76
|
+
label=_("Current Month"),
|
|
77
|
+
method="relayout",
|
|
78
|
+
args=[
|
|
79
|
+
{
|
|
80
|
+
"xaxis.range": current_month_range(),
|
|
81
|
+
"xaxis.dtick": "D3",
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
),
|
|
85
|
+
dict(
|
|
86
|
+
label=_("Current Year"),
|
|
87
|
+
method="relayout",
|
|
88
|
+
args=[
|
|
89
|
+
{
|
|
90
|
+
"xaxis.range": current_year_range(),
|
|
91
|
+
"xaxis.dtick": 86400000.0 * 14,
|
|
92
|
+
"xaxis.tickformat": "%b %d",
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
),
|
|
96
|
+
dict(
|
|
97
|
+
label=_("All"),
|
|
98
|
+
method="relayout",
|
|
99
|
+
args=[
|
|
100
|
+
{"xaxis.range": [start, end]},
|
|
101
|
+
],
|
|
102
|
+
),
|
|
103
|
+
],
|
|
104
|
+
)
|
|
105
|
+
],
|
|
106
|
+
yaxis=dict(
|
|
107
|
+
title="",
|
|
108
|
+
titlefont=dict(color="#000000"),
|
|
109
|
+
tickfont=dict(size=11, family="Courier", color="#000000"),
|
|
110
|
+
anchor="x",
|
|
111
|
+
side="left",
|
|
112
|
+
showline=False,
|
|
113
|
+
linewidth=0.5,
|
|
114
|
+
linecolor="black",
|
|
115
|
+
showgrid=True,
|
|
116
|
+
gridcolor="lightgray",
|
|
117
|
+
gridwidth=1,
|
|
118
|
+
showspikes=True,
|
|
119
|
+
spikecolor="black",
|
|
120
|
+
spikethickness=1,
|
|
121
|
+
),
|
|
122
|
+
xaxis=dict(
|
|
123
|
+
title="",
|
|
124
|
+
titlefont=dict(color="#000000"),
|
|
125
|
+
tickfont=dict(color="#000000"),
|
|
126
|
+
showline=False,
|
|
127
|
+
linewidth=0.5,
|
|
128
|
+
linecolor="black",
|
|
129
|
+
showgrid=True,
|
|
130
|
+
gridcolor="lightgray",
|
|
131
|
+
gridwidth=2,
|
|
132
|
+
rangeslider=dict(visible=True),
|
|
133
|
+
range=current_week_range(),
|
|
134
|
+
dtick="D1",
|
|
135
|
+
type="date",
|
|
136
|
+
showspikes=True,
|
|
137
|
+
spikemode="across",
|
|
138
|
+
spikecolor="black",
|
|
139
|
+
spikesnap="cursor",
|
|
140
|
+
spikethickness=1,
|
|
141
|
+
),
|
|
142
|
+
spikedistance=1000,
|
|
143
|
+
hoverdistance=100,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return fig
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _get_types_color_map():
|
|
150
|
+
type_dict = dict(AbsenceRequestType.objects.values_list("title", "color"))
|
|
151
|
+
type_dict["Day Off"] = "silver"
|
|
152
|
+
type_dict["Holiday"] = "lightblue"
|
|
153
|
+
return type_dict
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _get_status_pattern_map():
|
|
157
|
+
return {
|
|
158
|
+
"APPROVED": "",
|
|
159
|
+
"DRAFT": ".",
|
|
160
|
+
"PENDING": "x",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class AbsenceRequestPlanner(viewsets.ChartViewSet):
|
|
165
|
+
IDENTIFIER = "wbhuman_resources:absenceplanner"
|
|
166
|
+
|
|
167
|
+
filterset_class = AbsenceRequestPlannerFilter
|
|
168
|
+
queryset = AbsenceRequestPeriods.objects.all()
|
|
169
|
+
|
|
170
|
+
title_config_class = AbsenceRequestPlannerTitleConfig
|
|
171
|
+
endpoint_config_class = AbsenceRequestPlannerEndpointConfig
|
|
172
|
+
|
|
173
|
+
def get_plotly(self, queryset):
|
|
174
|
+
start, end = get_date_interval_from_request(self.request)
|
|
175
|
+
calendar = get_object_or_404(DayOffCalendar, pk=self.request.GET.get("calendar", None))
|
|
176
|
+
only_employee_with_absence_periods = (
|
|
177
|
+
self.request.GET.get("only_employee_with_absence_periods", "false") == "true"
|
|
178
|
+
)
|
|
179
|
+
employees = EmployeeHumanResource.active_internal_employees.all()
|
|
180
|
+
|
|
181
|
+
if position_id := self.request.GET.get("position", None):
|
|
182
|
+
position = get_object_or_404(Position, pk=position_id)
|
|
183
|
+
employees = employees.filter(position__in=position.get_descendants(include_self=True))
|
|
184
|
+
|
|
185
|
+
if queryset.exists() and start and end and employees.exists():
|
|
186
|
+
df = EmployeeHumanResource.get_employee_absence_periods_df(
|
|
187
|
+
calendar, start, end, employees, only_employee_with_absence_periods=only_employee_with_absence_periods
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
employees_map = dict(employees.values_list("id", "computed_str"))
|
|
191
|
+
df["employee"] = df.employee.map(employees_map)
|
|
192
|
+
fig = px.timeline(
|
|
193
|
+
df,
|
|
194
|
+
x_start="start",
|
|
195
|
+
x_end="end",
|
|
196
|
+
y=df.employee,
|
|
197
|
+
color="type",
|
|
198
|
+
template="seaborn",
|
|
199
|
+
color_discrete_map=_get_types_color_map(),
|
|
200
|
+
pattern_shape="status",
|
|
201
|
+
pattern_shape_map=_get_status_pattern_map(),
|
|
202
|
+
hover_name="employee",
|
|
203
|
+
hover_data={
|
|
204
|
+
"start": True,
|
|
205
|
+
"end": True,
|
|
206
|
+
"employee": False,
|
|
207
|
+
"status": False,
|
|
208
|
+
"type": False,
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
now = timezone.now().astimezone(calendar.timezone)
|
|
212
|
+
default_calendar_period = calendar.get_default_fullday_period(now.date())
|
|
213
|
+
valid_now = min(
|
|
214
|
+
max([now, default_calendar_period.lower]), default_calendar_period.upper
|
|
215
|
+
) # we make sure that the "now" vertical line is within the range of a default calendar day
|
|
216
|
+
fig.add_vline(x=valid_now, line_width=3, line_dash="dash", line_color="red")
|
|
217
|
+
fig = update_layoute(fig, start, end)
|
|
218
|
+
|
|
219
|
+
def _time_to_decimal(ts):
|
|
220
|
+
return (ts.hour * 60 + ts.minute) / 60
|
|
221
|
+
|
|
222
|
+
rangebreaks = [
|
|
223
|
+
{"pattern": "hour", "bounds": [_time_to_decimal(hours_range[0]), _time_to_decimal(hours_range[1])]}
|
|
224
|
+
for hours_range in calendar.get_unworked_time_range(start_time=time(4, 0, 1))
|
|
225
|
+
]
|
|
226
|
+
fig.update_xaxes(rangebreaks=rangebreaks)
|
|
227
|
+
|
|
228
|
+
return fig
|
|
229
|
+
return go.Figure()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class AbsenceTablePandasViewSet(PandasAPIViewSet):
|
|
233
|
+
IDENTIFIER = "wbhuman_resources:absence_table"
|
|
234
|
+
|
|
235
|
+
queryset = AbsenceRequestPeriods.objects.filter(
|
|
236
|
+
Q(employee__in=EmployeeHumanResource.active_internal_employees.all())
|
|
237
|
+
& Q(request__status=AbsenceRequest.Status.APPROVED.name)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
filterset_class = AbsenceTableFilter
|
|
241
|
+
|
|
242
|
+
pandas_fields = pf.PandasFields(
|
|
243
|
+
fields=(
|
|
244
|
+
pf.PKField(key="employee", label=_("ID")),
|
|
245
|
+
pf.PKField(key="employee_repr", label=_("Employee"), help_text=_("Test")),
|
|
246
|
+
pf.CharField(key="position", label=_("Department")),
|
|
247
|
+
pf.FloatField(key="monday", label=_("Monday")),
|
|
248
|
+
pf.FloatField(key="tuesday", label=_("Tuesday")),
|
|
249
|
+
pf.FloatField(key="wednesday", label=_("Wednesday")),
|
|
250
|
+
pf.FloatField(key="thursday", label=_("Thursday")),
|
|
251
|
+
pf.FloatField(key="friday", label=_("Friday")),
|
|
252
|
+
pf.FloatField(key="saturday", label=_("Saturday")),
|
|
253
|
+
pf.FloatField(key="sunday", label=_("Sunday")),
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
display_config_class = AbsenceTablePandasDisplayConfig
|
|
257
|
+
title_config_class = AbsenceTablePandasTitleConfig
|
|
258
|
+
endpoint_config_class = AbsenceTablePandasEndpointConfig
|
|
259
|
+
|
|
260
|
+
ordering_fields = [
|
|
261
|
+
"employee_repr",
|
|
262
|
+
"position",
|
|
263
|
+
"monday",
|
|
264
|
+
"tuesday",
|
|
265
|
+
"wednesday",
|
|
266
|
+
"thursday",
|
|
267
|
+
"friday",
|
|
268
|
+
]
|
|
269
|
+
ordering = ["position"]
|
|
270
|
+
search_fields = ["employee_repr", "position"]
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def start_and_end(self) -> tuple[date, date] | None:
|
|
274
|
+
if date_repr := self.request.GET.get("date", None):
|
|
275
|
+
return current_week_range(parse_date(date_repr))
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
def get_dataframe(self, request, queryset, **kwargs) -> pd.DataFrame:
|
|
279
|
+
def _rename_columns(df: pd.DataFrame) -> pd.DataFrame:
|
|
280
|
+
# Rename the columns from datetime.date to the week day name representation (e.g. Monday)
|
|
281
|
+
rename_map = {col: day_name[col.weekday()].lower() for col in df.columns if not isinstance(col, str)}
|
|
282
|
+
return df.rename(columns=rename_map)
|
|
283
|
+
|
|
284
|
+
def _get_position(employee_id: int) -> Position | None:
|
|
285
|
+
employee = EmployeeHumanResource.objects.get(id=employee_id)
|
|
286
|
+
if pos := employee.position:
|
|
287
|
+
return pos.get_root().name
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
def _custom_agg(group: pd.DataFrame) -> int:
|
|
291
|
+
default_periods = calendar.default_periods.count()
|
|
292
|
+
|
|
293
|
+
if default_periods:
|
|
294
|
+
if len(group) == default_periods:
|
|
295
|
+
if group["type"].eq("Home Office").sum() == default_periods:
|
|
296
|
+
# Remote
|
|
297
|
+
return 3
|
|
298
|
+
elif group["type"].eq("Home Office").sum() > 0:
|
|
299
|
+
# Partially Remote
|
|
300
|
+
return 2
|
|
301
|
+
else:
|
|
302
|
+
# Absent
|
|
303
|
+
return -1
|
|
304
|
+
elif len(group) > 0:
|
|
305
|
+
# Partially Present
|
|
306
|
+
return 1
|
|
307
|
+
return 0
|
|
308
|
+
|
|
309
|
+
if self.start_and_end:
|
|
310
|
+
start, end = self.start_and_end
|
|
311
|
+
calendar = get_object_or_404(DayOffCalendar, pk=self.request.GET.get("calendar", None))
|
|
312
|
+
employees = EmployeeHumanResource.active_internal_employees.all()
|
|
313
|
+
|
|
314
|
+
if position_id := self.request.GET.get("position", None):
|
|
315
|
+
position = get_object_or_404(Position, pk=position_id)
|
|
316
|
+
employees = employees.filter(position__in=position.get_descendants(include_self=True))
|
|
317
|
+
|
|
318
|
+
df = EmployeeHumanResource.get_employee_absence_periods_df(calendar, start, end, employees)
|
|
319
|
+
df = df[["date", "employee", "period", "type"]]
|
|
320
|
+
df = df.groupby(["date", "employee"]).apply(_custom_agg).unstack(fill_value=0) # Present
|
|
321
|
+
df.reset_index(inplace=True)
|
|
322
|
+
df.set_index("date", inplace=True)
|
|
323
|
+
df = _rename_columns(df.reindex(pd.date_range(start, end), fill_value=0).transpose()).reset_index()
|
|
324
|
+
df["position"] = df.employee.apply(lambda x: _get_position(x))
|
|
325
|
+
df["employee_repr"] = df.employee.map(dict(employees.values_list("id", "computed_str")))
|
|
326
|
+
df = df.sort_values(by="employee_repr", ascending=False)
|
|
327
|
+
return df.where(pd.notnull(df), None)
|
|
328
|
+
return pd.DataFrame()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from django.utils.translation import gettext as _
|
|
2
|
+
from wbcore.contrib.icons import WBIcon
|
|
3
|
+
from wbcore.enums import RequestType
|
|
4
|
+
from wbcore.metadata.configs import buttons as bt
|
|
5
|
+
from wbcore.metadata.configs.buttons.view_config import ButtonViewConfig
|
|
6
|
+
from wbcore.metadata.configs.display.instance_display.shortcuts import (
|
|
7
|
+
create_simple_display,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from wbhuman_resources.serializers import IncreaseDaySerializer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AbsenceRequestButtonConfig(ButtonViewConfig):
|
|
14
|
+
def get_custom_instance_buttons(self):
|
|
15
|
+
return {
|
|
16
|
+
bt.ActionButton(
|
|
17
|
+
method=RequestType.PATCH,
|
|
18
|
+
identifiers=("wbhuman_resources:absencerequest",),
|
|
19
|
+
key="increase_days",
|
|
20
|
+
action_label=_("Increasing Days"),
|
|
21
|
+
label=_("Increase Days"),
|
|
22
|
+
icon=WBIcon.ADD.icon,
|
|
23
|
+
title=_("Increase Days"),
|
|
24
|
+
serializer=IncreaseDaySerializer,
|
|
25
|
+
description_fields=_(
|
|
26
|
+
"""
|
|
27
|
+
<p>You are about to increase the abscence request by {{number_days}} days</p>
|
|
28
|
+
"""
|
|
29
|
+
),
|
|
30
|
+
instance_display=create_simple_display([["number_days"]]),
|
|
31
|
+
)
|
|
32
|
+
}
|