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,654 @@
|
|
|
1
|
+
from contextlib import suppress
|
|
2
|
+
from datetime import date, datetime, timedelta
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
from typing import Any, Dict, Generator, Iterable, Iterator, Optional
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from celery import shared_task
|
|
8
|
+
from dateutil import rrule
|
|
9
|
+
from django.contrib.auth import get_user_model
|
|
10
|
+
from django.contrib.auth.models import Group
|
|
11
|
+
from django.contrib.auth.models import User as BaseUser
|
|
12
|
+
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
13
|
+
from django.contrib.contenttypes.models import ContentType
|
|
14
|
+
from django.contrib.postgres.fields import DecimalRangeField
|
|
15
|
+
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
|
16
|
+
from django.core.serializers.json import DjangoJSONEncoder
|
|
17
|
+
from django.db import models
|
|
18
|
+
from django.db.models.signals import pre_delete, pre_save
|
|
19
|
+
from django.dispatch import receiver
|
|
20
|
+
from django.template.loader import get_template
|
|
21
|
+
from django.utils.functional import cached_property
|
|
22
|
+
from django.utils.translation import gettext_lazy as _
|
|
23
|
+
from pandas.tseries.offsets import BDay
|
|
24
|
+
from psycopg.types.range import DateRange
|
|
25
|
+
from rest_framework.reverse import reverse
|
|
26
|
+
from wbcore.contrib.directory.models import Person
|
|
27
|
+
from wbcore.contrib.guardian.models.mixins import PermissionObjectModelMixin
|
|
28
|
+
from wbcore.contrib.notifications.dispatch import send_notification
|
|
29
|
+
from wbcore.models import WBModel
|
|
30
|
+
from wbcore.utils.models import ComplexToStringMixin
|
|
31
|
+
from wbcore.utils.rrules import convert_rrulestr_to_dict
|
|
32
|
+
|
|
33
|
+
from .backend import AbstractRuleBackend
|
|
34
|
+
from .checks import RiskCheck
|
|
35
|
+
from .incidents import CheckedObjectIncidentRelationship, RiskIncident, RiskIncidentType
|
|
36
|
+
|
|
37
|
+
User: BaseUser = get_user_model()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RuleGroup(models.Model):
|
|
41
|
+
key = models.CharField(max_length=255, unique=True)
|
|
42
|
+
name = models.CharField(max_length=255)
|
|
43
|
+
|
|
44
|
+
def save(self, *args, **kwargs):
|
|
45
|
+
if not self.name:
|
|
46
|
+
self.name = self.key.title()
|
|
47
|
+
super().save(*args, **kwargs)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def get_representation_endpoint(cls) -> str:
|
|
51
|
+
return "wbcompliance:rulegrouprepresentation-list"
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def get_representation_value_key(cls) -> str:
|
|
55
|
+
return "id"
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def get_representation_label_key(cls) -> str:
|
|
59
|
+
return "{{name}}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RuleCheckedObjectRelationshipDefaultManager(models.Manager):
|
|
63
|
+
def get_for_object(self, obj, **extra_filter_kwargs):
|
|
64
|
+
return self.get_queryset().filter(
|
|
65
|
+
checked_object_content_type=ContentType.objects.get_for_model(obj),
|
|
66
|
+
checked_object_id=obj.id,
|
|
67
|
+
**extra_filter_kwargs,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RuleCheckedObjectRelationship(ComplexToStringMixin, models.Model):
|
|
72
|
+
rule = models.ForeignKey(
|
|
73
|
+
to="wbcompliance.RiskRule", related_name="checked_object_relationships", on_delete=models.CASCADE
|
|
74
|
+
)
|
|
75
|
+
checked_object_content_type = models.ForeignKey(
|
|
76
|
+
ContentType, on_delete=models.CASCADE, related_name="risk_management_checked_objects"
|
|
77
|
+
)
|
|
78
|
+
checked_object_id = models.PositiveIntegerField()
|
|
79
|
+
checked_object = GenericForeignKey("checked_object_content_type", "checked_object_id")
|
|
80
|
+
checked_object_repr = models.CharField(max_length=256, blank=True, null=True)
|
|
81
|
+
|
|
82
|
+
objects = RuleCheckedObjectRelationshipDefaultManager()
|
|
83
|
+
|
|
84
|
+
class Meta:
|
|
85
|
+
verbose_name = "Checked Object to Rule relationship"
|
|
86
|
+
verbose_name_plural = "Checked Object to Rule relationships"
|
|
87
|
+
indexes = [
|
|
88
|
+
models.Index(fields=["rule", "checked_object_content_type", "checked_object_id"]),
|
|
89
|
+
]
|
|
90
|
+
unique_together = [("rule", "checked_object_content_type", "checked_object_id")]
|
|
91
|
+
|
|
92
|
+
def clean(self):
|
|
93
|
+
allowed_checked_object_content_type = self.rule.rule_backend.allowed_checked_object_content_type
|
|
94
|
+
if allowed_checked_object_content_type and (
|
|
95
|
+
allowed_checked_object_content_type != self.checked_object_content_type
|
|
96
|
+
):
|
|
97
|
+
raise ValidationError(
|
|
98
|
+
_(
|
|
99
|
+
"The relationship content type ({}) needs to match the rule's backend allowed content type ({})"
|
|
100
|
+
).format(self.checked_object_content_type, allowed_checked_object_content_type)
|
|
101
|
+
)
|
|
102
|
+
super().clean()
|
|
103
|
+
|
|
104
|
+
def save(self, *args, **kwargs):
|
|
105
|
+
self.full_clean()
|
|
106
|
+
self.checked_object_repr = str(self.checked_object)
|
|
107
|
+
super().save(*args, **kwargs)
|
|
108
|
+
|
|
109
|
+
def compute_str(self) -> str:
|
|
110
|
+
return _("Rule Relationship {} -> {} {}").format(
|
|
111
|
+
self.rule, self.checked_object_content_type.name, self.checked_object
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def process_rule(self, evaluation_date: date, override_incident: bool = False) -> bool:
|
|
115
|
+
"""
|
|
116
|
+
Trigger the check between the rule and the checked object attached to this relationship
|
|
117
|
+
"""
|
|
118
|
+
rule_backend = self.rule.rule_backend.backend(
|
|
119
|
+
evaluation_date, self.checked_object, self.rule.parameters, self.rule.thresholds.all()
|
|
120
|
+
)
|
|
121
|
+
incident_detected = False
|
|
122
|
+
if rule_backend.is_passive_evaluation_valid():
|
|
123
|
+
check = RiskCheck.objects.create(rule_checked_object_relationship=self, evaluation_date=evaluation_date)
|
|
124
|
+
# we create the check but the rule might not be allowed to be processed on that particular date (e.g. wrong frequency)
|
|
125
|
+
if self.rule.is_evaluation_date_valid(evaluation_date):
|
|
126
|
+
for incident in check.evaluate(override_incident=override_incident):
|
|
127
|
+
incident_detected = True
|
|
128
|
+
incident.post_workflow()
|
|
129
|
+
return incident_detected
|
|
130
|
+
|
|
131
|
+
def get_unchecked_dates(
|
|
132
|
+
self, from_date: Optional[date] = None, to_date: Optional[date] = None, maximum_day_interval: int = 30
|
|
133
|
+
) -> Iterator[date]:
|
|
134
|
+
"""
|
|
135
|
+
if a checks exists it generates all dates between it and the specified to_date (if exists, otherwise, return just the next day after the last check
|
|
136
|
+
if checks does not exist but to_date is specified, generates a unique date
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
to_date: The limit at which we want to check the next expected check date. Defaults None (no upper bound).
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
A list of date to be checked
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
if not from_date:
|
|
146
|
+
if self.checks.exists():
|
|
147
|
+
from_date = (self.checks.latest("evaluation_date").evaluation_date + BDay(1)).date()
|
|
148
|
+
elif to_date:
|
|
149
|
+
from_date = to_date - timedelta(days=maximum_day_interval)
|
|
150
|
+
if to_date:
|
|
151
|
+
minimum_allowed_from_date = to_date - timedelta(days=maximum_day_interval)
|
|
152
|
+
from_date = max([from_date, minimum_allowed_from_date])
|
|
153
|
+
if not to_date:
|
|
154
|
+
to_date = from_date
|
|
155
|
+
if not from_date and not to_date:
|
|
156
|
+
raise ValueError("Either from or To date needs to be provided")
|
|
157
|
+
for evaluation_date in pd.date_range(from_date, to_date, freq="B"):
|
|
158
|
+
if not self.checks.filter(evaluation_date=evaluation_date.date()).exists():
|
|
159
|
+
yield evaluation_date.date()
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def get_representation_endpoint(cls) -> str:
|
|
163
|
+
return "wbcompliance:rulechecked_objectrelationshiprepresentation-list"
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def get_representation_value_key(cls) -> str:
|
|
167
|
+
return "id"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class RuleBackend(models.Model):
|
|
171
|
+
"""
|
|
172
|
+
Represent a rule backend that links to a module where a RuleBackend class is defined.
|
|
173
|
+
|
|
174
|
+
We expect this class to at least define the following interface:
|
|
175
|
+
* check(evaluation_date, checked_object, **json_parameters) // Check for a given date, triggering object, a set of
|
|
176
|
+
parameters and corresponding severity thresholds if a rule is in breach.
|
|
177
|
+
|
|
178
|
+
Optionally, it can define the following methods:
|
|
179
|
+
* is_passive_evaluation_valid(eval_date, evaluated_object)
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
name = models.CharField(max_length=128)
|
|
183
|
+
backend_class_path = models.CharField(max_length=512)
|
|
184
|
+
backend_class_name = models.CharField(max_length=128, default="RuleBackend")
|
|
185
|
+
allowed_checked_object_content_type = models.ForeignKey(
|
|
186
|
+
ContentType, on_delete=models.CASCADE, blank=True, null=True
|
|
187
|
+
)
|
|
188
|
+
rule_group = models.ForeignKey(
|
|
189
|
+
to="wbcompliance.RuleGroup", related_name="rules", null=True, blank=True, on_delete=models.SET_NULL
|
|
190
|
+
)
|
|
191
|
+
incident_report_template = models.TextField(
|
|
192
|
+
default=get_template("risk_management/incident_report.html").template.source, verbose_name="Incident Template"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
class Meta:
|
|
196
|
+
verbose_name = "Rule Backend"
|
|
197
|
+
verbose_name_plural = "Rule Backends"
|
|
198
|
+
|
|
199
|
+
@cached_property
|
|
200
|
+
def backend_class(self) -> type[AbstractRuleBackend]:
|
|
201
|
+
"""
|
|
202
|
+
Return the imported backend class
|
|
203
|
+
Returns:
|
|
204
|
+
The backend class
|
|
205
|
+
"""
|
|
206
|
+
return getattr(import_module(self.backend_class_path), self.backend_class_name)
|
|
207
|
+
|
|
208
|
+
def backend(
|
|
209
|
+
self,
|
|
210
|
+
evaluation_date: date,
|
|
211
|
+
evaluated_object: models.Model,
|
|
212
|
+
json_parameters: Dict[str, Any],
|
|
213
|
+
thresholds: "models.QuerySet[RuleThreshold]",
|
|
214
|
+
) -> AbstractRuleBackend:
|
|
215
|
+
"""
|
|
216
|
+
Args:
|
|
217
|
+
evaluation_date: The evaluation rule date
|
|
218
|
+
evaluated_object: The object that needs evaluation
|
|
219
|
+
json_parameters: Set of paramaters as dictionary. Might expect deserialization that will be handled within the backend
|
|
220
|
+
thresholds: List of numerical range, severity pairs
|
|
221
|
+
|
|
222
|
+
Return the instantiated backend imported through the specified dotted path and the passed parameters
|
|
223
|
+
Returns:
|
|
224
|
+
The instantiated backend
|
|
225
|
+
"""
|
|
226
|
+
checked_object_content_type = ContentType.objects.get_for_model(evaluated_object)
|
|
227
|
+
if self.allowed_checked_object_content_type and (
|
|
228
|
+
checked_object_content_type != self.allowed_checked_object_content_type
|
|
229
|
+
):
|
|
230
|
+
raise ValidationError(
|
|
231
|
+
_("Passed content type {} does not match the allowed backend content type {}").format(
|
|
232
|
+
checked_object_content_type, self.allowed_checked_object_content_type
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
return self.backend_class(evaluation_date, evaluated_object, json_parameters, thresholds)
|
|
236
|
+
|
|
237
|
+
def get_all_active_relationships(self) -> Iterable:
|
|
238
|
+
try:
|
|
239
|
+
return self.backend_class.get_all_active_relationships()
|
|
240
|
+
except NotImplementedError:
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
def __str__(self):
|
|
244
|
+
return _("Rule Backend {}").format(self.name)
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def get_representation_endpoint(cls) -> str:
|
|
248
|
+
return "wbcompliance:rulebackendrepresentation-list"
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def get_representation_value_key(cls) -> str:
|
|
252
|
+
return "id"
|
|
253
|
+
|
|
254
|
+
@classmethod
|
|
255
|
+
def get_representation_label_key(cls) -> str:
|
|
256
|
+
return "{{name}}"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class RuleThreshold(ComplexToStringMixin, models.Model):
|
|
260
|
+
"""
|
|
261
|
+
Represent the list of threshold and its associated severity link to a certain rule
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
rule = models.ForeignKey(to="wbcompliance.RiskRule", related_name="thresholds", on_delete=models.CASCADE)
|
|
265
|
+
range = DecimalRangeField(
|
|
266
|
+
verbose_name=_("Threshold range"),
|
|
267
|
+
help_text=_("The range which triggers the specified severity. null bound represent infinity"),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
severity = models.ForeignKey(
|
|
271
|
+
"wbcompliance.RiskIncidentType",
|
|
272
|
+
on_delete=models.CASCADE,
|
|
273
|
+
verbose_name=_("Triggered Severity"),
|
|
274
|
+
help_text=_("The Triggered Severity when the rule is within the threshold range"),
|
|
275
|
+
related_name="thresholds",
|
|
276
|
+
)
|
|
277
|
+
upgradable_after_days = models.PositiveIntegerField(
|
|
278
|
+
blank=True,
|
|
279
|
+
null=True,
|
|
280
|
+
verbose_name=_("Upgradable to next severity after X Days"),
|
|
281
|
+
help_text=_(
|
|
282
|
+
"If set to a positive integer, the resulting incident will be elevated to the next rule severity after an incident remains open this number of days"
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
notifiable_users = models.ManyToManyField(
|
|
287
|
+
"directory.Person",
|
|
288
|
+
related_name="notified_rule_thresholds",
|
|
289
|
+
blank=True,
|
|
290
|
+
verbose_name=_("Notifiable Persons"),
|
|
291
|
+
help_text=_("Notified Persons for this rule and severity"),
|
|
292
|
+
)
|
|
293
|
+
notifiable_groups = models.ManyToManyField(
|
|
294
|
+
Group,
|
|
295
|
+
related_name="notified_rule_thresholds",
|
|
296
|
+
blank=True,
|
|
297
|
+
verbose_name=_("Notifiable Groups"),
|
|
298
|
+
help_text=_("Notified Groups for this rule and severity"),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def next_threshold(self):
|
|
303
|
+
"""
|
|
304
|
+
Property that hold the next higher severity order threshold
|
|
305
|
+
|
|
306
|
+
Returns: The next Threshold in the severity order sense
|
|
307
|
+
"""
|
|
308
|
+
higher_thresholds = (
|
|
309
|
+
self.rule.thresholds.exclude(id=self.id)
|
|
310
|
+
.filter(severity__severity_order__gt=self.severity.severity_order)
|
|
311
|
+
.order_by("severity__severity_order")
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
if higher_thresholds.exists():
|
|
315
|
+
return higher_thresholds.first()
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def numerical_range(self) -> tuple[float, float]:
|
|
319
|
+
return float(self.range.lower) if self.range.lower is not None else float("-inf"), float( # type: ignore
|
|
320
|
+
self.range.upper # type: ignore
|
|
321
|
+
) if self.range.upper is not None else float(
|
|
322
|
+
"inf"
|
|
323
|
+
) # type: ignore
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def range_repr(self) -> str:
|
|
327
|
+
upper_bound_repr = "{}]".format(self.range.upper) if self.range.upper is not None else "∞[" # type: ignore
|
|
328
|
+
lower_bound_repr = "[{}".format(self.range.lower) if self.range.lower is not None else "]-∞" # type: ignore
|
|
329
|
+
return f"{lower_bound_repr}, {upper_bound_repr}"
|
|
330
|
+
|
|
331
|
+
def is_inrange(self, value) -> bool:
|
|
332
|
+
"""
|
|
333
|
+
Utility function to check whether is within this threshold range
|
|
334
|
+
Args:
|
|
335
|
+
value: The value to check against
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
True if the value is in range
|
|
339
|
+
"""
|
|
340
|
+
return value > self.numerical_range[0] and value < self.numerical_range[1]
|
|
341
|
+
|
|
342
|
+
def save(self, *args, **kwargs):
|
|
343
|
+
super().save(*args, **kwargs)
|
|
344
|
+
|
|
345
|
+
def compute_str(self) -> str:
|
|
346
|
+
return _("Range: {} (Severity {})").format(self.range_repr, self.severity)
|
|
347
|
+
|
|
348
|
+
def get_notifiable_users(self) -> "models.QuerySet[Person]":
|
|
349
|
+
"""
|
|
350
|
+
Returns the incident notifiable persons
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
A queryset of Person
|
|
354
|
+
"""
|
|
355
|
+
try:
|
|
356
|
+
return User.objects.filter(
|
|
357
|
+
models.Q(profile__in=self.notifiable_users.values("id"))
|
|
358
|
+
| models.Q(groups__in=self.notifiable_groups.all())
|
|
359
|
+
).distinct()
|
|
360
|
+
except ObjectDoesNotExist:
|
|
361
|
+
return User.objects.none()
|
|
362
|
+
|
|
363
|
+
class Meta:
|
|
364
|
+
unique_together = ("rule", "severity")
|
|
365
|
+
|
|
366
|
+
@classmethod
|
|
367
|
+
def get_representation_endpoint(cls) -> str:
|
|
368
|
+
return "wbcompliance:rulethresholdrepresentation-list"
|
|
369
|
+
|
|
370
|
+
@classmethod
|
|
371
|
+
def get_representation_value_key(cls) -> str:
|
|
372
|
+
return "id"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class RiskRule(PermissionObjectModelMixin, WBModel):
|
|
376
|
+
"""
|
|
377
|
+
Base class for rule management. All the data that defined the rule parameters.
|
|
378
|
+
|
|
379
|
+
Expected to be inherited (Abstract)
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
name = models.CharField(max_length=512)
|
|
383
|
+
description = models.CharField(max_length=516, default="", verbose_name=_("Quick Description"))
|
|
384
|
+
rule_backend = models.ForeignKey(to="wbcompliance.RuleBackend", related_name="rules", on_delete=models.CASCADE)
|
|
385
|
+
|
|
386
|
+
is_enable = models.BooleanField(default=True, verbose_name=_("Enabled"))
|
|
387
|
+
only_passive_check_allowed = models.BooleanField(
|
|
388
|
+
default=True,
|
|
389
|
+
verbose_name=_("Passive Only"),
|
|
390
|
+
help_text=_("If False, This rule can only be triggered passively"),
|
|
391
|
+
)
|
|
392
|
+
is_silent = models.BooleanField(
|
|
393
|
+
default=True, help_text=_("If true, the notification won't be send through System nor Mail")
|
|
394
|
+
)
|
|
395
|
+
is_mandatory = models.BooleanField(
|
|
396
|
+
default=False, help_text=_("A mandatory rule cannot be modified by anyone other than the administrators")
|
|
397
|
+
)
|
|
398
|
+
automatically_close_incident = models.BooleanField(
|
|
399
|
+
default=False, help_text=_("If True, this rule will automatically close all encountered incidents")
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
apply_to_all_active_relationships = models.BooleanField(
|
|
403
|
+
default=False,
|
|
404
|
+
help_text=_("If True, will keep this rule syncrhonize with the backend definition of all active relationship"),
|
|
405
|
+
)
|
|
406
|
+
activation_date = models.DateField(null=True, blank=True, verbose_name="Activation Date")
|
|
407
|
+
frequency = models.CharField(
|
|
408
|
+
null=True,
|
|
409
|
+
blank=True,
|
|
410
|
+
max_length=56,
|
|
411
|
+
verbose_name=_("Evaluation Frequency"),
|
|
412
|
+
help_text=_("The Evaluation Frequency in RRULE format"),
|
|
413
|
+
)
|
|
414
|
+
parameters = models.JSONField(blank=True, default=dict, encoder=DjangoJSONEncoder)
|
|
415
|
+
|
|
416
|
+
class Meta(PermissionObjectModelMixin.Meta):
|
|
417
|
+
verbose_name = "Risk Rule"
|
|
418
|
+
verbose_name_plural = "Risk Rules"
|
|
419
|
+
|
|
420
|
+
@property
|
|
421
|
+
def checked_object_representation(self) -> str:
|
|
422
|
+
try:
|
|
423
|
+
backend_class = self.rule_backend.backend_class
|
|
424
|
+
checked_object_repr = getattr(backend_class, "OBJECT_FIELD_NAME").title()
|
|
425
|
+
except AttributeError:
|
|
426
|
+
checked_object_repr = "Object"
|
|
427
|
+
return checked_object_repr
|
|
428
|
+
|
|
429
|
+
@property
|
|
430
|
+
def checked_objects(self) -> Generator[models.Model, None, None]:
|
|
431
|
+
"""
|
|
432
|
+
All objects that share a relationship with this rule instance
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
An generator of Object (any type)
|
|
436
|
+
|
|
437
|
+
"""
|
|
438
|
+
for relation in self.checked_object_relationships.all():
|
|
439
|
+
yield relation.checked_object
|
|
440
|
+
|
|
441
|
+
@property
|
|
442
|
+
def checks(self) -> "models.QuerySet[RiskCheck]":
|
|
443
|
+
return RiskCheck.objects.filter(rule_checked_object_relationship__rule=self).distinct()
|
|
444
|
+
|
|
445
|
+
def save(self, *args, **kwargs):
|
|
446
|
+
if not self.parameters:
|
|
447
|
+
serializer = self.rule_backend.backend_class.get_serializer_class()(self.parameters)
|
|
448
|
+
self.parameters = serializer.data
|
|
449
|
+
super().save(*args, **kwargs)
|
|
450
|
+
|
|
451
|
+
def __str__(self):
|
|
452
|
+
return _("Rule {}").format(self.name)
|
|
453
|
+
|
|
454
|
+
def is_evaluation_date_valid(self, evaluation_date: date) -> bool:
|
|
455
|
+
if self.frequency and self.activation_date:
|
|
456
|
+
rrule_dict = convert_rrulestr_to_dict(self.frequency, dtstart=self.activation_date, until=evaluation_date)
|
|
457
|
+
valid_dates = list(map(lambda o: o.date(), rrule.rrule(**rrule_dict)))
|
|
458
|
+
return evaluation_date in valid_dates
|
|
459
|
+
return self.activation_date is not None and self.activation_date <= evaluation_date
|
|
460
|
+
|
|
461
|
+
def process_rule(self, evaluation_date: date, override_incident: bool = False, silent_notification: bool = False):
|
|
462
|
+
"""
|
|
463
|
+
Wrapper function that calls the not implemented check_risk to evaluate the rule against a date and all linked risk management instances.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
evaluation_date: The evaluation date
|
|
467
|
+
override_incident: If True, the existing incidents will be reopened instead of being ignored
|
|
468
|
+
silent_notification: If True, explicitly silent notification even if they are due
|
|
469
|
+
"""
|
|
470
|
+
if not self.is_enable:
|
|
471
|
+
raise ValueError("This rule cannot be triggered (disabled or active)")
|
|
472
|
+
for relationship in self.checked_object_relationships.iterator():
|
|
473
|
+
relationship.process_rule(evaluation_date, override_incident=override_incident)
|
|
474
|
+
if not silent_notification:
|
|
475
|
+
self.notify(evaluation_date)
|
|
476
|
+
|
|
477
|
+
def get_permissions_for_user(self, user, created: datetime | None = None) -> dict[str, bool]:
|
|
478
|
+
permissions = super().get_permissions_for_user(user, created)
|
|
479
|
+
|
|
480
|
+
if user.has_perm(self.view_perm_str):
|
|
481
|
+
permissions[self.view_perm_str] = False
|
|
482
|
+
|
|
483
|
+
return permissions
|
|
484
|
+
|
|
485
|
+
def get_or_create_incident(
|
|
486
|
+
self,
|
|
487
|
+
evaluation_date: date,
|
|
488
|
+
incident_severity: "RiskIncidentType",
|
|
489
|
+
breached_object: Any,
|
|
490
|
+
breached_object_repr: str,
|
|
491
|
+
) -> tuple[RiskIncident, bool]:
|
|
492
|
+
"""
|
|
493
|
+
Utility function to get or create incident base on the given breached object
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
rule: The rule this incident happens
|
|
497
|
+
evaluation_date: The incident date
|
|
498
|
+
incident_severity: The incident severity
|
|
499
|
+
breached_object: The breached object (i.e. the object that triggers the incident)
|
|
500
|
+
breached_object_repr: Its string representation
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
A tuple (incident, is_created)
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
# Consider that if an incident happens one business day in the future or past, it is continue
|
|
507
|
+
date_range = DateRange( # type: ignore
|
|
508
|
+
(evaluation_date - BDay(1)).date(), (evaluation_date + BDay(1)).date(), "[]"
|
|
509
|
+
)
|
|
510
|
+
incidents = RiskIncident.objects.filter(rule=self, date_range__overlap=date_range)
|
|
511
|
+
if breached_object:
|
|
512
|
+
# If a breached_object is provided, we lookup over it
|
|
513
|
+
incidents = incidents.filter(
|
|
514
|
+
breached_content_type=ContentType.objects.get_for_model(breached_object),
|
|
515
|
+
breached_object_id=breached_object.id,
|
|
516
|
+
)
|
|
517
|
+
else:
|
|
518
|
+
# Otherwise, we lookup over its string representation. Might need to change as this is not very robust
|
|
519
|
+
incidents = incidents.filter(
|
|
520
|
+
breached_object_repr=breached_object_repr,
|
|
521
|
+
)
|
|
522
|
+
if incident := incidents.first():
|
|
523
|
+
incident.date_range = DateRange(lower=incident.date_range.lower, upper=(evaluation_date + timedelta(days=1))) # type: ignore
|
|
524
|
+
if self.automatically_close_incident:
|
|
525
|
+
incident.status = RiskIncident.Status.CLOSED
|
|
526
|
+
incident.save()
|
|
527
|
+
return incident, False
|
|
528
|
+
else:
|
|
529
|
+
return (
|
|
530
|
+
RiskIncident.objects.create(
|
|
531
|
+
rule=self,
|
|
532
|
+
date_range=DateRange(lower=evaluation_date, upper=(evaluation_date + timedelta(days=1))), # type: ignore
|
|
533
|
+
breached_content_type=ContentType.objects.get_for_model(breached_object)
|
|
534
|
+
if breached_object
|
|
535
|
+
else None,
|
|
536
|
+
breached_object_id=breached_object.id if breached_object else None,
|
|
537
|
+
breached_object_repr=breached_object_repr,
|
|
538
|
+
severity=incident_severity,
|
|
539
|
+
status=RiskIncident.Status.CLOSED
|
|
540
|
+
if self.automatically_close_incident
|
|
541
|
+
else RiskIncident.Status.OPEN,
|
|
542
|
+
),
|
|
543
|
+
True,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
def notify(self, evaluation_date: date):
|
|
547
|
+
"""
|
|
548
|
+
Create the notification for this rule relationship and all implied persons
|
|
549
|
+
"""
|
|
550
|
+
# Check if the incident needs to be notified
|
|
551
|
+
incidents = RiskIncident.objects.filter(
|
|
552
|
+
models.Q(checked_object_relationships__rule_check__evaluation_date=evaluation_date)
|
|
553
|
+
& models.Q(rule=self)
|
|
554
|
+
& (models.Q(status=RiskIncident.Status.OPEN) | models.Q(is_notified=False))
|
|
555
|
+
)
|
|
556
|
+
if not self.is_silent:
|
|
557
|
+
for threshold in self.thresholds.filter(
|
|
558
|
+
severity__is_informational=False
|
|
559
|
+
): # ignore informational incident from being ignored
|
|
560
|
+
threshold_incidents = incidents.filter(severity=threshold.severity)
|
|
561
|
+
notified_users = threshold.get_notifiable_users()
|
|
562
|
+
if notified_users.exists() and threshold_incidents.exists():
|
|
563
|
+
evaluation_date_sub_incidents = CheckedObjectIncidentRelationship.objects.filter(
|
|
564
|
+
incident__in=threshold_incidents,
|
|
565
|
+
rule_check__evaluation_date=evaluation_date,
|
|
566
|
+
severity=threshold.severity,
|
|
567
|
+
).order_by("rule_check__rule_checked_object_relationship__checked_object_repr")
|
|
568
|
+
if evaluation_date_sub_incidents.exists():
|
|
569
|
+
breached_content_types = ContentType.objects.filter(
|
|
570
|
+
id__in=evaluation_date_sub_incidents.values("incident__breached_content_type")
|
|
571
|
+
)
|
|
572
|
+
breached_content_type_name = "Object"
|
|
573
|
+
if breached_content_types.count() == 1:
|
|
574
|
+
breached_content_type_name = breached_content_types.first().name
|
|
575
|
+
html = get_template("risk_management/incident_notification.html").render(
|
|
576
|
+
{
|
|
577
|
+
"evaluation_date_sub_incidents": evaluation_date_sub_incidents,
|
|
578
|
+
"rule": self,
|
|
579
|
+
"threshold": threshold,
|
|
580
|
+
"evaluation_date": evaluation_date,
|
|
581
|
+
"check_object_content_type_name": "Checked " + self.checked_object_representation,
|
|
582
|
+
"breached_content_type_name": breached_content_type_name,
|
|
583
|
+
}
|
|
584
|
+
)
|
|
585
|
+
for user in notified_users:
|
|
586
|
+
send_notification(
|
|
587
|
+
code="wbcompliance.riskincident.notify",
|
|
588
|
+
title=_("{} Broken Rule: {} as of {}").format(
|
|
589
|
+
threshold.severity.name, self.name, evaluation_date.strftime("%d.%m.%Y")
|
|
590
|
+
),
|
|
591
|
+
body=html,
|
|
592
|
+
user=user,
|
|
593
|
+
endpoint=reverse("wbcompliance:riskrule-detail", args=[self.id]),
|
|
594
|
+
)
|
|
595
|
+
incidents.update(is_notified=True)
|
|
596
|
+
|
|
597
|
+
@classmethod
|
|
598
|
+
def get_rules_for_object(cls, obj) -> "models.QuerySet[RiskRule]":
|
|
599
|
+
"""
|
|
600
|
+
Returns a Queryset of document linked to the passed object
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
obj: The related object
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
A queryset of documents
|
|
607
|
+
"""
|
|
608
|
+
rule_ids = RuleCheckedObjectRelationship.objects.filter(
|
|
609
|
+
checked_object_content_type=ContentType.objects.get_for_model(obj), checked_object_id=obj.id
|
|
610
|
+
).values("rule")
|
|
611
|
+
return cls.objects.filter(id__in=rule_ids)
|
|
612
|
+
|
|
613
|
+
@classmethod
|
|
614
|
+
def get_endpoint_basename(cls) -> str:
|
|
615
|
+
return "wbcompliance:riskrule"
|
|
616
|
+
|
|
617
|
+
@classmethod
|
|
618
|
+
def get_representation_endpoint(cls) -> str:
|
|
619
|
+
return "wbcompliance:riskrulerepresentation-list"
|
|
620
|
+
|
|
621
|
+
@classmethod
|
|
622
|
+
def get_representation_value_key(cls) -> str:
|
|
623
|
+
return "id"
|
|
624
|
+
|
|
625
|
+
@classmethod
|
|
626
|
+
def get_representation_label_key(cls) -> str:
|
|
627
|
+
return "{{name}}"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@receiver(pre_save, sender="wbcompliance.RuleThreshold")
|
|
631
|
+
def pre_save_risk_threshold(sender, instance, **kwargs):
|
|
632
|
+
with suppress(RuleThreshold.DoesNotExist):
|
|
633
|
+
pre_save_instance = RuleThreshold.objects.get(id=instance.id)
|
|
634
|
+
RiskIncident.objects.filter(rule=instance.rule, severity=pre_save_instance.severity).update(
|
|
635
|
+
severity=instance.severity
|
|
636
|
+
)
|
|
637
|
+
CheckedObjectIncidentRelationship.objects.filter(
|
|
638
|
+
incident__rule=instance.rule, severity=pre_save_instance.severity
|
|
639
|
+
).update(severity=instance.severity)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
@receiver(pre_delete, sender="wbcompliance.RuleThreshold")
|
|
643
|
+
def pre_delete_risk_threshold(sender, instance, **kwargs):
|
|
644
|
+
RiskIncident.objects.filter(rule=instance.rule, severity=instance.severity).delete()
|
|
645
|
+
CheckedObjectIncidentRelationship.objects.filter(incident__rule=instance.rule, severity=instance.severity).delete()
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
@shared_task
|
|
649
|
+
def process_rule_as_task(rule_id: int, evaluation_date: date, override_incident: bool | None = False):
|
|
650
|
+
"""
|
|
651
|
+
Async task to process rule
|
|
652
|
+
"""
|
|
653
|
+
rule = RiskRule.objects.get(id=rule_id)
|
|
654
|
+
rule.process_rule(evaluation_date, override_incident=override_incident)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from guardian.core import ObjectPermissionChecker
|
|
2
|
+
from rest_framework.permissions import IsAuthenticated
|
|
3
|
+
from wbcompliance.models.risk_management import RiskRule
|
|
4
|
+
from wbcore.permissions.shortcuts import is_internal_user
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RulePermission(IsAuthenticated):
|
|
8
|
+
def has_permission(self, request, view):
|
|
9
|
+
permission = super().has_permission(request, view)
|
|
10
|
+
if rule_id := view.kwargs.get("rule_id", None):
|
|
11
|
+
checker = ObjectPermissionChecker(request.user)
|
|
12
|
+
rule = RiskRule.objects.get(id=rule_id)
|
|
13
|
+
permission &= checker.has_perm(rule.view_perm_str, rule)
|
|
14
|
+
return permission
|
|
15
|
+
|
|
16
|
+
def has_object_permission(self, request, view, obj):
|
|
17
|
+
permission = super().has_object_permission(request, view, obj)
|
|
18
|
+
# Handle case when checked_objectincidentRelationship object is given
|
|
19
|
+
if incident := getattr(obj, "incident", None):
|
|
20
|
+
obj = incident
|
|
21
|
+
if rule := getattr(obj, "rule", None):
|
|
22
|
+
checker = ObjectPermissionChecker(request.user)
|
|
23
|
+
permission &= checker.has_perm(rule.view_perm_str, rule)
|
|
24
|
+
return permission
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_admin_compliance(request):
|
|
28
|
+
return is_internal_user(request.user) and request.user.has_perm("wbcompliance.administrate_compliance")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_internal_employee_compliance(request):
|
|
32
|
+
return is_internal_user(request.user) and not is_admin_compliance(request)
|