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,982 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from typing import TypeVar
|
|
4
|
+
|
|
5
|
+
from celery import shared_task
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.contrib.auth import get_user_model
|
|
8
|
+
from django.contrib.sites.models import Site
|
|
9
|
+
from django.core.files.base import ContentFile
|
|
10
|
+
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
11
|
+
from django.db import models
|
|
12
|
+
from django.db.models import (
|
|
13
|
+
BooleanField,
|
|
14
|
+
Case,
|
|
15
|
+
F,
|
|
16
|
+
OuterRef,
|
|
17
|
+
Q,
|
|
18
|
+
QuerySet,
|
|
19
|
+
Subquery,
|
|
20
|
+
Sum,
|
|
21
|
+
Value,
|
|
22
|
+
When,
|
|
23
|
+
)
|
|
24
|
+
from django.db.models.functions import Coalesce
|
|
25
|
+
from django.db.models.signals import pre_save
|
|
26
|
+
from django.dispatch import receiver
|
|
27
|
+
from django.template.loader import get_template
|
|
28
|
+
from django.utils import timezone
|
|
29
|
+
from django.utils.translation import gettext
|
|
30
|
+
from django.utils.translation import gettext_lazy as _
|
|
31
|
+
from django_fsm import FSMField, transition
|
|
32
|
+
from slugify import slugify
|
|
33
|
+
from wbcore.contrib.color.enums import WBColor
|
|
34
|
+
from wbcore.contrib.directory.models import Person
|
|
35
|
+
from wbcore.contrib.documents.models import Document, DocumentType
|
|
36
|
+
from wbcore.contrib.icons import WBIcon
|
|
37
|
+
from wbcore.contrib.notifications.dispatch import send_notification
|
|
38
|
+
from wbcore.contrib.notifications.utils import create_notification_type
|
|
39
|
+
from wbcore.enums import RequestType
|
|
40
|
+
from wbcore.markdown.utils import custom_url_fetcher
|
|
41
|
+
from wbcore.metadata.configs.buttons import ActionButton, ButtonDefaultColor
|
|
42
|
+
from wbcore.models import WBModel
|
|
43
|
+
from wbcore.models.fields import YearField
|
|
44
|
+
from wbcore.models.orderable import OrderableModel
|
|
45
|
+
from wbcore.utils.models import CloneMixin, ComplexToStringMixin
|
|
46
|
+
from weasyprint import HTML
|
|
47
|
+
|
|
48
|
+
from wbhuman_resources.models.employee import get_main_company
|
|
49
|
+
from wbhuman_resources.models.kpi import Evaluation
|
|
50
|
+
|
|
51
|
+
SelfReview = TypeVar("SelfReview", bound="Review")
|
|
52
|
+
User = get_user_model()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def can_trigger_review(instance, user):
|
|
56
|
+
return user.profile == instance.moderator and not instance.is_template and instance.reviewee and instance.reviewer
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def can_validate_review(instance, user):
|
|
60
|
+
return can_trigger_review(instance, user) and instance.signed_reviewee and instance.signed_reviewer
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ReviewGroup(WBModel):
|
|
64
|
+
class Meta:
|
|
65
|
+
verbose_name = _("Review Group")
|
|
66
|
+
verbose_name_plural = _("Review Groups")
|
|
67
|
+
|
|
68
|
+
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
|
69
|
+
employees = models.ManyToManyField("directory.Person", related_name="reviewgroups", blank=True)
|
|
70
|
+
|
|
71
|
+
def __str__(self):
|
|
72
|
+
return f"{self.name}"
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def get_endpoint_basename(cls):
|
|
76
|
+
return "wbhuman_resources:reviewgroup"
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def get_representation_endpoint(cls):
|
|
80
|
+
return "wbhuman_resources:reviewgrouprepresentation-list"
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def get_representation_value_key(cls):
|
|
84
|
+
return "id"
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def get_representation_label_key(cls):
|
|
88
|
+
return "{{name}}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Review(CloneMixin, ComplexToStringMixin, WBModel):
|
|
92
|
+
class Status(models.TextChoices):
|
|
93
|
+
PREPARATION_OF_REVIEW = "PREPARATION_OF_REVIEW", _("Stage 1: Preparation of review")
|
|
94
|
+
FILL_IN_REVIEW = "FILL_IN_REVIEW", _("Stage 2: Fill in review")
|
|
95
|
+
REVIEW = "REVIEW", _("Stage 3: Review")
|
|
96
|
+
EVALUATION = "EVALUATION", _("Stage 4: Evalutation")
|
|
97
|
+
VALIDATION = "VALIDATION", _("Stage 5: Validation")
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def get_color_map(cls):
|
|
101
|
+
colors = [
|
|
102
|
+
WBColor.BLUE_LIGHT.value,
|
|
103
|
+
"rgb(230,230,250)",
|
|
104
|
+
WBColor.YELLOW_LIGHT.value,
|
|
105
|
+
WBColor.GREEN_LIGHT.value,
|
|
106
|
+
WBColor.GREEN_DARK.value,
|
|
107
|
+
]
|
|
108
|
+
return [choice for choice in zip(cls, colors, strict=False)]
|
|
109
|
+
|
|
110
|
+
class Type(models.TextChoices):
|
|
111
|
+
ANNUAL = "ANNUAL", _("Annual")
|
|
112
|
+
INTERMEDIARY = "INTERMEDIARY", _("Intermediary")
|
|
113
|
+
|
|
114
|
+
class Meta:
|
|
115
|
+
verbose_name = _("Review")
|
|
116
|
+
verbose_name_plural = _("Reviews")
|
|
117
|
+
permissions = [
|
|
118
|
+
("administrate_review", "Can Administrate Reviews"),
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
notification_types = [
|
|
122
|
+
create_notification_type(
|
|
123
|
+
code="wbhuman_resources.review.notify",
|
|
124
|
+
title="Review Notification",
|
|
125
|
+
help_text="Notifies you when a review has been submitted",
|
|
126
|
+
)
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
from_date = models.DateField(null=True, blank=True, verbose_name=_("From"))
|
|
130
|
+
to_date = models.DateField(null=True, blank=True, verbose_name=_("To"))
|
|
131
|
+
review_deadline = models.DateField(null=True, blank=True, verbose_name=_("Deadline"))
|
|
132
|
+
review = models.DateTimeField(null=True, blank=True, verbose_name=_("Review Date"))
|
|
133
|
+
auto_apply_deadline = models.BooleanField(default=True, verbose_name=_("Auto Apply Deadline"))
|
|
134
|
+
year = YearField(null=True, blank=True, verbose_name=_("Year"))
|
|
135
|
+
type = models.CharField(max_length=32, choices=Type.choices, default=Type.ANNUAL, verbose_name=_("Type"))
|
|
136
|
+
status = FSMField(
|
|
137
|
+
default=Status.PREPARATION_OF_REVIEW,
|
|
138
|
+
choices=Status.choices,
|
|
139
|
+
verbose_name=_("Status"),
|
|
140
|
+
help_text=_("Indicates one of the four stages defined by the workflow"),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
reviewee = models.ForeignKey(
|
|
144
|
+
to="directory.Person",
|
|
145
|
+
null=True,
|
|
146
|
+
blank=True,
|
|
147
|
+
related_name="reviewee_reviews",
|
|
148
|
+
on_delete=models.deletion.SET_NULL,
|
|
149
|
+
verbose_name=_("Reviewee"),
|
|
150
|
+
)
|
|
151
|
+
reviewer = models.ForeignKey(
|
|
152
|
+
to="directory.Person",
|
|
153
|
+
null=True,
|
|
154
|
+
blank=True,
|
|
155
|
+
related_name="reviewer_reviews",
|
|
156
|
+
on_delete=models.deletion.SET_NULL,
|
|
157
|
+
verbose_name=_("Reviewer"),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
moderator = models.ForeignKey(
|
|
161
|
+
to="directory.Person",
|
|
162
|
+
null=True,
|
|
163
|
+
blank=True,
|
|
164
|
+
related_name="moderator_reviews",
|
|
165
|
+
on_delete=models.deletion.SET_NULL,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
review_group = models.ForeignKey(
|
|
169
|
+
to="wbhuman_resources.ReviewGroup",
|
|
170
|
+
blank=True,
|
|
171
|
+
null=True,
|
|
172
|
+
on_delete=models.SET_NULL,
|
|
173
|
+
related_name="review_related",
|
|
174
|
+
verbose_name=_("Group"),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
is_template = models.BooleanField(default=False)
|
|
178
|
+
feedback_reviewee = models.TextField(default="", blank=True, verbose_name=_("Feedback Reviewee"))
|
|
179
|
+
feedback_reviewer = models.TextField(default="", blank=True, verbose_name=_("Feedback Reviewer"))
|
|
180
|
+
|
|
181
|
+
signed_reviewee = models.DateTimeField(null=True, blank=True, verbose_name=_("Date of reviewee's signature"))
|
|
182
|
+
signed_reviewer = models.DateTimeField(null=True, blank=True, verbose_name=_("Date of reviewer's signature"))
|
|
183
|
+
|
|
184
|
+
completely_filled_reviewee = models.DateTimeField(
|
|
185
|
+
null=True, blank=True, verbose_name=_("Completely Filled Out Reviewee")
|
|
186
|
+
)
|
|
187
|
+
completely_filled_reviewer = models.DateTimeField(
|
|
188
|
+
null=True, blank=True, verbose_name=_("Completely Filled Out Reviewer")
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
changed = models.DateTimeField(auto_now=True, null=True, blank=True)
|
|
192
|
+
|
|
193
|
+
def compute_str(self) -> str:
|
|
194
|
+
_str = ""
|
|
195
|
+
if self.is_template:
|
|
196
|
+
_str += (
|
|
197
|
+
_("{group} [Template]").format(group=str(self.review_group)) if self.review_group else _("[Template]")
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
_str += (
|
|
201
|
+
_("{reviewee}'s Review").format(reviewee=self.reviewee.computed_str) if self.reviewee else _("Review")
|
|
202
|
+
)
|
|
203
|
+
if self.from_date and self.to_date:
|
|
204
|
+
_str += f" - ({self.from_date} - {self.to_date})"
|
|
205
|
+
elif self.year:
|
|
206
|
+
_str += f" - ({self.year})"
|
|
207
|
+
return _str
|
|
208
|
+
|
|
209
|
+
def __str__(self):
|
|
210
|
+
return self.computed_str
|
|
211
|
+
|
|
212
|
+
def save(self, *args, **kwargs):
|
|
213
|
+
if self.reviewee and (employee := getattr(self.reviewee, "human_resources", None)) and not self.reviewer:
|
|
214
|
+
self.reviewer = next(employee.get_managers(only_direct_manager=True), None)
|
|
215
|
+
self.computed_str = self.compute_str()
|
|
216
|
+
super().save(*args, **kwargs)
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def is_cloneable(self) -> bool:
|
|
220
|
+
"""
|
|
221
|
+
Property used by the CloneMixin to disable the instance cloning functionality from the view
|
|
222
|
+
"""
|
|
223
|
+
return self.is_template
|
|
224
|
+
|
|
225
|
+
def _clone(self, **kwargs) -> SelfReview:
|
|
226
|
+
"""
|
|
227
|
+
Create new row in database with the same data as original instance have.
|
|
228
|
+
We need to clone also the related objects and set correct foreign key value
|
|
229
|
+
"""
|
|
230
|
+
object_copy = Review.objects.get(id=self.id)
|
|
231
|
+
object_copy.id = None
|
|
232
|
+
object_copy.save()
|
|
233
|
+
for related_object in self.questions.all():
|
|
234
|
+
related_object.review = object_copy
|
|
235
|
+
related_object.id = None
|
|
236
|
+
related_object.save()
|
|
237
|
+
return object_copy
|
|
238
|
+
|
|
239
|
+
@classmethod
|
|
240
|
+
def get_administrators(cls) -> QuerySet[User]:
|
|
241
|
+
return (
|
|
242
|
+
get_user_model()
|
|
243
|
+
.objects.filter(
|
|
244
|
+
Q(groups__permissions__codename="administrate_review")
|
|
245
|
+
| Q(user_permissions__codename="administrate_review")
|
|
246
|
+
)
|
|
247
|
+
.distinct()
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
@transition(
|
|
251
|
+
field=status,
|
|
252
|
+
source=Status.PREPARATION_OF_REVIEW,
|
|
253
|
+
target=Status.FILL_IN_REVIEW,
|
|
254
|
+
permission=can_trigger_review,
|
|
255
|
+
custom={
|
|
256
|
+
"_transition_button": ActionButton(
|
|
257
|
+
method=RequestType.PATCH,
|
|
258
|
+
color=ButtonDefaultColor.WARNING,
|
|
259
|
+
identifiers=("wbhuman_resources:review",),
|
|
260
|
+
icon=WBIcon.SEND.icon,
|
|
261
|
+
key="submit",
|
|
262
|
+
label=_("Submit"),
|
|
263
|
+
action_label=_("Stage 2: Fill in review"),
|
|
264
|
+
description_fields=_(
|
|
265
|
+
"<p>Status: <b>Stage 1: Preparation of review</b></p> <p>Reviewee: <b>{{_reviewee.computed_str}}</b></p> <p>Reviewer: <b>{{_reviewer.computed_str}}</b></p> <p>From: <b>{{from_date}}</b></p> <p>To: <b>{{to_date}}</b></p> <p>Deadline: <b>{{review_deadline}}</b></p> <p>Do you want to submit this review to <b>Stage 2: Fill in review?</b></p>"
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
def submit(self, by=None, description=None, **kwargs):
|
|
271
|
+
submit_review.delay(self.id)
|
|
272
|
+
|
|
273
|
+
@transition(
|
|
274
|
+
field=status,
|
|
275
|
+
source=Status.FILL_IN_REVIEW,
|
|
276
|
+
target=Status.REVIEW,
|
|
277
|
+
permission=can_trigger_review,
|
|
278
|
+
custom={
|
|
279
|
+
"_transition_button": ActionButton(
|
|
280
|
+
method=RequestType.PATCH,
|
|
281
|
+
color=ButtonDefaultColor.WARNING,
|
|
282
|
+
identifiers=("wbhuman_resources:review",),
|
|
283
|
+
icon=WBIcon.SEND.icon,
|
|
284
|
+
key="finalize",
|
|
285
|
+
label=_("Finalize"),
|
|
286
|
+
action_label=_("Stage 3: Review"),
|
|
287
|
+
description_fields=_(
|
|
288
|
+
"<p>Status: <b>Stage 2: Fill in review</b></p> <p>Reviewee: <b>{{_reviewee.computed_str}}</b></p> <p>Reviewer: <b>{{_reviewer.computed_str}}</b></p> <p>From: <b>{{from_date}}</b></p> <p>To: <b>{{to_date}}</b></p> <p>Deadline: <b>{{review_deadline}}</b></p> <p>Do you want to send this review to <b>Stage 3: Review?</b></p>"
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
def finalize(self, by=None, description=None, **kwargs):
|
|
294
|
+
if not self.review_deadline:
|
|
295
|
+
self.review_deadline = datetime.now().date()
|
|
296
|
+
finalize_review.delay(self.id)
|
|
297
|
+
if not by:
|
|
298
|
+
self.send_review_notification(
|
|
299
|
+
title=gettext("Stage 3: Review - {self}").format(self=str(self)),
|
|
300
|
+
message=gettext("{self} has moved to stage 3: Review. You can now organize the evaluation").format(
|
|
301
|
+
self=str(self)
|
|
302
|
+
),
|
|
303
|
+
recipient=self.moderator.user_account,
|
|
304
|
+
message_alert_deadline=False,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
@transition(
|
|
308
|
+
field=status,
|
|
309
|
+
source=Status.REVIEW,
|
|
310
|
+
target=Status.FILL_IN_REVIEW,
|
|
311
|
+
permission=can_trigger_review,
|
|
312
|
+
custom={
|
|
313
|
+
"_transition_button": ActionButton(
|
|
314
|
+
method=RequestType.PATCH,
|
|
315
|
+
color=ButtonDefaultColor.WARNING,
|
|
316
|
+
identifiers=("wbhuman_resources:review",),
|
|
317
|
+
icon=WBIcon.EDIT.icon,
|
|
318
|
+
key="undo",
|
|
319
|
+
label=_("Undo"),
|
|
320
|
+
action_label=_("Stage 2: Fill in review"),
|
|
321
|
+
description_fields=_(
|
|
322
|
+
"<p>Status: <b>Stage 3: Review</b></p> <p>Reviewee: <b>{{_reviewee.computed_str}}</b></p> <p>Reviewer: <b>{{_reviewer.computed_str}}</b></p> <p>From: <b>{{from_date}}</b></p> <p>To: <b>{{to_date}}</b></p> <p>Deadline: <b>{{review_deadline}}</b></p> <p>Do you want to send this review to <b>Stage 2: Fill in review?</b></p>"
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
},
|
|
326
|
+
)
|
|
327
|
+
def undo(self, by=None, description=None, **kwargs):
|
|
328
|
+
if self.review_deadline <= datetime.now().date():
|
|
329
|
+
self.review_deadline = datetime.now().date() + timedelta(days=1)
|
|
330
|
+
|
|
331
|
+
@transition(
|
|
332
|
+
field=status,
|
|
333
|
+
source=Status.REVIEW,
|
|
334
|
+
target=Status.EVALUATION,
|
|
335
|
+
permission=can_trigger_review,
|
|
336
|
+
custom={
|
|
337
|
+
"_transition_button": ActionButton(
|
|
338
|
+
method=RequestType.PATCH,
|
|
339
|
+
color=ButtonDefaultColor.WARNING,
|
|
340
|
+
identifiers=("wbhuman_resources:review",),
|
|
341
|
+
icon=WBIcon.SEND.icon,
|
|
342
|
+
key="evaluate",
|
|
343
|
+
label=_("Evaluate"),
|
|
344
|
+
action_label=_("Stage 4: Evaluation"),
|
|
345
|
+
description_fields=_(
|
|
346
|
+
"<p>Status: <b>Stage 3: Review</b></p> <p>Reviewee: <b>{{_reviewee.computed_str}}</b></p> <p>Reviewer: <b>{{_reviewer.computed_str}}</b></p> <p>From: <b>{{from_date}}</b></p> <p>To: <b>{{to_date}}</b></p> <p>Deadline: <b>{{review_deadline}}</b></p> <p>Do you want to send this review to <b>Stage 4: Evaluation?</b></p>"
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
},
|
|
350
|
+
)
|
|
351
|
+
def evaluate(self, by=None, description=None, **kwargs):
|
|
352
|
+
if not self.review:
|
|
353
|
+
self.review = timezone.now()
|
|
354
|
+
|
|
355
|
+
@transition(
|
|
356
|
+
field=status,
|
|
357
|
+
source=Status.EVALUATION,
|
|
358
|
+
target=Status.VALIDATION,
|
|
359
|
+
permission=can_validate_review,
|
|
360
|
+
custom={
|
|
361
|
+
"_transition_button": ActionButton(
|
|
362
|
+
method=RequestType.PATCH,
|
|
363
|
+
color=ButtonDefaultColor.WARNING,
|
|
364
|
+
identifiers=("wbhuman_resources:review",),
|
|
365
|
+
icon=WBIcon.SEND.icon,
|
|
366
|
+
key="validation",
|
|
367
|
+
label=_("Validate"),
|
|
368
|
+
action_label=_("Stage 4: Evaluation"),
|
|
369
|
+
description_fields=_(
|
|
370
|
+
"<p>Status: <b>Stage 4: Evaluation</b></p> <p>Reviewee: <b>{{_reviewee.computed_str}}</b></p> <p>Reviewer: <b>{{_reviewer.computed_str}}</b></p> <p>From: <b>{{from_date}}</b></p> <p>To: <b>{{to_date}}</b></p> <p>Do you want to send this review to <b>Stage 5: Validation?</b></p>"
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
},
|
|
374
|
+
)
|
|
375
|
+
def validation(self, by=None, description=None, **kwargs):
|
|
376
|
+
users = (
|
|
377
|
+
get_user_model()
|
|
378
|
+
.objects.filter(
|
|
379
|
+
Q(groups__permissions__codename="administrate_review")
|
|
380
|
+
| Q(user_permissions__codename="administrate_review")
|
|
381
|
+
)
|
|
382
|
+
.distinct()
|
|
383
|
+
)
|
|
384
|
+
for user in users:
|
|
385
|
+
send_review_report_via_mail.delay(user.id, self.id)
|
|
386
|
+
|
|
387
|
+
def send_review_notification(self, title, message, recipient, message_alert_deadline=True):
|
|
388
|
+
if message_alert_deadline:
|
|
389
|
+
message += gettext(
|
|
390
|
+
"<p>Please pay particular attention to the deadline <b>{deadline}</b>, the review will automatically move to step 3 of the workflow on that date. At this stage the data will be frozen, you will not be able to modify it.</p>"
|
|
391
|
+
).format(deadline=self.review_deadline)
|
|
392
|
+
|
|
393
|
+
send_notification(
|
|
394
|
+
code="wbhuman_resources.review.notify",
|
|
395
|
+
title=title,
|
|
396
|
+
body=message,
|
|
397
|
+
user=recipient,
|
|
398
|
+
reverse_name="wbhuman_resources:review-detail",
|
|
399
|
+
reverse_args=[self.id],
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def generate_pdf(self):
|
|
403
|
+
html = get_template("review/review_report.html")
|
|
404
|
+
|
|
405
|
+
text_answers = ReviewAnswer.objects.filter(
|
|
406
|
+
Q(question__review=self) & Q(question__answer_type=ReviewQuestion.ANSWERTYPE.TEXT)
|
|
407
|
+
).order_by("question__category__order", "question__order")
|
|
408
|
+
category_ids = text_answers.values_list("question__category", flat=True).distinct()
|
|
409
|
+
|
|
410
|
+
table = {}
|
|
411
|
+
for category_id in category_ids:
|
|
412
|
+
if category_id:
|
|
413
|
+
category = ReviewQuestionCategory.objects.get(id=category_id)
|
|
414
|
+
table[category.id] = {"name": category.name, "questions": {}}
|
|
415
|
+
else:
|
|
416
|
+
category_id = ""
|
|
417
|
+
category = None
|
|
418
|
+
table[""] = {"name": "", "questions": {}}
|
|
419
|
+
question_ids = (
|
|
420
|
+
text_answers.filter(question__category=category)
|
|
421
|
+
.order_by("question__order")
|
|
422
|
+
.values_list("question", flat=True)
|
|
423
|
+
.distinct()
|
|
424
|
+
)
|
|
425
|
+
for question_id in question_ids:
|
|
426
|
+
question = ReviewQuestion.objects.get(id=question_id)
|
|
427
|
+
table[category_id]["questions"][question_id] = {"name": question.question, "answers": {}}
|
|
428
|
+
for answer in text_answers.filter(
|
|
429
|
+
Q(question__category=category) & Q(question__id=question_id)
|
|
430
|
+
).order_by("question__order", "answered_by"):
|
|
431
|
+
table[category_id]["questions"][question_id]["answers"][answer.answered_by] = answer.answer_text
|
|
432
|
+
|
|
433
|
+
rating_answers = (
|
|
434
|
+
ReviewAnswer.objects.filter(Q(question__review=self) & Q(question__answer_type="RATING"))
|
|
435
|
+
.annotate(
|
|
436
|
+
is_reviewee=Case(
|
|
437
|
+
When(answered_by=self.reviewee, then=Value(True)),
|
|
438
|
+
default=Value(False),
|
|
439
|
+
output_field=BooleanField(),
|
|
440
|
+
),
|
|
441
|
+
is_reviewer=Case(
|
|
442
|
+
When(answered_by=self.reviewer, then=Value(True)),
|
|
443
|
+
default=Value(False),
|
|
444
|
+
output_field=BooleanField(),
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
.order_by("question__category__order")
|
|
448
|
+
)
|
|
449
|
+
category_ids = rating_answers.values_list("question__category", flat=True).distinct()
|
|
450
|
+
|
|
451
|
+
rating_table = {}
|
|
452
|
+
for category_id in category_ids:
|
|
453
|
+
if category_id:
|
|
454
|
+
category = ReviewQuestionCategory.objects.get(id=category_id)
|
|
455
|
+
rating_table[category.id] = {"name": category.name, "questions": {}}
|
|
456
|
+
else:
|
|
457
|
+
category_id = ""
|
|
458
|
+
category = None
|
|
459
|
+
rating_table[""] = {"name": "", "questions": {}}
|
|
460
|
+
question_ids = (
|
|
461
|
+
rating_answers.filter(question__category=category)
|
|
462
|
+
.order_by("question__order")
|
|
463
|
+
.values_list("question", flat=True)
|
|
464
|
+
.distinct()
|
|
465
|
+
)
|
|
466
|
+
for question_id in question_ids:
|
|
467
|
+
question = ReviewQuestion.objects.get(id=question_id)
|
|
468
|
+
rating_table[category_id]["questions"][question_id] = {
|
|
469
|
+
"name": question.question,
|
|
470
|
+
"answers": {},
|
|
471
|
+
"answers_text": {},
|
|
472
|
+
}
|
|
473
|
+
for answer in rating_answers.filter(
|
|
474
|
+
Q(question__category=category) & Q(question__id=question_id)
|
|
475
|
+
).order_by("question__order", "answered_by"):
|
|
476
|
+
if answer.is_reviewee:
|
|
477
|
+
rating_table[category_id]["questions"][question_id]["answers"]["reviewee"] = (
|
|
478
|
+
answer.answer_number if answer.answer_number else "-"
|
|
479
|
+
)
|
|
480
|
+
rating_table[category_id]["questions"][question_id]["answers_text"]["reviewee"] = (
|
|
481
|
+
answer.answer_text if answer.answer_text else None
|
|
482
|
+
)
|
|
483
|
+
if answer.is_reviewer:
|
|
484
|
+
rating_table[category_id]["questions"][question_id]["answers"]["reviewer"] = (
|
|
485
|
+
answer.answer_number if answer.answer_number else "-"
|
|
486
|
+
)
|
|
487
|
+
rating_table[category_id]["questions"][question_id]["answers_text"]["reviewer"] = (
|
|
488
|
+
answer.answer_text if answer.answer_text else None
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
total_reviewee = rating_answers.filter(is_reviewee=True).aggregate(total=Sum("question__weight"))["total"]
|
|
492
|
+
total_reviewer = rating_answers.filter(is_reviewer=True).aggregate(total=Sum("question__weight"))["total"]
|
|
493
|
+
global_rating_reviewee = (
|
|
494
|
+
rating_answers.filter(is_reviewee=True)
|
|
495
|
+
.annotate(global_rating=F("question__weight") * F("answer_number"))
|
|
496
|
+
.aggregate(total=Sum("global_rating"))["total"]
|
|
497
|
+
)
|
|
498
|
+
global_rating_reviewer = (
|
|
499
|
+
rating_answers.filter(is_reviewer=True)
|
|
500
|
+
.annotate(global_rating=F("question__weight") * F("answer_number"))
|
|
501
|
+
.aggregate(total=Sum("global_rating"))["total"]
|
|
502
|
+
)
|
|
503
|
+
global_rating_reviewee = (
|
|
504
|
+
0 if total_reviewee is None or global_rating_reviewee is None else global_rating_reviewee / total_reviewee
|
|
505
|
+
)
|
|
506
|
+
global_rating_reviewer = (
|
|
507
|
+
0 if total_reviewer is None or global_rating_reviewer is None else global_rating_reviewer / total_reviewer
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
html_content = html.render(
|
|
511
|
+
{
|
|
512
|
+
"base_url": Site.objects.get_current().domain,
|
|
513
|
+
"review": self,
|
|
514
|
+
"table": table,
|
|
515
|
+
"rating_table": rating_table,
|
|
516
|
+
"global_rating_reviewee": round(global_rating_reviewee, 2),
|
|
517
|
+
"global_rating_reviewer": round(global_rating_reviewer, 2),
|
|
518
|
+
"number_total_rating": ReviewQuestion.objects.filter(Q(review=self) & Q(answer_type="RATING")).count(),
|
|
519
|
+
}
|
|
520
|
+
)
|
|
521
|
+
return HTML(
|
|
522
|
+
string=html_content, base_url=settings.BASE_ENDPOINT_URL, url_fetcher=custom_url_fetcher
|
|
523
|
+
).write_pdf()
|
|
524
|
+
|
|
525
|
+
@classmethod
|
|
526
|
+
def get_subquery_review_related_to(cls, requester):
|
|
527
|
+
answers = ReviewAnswer.objects.filter(question__review=OuterRef("pk"), answered_by=requester).order_by()
|
|
528
|
+
return Coalesce(Subquery(answers.values("question__review")[:1]), None)
|
|
529
|
+
|
|
530
|
+
@classmethod
|
|
531
|
+
def subquery_global_rating(cls, requester):
|
|
532
|
+
answers = ReviewAnswer.objects.filter(
|
|
533
|
+
question__review=OuterRef("pk"),
|
|
534
|
+
answered_by=requester,
|
|
535
|
+
question__answer_type=ReviewQuestion.ANSWERTYPE.RATING,
|
|
536
|
+
answer_number__isnull=False,
|
|
537
|
+
).order_by()
|
|
538
|
+
|
|
539
|
+
subquery = (
|
|
540
|
+
answers.annotate(
|
|
541
|
+
rating_weighted=F("question__weight") * F("answer_number"),
|
|
542
|
+
)
|
|
543
|
+
.values("question__review")
|
|
544
|
+
.annotate(
|
|
545
|
+
total_rating=Sum("rating_weighted"),
|
|
546
|
+
total_weight=Sum("question__weight"),
|
|
547
|
+
)
|
|
548
|
+
.annotate(global_rating=Coalesce(F("total_rating") / F("total_weight"), Decimal(0)))
|
|
549
|
+
.values("global_rating")[:1]
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
return Coalesce(Subquery(subquery), None)
|
|
553
|
+
|
|
554
|
+
def get_question_categories(self) -> models.QuerySet:
|
|
555
|
+
return ReviewQuestionCategory.objects.filter(questions_related__review=self).order_by("order").distinct()
|
|
556
|
+
|
|
557
|
+
def get_answer_categories_for_user(self, user: User) -> models.QuerySet:
|
|
558
|
+
if self.status in [self.Status.FILL_IN_REVIEW, self.Status.REVIEW]:
|
|
559
|
+
return (
|
|
560
|
+
ReviewQuestionCategory.objects.filter(
|
|
561
|
+
questions_related__answers__answered_by=user.profile, questions_related__review=self
|
|
562
|
+
)
|
|
563
|
+
.order_by("order")
|
|
564
|
+
.distinct()
|
|
565
|
+
)
|
|
566
|
+
else: # EVALUATION
|
|
567
|
+
return (
|
|
568
|
+
ReviewQuestionCategory.objects.filter(
|
|
569
|
+
questions_related__answer_type=ReviewQuestion.ANSWERTYPE.TEXT, questions_related__review=self
|
|
570
|
+
)
|
|
571
|
+
.order_by("order")
|
|
572
|
+
.distinct()
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
@classmethod
|
|
576
|
+
def get_endpoint_basename(cls):
|
|
577
|
+
return "wbhuman_resources:review"
|
|
578
|
+
|
|
579
|
+
@classmethod
|
|
580
|
+
def get_representation_endpoint(cls):
|
|
581
|
+
return "wbhuman_resources:reviewrepresentation-list"
|
|
582
|
+
|
|
583
|
+
@classmethod
|
|
584
|
+
def get_representation_value_key(cls):
|
|
585
|
+
return "id"
|
|
586
|
+
|
|
587
|
+
@classmethod
|
|
588
|
+
def get_representation_label_key(cls):
|
|
589
|
+
return "{{computed_str}}"
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
class ReviewQuestionCategory(OrderableModel, WBModel):
|
|
593
|
+
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
|
594
|
+
weight = models.DecimalField(default=1.0, max_digits=16, decimal_places=1, verbose_name=_("Weight"))
|
|
595
|
+
|
|
596
|
+
class Meta(OrderableModel.Meta):
|
|
597
|
+
verbose_name = _("Review Question Category")
|
|
598
|
+
verbose_name_plural = _("Review Question Categories")
|
|
599
|
+
|
|
600
|
+
def __str__(self):
|
|
601
|
+
return f"{self.name}"
|
|
602
|
+
|
|
603
|
+
@classmethod
|
|
604
|
+
def get_endpoint_basename(cls):
|
|
605
|
+
return "wbhuman_resources:reviewquestioncategory"
|
|
606
|
+
|
|
607
|
+
@classmethod
|
|
608
|
+
def get_representation_endpoint(cls):
|
|
609
|
+
return "wbhuman_resources:reviewquestioncategoryrepresentation-list"
|
|
610
|
+
|
|
611
|
+
@classmethod
|
|
612
|
+
def get_representation_value_key(cls):
|
|
613
|
+
return "id"
|
|
614
|
+
|
|
615
|
+
@classmethod
|
|
616
|
+
def get_representation_label_key(cls):
|
|
617
|
+
return "{{name}}"
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
class ReviewQuestion(ComplexToStringMixin, OrderableModel, WBModel):
|
|
621
|
+
order_with_respect_to = ("review", "category")
|
|
622
|
+
|
|
623
|
+
class ANSWERTYPE(models.TextChoices):
|
|
624
|
+
TEXT = "TEXT", _("Text")
|
|
625
|
+
RATING = "RATING", _("Rating")
|
|
626
|
+
|
|
627
|
+
review = models.ForeignKey(
|
|
628
|
+
to="wbhuman_resources.Review",
|
|
629
|
+
on_delete=models.CASCADE,
|
|
630
|
+
related_name="questions",
|
|
631
|
+
verbose_name=_("Review"),
|
|
632
|
+
)
|
|
633
|
+
category = models.ForeignKey(
|
|
634
|
+
to="wbhuman_resources.ReviewQuestionCategory",
|
|
635
|
+
blank=True,
|
|
636
|
+
null=True,
|
|
637
|
+
on_delete=models.SET_NULL,
|
|
638
|
+
related_name="questions_related",
|
|
639
|
+
verbose_name=_("Category"),
|
|
640
|
+
)
|
|
641
|
+
question = models.TextField(default="", blank=True, verbose_name=_("Question"))
|
|
642
|
+
mandatory = models.BooleanField(default=True, verbose_name=_("Mandatory"))
|
|
643
|
+
answer_type = models.CharField(
|
|
644
|
+
max_length=32,
|
|
645
|
+
default=ANSWERTYPE.TEXT,
|
|
646
|
+
choices=ANSWERTYPE.choices,
|
|
647
|
+
verbose_name=_("Type"),
|
|
648
|
+
)
|
|
649
|
+
for_reviewee = models.BooleanField(default=True, verbose_name=_("For Reviewee"))
|
|
650
|
+
for_reviewer = models.BooleanField(default=True, verbose_name=_("For Reviewer"))
|
|
651
|
+
for_department_peers = models.BooleanField(default=False, verbose_name=_("For Department Peers"))
|
|
652
|
+
for_company_peers = models.BooleanField(default=False, verbose_name=_("For Company Peers"))
|
|
653
|
+
weight = models.DecimalField(default=1.0, max_digits=16, decimal_places=1, verbose_name=_("Weight"))
|
|
654
|
+
evaluation = models.ForeignKey(
|
|
655
|
+
to="wbhuman_resources.Evaluation",
|
|
656
|
+
blank=True,
|
|
657
|
+
null=True,
|
|
658
|
+
on_delete=models.SET_NULL,
|
|
659
|
+
related_name="evaluation_questions",
|
|
660
|
+
verbose_name=_("Evaluation"),
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
class Meta:
|
|
664
|
+
verbose_name = _("Review Question")
|
|
665
|
+
verbose_name_plural = _("Review Questions")
|
|
666
|
+
ordering = ("review", "category", "order")
|
|
667
|
+
|
|
668
|
+
def compute_str(self) -> str:
|
|
669
|
+
return f"{self.question}"
|
|
670
|
+
|
|
671
|
+
def __str__(self):
|
|
672
|
+
return f"({self.id}) - {self.computed_str}"
|
|
673
|
+
|
|
674
|
+
def save(self, *args, **kwargs):
|
|
675
|
+
self.computed_str = self.compute_str()
|
|
676
|
+
super().save(*args, **kwargs)
|
|
677
|
+
|
|
678
|
+
@classmethod
|
|
679
|
+
def get_endpoint_basename(cls):
|
|
680
|
+
return "wbhuman_resources:reviewquestion"
|
|
681
|
+
|
|
682
|
+
@classmethod
|
|
683
|
+
def get_representation_endpoint(cls):
|
|
684
|
+
return "wbhuman_resources:reviewquestionrepresentation-list"
|
|
685
|
+
|
|
686
|
+
@classmethod
|
|
687
|
+
def get_representation_value_key(cls):
|
|
688
|
+
return "id"
|
|
689
|
+
|
|
690
|
+
@classmethod
|
|
691
|
+
def get_representation_label_key(cls):
|
|
692
|
+
return "{{computed_str}}"
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
class ReviewAnswer(models.Model):
|
|
696
|
+
class Meta:
|
|
697
|
+
verbose_name = _("Review Answer")
|
|
698
|
+
verbose_name_plural = _("Review Answers")
|
|
699
|
+
|
|
700
|
+
question = models.ForeignKey(
|
|
701
|
+
to="wbhuman_resources.ReviewQuestion",
|
|
702
|
+
on_delete=models.CASCADE,
|
|
703
|
+
related_name="answers",
|
|
704
|
+
verbose_name=_("Question"),
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
answered_by = models.ForeignKey(
|
|
708
|
+
to="directory.Person",
|
|
709
|
+
null=True,
|
|
710
|
+
blank=True,
|
|
711
|
+
related_name="related_answers",
|
|
712
|
+
on_delete=models.deletion.SET_NULL,
|
|
713
|
+
verbose_name=_("Answered By"),
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
answered_anonymized = models.CharField(
|
|
717
|
+
null=True, blank=True, max_length=255, verbose_name=_("Answered Anonymized")
|
|
718
|
+
)
|
|
719
|
+
answer_number = models.PositiveIntegerField(
|
|
720
|
+
null=True, blank=True, verbose_name=_("Rating"), validators=[MinValueValidator(1), MaxValueValidator(4)]
|
|
721
|
+
)
|
|
722
|
+
answer_text = models.TextField(null=True, blank=True, verbose_name=_("Comment"))
|
|
723
|
+
|
|
724
|
+
def __str__(self) -> str:
|
|
725
|
+
return f"{self.question} - {self.answered_by}"
|
|
726
|
+
|
|
727
|
+
@classmethod
|
|
728
|
+
def get_endpoint_basename(cls):
|
|
729
|
+
return "wbhuman_resources:reviewanswer"
|
|
730
|
+
|
|
731
|
+
@classmethod
|
|
732
|
+
def get_representation_value_key(cls):
|
|
733
|
+
return "id"
|
|
734
|
+
|
|
735
|
+
@classmethod
|
|
736
|
+
def get_representation_label_key(cls):
|
|
737
|
+
return "id"
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
@receiver(pre_save, sender=Review)
|
|
741
|
+
def pre_save_review(sender, instance, **kwargs):
|
|
742
|
+
if (
|
|
743
|
+
not instance.is_template
|
|
744
|
+
and instance.auto_apply_deadline
|
|
745
|
+
and instance.review_deadline
|
|
746
|
+
and instance.status == Review.Status.FILL_IN_REVIEW
|
|
747
|
+
):
|
|
748
|
+
if instance.review_deadline < datetime.now().date():
|
|
749
|
+
instance.finalize()
|
|
750
|
+
|
|
751
|
+
if (
|
|
752
|
+
not instance.is_template
|
|
753
|
+
and instance.signed_reviewee
|
|
754
|
+
and instance.signed_reviewer
|
|
755
|
+
and instance.status == Review.Status.EVALUATION
|
|
756
|
+
):
|
|
757
|
+
instance.validation()
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
@shared_task
|
|
761
|
+
def finalize_review(review_id):
|
|
762
|
+
review = Review.objects.get(id=review_id)
|
|
763
|
+
ReviewAnswer.objects.filter(
|
|
764
|
+
Q(question__review=review)
|
|
765
|
+
& Q(answered_by=review.reviewee)
|
|
766
|
+
& Q(question__answer_type=ReviewQuestion.ANSWERTYPE.RATING)
|
|
767
|
+
& Q(answer_number=None)
|
|
768
|
+
).update(answer_number=1)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
@shared_task
|
|
772
|
+
def submit_reviews_from_group(group_id, user_id):
|
|
773
|
+
user = get_user_model().objects.get(id=user_id)
|
|
774
|
+
|
|
775
|
+
reviews = Review.objects.filter(
|
|
776
|
+
Q(review_group__id=group_id)
|
|
777
|
+
& Q(status=Review.Status.PREPARATION_OF_REVIEW)
|
|
778
|
+
& Q(moderator=user.profile)
|
|
779
|
+
& Q(is_template=False)
|
|
780
|
+
)
|
|
781
|
+
for review in reviews:
|
|
782
|
+
review.submit()
|
|
783
|
+
review.save()
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
@shared_task
|
|
787
|
+
def create_review_from_template(
|
|
788
|
+
template_id, from_date, to_date, review_deadline, auto_apply_deadline, employees, include_kpi
|
|
789
|
+
):
|
|
790
|
+
template = Review.objects.get(id=template_id)
|
|
791
|
+
|
|
792
|
+
from_date = datetime.strptime(from_date, "%Y-%m-%d").date() if from_date and from_date != "null" else None
|
|
793
|
+
to_date = datetime.strptime(to_date, "%Y-%m-%d").date() if to_date and to_date != "null" else None
|
|
794
|
+
review_deadline = (
|
|
795
|
+
datetime.strptime(review_deadline, "%Y-%m-%d").date()
|
|
796
|
+
if review_deadline and review_deadline != "null"
|
|
797
|
+
else None
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
include_kpi = str(include_kpi).lower() in ("yes", "true", "t", "1") if include_kpi else False
|
|
801
|
+
|
|
802
|
+
_auto_apply_deadline = (
|
|
803
|
+
str(auto_apply_deadline).lower() in ("yes", "true", "t", "1")
|
|
804
|
+
if auto_apply_deadline
|
|
805
|
+
else template.auto_apply_deadline
|
|
806
|
+
)
|
|
807
|
+
list_employees = Person.objects.filter(id__in=employees.split(",")) if employees else []
|
|
808
|
+
|
|
809
|
+
for employee in list_employees:
|
|
810
|
+
review = Review.objects.create(
|
|
811
|
+
from_date=from_date,
|
|
812
|
+
to_date=to_date,
|
|
813
|
+
review_deadline=review_deadline,
|
|
814
|
+
reviewee=employee,
|
|
815
|
+
moderator=template.moderator,
|
|
816
|
+
auto_apply_deadline=_auto_apply_deadline,
|
|
817
|
+
status=template.status,
|
|
818
|
+
review_group=template.review_group,
|
|
819
|
+
year=template.year,
|
|
820
|
+
type=template.type,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
for _question in ReviewQuestion.objects.filter(review__id=template.id).order_by("order"):
|
|
824
|
+
kwargs = {
|
|
825
|
+
"review": review,
|
|
826
|
+
"category": _question.category,
|
|
827
|
+
"question": _question.question,
|
|
828
|
+
"answer_type": _question.answer_type,
|
|
829
|
+
"mandatory": _question.mandatory,
|
|
830
|
+
"for_reviewee": _question.for_reviewee,
|
|
831
|
+
"for_reviewer": _question.for_reviewer,
|
|
832
|
+
"for_department_peers": _question.for_department_peers,
|
|
833
|
+
"for_company_peers": _question.for_company_peers,
|
|
834
|
+
"order": _question.order,
|
|
835
|
+
"weight": _question.weight,
|
|
836
|
+
}
|
|
837
|
+
ReviewQuestion.objects.create(**kwargs)
|
|
838
|
+
|
|
839
|
+
if include_kpi:
|
|
840
|
+
qs_evaluations = Evaluation.objects.filter(is_active=True)
|
|
841
|
+
evaluations = (
|
|
842
|
+
qs_evaluations.filter(
|
|
843
|
+
Q(person=employee) | (Q(person__isnull=True) & Q(kpi__evaluated_persons=employee))
|
|
844
|
+
)
|
|
845
|
+
.annotate(
|
|
846
|
+
goal=F("kpi__goal"),
|
|
847
|
+
kpi_name=F("kpi__name"),
|
|
848
|
+
interval=F("kpi__evaluated_intervals"),
|
|
849
|
+
period=F("kpi__period"),
|
|
850
|
+
person_name=F("person__computed_str"),
|
|
851
|
+
)
|
|
852
|
+
.order_by("kpi", "-evaluation_date")
|
|
853
|
+
.distinct("kpi")
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
category, _ = ReviewQuestionCategory.objects.get_or_create(name="KPI Evaluations")
|
|
857
|
+
for evaluation in evaluations:
|
|
858
|
+
percentage = round((evaluation.evaluated_score / evaluation.goal) * 100, 2)
|
|
859
|
+
person_name = evaluation.person_name if evaluation.person_name else "Group"
|
|
860
|
+
_question = gettext(
|
|
861
|
+
"""The KPI '{kpi_name}' is evaluated {interval} over the period from {lower_period} to {upper_period}.
|
|
862
|
+
{person_name}'s evaluation as of {evaluation_date} was {evaluated_score} for a goal of {goal}. That is an accomplished percentage of {percentage}%.
|
|
863
|
+
What do you think of this result?
|
|
864
|
+
"""
|
|
865
|
+
).format(
|
|
866
|
+
kpi_name=evaluation.kpi_name,
|
|
867
|
+
interval=evaluation.interval,
|
|
868
|
+
lower_period=evaluation.period.lower,
|
|
869
|
+
upper_period=evaluation.period.upper,
|
|
870
|
+
person_name=person_name,
|
|
871
|
+
evaluation_date=evaluation.evaluation_date,
|
|
872
|
+
evaluated_score=evaluation.evaluated_score,
|
|
873
|
+
goal=evaluation.goal,
|
|
874
|
+
percentage=percentage,
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
kwargs = {
|
|
878
|
+
"review": review,
|
|
879
|
+
"category": category,
|
|
880
|
+
"question": _question,
|
|
881
|
+
"answer_type": ReviewQuestion.ANSWERTYPE.RATING,
|
|
882
|
+
"evaluation": evaluation,
|
|
883
|
+
}
|
|
884
|
+
ReviewQuestion.objects.create(**kwargs)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
@shared_task
|
|
888
|
+
def submit_review(review_id):
|
|
889
|
+
review = Review.objects.get(id=review_id)
|
|
890
|
+
questions = ReviewQuestion.objects.filter(review=review)
|
|
891
|
+
dict_questions = {}
|
|
892
|
+
|
|
893
|
+
questions_for_company = questions.filter(for_company_peers=True)
|
|
894
|
+
if questions_for_company.exists() and (main_company_id := get_main_company()):
|
|
895
|
+
if _company := review.reviewee.employers.filter(id=main_company_id).first():
|
|
896
|
+
for _person in (
|
|
897
|
+
Person.objects.filter_only_internal()
|
|
898
|
+
.filter(employers=_company)
|
|
899
|
+
.exclude(Q(id=review.reviewee.id) | Q(id=review.reviewer.id))
|
|
900
|
+
):
|
|
901
|
+
dict_questions[_person] = questions_for_company
|
|
902
|
+
|
|
903
|
+
questions_for_department = questions.filter(for_department_peers=True)
|
|
904
|
+
if questions_for_department.exists() and hasattr(review.reviewee, "human_resources"):
|
|
905
|
+
if review.reviewee.human_resources.position:
|
|
906
|
+
employees_peers = []
|
|
907
|
+
for _position in review.reviewee.human_resources.position.get_ancestors(include_self=True):
|
|
908
|
+
employees_peers += _position.get_employees().exclude(
|
|
909
|
+
Q(id=review.reviewee.id) & Q(id=review.reviewer.id)
|
|
910
|
+
)
|
|
911
|
+
person_peers = [_employee.profile for _employee in list(set(employees_peers))]
|
|
912
|
+
for _peer in list(set(person_peers)):
|
|
913
|
+
if _peer in dict_questions:
|
|
914
|
+
dict_questions[_peer] = questions_for_department.union(questions_for_company)
|
|
915
|
+
else:
|
|
916
|
+
dict_questions[_peer] = questions_for_department
|
|
917
|
+
|
|
918
|
+
dict_questions[review.reviewee] = questions.filter(for_reviewee=True)
|
|
919
|
+
dict_questions[review.reviewer] = questions.filter(for_reviewer=True)
|
|
920
|
+
|
|
921
|
+
for person, values in dict_questions.items():
|
|
922
|
+
for _question in values:
|
|
923
|
+
kwargs = {"question": _question, "answered_by": person, "answered_anonymized": hash(person)}
|
|
924
|
+
if _evaluation := _question.evaluation:
|
|
925
|
+
rating = _evaluation.get_rating()
|
|
926
|
+
kwargs.update({"answer_number": rating})
|
|
927
|
+
ReviewAnswer.objects.create(**kwargs)
|
|
928
|
+
if hasattr(person, "user_account"):
|
|
929
|
+
if person == review.reviewee:
|
|
930
|
+
msg = gettext(
|
|
931
|
+
"Dear {first_name} {last_name}, <p>your review is ready. You can fill it in until {deadline}.</p>"
|
|
932
|
+
).format(first_name=person.first_name, last_name=person.last_name, deadline=review.review_deadline)
|
|
933
|
+
elif person == review.reviewer:
|
|
934
|
+
msg = gettext(
|
|
935
|
+
"Dear {first_name} {last_name}, <p>you are the reviewer of the review: {review}. You can fill in the questions related to you until <b>{deadline}.</b></p>"
|
|
936
|
+
).format(
|
|
937
|
+
first_name=person.first_name,
|
|
938
|
+
last_name=person.last_name,
|
|
939
|
+
review=str(review),
|
|
940
|
+
deadline=review.review_deadline,
|
|
941
|
+
)
|
|
942
|
+
else:
|
|
943
|
+
msg = gettext(
|
|
944
|
+
"Dear {first_name} {last_name}, <p>you are a peer of the review: {review}. You can fill in the questions related to you until <b>{deadline}.</b></p>"
|
|
945
|
+
).format(
|
|
946
|
+
first_name=person.first_name,
|
|
947
|
+
last_name=person.last_name,
|
|
948
|
+
review=str(review),
|
|
949
|
+
deadline=review.review_deadline,
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
review.send_review_notification(
|
|
953
|
+
title=gettext("Stage 2: Fill in review - {review}").format(review=str(review)),
|
|
954
|
+
message=msg,
|
|
955
|
+
recipient=person.user_account,
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
@shared_task
|
|
960
|
+
def send_review_report_via_mail(user_id, review_id):
|
|
961
|
+
user = get_user_model().objects.get(id=user_id)
|
|
962
|
+
review = Review.objects.get(id=review_id)
|
|
963
|
+
pdf_content = review.generate_pdf()
|
|
964
|
+
filename = f"{slugify(str(review))}.pdf"
|
|
965
|
+
|
|
966
|
+
content_file = ContentFile(pdf_content, name=filename)
|
|
967
|
+
document_type, _ = DocumentType.objects.get_or_create(name="mailing")
|
|
968
|
+
|
|
969
|
+
document, _ = Document.objects.update_or_create(
|
|
970
|
+
document_type=document_type,
|
|
971
|
+
system_created=True,
|
|
972
|
+
system_key=f"review-{review.id}-{filename}",
|
|
973
|
+
defaults={
|
|
974
|
+
"file": content_file,
|
|
975
|
+
"name": filename,
|
|
976
|
+
"permission_type": Document.PermissionType.PRIVATE,
|
|
977
|
+
"creator": user,
|
|
978
|
+
},
|
|
979
|
+
)
|
|
980
|
+
document.link(review)
|
|
981
|
+
|
|
982
|
+
document.send_email(to_emails=user.email, as_link=True, subject=gettext("Review PDF - ") + review.computed_str)
|