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,478 @@
1
+ import zoneinfo
2
+ from datetime import date, timedelta
3
+ from unittest.mock import patch
4
+
5
+ import pandas as pd
6
+ import pytest
7
+ from django.contrib.auth.models import Group, Permission
8
+ from django.db.models import Sum
9
+ from faker import Faker
10
+ from psycopg.types.range import TimestamptzRange
11
+
12
+ from wbhuman_resources.models import (
13
+ AbsenceRequest,
14
+ AbsenceRequestPeriods,
15
+ AbsenceRequestType,
16
+ BalanceHourlyAllowance,
17
+ )
18
+ from wbhuman_resources.models.absence import (
19
+ can_cancel_request,
20
+ can_validate_or_deny_request,
21
+ )
22
+ from wbhuman_resources.models.preferences import (
23
+ get_previous_year_balance_expiration_date,
24
+ )
25
+
26
+ fake = Faker()
27
+
28
+
29
+ @pytest.mark.django_db
30
+ class TestAbsenceRequest:
31
+ @pytest.fixture()
32
+ def past_aware_datetime(self):
33
+ return fake.past_datetime().astimezone(zoneinfo.ZoneInfo("UTC"))
34
+
35
+ @pytest.fixture()
36
+ def future_aware_datetime(self):
37
+ return fake.future_datetime().astimezone(zoneinfo.ZoneInfo("UTC"))
38
+
39
+ def test_normal_user_cannot_cancel_past_request(self, vacation_request_factory, past_aware_datetime):
40
+ request = vacation_request_factory(
41
+ status=AbsenceRequest.Status.APPROVED,
42
+ period=TimestamptzRange(lower=past_aware_datetime - timedelta(days=2), upper=past_aware_datetime),
43
+ )
44
+ assert not can_cancel_request(request, request.employee.profile.user_account)
45
+
46
+ def test_normal_user_can_cancel_future_request(
47
+ self, vacation_request_factory, user_factory, future_aware_datetime, employee_human_resource_factory
48
+ ):
49
+ admin = user_factory.create(is_active=True)
50
+ admin.user_permissions.add(Permission.objects.get(codename="administrate_absencerequest"))
51
+ manager = employee_human_resource_factory.create()
52
+
53
+ employee = employee_human_resource_factory.create(direct_manager=manager.profile)
54
+ request = vacation_request_factory(
55
+ employee=employee,
56
+ status=AbsenceRequest.Status.APPROVED,
57
+ period=TimestamptzRange(lower=future_aware_datetime, upper=future_aware_datetime + timedelta(days=2)),
58
+ )
59
+
60
+ assert can_cancel_request(request, request.employee.profile.user_account)
61
+ assert can_cancel_request(request, admin)
62
+ assert can_cancel_request(request, manager.profile.user_account)
63
+
64
+ def test_can_validate_or_deny_request(
65
+ self, vacation_request_factory, user_factory, future_aware_datetime, employee_human_resource_factory
66
+ ):
67
+ admin = user_factory.create(is_active=True)
68
+ admin.user_permissions.add(Permission.objects.get(codename="administrate_absencerequest"))
69
+ manager = employee_human_resource_factory.create()
70
+
71
+ employee = employee_human_resource_factory.create(direct_manager=manager.profile)
72
+ request = vacation_request_factory(
73
+ employee=employee,
74
+ status=AbsenceRequest.Status.APPROVED,
75
+ period=TimestamptzRange(lower=future_aware_datetime, upper=future_aware_datetime + timedelta(days=2)),
76
+ )
77
+
78
+ assert not can_validate_or_deny_request(request, request.employee.profile.user_account)
79
+ assert can_validate_or_deny_request(request, admin)
80
+ assert can_validate_or_deny_request(request, manager.profile.user_account)
81
+
82
+ def test_submit(self, absence_request_factory, employee_human_resource):
83
+ request = absence_request_factory.create(status=AbsenceRequest.Status.DRAFT)
84
+ request.employee.direct_manager = employee_human_resource.profile
85
+ request.employee.save()
86
+
87
+ request.submit()
88
+ request.save()
89
+ assert request.status == AbsenceRequest.Status.PENDING
90
+
91
+ def test_approve(self, absence_request_factory):
92
+ request = absence_request_factory.create(status=AbsenceRequest.Status.PENDING)
93
+
94
+ request.approve()
95
+ request.save()
96
+ assert request.status == AbsenceRequest.Status.APPROVED
97
+
98
+ def test_deny_not_vacation_request(self, time_off_request_factory):
99
+ request = time_off_request_factory.create(status=AbsenceRequest.Status.PENDING)
100
+ request.deny()
101
+ request.save()
102
+ assert request.status == AbsenceRequest.Status.DENIED
103
+
104
+ def test_deny_vacation_request(self, vacation_request_factory):
105
+ request = vacation_request_factory.create(status=AbsenceRequest.Status.PENDING)
106
+ request.deny()
107
+ request.save()
108
+ assert request.status == AbsenceRequest.Status.DENIED
109
+
110
+ def test_backtodraft(self, absence_request_factory):
111
+ request = absence_request_factory.create(status=AbsenceRequest.Status.PENDING)
112
+ request.backtodraft()
113
+ request.save()
114
+ assert request.status == AbsenceRequest.Status.DRAFT
115
+
116
+ def test_cancel(self, absence_request_factory, employee_human_resource):
117
+ request = absence_request_factory.create(status=AbsenceRequest.Status.APPROVED)
118
+ request.employee.direct_manager = employee_human_resource.profile
119
+ request.employee.save()
120
+
121
+ request.cancel()
122
+ request.save()
123
+ assert request.status == AbsenceRequest.Status.CANCELLED
124
+
125
+ def test_timespan(self, absence_request_periods):
126
+ request = absence_request_periods.request
127
+ assert (
128
+ request.periods_timespan.lower
129
+ == request.periods.earliest("timespan__startswith").request.periods_timespan.lower
130
+ )
131
+ assert (
132
+ request.periods_timespan.upper
133
+ == request.periods.latest("timespan__startswith").request.periods_timespan.upper
134
+ )
135
+
136
+ def test_can_delete_draft_request(self, absence_request_factory, user):
137
+ request = absence_request_factory(status=AbsenceRequest.Status.DRAFT)
138
+ assert request.is_deletable_for_user(user)
139
+
140
+ def test_can_delete_future_pending_request(self, absence_request_factory, future_aware_datetime, user):
141
+ request = absence_request_factory(
142
+ status=AbsenceRequest.Status.PENDING,
143
+ period=TimestamptzRange(lower=future_aware_datetime, upper=future_aware_datetime + timedelta(days=2)),
144
+ )
145
+ assert request.is_deletable_for_user(user)
146
+
147
+ def test_can_delete_past_pending_request(self, absence_request_factory, past_aware_datetime, user_factory):
148
+ normal_user = user_factory.create()
149
+ admin_user = user_factory.create()
150
+ admin_user.user_permissions.add(Permission.objects.get(codename="administrate_absencerequest"))
151
+ request = absence_request_factory.create(
152
+ status=AbsenceRequest.Status.PENDING,
153
+ period=TimestamptzRange(lower=past_aware_datetime - timedelta(days=2), upper=past_aware_datetime),
154
+ )
155
+ assert not request.is_deletable_for_user(normal_user)
156
+ assert request.is_deletable_for_user(admin_user)
157
+
158
+ # Property checks and test
159
+ def test_total_hours(self, day_off_calendar, absence_request_periods_factory):
160
+ morning = day_off_calendar.default_periods.earliest("lower_time")
161
+ afternoon = day_off_calendar.default_periods.latest("lower_time")
162
+ p1 = absence_request_periods_factory.create(default_period=morning)
163
+ request = p1.request
164
+ absence_request_periods_factory.create(request=request, default_period=afternoon, date=p1.date)
165
+ assert request.total_hours == morning.total_hours + afternoon.total_hours
166
+ assert AbsenceRequest.objects.get(id=request.id)._total_hours == morning.total_hours + afternoon.total_hours
167
+ absence_request_periods_factory.create(
168
+ request=request, default_period=morning, date=(p1.date + pd.tseries.offsets.BDay(1)).date()
169
+ )
170
+ absence_request_periods_factory.create(
171
+ request=request, default_period=afternoon, date=(p1.date + pd.tseries.offsets.BDay(1)).date()
172
+ )
173
+ assert request.total_hours == (morning.total_hours + afternoon.total_hours) * 2
174
+ assert (
175
+ AbsenceRequest.objects.get(id=request.id)._total_hours == (morning.total_hours + afternoon.total_hours) * 2
176
+ )
177
+
178
+ def test_total_vacation_hours(self, absence_request_factory, absence_request_type_factory):
179
+ time_off_request = absence_request_factory.create(
180
+ status=AbsenceRequest.Status.APPROVED, type=absence_request_type_factory.create(is_vacation=False)
181
+ )
182
+ vacation_request = absence_request_factory.create(
183
+ status=AbsenceRequest.Status.APPROVED, type=absence_request_type_factory.create(is_vacation=True)
184
+ )
185
+ unapprove_vacation_request = absence_request_factory.create(
186
+ type=absence_request_type_factory.create(is_vacation=True)
187
+ )
188
+
189
+ assert time_off_request.total_vacation_hours == 0
190
+ assert AbsenceRequest.objects.get(id=time_off_request.id)._total_vacation_hours == 0
191
+
192
+ assert unapprove_vacation_request.total_vacation_hours == 0
193
+ assert AbsenceRequest.objects.get(id=unapprove_vacation_request.id)._total_vacation_hours == 0
194
+
195
+ assert vacation_request.total_vacation_hours
196
+ assert vacation_request.total_vacation_hours == vacation_request.periods.aggregate(s=Sum("_total_hours"))["s"]
197
+ assert (
198
+ AbsenceRequest.objects.get(id=vacation_request.id)._total_vacation_hours
199
+ == vacation_request.total_vacation_hours
200
+ )
201
+
202
+ def test_total_hours_in_days(self, absence_request):
203
+ exp_res = (
204
+ absence_request.periods.aggregate(s=Sum("_total_hours"))["s"]
205
+ / absence_request.employee.calendar.get_daily_hours()
206
+ )
207
+ assert exp_res
208
+ assert absence_request.total_hours_in_days == exp_res
209
+ assert AbsenceRequest.objects.get(id=absence_request.id)._total_hours_in_days == exp_res
210
+
211
+ def test_total_vacation_hours_in_days(self, absence_request_factory, absence_request_type_factory):
212
+ vacation_request = absence_request_factory.create(
213
+ status=AbsenceRequest.Status.APPROVED, type=absence_request_type_factory.create(is_vacation=True)
214
+ )
215
+ assert vacation_request.total_vacation_hours_in_days
216
+ assert (
217
+ vacation_request.total_vacation_hours_in_days
218
+ == vacation_request.periods.aggregate(s=Sum("_total_hours"))["s"]
219
+ / vacation_request.employee.calendar.get_daily_hours()
220
+ )
221
+ assert (
222
+ AbsenceRequest.objects.get(id=vacation_request.id)._total_vacation_hours_in_days
223
+ == vacation_request.total_vacation_hours_in_days
224
+ )
225
+
226
+ @patch("wbhuman_resources.models.absence.send_notification")
227
+ def test_notify_requester(self, mock_fct, absence_request):
228
+ title = fake.sentence()
229
+ message = fake.sentence()
230
+ absence_request.notify(title, message, to_requester=True)
231
+ mock_fct.assert_called_with(
232
+ code="wbhuman_resources.absencerequest.notify",
233
+ title=title,
234
+ body=message,
235
+ reverse_name="wbhuman_resources:absencerequest-detail",
236
+ reverse_args=[absence_request.id],
237
+ user=absence_request.employee.profile.user_account,
238
+ )
239
+
240
+ @patch("wbhuman_resources.models.absence.send_notification")
241
+ def test_notify_managers(self, mock_fct, absence_request, authenticated_person_factory):
242
+ direct_manager = authenticated_person_factory.create()
243
+ absence_request.employee.direct_manager = direct_manager
244
+ absence_request.employee.save()
245
+
246
+ # add a general manager
247
+ general_manager = authenticated_person_factory.create()
248
+ general_manager.user_account.user_permissions.add(
249
+ Permission.objects.get(codename="administrate_employeehumanresource")
250
+ )
251
+
252
+ absence_request.refresh_from_db()
253
+
254
+ title = fake.sentence()
255
+ message = fake.sentence()
256
+ absence_request.notify(title, message, to_requester=False, to_manager=True)
257
+ mock_fct.assert_any_call(
258
+ code="wbhuman_resources.absencerequest.notify",
259
+ title=title,
260
+ body=message,
261
+ reverse_name="wbhuman_resources:absencerequest-detail",
262
+ reverse_args=[absence_request.id],
263
+ user=general_manager.user_account,
264
+ )
265
+ mock_fct.assert_any_call(
266
+ code="wbhuman_resources.absencerequest.notify",
267
+ title=title,
268
+ body=message,
269
+ reverse_name="wbhuman_resources:absencerequest-detail",
270
+ reverse_args=[absence_request.id],
271
+ user=direct_manager.user_account,
272
+ )
273
+
274
+ @patch("wbhuman_resources.models.absence.send_notification")
275
+ def test_notify_extra_notify_user(self, mock_fct, absence_request, user):
276
+ # We create a user, add it to a test group and add this group to the absence request type "extra_notify_group"
277
+ group = Group.objects.create(name="test")
278
+ user.groups.add(group)
279
+ absence_request.type.extra_notify_groups.add(group)
280
+ absence_request.refresh_from_db()
281
+
282
+ title = fake.sentence()
283
+ message = fake.sentence()
284
+ absence_request.notify(title, message, to_requester=False, to_manager=True)
285
+ mock_fct.assert_called_with(
286
+ code="wbhuman_resources.absencerequest.notify",
287
+ title=title,
288
+ body=message,
289
+ reverse_name="wbhuman_resources:absencerequest-detail",
290
+ reverse_args=[absence_request.id],
291
+ user=user,
292
+ )
293
+
294
+
295
+ @pytest.mark.django_db
296
+ class TestAbsenceRequestType:
297
+ def test_get_choices(self, absence_request_type_factory):
298
+ t1 = absence_request_type_factory.create()
299
+ t2 = absence_request_type_factory.create()
300
+ assert AbsenceRequestType.get_choices() == [(t1.id, t1.title), (t2.id, t2.title)]
301
+
302
+ def test_validate_country_needed_but_not_specified(self, absence_request_type_factory):
303
+ absence_request_type = absence_request_type_factory.create(is_country_necessary=True)
304
+ with pytest.raises(ValueError):
305
+ absence_request_type.validate_country(None)
306
+
307
+ def test_validate_country_specified_and_allowed(self, absence_request_type_factory, country):
308
+ absence_request_type = absence_request_type_factory.create(
309
+ is_country_necessary=True,
310
+ ) # test that the post_save automatically appends the newly created country to the list of allowed countries
311
+ assert absence_request_type.validate_country(country)
312
+
313
+ def test_validate_country_specified_but_not_allowed(self, absence_request_type_factory, country):
314
+ absence_request_type = absence_request_type_factory.create(is_country_necessary=True)
315
+ absence_request_type.crossborder_countries.clear()
316
+ with pytest.raises(ValueError):
317
+ absence_request_type.validate_country(country)
318
+
319
+
320
+ @pytest.mark.django_db
321
+ class TestAbsenceRequestPeriod:
322
+ def test_total_hours(self, absence_request_periods):
323
+ exp_res = absence_request_periods.default_period.total_hours
324
+ assert exp_res
325
+ assert absence_request_periods.total_hours == exp_res
326
+ assert AbsenceRequestPeriods.objects.get(id=absence_request_periods.id)._total_hours == exp_res
327
+
328
+ @pytest.mark.parametrize("past_date,future_date", [(fake.past_date(), fake.future_date())])
329
+ def test_previous_vacation_period(self, absence_request_periods_factory, past_date, future_date):
330
+ current = absence_request_periods_factory.create(date=date.today())
331
+ past = absence_request_periods_factory.create(employee=current.employee, date=past_date)
332
+ future = absence_request_periods_factory.create(employee=current.employee, date=future_date)
333
+ AbsenceRequest.objects.update(status=AbsenceRequest.Status.APPROVED, type=current.request.type)
334
+ assert current.previous_period == past
335
+ assert future.previous_period == current
336
+ assert past.previous_period is None
337
+
338
+ @pytest.mark.parametrize("test_date", [(fake.date_this_year())])
339
+ def test_get_periods_as_df(
340
+ self, day_off_calendar, employee_human_resource_factory, absence_request_periods_factory, test_date
341
+ ):
342
+ employee1 = employee_human_resource_factory.create(calendar=day_off_calendar, is_active=True)
343
+ employee2 = employee_human_resource_factory.create(
344
+ calendar=day_off_calendar, is_active=False
345
+ ) # Expect this employee to not be present
346
+ p1 = absence_request_periods_factory.create(employee=employee1, date=test_date)
347
+ p2 = absence_request_periods_factory.create(employee=employee2, date=test_date)
348
+ p3 = absence_request_periods_factory.create(employee=employee1, date=test_date - timedelta(days=1))
349
+ res = AbsenceRequestPeriods.get_periods_as_df(
350
+ test_date, test_date + timedelta(days=1), employee__is_active=True
351
+ )
352
+ assert res.shape == (1, 5)
353
+ res = res.set_index(["employee", "period", "date"])
354
+ assert res.loc[(employee1.id, p1.default_period.id, test_date), :].values.tolist() == [
355
+ p1.request.type.title,
356
+ p1.request.status,
357
+ ]
358
+
359
+ with pytest.raises(KeyError):
360
+ assert res.loc[(employee2.id, p2.default_period.id, test_date), :].values.tolist() == [
361
+ p2.request.type.title,
362
+ p2.request.status,
363
+ ]
364
+ assert res.loc[(employee1.id, p3.period.id, test_date - timedelta(days=1)), :]
365
+
366
+ @pytest.mark.parametrize("year_str", [(fake.year())])
367
+ def test_assign_balance(
368
+ self, employee_human_resource, absence_request_periods_factory, employee_year_balance_factory, year_str
369
+ ):
370
+ year = int(year_str)
371
+ previous_balance = employee_year_balance_factory.create(
372
+ employee=employee_human_resource, extra_balance=0, year=year - 1
373
+ )
374
+ current_balance = employee_year_balance_factory.create(
375
+ employee=employee_human_resource, extra_balance=0, year=year
376
+ )
377
+ next_balance = employee_year_balance_factory.create(
378
+ employee=employee_human_resource, extra_balance=0, year=year + 1
379
+ )
380
+ BalanceHourlyAllowance.objects.update(
381
+ hourly_allowance=4
382
+ ) # Update all created balance allowance with 4 crdits (corresponds to a period)
383
+
384
+ p1 = absence_request_periods_factory.create(
385
+ balance=None, employee=employee_human_resource, date=fake.date_between(date(year, 1, 1), date(year, 3, 31))
386
+ )
387
+ p1.assign_balance()
388
+ assert p1.balance == previous_balance
389
+
390
+ p2 = absence_request_periods_factory.create(
391
+ balance=None,
392
+ employee=employee_human_resource,
393
+ date=fake.date_between(date(year, 3, 31), date(year, 6, 30)),
394
+ )
395
+ p2.assign_balance()
396
+ assert p2.balance == current_balance
397
+
398
+ p3 = absence_request_periods_factory.create(
399
+ balance=None,
400
+ employee=employee_human_resource,
401
+ date=fake.date_between(date(year, 6, 30), date(year, 12, 31)),
402
+ )
403
+ p3.assign_balance()
404
+ assert p3.balance == next_balance
405
+
406
+ p4 = absence_request_periods_factory.create( # request but no balance left
407
+ balance=None,
408
+ employee=employee_human_resource,
409
+ date=fake.date_between(date(year, 1, 1), date(year, 12, 31)),
410
+ )
411
+ p4.assign_balance()
412
+ assert p4.balance.year == year + 2
413
+
414
+ @pytest.mark.parametrize("year_str", [(fake.year())])
415
+ def test_assign_balance_with_expired_balance(
416
+ self, employee_human_resource, absence_request_periods_factory, employee_year_balance_factory, year_str
417
+ ):
418
+ year = int(year_str)
419
+ expiration_date = get_previous_year_balance_expiration_date(year)
420
+
421
+ for y in range(year, expiration_date.year - 1):
422
+ employee_year_balance_factory.create(employee=employee_human_resource, extra_balance=0, year=y)
423
+ BalanceHourlyAllowance.objects.filter(balance__year=year).update(
424
+ hourly_allowance=4
425
+ ) # Update all created balance allowance with 4 crdits (corresponds to a period)
426
+ BalanceHourlyAllowance.objects.exclude(balance__year=year).update(
427
+ hourly_allowance=0
428
+ ) # and set anything else to 0
429
+ p1 = absence_request_periods_factory.create(
430
+ balance=None,
431
+ employee=employee_human_resource,
432
+ date=fake.date_between(expiration_date, date(expiration_date.year, 12, 31)),
433
+ )
434
+ p1.assign_balance()
435
+ assert p1.balance is not None
436
+ assert p1.balance.year == year + 1
437
+ assert p1.balance.balance == 0
438
+
439
+ def test_no_vacation_or_approved_request_has_no_balance(self, absence_request_periods):
440
+ AbsenceRequest.objects.update(status=AbsenceRequest.Status.DRAFT)
441
+ AbsenceRequestType.objects.update(is_vacation=False)
442
+ AbsenceRequestPeriods.objects.update(balance=None)
443
+ absence_request_periods.refresh_from_db()
444
+ absence_request_periods.assign_balance()
445
+ assert absence_request_periods.balance is None
446
+
447
+ def test_get_consecutive_hours_count(
448
+ self,
449
+ absence_request_periods_factory,
450
+ employee_human_resource,
451
+ employee_weekly_off_periods_factory,
452
+ day_off_factory,
453
+ ):
454
+ morning = employee_human_resource.calendar.default_periods.earliest("lower_time")
455
+ afternoon = employee_human_resource.calendar.default_periods.latest("lower_time")
456
+ hours_per_period = 4
457
+ p1 = absence_request_periods_factory.create(employee=employee_human_resource, default_period=morning)
458
+ assert p1.consecutive_hours_count == hours_per_period
459
+ p2 = absence_request_periods_factory.create(
460
+ date=p1.date, employee=employee_human_resource, default_period=afternoon
461
+ ) # Straight next period, counter should be incremented
462
+ assert p2.consecutive_hours_count == hours_per_period * 2
463
+ employee_weekly_off_periods_factory.create(
464
+ period=morning, weekday=(p1.date.weekday() + 1) % 6, employee=employee_human_resource
465
+ )
466
+ p3 = absence_request_periods_factory.create(
467
+ date=p1.date + timedelta(days=1), employee=employee_human_resource, default_period=afternoon
468
+ ) # Expected to keep incremeting counter because the previous employee's weekly day off period is jumped
469
+ assert p3.consecutive_hours_count == hours_per_period * 3
470
+ day_off_factory.create(calendar=employee_human_resource.calendar, date=p1.date + timedelta(days=2))
471
+ p4 = absence_request_periods_factory.create(
472
+ date=p1.date + timedelta(days=3), employee=employee_human_resource, default_period=morning
473
+ ) # Expected to keep incremeting counter because the previous day off is jumped
474
+ assert p4.consecutive_hours_count == hours_per_period * 4
475
+ p5 = absence_request_periods_factory.create(
476
+ date=p1.date + timedelta(days=4), employee=employee_human_resource, default_period=morning
477
+ )
478
+ assert p5.consecutive_hours_count == hours_per_period # Expect reset of counter
@@ -0,0 +1,209 @@
1
+ import zoneinfo
2
+ from datetime import datetime, time, timedelta
3
+ from importlib import import_module
4
+
5
+ import pytest
6
+ from django.db.models import Q
7
+ from django.utils.timezone import make_aware
8
+ from faker import Faker
9
+ from psycopg.types.range import TimestamptzRange
10
+
11
+ from wbhuman_resources.models.calendars import (
12
+ DayOff,
13
+ InvalidDayOffCalendarResourceError,
14
+ )
15
+ from wbhuman_resources.models.employee import EmployeeHumanResource
16
+
17
+ fake = Faker()
18
+
19
+
20
+ @pytest.mark.django_db
21
+ class TestDayOffCalendar:
22
+ def test_get_period_start_choices(self, day_off_calendar):
23
+ assert day_off_calendar.get_period_start_choices() == ["09:00:00", "14:00:00"]
24
+
25
+ def test_get_period_end_choices(self, day_off_calendar):
26
+ assert day_off_calendar.get_period_end_choices() == ["13:00:00", "18:00:00"]
27
+
28
+ @pytest.mark.parametrize("val_date", [(fake.date_this_decade())])
29
+ def test_create_public_holidays_sanitize_resource(self, day_off_calendar_factory, val_date):
30
+ with pytest.raises(InvalidDayOffCalendarResourceError):
31
+ calendar = day_off_calendar_factory.create(resource="eurasia.China")
32
+ calendar.create_public_holidays(val_date.year)
33
+ with pytest.raises(InvalidDayOffCalendarResourceError):
34
+ calendar = day_off_calendar_factory.create(resource="europe.Listenbourg")
35
+ calendar.create_public_holidays(val_date.year)
36
+
37
+ @pytest.mark.parametrize(
38
+ "val_date,continent,region",
39
+ [
40
+ (fake.date_this_decade(), "europe", "Berlin"),
41
+ (fake.date_this_decade(), "europe", "Switzerland"),
42
+ ],
43
+ )
44
+ def test_create_public_holidays_valid_resource(self, day_off_calendar_factory, val_date, continent, region):
45
+ calendar = day_off_calendar_factory.create(resource=f"{continent}.{region}")
46
+ calendar.create_public_holidays(val_date.year)
47
+ workalendar = import_module(f"workalendar.{continent}")
48
+ cal = getattr(workalendar, region)()
49
+ for _d, _ in cal.holidays(val_date.year):
50
+ assert DayOff.objects.filter(
51
+ date=_d,
52
+ calendar=calendar,
53
+ )
54
+
55
+ def test_get_day_off_per_employee_df(self, day_off_calendar, employee_human_resource_factory, day_off_factory):
56
+ employee1 = employee_human_resource_factory.create()
57
+ employee2 = employee_human_resource_factory.create()
58
+ employee_human_resource_factory.create()
59
+ base_day_off = day_off_factory.create()
60
+ period1 = day_off_calendar.default_periods.first()
61
+ period2 = day_off_calendar.default_periods.last()
62
+ day_off_factory.create(date=base_day_off.date - timedelta(days=1)) # create left_outside_day_off
63
+ day_off_factory.create(date=base_day_off.date + timedelta(days=1)) # create right_outside_day_off
64
+ res = (
65
+ day_off_calendar.get_day_off_per_employee_df(
66
+ base_day_off.date,
67
+ base_day_off.date,
68
+ EmployeeHumanResource.objects.filter(Q(id=employee1.id) | Q(id=employee2.id)),
69
+ )
70
+ .set_index(["employee", "period"])
71
+ .to_dict("index")
72
+ )
73
+ assert res == {
74
+ (employee1.id, period1.id): {"date": base_day_off.date, "type": "Holiday", "status": "APPROVED"},
75
+ (employee1.id, period2.id): {"date": base_day_off.date, "type": "Holiday", "status": "APPROVED"},
76
+ (employee2.id, period1.id): {"date": base_day_off.date, "type": "Holiday", "status": "APPROVED"},
77
+ (employee2.id, period2.id): {"date": base_day_off.date, "type": "Holiday", "status": "APPROVED"},
78
+ }
79
+
80
+ @pytest.mark.parametrize("h1,h2", [(fake.pyint(min_value=1), fake.pyint(min_value=1))])
81
+ def test_get_daily_hours(self, day_off_calendar_without_period, default_daily_period_factory, h1, h2):
82
+ default_daily_period_factory.create(
83
+ calendar=day_off_calendar_without_period,
84
+ total_hours=h1,
85
+ lower_time=time(9, 0, 0),
86
+ upper_time=time(13, 0, 0),
87
+ )
88
+ default_daily_period_factory.create(
89
+ calendar=day_off_calendar_without_period,
90
+ total_hours=h2,
91
+ lower_time=time(14, 0, 0),
92
+ upper_time=time(18, 0, 0),
93
+ )
94
+ assert day_off_calendar_without_period.get_daily_hours() == h1 + h2
95
+
96
+ @pytest.mark.parametrize(
97
+ "ranges, hour_start, expected_res",
98
+ [
99
+ (
100
+ [(time(9, 0, 0), time(13, 0, 0)), (time(14, 0, 0), time(18, 0, 0))],
101
+ time(0, 0),
102
+ [(time(0, 0), time(8, 59)), (time(13, 1), time(13, 59)), (time(18, 1), time(23, 59))],
103
+ ),
104
+ (
105
+ [(time(9, 0, 0), time(13, 0, 0)), (time(14, 0, 0), time(18, 0, 0))],
106
+ time(4, 0),
107
+ [(time(4, 0), time(8, 59)), (time(13, 1), time(13, 59)), (time(18, 1), time(3, 59))],
108
+ ),
109
+ (
110
+ [(time(9, 0, 0), time(13, 0, 0)), (time(14, 0, 0), time(18, 0, 0))],
111
+ time(15, 0),
112
+ [(time(18, 1), time(8, 59)), (time(13, 1), time(13, 59))],
113
+ ),
114
+ (
115
+ [(time(9, 0, 0), time(13, 0, 0)), (time(14, 0, 0), time(18, 0, 0))],
116
+ time(20, 0),
117
+ [(time(20, 0), time(8, 59)), (time(13, 1), time(13, 59)), (time(18, 1), time(19, 59))],
118
+ ),
119
+ ],
120
+ )
121
+ def test_get_unworked_time_range(
122
+ self, day_off_calendar_without_period, default_daily_period_factory, ranges, hour_start, expected_res
123
+ ):
124
+ for lower, upper in ranges:
125
+ default_daily_period_factory.create(
126
+ calendar=day_off_calendar_without_period, lower_time=lower, upper_time=upper
127
+ )
128
+
129
+ assert list(day_off_calendar_without_period.get_unworked_time_range(hour_start)) == expected_res
130
+
131
+ @pytest.mark.parametrize(
132
+ "unnormalized_lower_datetime, unnormalized_upper_datetime, expected_lower_datetime, expected_upper_datetime",
133
+ [
134
+ (datetime(2023, 1, 1, 8), datetime(2023, 1, 1, 20), datetime(2023, 1, 1, 9), datetime(2023, 1, 1, 18)),
135
+ (datetime(2023, 1, 1, 15), datetime(2023, 1, 1, 23), datetime(2023, 1, 1, 14), datetime(2023, 1, 1, 18)),
136
+ (
137
+ datetime(2023, 1, 1, 2, 3, 2),
138
+ datetime(2023, 1, 1, 23, 5, 6),
139
+ datetime(2023, 1, 1, 9),
140
+ datetime(2023, 1, 1, 18),
141
+ ),
142
+ ],
143
+ )
144
+ def test_normalize_period(
145
+ self,
146
+ day_off_calendar,
147
+ unnormalized_lower_datetime,
148
+ unnormalized_upper_datetime,
149
+ expected_lower_datetime,
150
+ expected_upper_datetime,
151
+ ):
152
+ normalized_period = day_off_calendar.normalize_period(
153
+ TimestamptzRange(
154
+ lower=make_aware(unnormalized_lower_datetime, day_off_calendar.timezone),
155
+ upper=make_aware(unnormalized_upper_datetime, day_off_calendar.timezone),
156
+ )
157
+ )
158
+ assert normalized_period.lower == make_aware(expected_lower_datetime, day_off_calendar.timezone)
159
+ assert normalized_period.upper == make_aware(expected_upper_datetime, day_off_calendar.timezone)
160
+
161
+
162
+ @pytest.mark.django_db
163
+ class TestDayOff:
164
+ @pytest.mark.parametrize(
165
+ "timezone_str, lower_time, upper_time",
166
+ [
167
+ ("Pacific/Kwajalein", time(9, 0, 0), time(18, 0, 0)),
168
+ ("Europe/Berlin", time(9, 0, 0), time(18, 0, 0)),
169
+ ("Europe/Berlin", time(15, 0, 0), time(16, 0, 0)),
170
+ ],
171
+ )
172
+ def test_get_timespan(
173
+ self,
174
+ base_day_off_calendar_factory,
175
+ default_daily_period_factory,
176
+ day_off_factory,
177
+ timezone_str,
178
+ lower_time,
179
+ upper_time,
180
+ ):
181
+ timezone = zoneinfo.ZoneInfo(timezone_str)
182
+ calendar = base_day_off_calendar_factory.create(timezone=timezone)
183
+ default_daily_period_factory.create(calendar=calendar, lower_time=lower_time, upper_time=upper_time)
184
+ day_off = day_off_factory.create(calendar=calendar)
185
+ assert day_off.period.lower == datetime.combine(day_off.date, lower_time, tzinfo=timezone).astimezone(
186
+ zoneinfo.ZoneInfo("UTC")
187
+ )
188
+ assert day_off.period.upper == datetime.combine(day_off.date, upper_time, tzinfo=timezone).astimezone(
189
+ zoneinfo.ZoneInfo("UTC")
190
+ )
191
+ #
192
+ # timespan = day_off.get_timespan(as_utc=False)
193
+ # assert timespan.lower == datetime.combine(day_off.date, lower_time, tzinfo=timezone)
194
+ # assert timespan.upper == datetime.combine(day_off.date, upper_time, tzinfo=timezone)
195
+
196
+
197
+ @pytest.mark.django_db
198
+ class TestDefaultDailyPeriod:
199
+ @pytest.mark.parametrize("val_date", [fake.date_object()])
200
+ def test_get_lower_datetime(self, default_daily_period, val_date):
201
+ assert default_daily_period.get_lower_datetime(val_date) == datetime.combine(
202
+ val_date, default_daily_period.lower_time, tzinfo=default_daily_period.calendar.timezone
203
+ ).astimezone(zoneinfo.ZoneInfo("UTC"))
204
+
205
+ @pytest.mark.parametrize("val_date", [fake.date_object()])
206
+ def test_get_upper_datetime(self, default_daily_period, val_date):
207
+ assert default_daily_period.get_upper_datetime(val_date) == datetime.combine(
208
+ val_date, default_daily_period.upper_time, tzinfo=default_daily_period.calendar.timezone
209
+ ).astimezone(zoneinfo.ZoneInfo("UTC"))