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,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)