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.
- wbcompliance/__init__.py +1 -0
- wbcompliance/admin/__init__.py +16 -0
- wbcompliance/admin/compliance_form.py +56 -0
- wbcompliance/admin/compliance_task.py +135 -0
- wbcompliance/admin/compliance_type.py +8 -0
- wbcompliance/admin/risk_management/__init__.py +3 -0
- wbcompliance/admin/risk_management/checks.py +7 -0
- wbcompliance/admin/risk_management/incidents.py +50 -0
- wbcompliance/admin/risk_management/rules.py +63 -0
- wbcompliance/admin/utils.py +46 -0
- wbcompliance/apps.py +14 -0
- wbcompliance/factories/__init__.py +21 -0
- wbcompliance/factories/compliance.py +246 -0
- wbcompliance/factories/risk_management/__init__.py +12 -0
- wbcompliance/factories/risk_management/backends.py +42 -0
- wbcompliance/factories/risk_management/checks.py +12 -0
- wbcompliance/factories/risk_management/incidents.py +84 -0
- wbcompliance/factories/risk_management/rules.py +100 -0
- wbcompliance/filters/__init__.py +2 -0
- wbcompliance/filters/compliances.py +189 -0
- wbcompliance/filters/risk_management/__init__.py +3 -0
- wbcompliance/filters/risk_management/checks.py +22 -0
- wbcompliance/filters/risk_management/incidents.py +113 -0
- wbcompliance/filters/risk_management/rules.py +110 -0
- wbcompliance/filters/risk_management/tables.py +112 -0
- wbcompliance/filters/risk_management/utils.py +3 -0
- wbcompliance/management/__init__.py +10 -0
- wbcompliance/migrations/0001_initial_squashed_squashed_0010_alter_checkedobjectincidentrelationship_resolved_by_and_more.py +1744 -0
- wbcompliance/migrations/0011_alter_riskrule_parameters.py +21 -0
- wbcompliance/migrations/0012_alter_compliancetype_options.py +20 -0
- wbcompliance/migrations/0013_alter_riskrule_unique_together.py +16 -0
- wbcompliance/migrations/0014_alter_reviewcompliancetask_year.py +27 -0
- wbcompliance/migrations/0015_auto_20240103_0957.py +43 -0
- wbcompliance/migrations/0016_checkedobjectincidentrelationship_report_details_and_more.py +37 -0
- wbcompliance/migrations/0017_alter_rulebackend_incident_report_template.py +20 -0
- wbcompliance/migrations/0018_alter_rulecheckedobjectrelationship_unique_together.py +39 -0
- wbcompliance/migrations/0019_rulegroup_riskrule_activation_date_and_more.py +60 -0
- wbcompliance/migrations/__init__.py +0 -0
- wbcompliance/models/__init__.py +20 -0
- wbcompliance/models/compliance_form.py +626 -0
- wbcompliance/models/compliance_task.py +800 -0
- wbcompliance/models/compliance_type.py +133 -0
- wbcompliance/models/enums.py +13 -0
- wbcompliance/models/risk_management/__init__.py +4 -0
- wbcompliance/models/risk_management/backend.py +139 -0
- wbcompliance/models/risk_management/checks.py +194 -0
- wbcompliance/models/risk_management/dispatch.py +41 -0
- wbcompliance/models/risk_management/incidents.py +619 -0
- wbcompliance/models/risk_management/mixins.py +115 -0
- wbcompliance/models/risk_management/rules.py +654 -0
- wbcompliance/permissions.py +32 -0
- wbcompliance/serializers/__init__.py +30 -0
- wbcompliance/serializers/compliance_form.py +320 -0
- wbcompliance/serializers/compliance_task.py +463 -0
- wbcompliance/serializers/compliance_type.py +26 -0
- wbcompliance/serializers/risk_management/__init__.py +19 -0
- wbcompliance/serializers/risk_management/checks.py +53 -0
- wbcompliance/serializers/risk_management/incidents.py +227 -0
- wbcompliance/serializers/risk_management/rules.py +158 -0
- wbcompliance/tasks.py +112 -0
- wbcompliance/tests/__init__.py +0 -0
- wbcompliance/tests/conftest.py +63 -0
- wbcompliance/tests/disable_signals.py +82 -0
- wbcompliance/tests/mixins.py +17 -0
- wbcompliance/tests/risk_management/__init__.py +0 -0
- wbcompliance/tests/risk_management/models/__init__.py +0 -0
- wbcompliance/tests/risk_management/models/test_backends.py +0 -0
- wbcompliance/tests/risk_management/models/test_checks.py +55 -0
- wbcompliance/tests/risk_management/models/test_incidents.py +327 -0
- wbcompliance/tests/risk_management/models/test_rules.py +255 -0
- wbcompliance/tests/signals.py +89 -0
- wbcompliance/tests/test_filters.py +23 -0
- wbcompliance/tests/test_models.py +57 -0
- wbcompliance/tests/test_serializers.py +48 -0
- wbcompliance/tests/test_views.py +377 -0
- wbcompliance/tests/tests.py +21 -0
- wbcompliance/urls.py +238 -0
- wbcompliance/viewsets/__init__.py +40 -0
- wbcompliance/viewsets/buttons/__init__.py +9 -0
- wbcompliance/viewsets/buttons/compliance_form.py +78 -0
- wbcompliance/viewsets/buttons/compliance_task.py +149 -0
- wbcompliance/viewsets/buttons/risk_managment/__init__.py +3 -0
- wbcompliance/viewsets/buttons/risk_managment/checks.py +11 -0
- wbcompliance/viewsets/buttons/risk_managment/incidents.py +51 -0
- wbcompliance/viewsets/buttons/risk_managment/rules.py +35 -0
- wbcompliance/viewsets/compliance_form.py +425 -0
- wbcompliance/viewsets/compliance_task.py +513 -0
- wbcompliance/viewsets/compliance_type.py +38 -0
- wbcompliance/viewsets/display/__init__.py +22 -0
- wbcompliance/viewsets/display/compliance_form.py +317 -0
- wbcompliance/viewsets/display/compliance_task.py +453 -0
- wbcompliance/viewsets/display/compliance_type.py +22 -0
- wbcompliance/viewsets/display/risk_managment/__init__.py +11 -0
- wbcompliance/viewsets/display/risk_managment/checks.py +46 -0
- wbcompliance/viewsets/display/risk_managment/incidents.py +155 -0
- wbcompliance/viewsets/display/risk_managment/rules.py +146 -0
- wbcompliance/viewsets/display/risk_managment/tables.py +51 -0
- wbcompliance/viewsets/endpoints/__init__.py +27 -0
- wbcompliance/viewsets/endpoints/compliance_form.py +207 -0
- wbcompliance/viewsets/endpoints/compliance_task.py +193 -0
- wbcompliance/viewsets/endpoints/compliance_type.py +9 -0
- wbcompliance/viewsets/endpoints/risk_managment/__init__.py +12 -0
- wbcompliance/viewsets/endpoints/risk_managment/checks.py +16 -0
- wbcompliance/viewsets/endpoints/risk_managment/incidents.py +36 -0
- wbcompliance/viewsets/endpoints/risk_managment/rules.py +32 -0
- wbcompliance/viewsets/endpoints/risk_managment/tables.py +14 -0
- wbcompliance/viewsets/menu/__init__.py +17 -0
- wbcompliance/viewsets/menu/compliance_form.py +49 -0
- wbcompliance/viewsets/menu/compliance_task.py +130 -0
- wbcompliance/viewsets/menu/compliance_type.py +17 -0
- wbcompliance/viewsets/menu/risk_management.py +56 -0
- wbcompliance/viewsets/risk_management/__init__.py +21 -0
- wbcompliance/viewsets/risk_management/checks.py +49 -0
- wbcompliance/viewsets/risk_management/incidents.py +204 -0
- wbcompliance/viewsets/risk_management/mixins.py +52 -0
- wbcompliance/viewsets/risk_management/rules.py +179 -0
- wbcompliance/viewsets/risk_management/tables.py +96 -0
- wbcompliance/viewsets/titles/__init__.py +17 -0
- wbcompliance/viewsets/titles/compliance_form.py +101 -0
- wbcompliance/viewsets/titles/compliance_task.py +60 -0
- wbcompliance/viewsets/titles/compliance_type.py +13 -0
- wbcompliance/viewsets/titles/risk_managment/__init__.py +1 -0
- wbcompliance/viewsets/titles/risk_managment/checks.py +0 -0
- wbcompliance/viewsets/titles/risk_managment/incidents.py +0 -0
- wbcompliance/viewsets/titles/risk_managment/rules.py +0 -0
- wbcompliance/viewsets/titles/risk_managment/tables.py +7 -0
- wbcompliance-2.2.1.dist-info/METADATA +7 -0
- wbcompliance-2.2.1.dist-info/RECORD +129 -0
- 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)
|