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