wbcompliance 2.2.1__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 (129) hide show
  1. wbcompliance/__init__.py +1 -0
  2. wbcompliance/admin/__init__.py +16 -0
  3. wbcompliance/admin/compliance_form.py +56 -0
  4. wbcompliance/admin/compliance_task.py +135 -0
  5. wbcompliance/admin/compliance_type.py +8 -0
  6. wbcompliance/admin/risk_management/__init__.py +3 -0
  7. wbcompliance/admin/risk_management/checks.py +7 -0
  8. wbcompliance/admin/risk_management/incidents.py +50 -0
  9. wbcompliance/admin/risk_management/rules.py +63 -0
  10. wbcompliance/admin/utils.py +46 -0
  11. wbcompliance/apps.py +14 -0
  12. wbcompliance/factories/__init__.py +21 -0
  13. wbcompliance/factories/compliance.py +246 -0
  14. wbcompliance/factories/risk_management/__init__.py +12 -0
  15. wbcompliance/factories/risk_management/backends.py +42 -0
  16. wbcompliance/factories/risk_management/checks.py +12 -0
  17. wbcompliance/factories/risk_management/incidents.py +84 -0
  18. wbcompliance/factories/risk_management/rules.py +100 -0
  19. wbcompliance/filters/__init__.py +2 -0
  20. wbcompliance/filters/compliances.py +189 -0
  21. wbcompliance/filters/risk_management/__init__.py +3 -0
  22. wbcompliance/filters/risk_management/checks.py +22 -0
  23. wbcompliance/filters/risk_management/incidents.py +113 -0
  24. wbcompliance/filters/risk_management/rules.py +110 -0
  25. wbcompliance/filters/risk_management/tables.py +112 -0
  26. wbcompliance/filters/risk_management/utils.py +3 -0
  27. wbcompliance/management/__init__.py +10 -0
  28. wbcompliance/migrations/0001_initial_squashed_squashed_0010_alter_checkedobjectincidentrelationship_resolved_by_and_more.py +1744 -0
  29. wbcompliance/migrations/0011_alter_riskrule_parameters.py +21 -0
  30. wbcompliance/migrations/0012_alter_compliancetype_options.py +20 -0
  31. wbcompliance/migrations/0013_alter_riskrule_unique_together.py +16 -0
  32. wbcompliance/migrations/0014_alter_reviewcompliancetask_year.py +27 -0
  33. wbcompliance/migrations/0015_auto_20240103_0957.py +43 -0
  34. wbcompliance/migrations/0016_checkedobjectincidentrelationship_report_details_and_more.py +37 -0
  35. wbcompliance/migrations/0017_alter_rulebackend_incident_report_template.py +20 -0
  36. wbcompliance/migrations/0018_alter_rulecheckedobjectrelationship_unique_together.py +39 -0
  37. wbcompliance/migrations/0019_rulegroup_riskrule_activation_date_and_more.py +60 -0
  38. wbcompliance/migrations/__init__.py +0 -0
  39. wbcompliance/models/__init__.py +20 -0
  40. wbcompliance/models/compliance_form.py +626 -0
  41. wbcompliance/models/compliance_task.py +800 -0
  42. wbcompliance/models/compliance_type.py +133 -0
  43. wbcompliance/models/enums.py +13 -0
  44. wbcompliance/models/risk_management/__init__.py +4 -0
  45. wbcompliance/models/risk_management/backend.py +139 -0
  46. wbcompliance/models/risk_management/checks.py +194 -0
  47. wbcompliance/models/risk_management/dispatch.py +41 -0
  48. wbcompliance/models/risk_management/incidents.py +619 -0
  49. wbcompliance/models/risk_management/mixins.py +115 -0
  50. wbcompliance/models/risk_management/rules.py +654 -0
  51. wbcompliance/permissions.py +32 -0
  52. wbcompliance/serializers/__init__.py +30 -0
  53. wbcompliance/serializers/compliance_form.py +320 -0
  54. wbcompliance/serializers/compliance_task.py +463 -0
  55. wbcompliance/serializers/compliance_type.py +26 -0
  56. wbcompliance/serializers/risk_management/__init__.py +19 -0
  57. wbcompliance/serializers/risk_management/checks.py +53 -0
  58. wbcompliance/serializers/risk_management/incidents.py +227 -0
  59. wbcompliance/serializers/risk_management/rules.py +158 -0
  60. wbcompliance/tasks.py +112 -0
  61. wbcompliance/tests/__init__.py +0 -0
  62. wbcompliance/tests/conftest.py +63 -0
  63. wbcompliance/tests/disable_signals.py +82 -0
  64. wbcompliance/tests/mixins.py +17 -0
  65. wbcompliance/tests/risk_management/__init__.py +0 -0
  66. wbcompliance/tests/risk_management/models/__init__.py +0 -0
  67. wbcompliance/tests/risk_management/models/test_backends.py +0 -0
  68. wbcompliance/tests/risk_management/models/test_checks.py +55 -0
  69. wbcompliance/tests/risk_management/models/test_incidents.py +327 -0
  70. wbcompliance/tests/risk_management/models/test_rules.py +255 -0
  71. wbcompliance/tests/signals.py +89 -0
  72. wbcompliance/tests/test_filters.py +23 -0
  73. wbcompliance/tests/test_models.py +57 -0
  74. wbcompliance/tests/test_serializers.py +48 -0
  75. wbcompliance/tests/test_views.py +377 -0
  76. wbcompliance/tests/tests.py +21 -0
  77. wbcompliance/urls.py +238 -0
  78. wbcompliance/viewsets/__init__.py +40 -0
  79. wbcompliance/viewsets/buttons/__init__.py +9 -0
  80. wbcompliance/viewsets/buttons/compliance_form.py +78 -0
  81. wbcompliance/viewsets/buttons/compliance_task.py +149 -0
  82. wbcompliance/viewsets/buttons/risk_managment/__init__.py +3 -0
  83. wbcompliance/viewsets/buttons/risk_managment/checks.py +11 -0
  84. wbcompliance/viewsets/buttons/risk_managment/incidents.py +51 -0
  85. wbcompliance/viewsets/buttons/risk_managment/rules.py +35 -0
  86. wbcompliance/viewsets/compliance_form.py +425 -0
  87. wbcompliance/viewsets/compliance_task.py +513 -0
  88. wbcompliance/viewsets/compliance_type.py +38 -0
  89. wbcompliance/viewsets/display/__init__.py +22 -0
  90. wbcompliance/viewsets/display/compliance_form.py +317 -0
  91. wbcompliance/viewsets/display/compliance_task.py +453 -0
  92. wbcompliance/viewsets/display/compliance_type.py +22 -0
  93. wbcompliance/viewsets/display/risk_managment/__init__.py +11 -0
  94. wbcompliance/viewsets/display/risk_managment/checks.py +46 -0
  95. wbcompliance/viewsets/display/risk_managment/incidents.py +155 -0
  96. wbcompliance/viewsets/display/risk_managment/rules.py +146 -0
  97. wbcompliance/viewsets/display/risk_managment/tables.py +51 -0
  98. wbcompliance/viewsets/endpoints/__init__.py +27 -0
  99. wbcompliance/viewsets/endpoints/compliance_form.py +207 -0
  100. wbcompliance/viewsets/endpoints/compliance_task.py +193 -0
  101. wbcompliance/viewsets/endpoints/compliance_type.py +9 -0
  102. wbcompliance/viewsets/endpoints/risk_managment/__init__.py +12 -0
  103. wbcompliance/viewsets/endpoints/risk_managment/checks.py +16 -0
  104. wbcompliance/viewsets/endpoints/risk_managment/incidents.py +36 -0
  105. wbcompliance/viewsets/endpoints/risk_managment/rules.py +32 -0
  106. wbcompliance/viewsets/endpoints/risk_managment/tables.py +14 -0
  107. wbcompliance/viewsets/menu/__init__.py +17 -0
  108. wbcompliance/viewsets/menu/compliance_form.py +49 -0
  109. wbcompliance/viewsets/menu/compliance_task.py +130 -0
  110. wbcompliance/viewsets/menu/compliance_type.py +17 -0
  111. wbcompliance/viewsets/menu/risk_management.py +56 -0
  112. wbcompliance/viewsets/risk_management/__init__.py +21 -0
  113. wbcompliance/viewsets/risk_management/checks.py +49 -0
  114. wbcompliance/viewsets/risk_management/incidents.py +204 -0
  115. wbcompliance/viewsets/risk_management/mixins.py +52 -0
  116. wbcompliance/viewsets/risk_management/rules.py +179 -0
  117. wbcompliance/viewsets/risk_management/tables.py +96 -0
  118. wbcompliance/viewsets/titles/__init__.py +17 -0
  119. wbcompliance/viewsets/titles/compliance_form.py +101 -0
  120. wbcompliance/viewsets/titles/compliance_task.py +60 -0
  121. wbcompliance/viewsets/titles/compliance_type.py +13 -0
  122. wbcompliance/viewsets/titles/risk_managment/__init__.py +1 -0
  123. wbcompliance/viewsets/titles/risk_managment/checks.py +0 -0
  124. wbcompliance/viewsets/titles/risk_managment/incidents.py +0 -0
  125. wbcompliance/viewsets/titles/risk_managment/rules.py +0 -0
  126. wbcompliance/viewsets/titles/risk_managment/tables.py +7 -0
  127. wbcompliance-2.2.1.dist-info/METADATA +7 -0
  128. wbcompliance-2.2.1.dist-info/RECORD +129 -0
  129. wbcompliance-2.2.1.dist-info/WHEEL +5 -0
@@ -0,0 +1,619 @@
1
+ from datetime import date, timedelta
2
+ from typing import TYPE_CHECKING, Any, Optional
3
+
4
+ import pandas as pd
5
+ from celery import shared_task
6
+ from django.contrib.auth import get_user_model
7
+ from django.contrib.auth.models import User as BaseUser
8
+ from django.contrib.contenttypes.fields import GenericForeignKey
9
+ from django.contrib.contenttypes.models import ContentType
10
+ from django.contrib.postgres.fields import DateRangeField
11
+ from django.core.exceptions import ValidationError
12
+ from django.core.serializers.json import DjangoJSONEncoder
13
+ from django.db import models
14
+ from django.db.models import Value
15
+ from django.db.models.functions import TruncDate
16
+ from django.db.models.signals import post_save
17
+ from django.dispatch import receiver
18
+ from django.utils.translation import gettext as _
19
+ from django_fsm import FSMField, transition
20
+ from guardian.core import ObjectPermissionChecker
21
+ from psycopg.types.range import DateRange
22
+ from wbcore.content_type.utils import get_ancestors_content_type
23
+ from wbcore.contrib.color.enums import WBColor
24
+ from wbcore.contrib.directory.models import Person
25
+ from wbcore.contrib.icons import WBIcon
26
+ from wbcore.contrib.notifications.utils import create_notification_type
27
+ from wbcore.enums import RequestType
28
+ from wbcore.metadata.configs.buttons import ActionButton, ButtonDefaultColor
29
+ from wbcore.metadata.configs.display.instance_display.shortcuts import (
30
+ create_simple_display,
31
+ )
32
+ from wbcore.models import WBModel
33
+ from wbcore.utils.models import ComplexToStringMixin
34
+
35
+ User: BaseUser = get_user_model()
36
+
37
+ if TYPE_CHECKING:
38
+ from wbcompliance.models import RiskCheck
39
+
40
+
41
+ class RiskIncidentType(models.Model):
42
+ name = models.CharField(max_length=128)
43
+ severity_order = models.PositiveIntegerField(default=0, unique=True)
44
+
45
+ color = models.CharField(max_length=20, verbose_name=_("Color"), default=WBColor.YELLOW_LIGHT.value)
46
+ is_ignorable = models.BooleanField(default=True, verbose_name=_("Can be ignored"))
47
+ is_automatically_closed = models.BooleanField(default=False, verbose_name=_("Automatically closed"))
48
+ is_informational = models.BooleanField(
49
+ default=False,
50
+ verbose_name=_("Only Informational"),
51
+ help_text=_("If true, the associated rule is not considered an incident"),
52
+ )
53
+
54
+ def __str__(self) -> str:
55
+ return self.name
56
+
57
+ def save(self, *args, **kwargs):
58
+ if (
59
+ self.pk
60
+ and RiskIncidentType.objects.exclude(id=self.pk).filter(severity_order=self.severity_order).exists()
61
+ ):
62
+ self.severity_order += 1
63
+ return super().save(*args, **kwargs)
64
+
65
+ class Meta:
66
+ ordering = ("severity_order",)
67
+ verbose_name = "Risk Incident Type"
68
+ verbose_name_plural = "Risk Incidents Type"
69
+
70
+ @classmethod
71
+ def get_representation_endpoint(cls) -> str:
72
+ return "wbcompliance:riskincidenttyperepresentation-list"
73
+
74
+ @classmethod
75
+ def get_representation_value_key(cls) -> str:
76
+ return "id"
77
+
78
+ @classmethod
79
+ def get_representation_label_key(cls) -> str:
80
+ return "{{name}}"
81
+
82
+
83
+ class RiskIncidentMixin(models.Model):
84
+ class Status(models.TextChoices):
85
+ OPEN = "OPEN", "Open" # Newly created incidents
86
+ RESOLVED = "RESOLVED", "Resolved"
87
+ IGNORED = "IGNORED", "Ignored"
88
+ CLOSED = "CLOSED", "Closed"
89
+
90
+ severity = models.ForeignKey(
91
+ "wbcompliance.RiskIncidentType",
92
+ on_delete=models.CASCADE,
93
+ verbose_name=_("Severity"),
94
+ related_name="%(class)s",
95
+ )
96
+ comment = models.TextField(blank=True, null=True, verbose_name=_("Comment"))
97
+
98
+ resolved_by = models.ForeignKey(
99
+ "directory.Person",
100
+ on_delete=models.SET_NULL,
101
+ verbose_name=_("Handled by"),
102
+ help_text=_("The person that resolved or ignored this incident"),
103
+ related_name="%(class)s_handled",
104
+ blank=True,
105
+ null=True,
106
+ )
107
+
108
+ class Meta:
109
+ abstract = True
110
+
111
+
112
+ class CheckedObjectIncidentRelationship(ComplexToStringMixin, RiskIncidentMixin, WBModel):
113
+ incident = models.ForeignKey(
114
+ to="wbcompliance.RiskIncident",
115
+ related_name="checked_object_relationships",
116
+ null=True,
117
+ blank=True,
118
+ on_delete=models.SET_NULL,
119
+ )
120
+
121
+ rule_check = models.ForeignKey(
122
+ "wbcompliance.RiskCheck",
123
+ on_delete=models.CASCADE,
124
+ verbose_name=_("Check"),
125
+ help_text=_("The check that opened this incident"),
126
+ related_name="incidents",
127
+ )
128
+ breached_value = models.CharField(
129
+ blank=True,
130
+ null=True,
131
+ verbose_name=_("Breached Value"),
132
+ max_length=128,
133
+ help_text="The value that breached the rule threshold, can be None",
134
+ )
135
+
136
+ report = models.TextField(blank=True, null=True, verbose_name=_("Report"))
137
+ report_details = models.JSONField(default=dict, blank=True, encoder=DjangoJSONEncoder)
138
+
139
+ # A user can mark an incident as resolved, if the necessary actions were taken
140
+ status = FSMField(
141
+ default=RiskIncidentMixin.Status.OPEN, choices=RiskIncidentMixin.Status.choices, verbose_name=_("Status")
142
+ )
143
+
144
+ @transition(
145
+ status,
146
+ [RiskIncidentMixin.Status.OPEN],
147
+ RiskIncidentMixin.Status.RESOLVED,
148
+ permission=lambda instance, user: RiskIncident.can_manage(user, instance.rule),
149
+ custom={
150
+ "_transition_button": ActionButton(
151
+ method=RequestType.PATCH,
152
+ identifiers=("wbcompliance:riskincident",),
153
+ icon=WBIcon.APPROVE.icon,
154
+ color=ButtonDefaultColor.SUCCESS,
155
+ key="resolve",
156
+ label=_("Resolve"),
157
+ action_label=_("Resolve"),
158
+ description_fields=_("<p>Are you sure you want to resolve the incident {{computed_str}}"),
159
+ instance_display=create_simple_display([["comment"]]),
160
+ )
161
+ },
162
+ )
163
+ def resolve(self, by=None, **kwargs):
164
+ if by:
165
+ self.resolved_by = by.profile
166
+
167
+ @transition(
168
+ status,
169
+ [RiskIncidentMixin.Status.OPEN],
170
+ RiskIncidentMixin.Status.IGNORED,
171
+ permission=lambda instance, user: RiskIncident.can_manage(user, instance.rule),
172
+ custom={
173
+ "_transition_button": ActionButton(
174
+ method=RequestType.PATCH,
175
+ identifiers=("wbcompliance:riskincident",),
176
+ icon=WBIcon.IGNORE.icon,
177
+ color=ButtonDefaultColor.WARNING,
178
+ key="ignore",
179
+ label=_("Ignore"),
180
+ action_label=_("Ignore"),
181
+ description_fields=_("<p>Are you sure you want to ignore the incident {{computed_str}}"),
182
+ instance_display=create_simple_display([["severity"], ["comment"]]),
183
+ )
184
+ },
185
+ )
186
+ def ignore(self, by=None, **kwargs):
187
+ if by:
188
+ self.resolved_by = by.profile
189
+
190
+ def can_ignore(self):
191
+ if not self.severity.is_ignorable:
192
+ return {"severity": _("Incident type {} is not ignorable").format(self.severity)}
193
+ return dict()
194
+
195
+ @property
196
+ def incident_date(self) -> date:
197
+ return self.rule_check.evaluation_date
198
+
199
+ @property
200
+ def rule(self) -> Any:
201
+ if self.incident:
202
+ return self.incident.rule
203
+ return self.rule_check.rule
204
+
205
+ @property
206
+ def checked_object(self) -> models.Model:
207
+ return self.rule_check.rule_checked_object_relationship.checked_object
208
+
209
+ def clean(self):
210
+ allowed_checked_object_content_type = self.rule.rule_backend.allowed_checked_object_content_type
211
+ if not self.checked_object:
212
+ raise ValidationError("Checked Object cannot be null")
213
+ checked_object_content_type = ContentType.objects.get_for_model(self.checked_object)
214
+ if allowed_checked_object_content_type and (
215
+ allowed_checked_object_content_type not in list(get_ancestors_content_type(checked_object_content_type))
216
+ ):
217
+ raise ValidationError(
218
+ _(
219
+ "The relationship content type ({}) needs to match the incident rule backend allowed content type ({})"
220
+ ).format(self.checked_object, allowed_checked_object_content_type)
221
+ )
222
+ if self.checked_object not in list(self.rule.checked_objects):
223
+ raise ValidationError(
224
+ _("The relationship content object ({}) is not among the rule checked_objects").format(
225
+ self.checked_object
226
+ )
227
+ )
228
+ super().clean()
229
+
230
+ def save(self, *args, **kwargs):
231
+ self.full_clean()
232
+ super().save(*args, **kwargs)
233
+
234
+ class Meta:
235
+ verbose_name = "Incident to Checked Object relationship"
236
+ verbose_name_plural = "Incident to Checked Object relationships"
237
+ unique_together = ("incident", "rule_check")
238
+ indexes = [
239
+ models.Index(fields=["incident", "rule_check"]),
240
+ ]
241
+
242
+ def compute_str(self) -> str:
243
+ return _("{} {} Sub Incident for checked_object {}").format(self.status, self.severity, self.rule_check)
244
+
245
+ @classmethod
246
+ def get_representation_endpoint(cls) -> str:
247
+ return "wbcompliance:checkedobjectincidentrelationshiprepresentation-list"
248
+
249
+ @classmethod
250
+ def get_representation_value_key(cls) -> str:
251
+ return "id"
252
+
253
+ @classmethod
254
+ def get_endpoint_basename(cls) -> str:
255
+ return "wbcompliance:checkedobjectincidentrelationship"
256
+
257
+
258
+ class RiskIncidentDefaultManager(models.Manager):
259
+ def get_queryset(self):
260
+ return (
261
+ super()
262
+ .get_queryset()
263
+ .annotate(
264
+ ignore_until_time=models.F("last_ignored_date")
265
+ + models.F("ignore_duration")
266
+ + Value(timedelta(days=1)),
267
+ ignore_until=TruncDate("ignore_until_time"),
268
+ )
269
+ )
270
+
271
+
272
+ class RiskIncident(ComplexToStringMixin, RiskIncidentMixin, WBModel):
273
+ """
274
+ Instance defining the incident that has happened during a check initiated by a certain rule
275
+ """
276
+
277
+ date_range = DateRangeField(blank=True, null=True, help_text=_("The incident spans date interval"))
278
+ rule = models.ForeignKey(
279
+ "wbcompliance.RiskRule",
280
+ on_delete=models.CASCADE,
281
+ verbose_name=_("Rule"),
282
+ help_text=_("The rule that opened this incident"),
283
+ related_name="incidents",
284
+ )
285
+
286
+ breached_content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True)
287
+ breached_object_id = models.PositiveIntegerField(null=True, blank=True)
288
+ breached_content_object = GenericForeignKey("breached_content_type", "breached_object_id")
289
+ breached_object_repr = models.CharField(
290
+ max_length=128,
291
+ blank=True,
292
+ null=True,
293
+ verbose_name=_("Breached Object Representation"),
294
+ help_text=_("String Representation of the breached object"),
295
+ )
296
+
297
+ status = FSMField(
298
+ default=RiskIncidentMixin.Status.OPEN, choices=RiskIncidentMixin.Status.choices, verbose_name=_("Status")
299
+ )
300
+ is_notified = models.BooleanField(
301
+ default=False, verbose_name=_("Notified"), help_text=_("True if the incident is already notified to the users")
302
+ )
303
+
304
+ last_ignored_date = models.DateField(blank=True, null=True)
305
+ ignore_duration = models.DurationField(
306
+ blank=True, null=True, help_text=_("If set, will ignore the forthcoming incidents for the specified duration")
307
+ )
308
+
309
+ def get_ignore_until_date(self) -> date | None:
310
+ if self.last_ignored_date and self.ignore_duration:
311
+ return self.last_ignored_date + self.ignore_duration
312
+
313
+ @transition(
314
+ status,
315
+ [RiskIncidentMixin.Status.OPEN],
316
+ RiskIncidentMixin.Status.RESOLVED,
317
+ permission=lambda instance, user: RiskIncident.can_manage(user, instance.rule),
318
+ custom={
319
+ "_transition_button": ActionButton(
320
+ method=RequestType.PATCH,
321
+ identifiers=("wbcompliance:riskincident",),
322
+ icon=WBIcon.APPROVE.icon,
323
+ color=ButtonDefaultColor.SUCCESS,
324
+ key="resolve",
325
+ label=_("Resolve"),
326
+ action_label=_("Resolve"),
327
+ description_fields=_(
328
+ "<p>Are you sure you want to resolve the incident {{computed_str}} and its relationships?"
329
+ ),
330
+ instance_display=create_simple_display([["comment"]]),
331
+ )
332
+ },
333
+ )
334
+ def resolve(self, by=None, **kwargs):
335
+ if by:
336
+ self.resolved_by = by.profile
337
+ self.checked_object_relationships.filter(status=self.Status.OPEN).update(
338
+ status=self.status, resolved_by=self.resolved_by
339
+ )
340
+
341
+ @transition(
342
+ status,
343
+ [RiskIncidentMixin.Status.OPEN],
344
+ RiskIncidentMixin.Status.IGNORED,
345
+ permission=lambda instance, user: RiskIncident.can_manage(user, instance.rule),
346
+ custom={
347
+ "_transition_button": ActionButton(
348
+ method=RequestType.PATCH,
349
+ identifiers=("wbcompliance:riskincident",),
350
+ icon=WBIcon.IGNORE.icon,
351
+ color=ButtonDefaultColor.WARNING,
352
+ key="ignore",
353
+ label=_("Ignore"),
354
+ action_label=_("Ignore"),
355
+ description_fields=_(
356
+ "<p>Are you sure you want to ignore the incident {{computed_str}} and its relationships? If you set a days greater than 0, the forthcoming incident will be automatically ignore"
357
+ ),
358
+ instance_display=create_simple_display(
359
+ [["severity", "ignore_duration_in_days"], ["comment", "comment"]]
360
+ ),
361
+ )
362
+ },
363
+ )
364
+ def ignore(self, by=None, **kwargs):
365
+ if by:
366
+ self.last_ignored_date = self.date_range.upper # type: ignore
367
+ self.resolved_by = by.profile
368
+ self.checked_object_relationships.filter(status=self.Status.OPEN).update(
369
+ status=self.status, resolved_by=self.resolved_by
370
+ )
371
+
372
+ def can_ignore(self):
373
+ if not self.severity.is_ignorable:
374
+ return {"severity": _("Incident type {} is not ignorable").format(self.severity)}
375
+ return dict()
376
+
377
+ objects = RiskIncidentDefaultManager()
378
+
379
+ class Meta:
380
+ verbose_name = "Risk Incident"
381
+ verbose_name_plural = "Risk Incidents"
382
+
383
+ notification_types = [
384
+ create_notification_type(
385
+ code="wbcompliance.riskincident.notify",
386
+ title="Risk Incident Notification",
387
+ help_text="Notifies you when an incident is triggered.",
388
+ )
389
+ ]
390
+
391
+ def save(self, *args, **kwargs):
392
+ if not self.breached_object_repr:
393
+ self.breached_object_repr = str(self.breached_content_object)
394
+
395
+ if self.id and self.checked_object_relationships.exists():
396
+ # If this global incident is closed, we close all opened incident relationships
397
+ if self.status != self.Status.OPEN:
398
+ self.checked_object_relationships.filter(status=self.Status.OPEN).update(status=self.status)
399
+
400
+ existing_severity_orders = list(
401
+ self.checked_object_relationships.values_list("severity__severity_order", flat=True)
402
+ )
403
+ self.severity = RiskIncidentType.objects.get(
404
+ severity_order=max([self.severity.severity_order, *existing_severity_orders])
405
+ )
406
+ self.date_range = DateRange(
407
+ lower=self.checked_object_relationships.earliest("rule_check__evaluation_date").incident_date,
408
+ upper=self.checked_object_relationships.latest("rule_check__evaluation_date").incident_date
409
+ + timedelta(days=1),
410
+ ) # type: ignore
411
+ super().save(*args, **kwargs)
412
+
413
+ @property
414
+ def date_range_lower_repr(self) -> str:
415
+ return f"[{self.date_range.lower:%Y-%m-%d}" if self.date_range.lower else "]-∞" # type: ignore
416
+
417
+ @property
418
+ def date_range_upper_repr(self) -> str:
419
+ return f"{self.date_range.upper - timedelta(days=1):%Y-%m-%d}]" if self.date_range.upper else "+∞[" # type: ignore
420
+
421
+ @property
422
+ def automatically_close_incident(self) -> bool:
423
+ return self.rule.automatically_close_incident or self.severity.is_automatically_closed
424
+
425
+ def compute_str(self) -> str:
426
+ if self.severity:
427
+ return _("({}) {} Incident for breached object {} during {},{}").format(
428
+ self.status,
429
+ self.severity,
430
+ self.breached_content_object,
431
+ self.date_range_lower_repr,
432
+ self.date_range_upper_repr,
433
+ )
434
+ return _("({}) Incident for breached object {} during {}").format(
435
+ self.status, self.breached_content_object, self.date_range_lower_repr, self.date_range_upper_repr
436
+ )
437
+
438
+ def update_or_create_relationship(
439
+ self,
440
+ check: "RiskCheck",
441
+ incident_report: str,
442
+ incident_report_details: dict[str, Any],
443
+ breached_value: str | None,
444
+ incident_severity: "RiskIncidentType",
445
+ override_incident: bool | None = False,
446
+ ):
447
+ # We assume that if the incident was not created and its status is other than opened, then it is a recheck and this incident was already handled.
448
+ checked_object = check.rule_checked_object_relationship.checked_object
449
+ similar_incidents_relationships = CheckedObjectIncidentRelationship.objects.filter(
450
+ incident=self,
451
+ rule_check__rule_checked_object_relationship__checked_object_content_type=ContentType.objects.get_for_model(
452
+ checked_object
453
+ ),
454
+ rule_check__rule_checked_object_relationship__checked_object_id=checked_object.id,
455
+ ).distinct()
456
+ defaults = {
457
+ "report": incident_report,
458
+ "report_details": incident_report_details,
459
+ "breached_value": breached_value,
460
+ "severity": incident_severity,
461
+ }
462
+ if self.rule.automatically_close_incident:
463
+ defaults["status"] = RiskIncident.Status.CLOSED
464
+ # if a previous check created an incident with the same breached value, we don't reopen it
465
+ if override_incident or (
466
+ (previous_check := check.previous_check)
467
+ and similar_incidents_relationships.filter(rule_check=previous_check, breached_value=breached_value)
468
+ ):
469
+ defaults["status"] = self.status
470
+
471
+ potential_existing_incidents_relationships = similar_incidents_relationships.filter(
472
+ rule_check__evaluation_date=check.evaluation_date
473
+ )
474
+ if not potential_existing_incidents_relationships.exists() or not override_incident:
475
+ CheckedObjectIncidentRelationship.objects.create(rule_check=check, incident=self, **defaults)
476
+ else:
477
+ potential_existing_incidents_relationships.update(**defaults)
478
+
479
+ @property
480
+ def threshold(self) -> Any:
481
+ return self.rule.thresholds.get(severity=self.severity)
482
+
483
+ @property
484
+ def notifiable(self) -> bool:
485
+ """
486
+ Property for wether the incident can fire notification
487
+ Returns:
488
+ True if the incident can fire notification
489
+ """
490
+ return not self.rule.is_silent and self.threshold.get_notifiable_users().exists()
491
+
492
+ @property
493
+ def business_days(self) -> int:
494
+ """
495
+ Property to get the number of days this incident span
496
+
497
+ Returns:
498
+ The number of business days
499
+ """
500
+ return len(pd.date_range(self.date_range.lower, self.date_range.upper, freq="B", inclusive="left")) # type: ignore
501
+
502
+ def post_workflow(self):
503
+ """
504
+ Post save method to check the extra mechanisms
505
+
506
+ """
507
+
508
+ # Check if the incident is automatically closed
509
+ if self.automatically_close_incident:
510
+ self.status = RiskIncident.Status.CLOSED
511
+ # Check if the incident needs to be elevated
512
+ if (
513
+ (upgradable_after_days := self.threshold.upgradable_after_days)
514
+ and (next_threshold := self.threshold.next_threshold)
515
+ and (self.business_days > upgradable_after_days)
516
+ ):
517
+ self.severity = next_threshold.severity
518
+
519
+ self.save()
520
+
521
+ @classmethod
522
+ def get_endpoint_basename(cls) -> str:
523
+ return "wbcompliance:riskincident"
524
+
525
+ @classmethod
526
+ def get_representation_endpoint(cls) -> str:
527
+ return "wbcompliance:riskincidentrepresentation-list"
528
+
529
+ @classmethod
530
+ def get_representation_value_key(cls) -> str:
531
+ return "id"
532
+
533
+ @classmethod
534
+ def resolve_all_incidents(
535
+ cls,
536
+ resolved_by: "Person",
537
+ reviewer_comment: str,
538
+ is_resolved: Optional[bool] = True,
539
+ rule_id: Optional[int] = None,
540
+ ):
541
+ """
542
+ Utility methods to close all incidents that were created during an optional check and/or triggered by an optional rule
543
+ Args:
544
+ resolved_by: The user (Person) resolving all incidents.
545
+ reviewer_comment: The resolver's comment.
546
+ is_resolved: True if incident state becomes "RESOLVED", "IGNORED" otherwise. Defaults to True.
547
+ risk_check_id: Optional check id. Defaults to None (Closing all incidents from any check).
548
+ rule_id: Optional Rule id. Defaults to None (Closing all incidents from any rule).
549
+ """
550
+ qs = RiskIncident.objects.filter(status=RiskIncident.Status.OPEN)
551
+ if rule_id:
552
+ qs = qs.filter(rule__id=rule_id)
553
+ for incident in qs.iterator():
554
+ if is_resolved:
555
+ incident.resolve(by=resolved_by)
556
+ else:
557
+ incident.ignore(by=resolved_by)
558
+ incident.comment = reviewer_comment
559
+ incident.save()
560
+
561
+ @classmethod
562
+ def can_manage(cls, user: "User", rule: Optional[models.Model] = None) -> bool:
563
+ """
564
+ Utility function to check wether the given user can manage|edit all incidents (manager) or on optional incident
565
+ Args:
566
+ user: The user whose permission is checked.
567
+ incident: Optional Incident. If none, assume global permission check. Defaults to None.
568
+
569
+ Returns:
570
+ True if user can manage
571
+ """
572
+ from .rules import RiskRule
573
+
574
+ if rule:
575
+ checker = ObjectPermissionChecker(user)
576
+ return checker.has_perm(RiskRule.change_perm_str, rule) or checker.has_perm(RiskRule.admin_perm_str, rule)
577
+ return user.has_perm(RiskRule.admin_perm_str)
578
+
579
+
580
+ @receiver(post_save, sender="wbcompliance.CheckedObjectIncidentRelationship")
581
+ def post_save_incident_relationship(sender, instance, created, raw, **kwargs):
582
+ """
583
+ Trigger notification on incident creation
584
+ """
585
+ if not raw and instance.incident:
586
+ if (
587
+ created
588
+ and instance.status == RiskIncident.Status.OPEN
589
+ and not instance.severity.is_informational # do not reopen incident from informational severity
590
+ and (
591
+ instance.incident.status in [RiskIncident.Status.RESOLVED, RiskIncident.Status.CLOSED]
592
+ or (
593
+ instance.incident.status == RiskIncident.Status.IGNORED
594
+ and (
595
+ not (ignore_until := instance.incident.get_ignore_until_date())
596
+ or ignore_until < instance.incident_date
597
+ )
598
+ )
599
+ )
600
+ ):
601
+ instance.incident.status = RiskIncident.Status.OPEN
602
+ instance.incident.last_ignored_date = None
603
+ instance.incident.ignore_duration = None
604
+
605
+ instance.incident.save()
606
+
607
+
608
+ @shared_task
609
+ def resolve_all_incidents_as_task(
610
+ resolved_by_id,
611
+ reviewer_comment,
612
+ is_resolved: Optional[bool] = True,
613
+ rule_id: Optional[int] = None,
614
+ ):
615
+ """
616
+ Async task to resolve all incidents
617
+ """
618
+ resolved_by = User.objects.get(id=resolved_by_id)
619
+ RiskIncident.resolve_all_incidents(resolved_by, reviewer_comment, is_resolved, rule_id=rule_id)