wbhuman_resources 1.58.4__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. wbhuman_resources/__init__.py +1 -0
  2. wbhuman_resources/admin/__init__.py +5 -0
  3. wbhuman_resources/admin/absence.py +113 -0
  4. wbhuman_resources/admin/calendars.py +37 -0
  5. wbhuman_resources/admin/employee.py +109 -0
  6. wbhuman_resources/admin/kpi.py +21 -0
  7. wbhuman_resources/admin/review.py +157 -0
  8. wbhuman_resources/apps.py +23 -0
  9. wbhuman_resources/dynamic_preferences_registry.py +119 -0
  10. wbhuman_resources/factories/__init__.py +38 -0
  11. wbhuman_resources/factories/absence.py +109 -0
  12. wbhuman_resources/factories/calendars.py +60 -0
  13. wbhuman_resources/factories/employee.py +80 -0
  14. wbhuman_resources/factories/kpi.py +155 -0
  15. wbhuman_resources/filters/__init__.py +20 -0
  16. wbhuman_resources/filters/absence.py +109 -0
  17. wbhuman_resources/filters/absence_graphs.py +85 -0
  18. wbhuman_resources/filters/calendars.py +28 -0
  19. wbhuman_resources/filters/employee.py +81 -0
  20. wbhuman_resources/filters/kpi.py +35 -0
  21. wbhuman_resources/filters/review.py +134 -0
  22. wbhuman_resources/filters/signals.py +27 -0
  23. wbhuman_resources/locale/de/LC_MESSAGES/django.mo +0 -0
  24. wbhuman_resources/locale/de/LC_MESSAGES/django.po +2207 -0
  25. wbhuman_resources/locale/de/LC_MESSAGES/django.po.translated +2456 -0
  26. wbhuman_resources/locale/en/LC_MESSAGES/django.mo +0 -0
  27. wbhuman_resources/locale/en/LC_MESSAGES/django.po +2091 -0
  28. wbhuman_resources/locale/fr/LC_MESSAGES/django.mo +0 -0
  29. wbhuman_resources/locale/fr/LC_MESSAGES/django.po +2093 -0
  30. wbhuman_resources/management/__init__.py +23 -0
  31. wbhuman_resources/migrations/0001_initial_squashed_squashed_0015_alter_absencerequest_calendaritem_ptr_and_more.py +949 -0
  32. wbhuman_resources/migrations/0016_alter_employeehumanresource_options.py +20 -0
  33. wbhuman_resources/migrations/0017_absencerequest_crossborder_country_and_more.py +55 -0
  34. wbhuman_resources/migrations/0018_remove_position_group_position_groups.py +32 -0
  35. wbhuman_resources/migrations/0019_alter_absencerequest_options_alter_kpi_options_and_more.py +44 -0
  36. wbhuman_resources/migrations/0020_alter_employeeyearbalance_year_alter_review_year.py +27 -0
  37. wbhuman_resources/migrations/0021_alter_position_color.py +18 -0
  38. wbhuman_resources/migrations/0022_remove_review_editable_mode.py +64 -0
  39. wbhuman_resources/migrations/__init__.py +0 -0
  40. wbhuman_resources/models/__init__.py +23 -0
  41. wbhuman_resources/models/absence.py +903 -0
  42. wbhuman_resources/models/calendars.py +370 -0
  43. wbhuman_resources/models/employee.py +1241 -0
  44. wbhuman_resources/models/kpi.py +199 -0
  45. wbhuman_resources/models/preferences.py +40 -0
  46. wbhuman_resources/models/review.py +982 -0
  47. wbhuman_resources/permissions/__init__.py +0 -0
  48. wbhuman_resources/permissions/backend.py +26 -0
  49. wbhuman_resources/serializers/__init__.py +49 -0
  50. wbhuman_resources/serializers/absence.py +308 -0
  51. wbhuman_resources/serializers/calendars.py +73 -0
  52. wbhuman_resources/serializers/employee.py +267 -0
  53. wbhuman_resources/serializers/kpi.py +80 -0
  54. wbhuman_resources/serializers/review.py +415 -0
  55. wbhuman_resources/signals.py +4 -0
  56. wbhuman_resources/tasks.py +195 -0
  57. wbhuman_resources/templates/review/review_report.html +322 -0
  58. wbhuman_resources/tests/__init__.py +1 -0
  59. wbhuman_resources/tests/conftest.py +96 -0
  60. wbhuman_resources/tests/models/__init__.py +0 -0
  61. wbhuman_resources/tests/models/test_absences.py +478 -0
  62. wbhuman_resources/tests/models/test_calendars.py +209 -0
  63. wbhuman_resources/tests/models/test_employees.py +502 -0
  64. wbhuman_resources/tests/models/test_review.py +103 -0
  65. wbhuman_resources/tests/models/test_utils.py +110 -0
  66. wbhuman_resources/tests/signals.py +108 -0
  67. wbhuman_resources/tests/test_permission.py +64 -0
  68. wbhuman_resources/tests/test_tasks.py +74 -0
  69. wbhuman_resources/urls.py +221 -0
  70. wbhuman_resources/utils.py +43 -0
  71. wbhuman_resources/viewsets/__init__.py +61 -0
  72. wbhuman_resources/viewsets/absence.py +312 -0
  73. wbhuman_resources/viewsets/absence_charts.py +328 -0
  74. wbhuman_resources/viewsets/buttons/__init__.py +7 -0
  75. wbhuman_resources/viewsets/buttons/absence.py +32 -0
  76. wbhuman_resources/viewsets/buttons/employee.py +44 -0
  77. wbhuman_resources/viewsets/buttons/kpis.py +16 -0
  78. wbhuman_resources/viewsets/buttons/review.py +195 -0
  79. wbhuman_resources/viewsets/calendars.py +103 -0
  80. wbhuman_resources/viewsets/display/__init__.py +39 -0
  81. wbhuman_resources/viewsets/display/absence.py +334 -0
  82. wbhuman_resources/viewsets/display/calendars.py +83 -0
  83. wbhuman_resources/viewsets/display/employee.py +254 -0
  84. wbhuman_resources/viewsets/display/kpis.py +92 -0
  85. wbhuman_resources/viewsets/display/review.py +429 -0
  86. wbhuman_resources/viewsets/employee.py +210 -0
  87. wbhuman_resources/viewsets/endpoints/__init__.py +42 -0
  88. wbhuman_resources/viewsets/endpoints/absence.py +57 -0
  89. wbhuman_resources/viewsets/endpoints/calendars.py +18 -0
  90. wbhuman_resources/viewsets/endpoints/employee.py +51 -0
  91. wbhuman_resources/viewsets/endpoints/kpis.py +53 -0
  92. wbhuman_resources/viewsets/endpoints/review.py +191 -0
  93. wbhuman_resources/viewsets/kpi.py +280 -0
  94. wbhuman_resources/viewsets/menu/__init__.py +22 -0
  95. wbhuman_resources/viewsets/menu/absence.py +50 -0
  96. wbhuman_resources/viewsets/menu/administration.py +15 -0
  97. wbhuman_resources/viewsets/menu/calendars.py +33 -0
  98. wbhuman_resources/viewsets/menu/employee.py +44 -0
  99. wbhuman_resources/viewsets/menu/kpis.py +18 -0
  100. wbhuman_resources/viewsets/menu/review.py +97 -0
  101. wbhuman_resources/viewsets/mixins.py +14 -0
  102. wbhuman_resources/viewsets/review.py +837 -0
  103. wbhuman_resources/viewsets/titles/__init__.py +18 -0
  104. wbhuman_resources/viewsets/titles/absence.py +30 -0
  105. wbhuman_resources/viewsets/titles/employee.py +18 -0
  106. wbhuman_resources/viewsets/titles/kpis.py +15 -0
  107. wbhuman_resources/viewsets/titles/review.py +62 -0
  108. wbhuman_resources/viewsets/utils.py +28 -0
  109. wbhuman_resources-1.58.4.dist-info/METADATA +8 -0
  110. wbhuman_resources-1.58.4.dist-info/RECORD +111 -0
  111. wbhuman_resources-1.58.4.dist-info/WHEEL +5 -0
@@ -0,0 +1,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,7 @@
1
+ from .absence import AbsenceRequestButtonConfig
2
+ from .employee import EmployeeButtonConfig, YearBalanceEmployeeHumanResourceButtonConfig
3
+ from .kpis import KPIButtonConfig
4
+ from .review import (
5
+ ReviewButtonConfig,
6
+ ReviewGroupButtonConfig,
7
+ )
@@ -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
+ }