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,370 @@
1
+ import zoneinfo
2
+ from datetime import date, datetime, time
3
+ from importlib import import_module
4
+ from typing import TypeVar
5
+
6
+ import pandas as pd
7
+ from dateutil import rrule
8
+ from django.contrib.postgres.constraints import ExclusionConstraint
9
+ from django.contrib.postgres.fields import DateTimeRangeField, RangeOperators
10
+ from django.db import models
11
+ from django.db.models import CheckConstraint, F, Q, Sum
12
+ from django.db.models.signals import post_migrate
13
+ from django.dispatch import receiver
14
+ from django.utils import timezone
15
+ from django.utils.translation import gettext_lazy as _
16
+ from dynamic_preferences.registries import global_preferences_registry
17
+ from psycopg.types.range import TimestamptzRange
18
+ from timezone_field import TimeZoneField
19
+ from wbcore.contrib.agenda.models import CalendarItem
20
+ from wbcore.contrib.icons import WBIcon
21
+ from wbcore.models import WBModel
22
+
23
+ SelfDefaultDailyPeriod = TypeVar("SelfDefaultDailyPeriod", bound="DefaultDailyPeriod")
24
+
25
+
26
+ class InvalidDayOffCalendarResourceError(Exception):
27
+ pass
28
+
29
+
30
+ class DayOffCalendar(WBModel):
31
+ title = models.CharField(max_length=255)
32
+ resource = models.CharField(
33
+ max_length=255, null=True, blank=True, help_text=_("Used to fetch the days off from an API.")
34
+ )
35
+ timezone = TimeZoneField(default="UTC")
36
+
37
+ def __str__(self) -> str:
38
+ return f"{self.title}"
39
+
40
+ def get_period_start_choices(self) -> list[str]:
41
+ """
42
+ Get a text choices datastructure from the possible Default Periods starts time
43
+
44
+ Returns:
45
+ a list of tuple containing the string representation of the periods lower time
46
+ """
47
+ return [str(t) for t in self.default_periods.order_by("lower_time").values_list("lower_time", flat=True)]
48
+
49
+ def get_period_end_choices(self) -> list[str]:
50
+ """
51
+ Get a text choices datastructure from the possible Default Periods ends time
52
+
53
+ Returns:
54
+ a list of tuple containing the string representation of the periods upper time
55
+ """
56
+ return [str(t) for t in self.default_periods.order_by("upper_time").values_list("upper_time", flat=True)]
57
+
58
+ def create_public_holidays(self, year: int):
59
+ """
60
+ Utility function that generate all holiday provided by the "workalendar" package as DayOff for the given year
61
+
62
+ Args:
63
+ year: Year to generate holidays from
64
+ Raises:
65
+ InvalidDayOffCalendarResourceError if calendar's resource is wrongly formatted.
66
+ """
67
+ # If no resource is specified, then we exit early
68
+ if self.resource is None or self.resource == "":
69
+ return
70
+
71
+ continent, region = self.resource.split(".")
72
+ try:
73
+ workalendar = import_module(f"workalendar.{continent}")
74
+ holidays = getattr(workalendar, region)()
75
+ for holiday_date, holiday_title in holidays.holidays(year):
76
+ DayOff.objects.get_or_create(
77
+ date=holiday_date, calendar=self, defaults={"count_as_holiday": True, "title": holiday_title}
78
+ )
79
+ except ModuleNotFoundError as e:
80
+ raise InvalidDayOffCalendarResourceError(_("The continent you've supplied is invalid.")) from e
81
+ except AttributeError as e:
82
+ raise InvalidDayOffCalendarResourceError(_("The region you've supplied is invalid.")) from e
83
+
84
+ def get_day_off_per_employee_df(self, start: date, end: date, employees) -> pd.DataFrame:
85
+ """
86
+ Utility function that reshape the day off for a given calendar and a list of employees among a certain date range
87
+
88
+ Args:
89
+ start: Start filter
90
+ end: End filter
91
+ employees: Queryset of employees
92
+
93
+ Returns:
94
+ A dataframe whose columns are [employee, period, date, type, status] with type HOLIDAY and status APPROVED
95
+ """
96
+ employee_df = pd.DataFrame(employees.values("id")).rename(columns={"id": "employee"})
97
+ day_off_df = pd.DataFrame(self.days_off.filter(date__gte=start, date__lte=end).values("date"))
98
+ periods_df = pd.DataFrame(self.default_periods.values("id")).rename(columns={"id": "period"})
99
+ df = employee_df.merge(day_off_df, how="cross").merge(periods_df, how="cross")
100
+ df["type"] = "Holiday"
101
+ df["status"] = "APPROVED"
102
+ return df
103
+
104
+ def get_daily_hours(self) -> float:
105
+ """
106
+ Utility function to return the total number of hours that a typical working day spans from this calendar
107
+
108
+ Returns:
109
+ A float number corresponding to the total working hours
110
+ """
111
+ return self.default_periods.aggregate(s=Sum("total_hours"))["s"] or 0
112
+
113
+ def get_unworked_time_range(self, start_time: time = None) -> list[tuple[int, int]]:
114
+ """
115
+ Utility function that return a list of unworked time range. These time range are the exclusion of the periods defined in the default period table and a typical earth time range.
116
+
117
+ Args:
118
+ start_time: If specified, starts the day from that time. Default to time(0,0,0)
119
+
120
+ Returns:
121
+ A list of time range tuple
122
+ """
123
+ if not start_time:
124
+ start_time = time(0, 0, 0)
125
+ rules = rrule.rruleset()
126
+ rules.rrule(
127
+ rrule.rrule(
128
+ freq=rrule.MINUTELY,
129
+ dtstart=datetime(1, 1, 1, 0, 0),
130
+ until=datetime(1, 1, 1, 23, 59),
131
+ )
132
+ )
133
+ for period in self.default_periods.order_by("lower_time"):
134
+ rules.exrule(
135
+ rrule.rrule(
136
+ rrule.MINUTELY,
137
+ dtstart=datetime(1, 1, 1, period.lower_time.hour, period.lower_time.minute, 0),
138
+ until=datetime(1, 1, 1, period.upper_time.hour, period.upper_time.minute, 0),
139
+ )
140
+ )
141
+
142
+ non_workable_minutes = list(rules)
143
+ pivot_index = next(x[0] for x in enumerate(non_workable_minutes) if x[1].time() >= start_time)
144
+ non_workable_minutes = non_workable_minutes[pivot_index:] + non_workable_minutes[:pivot_index]
145
+
146
+ start_range = None
147
+ before_minute = None
148
+ for minute in non_workable_minutes:
149
+ if not start_range:
150
+ start_range = minute
151
+ if before_minute and minute.minute != (before_minute.minute + 1) % 60:
152
+ yield start_range.time(), before_minute.time()
153
+ start_range = minute
154
+ before_minute = minute
155
+ yield start_range.time(), before_minute.time()
156
+
157
+ def save(self, *args, **kwargs):
158
+ if not self.resource:
159
+ self.resource = global_preferences_registry.manager()[
160
+ "wbhuman_resources__calendar_default_public_holiday_package"
161
+ ]
162
+ if not self.timezone:
163
+ self.timezone = zoneinfo.ZoneInfo(
164
+ global_preferences_registry.manager()["wbhuman_resources__calendar_default_timezone"]
165
+ )
166
+ super().save(*args, **kwargs)
167
+
168
+ def get_default_fullday_period(self, val_date):
169
+ return TimestamptzRange(
170
+ lower=self.default_periods.earliest("lower_time").get_lower_datetime(val_date),
171
+ upper=self.default_periods.latest("lower_time").get_upper_datetime(val_date),
172
+ )
173
+
174
+ def normalize_period(self, period: TimestamptzRange) -> TimestamptzRange:
175
+ """
176
+ Given a aware range of datetime, ensure that the local time of each corresponds to a valid time choices
177
+ Args:
178
+ period: The period to normalize
179
+
180
+ Returns:
181
+ The normalize period
182
+ """
183
+
184
+ def _get_closest_time(periods, ts):
185
+ return sorted(
186
+ map(lambda x: (x[0], abs(ts - x[0].hour * 3600 + x[0].minute * 60 + x[0].second)), periods),
187
+ key=lambda x: x[1],
188
+ )[0][0]
189
+
190
+ local_lower_datetime = period.lower.astimezone(self.timezone)
191
+ local_upper_datetime = period.upper.astimezone(self.timezone)
192
+ if str(local_lower_datetime.astimezone(self.timezone).time()) not in self.get_period_start_choices():
193
+ closest_time = _get_closest_time(
194
+ self.default_periods.values_list("lower_time"),
195
+ local_lower_datetime.time().hour * 3600
196
+ + local_lower_datetime.time().minute * 60
197
+ + local_lower_datetime.time().second,
198
+ )
199
+ local_lower_datetime = datetime.combine(period.lower.date(), closest_time, tzinfo=self.timezone)
200
+ if str(local_upper_datetime.astimezone(self.timezone).time()) not in self.get_period_end_choices():
201
+ closest_time = _get_closest_time(
202
+ self.default_periods.exclude(lower_time__lt=local_lower_datetime.time()).values_list("upper_time"),
203
+ local_upper_datetime.time().hour * 3600
204
+ + local_upper_datetime.time().minute * 60
205
+ + local_upper_datetime.time().second,
206
+ )
207
+ local_upper_datetime = datetime.combine(period.upper.date(), closest_time, tzinfo=self.timezone)
208
+ return TimestamptzRange(lower=local_lower_datetime, upper=local_upper_datetime)
209
+
210
+ class Meta:
211
+ verbose_name = _("Day Off Calendar")
212
+ verbose_name_plural = _("Days Off Calendar")
213
+ unique_together = ("resource", "timezone")
214
+
215
+ @classmethod
216
+ def get_endpoint_basename(cls) -> str:
217
+ return "wbhuman_resources:dayoffcalendar"
218
+
219
+ @classmethod
220
+ def get_representation_endpoint(cls) -> str:
221
+ return "wbhuman_resources:dayoffcalendarrepresentation-list"
222
+
223
+ @classmethod
224
+ def get_representation_value_key(cls) -> str:
225
+ return "id"
226
+
227
+ @classmethod
228
+ def get_representation_label_key(cls) -> str:
229
+ return "{{title}}"
230
+
231
+
232
+ class DayOff(CalendarItem):
233
+ date = models.DateField(verbose_name=_("Date"))
234
+ count_as_holiday = models.BooleanField(
235
+ default=True,
236
+ verbose_name=_("Count as Holiday"),
237
+ help_text=_("If true, there is no work but the day counts towards the employees' vacation balance"),
238
+ )
239
+ calendar = models.ForeignKey(
240
+ to="wbhuman_resources.DayOffCalendar",
241
+ related_name="days_off",
242
+ on_delete=models.PROTECT,
243
+ verbose_name=_("Calendar"),
244
+ )
245
+
246
+ class Meta:
247
+ verbose_name = _("Day Off")
248
+ verbose_name_plural = _("Days Off")
249
+ constraints = [models.UniqueConstraint(fields=["date", "calendar"], name="unique_date_for_calendar")]
250
+
251
+ def __str__(self) -> str:
252
+ return f"{self.title} ({self.date})"
253
+
254
+ def get_color(self) -> str:
255
+ return "#211ae9" # dark blue
256
+
257
+ def get_icon(self) -> str:
258
+ return WBIcon.DAY_OFF.icon
259
+
260
+ def save(self, *args, **kwargs) -> TimestamptzRange:
261
+ default_periods = self.calendar.default_periods
262
+ self.all_day = True
263
+ if default_periods.exists():
264
+ self.period = self.calendar.get_default_fullday_period(self.date)
265
+ super().save(*args, **kwargs)
266
+
267
+ @classmethod
268
+ def get_endpoint_basename(cls) -> str:
269
+ return "wbhuman_resources:dayoff"
270
+
271
+ @classmethod
272
+ def get_representation_endpoint(cls) -> str:
273
+ return "wbhuman_resources:dayoffresentation-list"
274
+
275
+ @classmethod
276
+ def get_representation_value_key(cls) -> str:
277
+ return "id"
278
+
279
+ @classmethod
280
+ def get_representation_label_key(cls) -> str:
281
+ return "{{title}}"
282
+
283
+
284
+ class DefaultDailyPeriod(models.Model):
285
+ lower_time = models.TimeField()
286
+ upper_time = models.TimeField()
287
+
288
+ timespan = (
289
+ DateTimeRangeField()
290
+ ) # "readonly" field that default to combined time with the epoch date, Used to apply database constraint
291
+ title = models.CharField(max_length=128)
292
+ total_hours = models.FloatField()
293
+
294
+ calendar = models.ForeignKey(
295
+ to="wbhuman_resources.DayOffCalendar",
296
+ related_name="default_periods",
297
+ on_delete=models.PROTECT,
298
+ verbose_name=_("Calendar"),
299
+ )
300
+
301
+ class Meta:
302
+ verbose_name = _("Default Daily Period")
303
+ verbose_name_plural = _("Default Daily Periods")
304
+ constraints = [
305
+ CheckConstraint(check=Q(upper_time__gt=F("lower_time")), name="check_lower_time_lt_upper_time"),
306
+ ExclusionConstraint(
307
+ expressions=[("timespan", RangeOperators.OVERLAPS), ("calendar", RangeOperators.EQUAL)],
308
+ name="check_no_overlapping_default_periods_time",
309
+ ),
310
+ ]
311
+
312
+ def save(self, *args, **kwargs):
313
+ if not hasattr(self, "calendar") or not self.calendar:
314
+ self.calendar = DayOffCalendar.objects.first()
315
+ self.timespan = TimestamptzRange(
316
+ lower=self.get_lower_datetime(date(1970, 1, 1)),
317
+ upper=self.get_upper_datetime(date(1970, 1, 1)),
318
+ )
319
+ if not self.total_hours:
320
+ self.total_hours = (self.timespan.upper - self.timespan.lower).total_seconds() / 3600
321
+ super().save(*args, **kwargs)
322
+
323
+ def get_lower_datetime(self, val_date: date) -> datetime:
324
+ """
325
+ Getter function to build a datetime object from this lower period time and the given date
326
+
327
+ Args:
328
+ val_date: The date to combine the time with
329
+
330
+ Returns:
331
+ A datetime
332
+ """
333
+ return datetime.combine(val_date, self.lower_time, tzinfo=self.calendar.timezone)
334
+
335
+ def get_upper_datetime(self, val_date: date) -> datetime:
336
+ """
337
+ Getter function to build a datetime object from this uper period time and the given date
338
+
339
+ Args:
340
+ val_date: The date to combine the time with
341
+
342
+ Returns:
343
+ A datetime
344
+ """
345
+ return datetime.combine(val_date, self.upper_time, tzinfo=self.calendar.timezone)
346
+
347
+ def __str__(self) -> str:
348
+ return f"{self.title} ({self.calendar}) [{self.lower_time:%H:%M} - {self.upper_time:%H:%M}]"
349
+
350
+ @classmethod
351
+ def get_representation_endpoint(cls) -> str:
352
+ return "wbhuman_resources:defaultdailyperiodrepresentation-list"
353
+
354
+ @classmethod
355
+ def get_representation_value_key(cls) -> str:
356
+ return "id"
357
+
358
+ @classmethod
359
+ def get_representation_label_key(cls) -> str:
360
+ return "{{title}}"
361
+
362
+
363
+ @receiver(post_migrate, sender=DayOffCalendar)
364
+ def post_migrate_day_off_calendar(sender, verbosity, interactive, stdout, using, plan, apps, **kwargs):
365
+ """
366
+ After migration, we check if DayOffCalendar have at least one element. OOtherwise, we create a default one
367
+ """
368
+ if not DayOffCalendar.objects.exists():
369
+ calendar = DayOffCalendar.objects.create(title="Default Calendar", timezone=timezone.get_current_timezone())
370
+ global_preferences_registry["wbhuman_resources__employee_default_calendar"] = calendar