wbreport 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.
Potentially problematic release.
This version of wbreport might be problematic. Click here for more details.
- wbreport/__init__.py +1 -0
- wbreport/admin.py +87 -0
- wbreport/apps.py +6 -0
- wbreport/defaults/__init__.py +0 -0
- wbreport/defaults/factsheets/__init__.py +0 -0
- wbreport/defaults/factsheets/base.py +990 -0
- wbreport/defaults/factsheets/menu.py +93 -0
- wbreport/defaults/factsheets/mixins.py +35 -0
- wbreport/defaults/factsheets/multitheme.py +947 -0
- wbreport/dynamic_preferences_registry.py +15 -0
- wbreport/factories/__init__.py +8 -0
- wbreport/factories/data_classes.py +48 -0
- wbreport/factories/reports.py +79 -0
- wbreport/filters.py +37 -0
- wbreport/migrations/0001_initial_squashed_squashed_0007_report_key.py +238 -0
- wbreport/migrations/0008_alter_report_file_content_type.py +25 -0
- wbreport/migrations/0009_alter_report_color_palette.py +27 -0
- wbreport/migrations/0010_auto_20240103_0947.py +43 -0
- wbreport/migrations/0011_auto_20240207_1629.py +35 -0
- wbreport/migrations/0012_reportversion_lock.py +17 -0
- wbreport/migrations/0013_alter_reportversion_context.py +18 -0
- wbreport/migrations/0014_alter_reportcategory_options_and_more.py +25 -0
- wbreport/migrations/__init__.py +0 -0
- wbreport/mixins.py +183 -0
- wbreport/models.py +781 -0
- wbreport/pdf/__init__.py +0 -0
- wbreport/pdf/charts/__init__.py +0 -0
- wbreport/pdf/charts/legend.py +15 -0
- wbreport/pdf/charts/pie.py +169 -0
- wbreport/pdf/charts/timeseries.py +77 -0
- wbreport/pdf/sandbox/__init__.py +0 -0
- wbreport/pdf/sandbox/run.py +17 -0
- wbreport/pdf/sandbox/templates/__init__.py +0 -0
- wbreport/pdf/sandbox/templates/basic_factsheet.py +908 -0
- wbreport/pdf/sandbox/templates/fund_factsheet.py +864 -0
- wbreport/pdf/sandbox/templates/long_industry_exposure_factsheet.py +898 -0
- wbreport/pdf/sandbox/templates/multistrat_factsheet.py +872 -0
- wbreport/pdf/sandbox/templates/testfile.pdf +434 -0
- wbreport/pdf/tables/__init__.py +0 -0
- wbreport/pdf/tables/aggregated_tables.py +156 -0
- wbreport/pdf/tables/data_tables.py +75 -0
- wbreport/serializers.py +191 -0
- wbreport/tasks.py +60 -0
- wbreport/templates/__init__.py +0 -0
- wbreport/templatetags/__init__.py +0 -0
- wbreport/templatetags/portfolio_tags.py +35 -0
- wbreport/tests/__init__.py +0 -0
- wbreport/tests/conftest.py +24 -0
- wbreport/tests/test_models.py +253 -0
- wbreport/tests/test_tasks.py +17 -0
- wbreport/tests/test_viewsets.py +0 -0
- wbreport/tests/tests.py +12 -0
- wbreport/urls.py +29 -0
- wbreport/urls_public.py +10 -0
- wbreport/viewsets/__init__.py +10 -0
- wbreport/viewsets/configs/__init__.py +18 -0
- wbreport/viewsets/configs/buttons.py +193 -0
- wbreport/viewsets/configs/displays.py +116 -0
- wbreport/viewsets/configs/endpoints.py +23 -0
- wbreport/viewsets/configs/menus.py +8 -0
- wbreport/viewsets/configs/titles.py +30 -0
- wbreport/viewsets/viewsets.py +330 -0
- wbreport-2.2.1.dist-info/METADATA +6 -0
- wbreport-2.2.1.dist-info/RECORD +66 -0
- wbreport-2.2.1.dist-info/WHEEL +5 -0
- wbreport-2.2.1.dist-info/licenses/LICENSE +4 -0
wbreport/models.py
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import uuid
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from io import BytesIO
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from celery import shared_task
|
|
9
|
+
from colorfield.fields import ColorField
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
from django.contrib.auth import get_user_model
|
|
12
|
+
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
13
|
+
from django.contrib.contenttypes.models import ContentType
|
|
14
|
+
from django.core.serializers.json import DjangoJSONEncoder
|
|
15
|
+
from django.db import models
|
|
16
|
+
from django.dispatch import receiver
|
|
17
|
+
from django.forms.models import model_to_dict
|
|
18
|
+
from django.template import Context, Template
|
|
19
|
+
from django.utils.text import slugify
|
|
20
|
+
from dynamic_preferences.registries import global_preferences_registry
|
|
21
|
+
from guardian.shortcuts import get_objects_for_user
|
|
22
|
+
from mptt.models import MPTTModel, TreeForeignKey
|
|
23
|
+
from ordered_model.models import OrderedModel
|
|
24
|
+
from rest_framework.reverse import reverse
|
|
25
|
+
from wbcore.contrib.guardian.models.mixins import PermissionObjectModelMixin
|
|
26
|
+
from wbcore.contrib.notifications.dispatch import send_notification
|
|
27
|
+
from wbcore.contrib.notifications.utils import create_notification_type
|
|
28
|
+
from wbcore.models import WBModel
|
|
29
|
+
from wbmailing.models import MailTemplate, MassMail
|
|
30
|
+
|
|
31
|
+
User = get_user_model()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ReportAsset(models.Model):
|
|
35
|
+
"""
|
|
36
|
+
Assets that can be used in reports
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
key = models.CharField(max_length=255, unique=True)
|
|
40
|
+
description = models.TextField(null=True, blank=True)
|
|
41
|
+
|
|
42
|
+
text = models.TextField(null=True, blank=True)
|
|
43
|
+
asset = models.FileField(max_length=256, upload_to="report/assets", null=True, blank=True)
|
|
44
|
+
|
|
45
|
+
class Meta:
|
|
46
|
+
verbose_name = "Report Asset"
|
|
47
|
+
verbose_name_plural = "Report Assets"
|
|
48
|
+
|
|
49
|
+
def __str__(self) -> str:
|
|
50
|
+
return self.key
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ReportCategory(OrderedModel):
|
|
54
|
+
"""
|
|
55
|
+
An utility class to support categorization in report
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
title = models.CharField(max_length=128)
|
|
59
|
+
|
|
60
|
+
class Meta(OrderedModel.Meta):
|
|
61
|
+
verbose_name = "Report Category"
|
|
62
|
+
verbose_name_plural = "Report Categories"
|
|
63
|
+
|
|
64
|
+
def __str__(self):
|
|
65
|
+
return self.title
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def get_endpoint_basename(cls) -> str:
|
|
69
|
+
return "wbreport:reportcategory"
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def get_representation_endpoint(cls) -> str:
|
|
73
|
+
return "wbreport:reportcategoryrepresentation-list"
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def get_representation_value_key(cls) -> str:
|
|
77
|
+
return "id"
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def get_representation_label_key(cls) -> str:
|
|
81
|
+
return "{{title}}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ReportClass(WBModel):
|
|
85
|
+
"""
|
|
86
|
+
This class utilises the import module framework, which import the class ReportClass in the specified
|
|
87
|
+
class_path.
|
|
88
|
+
|
|
89
|
+
This class is necessary for a report to implement custom behaviors.
|
|
90
|
+
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
title = models.CharField(max_length=256)
|
|
94
|
+
class_path = models.CharField(max_length=256)
|
|
95
|
+
REPORT_CLASS_DEFAULT_METHODS = [
|
|
96
|
+
"has_view_permission",
|
|
97
|
+
"has_change_permission",
|
|
98
|
+
"has_delete_permission",
|
|
99
|
+
"generate_file",
|
|
100
|
+
"generate_html",
|
|
101
|
+
"get_context",
|
|
102
|
+
"serialize_context",
|
|
103
|
+
"deserialize_context",
|
|
104
|
+
"get_version_title",
|
|
105
|
+
"get_version_date",
|
|
106
|
+
"get_next_parameters",
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
def __init__(self, *args, **kwargs):
|
|
110
|
+
"""
|
|
111
|
+
Set the imported method from the attached module as class attributes.
|
|
112
|
+
"""
|
|
113
|
+
if (len(args) == 3 and (class_path := args[2])) or (class_path := kwargs.get("class_path", None)):
|
|
114
|
+
try:
|
|
115
|
+
if ReportClassModule := getattr(importlib.import_module(class_path), "ReportClass", None):
|
|
116
|
+
for method in self.REPORT_CLASS_DEFAULT_METHODS:
|
|
117
|
+
setattr(self, method, getattr(ReportClassModule, method))
|
|
118
|
+
except ModuleNotFoundError:
|
|
119
|
+
for method in self.REPORT_CLASS_DEFAULT_METHODS:
|
|
120
|
+
setattr(self, method, lambda *a, **k: None)
|
|
121
|
+
return super().__init__(*args, **kwargs)
|
|
122
|
+
|
|
123
|
+
class Meta:
|
|
124
|
+
verbose_name = "Report Class"
|
|
125
|
+
verbose_name_plural = "Report Classes"
|
|
126
|
+
|
|
127
|
+
def __str__(self) -> str:
|
|
128
|
+
return self.title
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def get_endpoint_basename(cls) -> str:
|
|
132
|
+
return "wbreport:report"
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def get_representation_endpoint(cls) -> str:
|
|
136
|
+
return "wbreport:reportrepresentation-list"
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def get_representation_value_key(cls) -> str:
|
|
140
|
+
return "id"
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def get_representation_label_key(cls) -> str:
|
|
144
|
+
return "{{title}}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Report(MPTTModel, PermissionObjectModelMixin):
|
|
148
|
+
"""
|
|
149
|
+
A class that represent the Report instance.
|
|
150
|
+
|
|
151
|
+
Inherit PermissionObjectModelMixin to enable user object based permission
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
class FileContentType(models.TextChoices):
|
|
155
|
+
PDF = "PDF", "application/pdf"
|
|
156
|
+
CSV = "CSV", "text/csv"
|
|
157
|
+
XLSX = "XLSX", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
158
|
+
|
|
159
|
+
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, blank=True, null=True)
|
|
160
|
+
object_id = models.PositiveIntegerField(blank=True, null=True)
|
|
161
|
+
content_object = GenericForeignKey("content_type", "object_id")
|
|
162
|
+
|
|
163
|
+
key = models.CharField(
|
|
164
|
+
max_length=256,
|
|
165
|
+
default="",
|
|
166
|
+
help_text="The key is like the family name: it represents the nature of the report.",
|
|
167
|
+
)
|
|
168
|
+
file_content_type = models.CharField(max_length=64, default=FileContentType.PDF, choices=FileContentType.choices)
|
|
169
|
+
|
|
170
|
+
category = models.ForeignKey(
|
|
171
|
+
ReportCategory,
|
|
172
|
+
related_name="reports",
|
|
173
|
+
null=True,
|
|
174
|
+
blank=True,
|
|
175
|
+
on_delete=models.SET_NULL,
|
|
176
|
+
verbose_name="Report Category",
|
|
177
|
+
help_text="The Visual Report category",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
parent_report = TreeForeignKey(
|
|
181
|
+
"self",
|
|
182
|
+
related_name="child_reports",
|
|
183
|
+
null=True,
|
|
184
|
+
blank=True,
|
|
185
|
+
on_delete=models.SET_NULL,
|
|
186
|
+
verbose_name="Parent Report",
|
|
187
|
+
help_text="The Parent Report attached to this report",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
is_active = models.BooleanField(
|
|
191
|
+
default=False,
|
|
192
|
+
help_text="True if a report needs to be available for this product",
|
|
193
|
+
)
|
|
194
|
+
file_disabled = models.BooleanField(default=False, help_text="True if this version file needs to be disabled")
|
|
195
|
+
base_color = ColorField(
|
|
196
|
+
max_length=64,
|
|
197
|
+
default="#FFF000",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
mailing_list = models.ForeignKey(
|
|
201
|
+
"wbmailing.MailingList",
|
|
202
|
+
related_name="reports",
|
|
203
|
+
blank=True,
|
|
204
|
+
null=True,
|
|
205
|
+
on_delete=models.SET_NULL,
|
|
206
|
+
verbose_name="Report Mailing List",
|
|
207
|
+
help_text="The Mailing List used to send the updated report link",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
report_class = models.ForeignKey(
|
|
211
|
+
ReportClass,
|
|
212
|
+
blank=True,
|
|
213
|
+
null=True,
|
|
214
|
+
on_delete=models.SET_NULL,
|
|
215
|
+
related_name="reports",
|
|
216
|
+
verbose_name="Report Class",
|
|
217
|
+
help_text="The method used to generate reports based on context",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
title = models.CharField(max_length=256)
|
|
221
|
+
namespace = models.CharField(max_length=256, default="")
|
|
222
|
+
|
|
223
|
+
logo_file = models.FileField(max_length=256, blank=True, null=True, upload_to="report/logo")
|
|
224
|
+
|
|
225
|
+
color_palette = models.ForeignKey(
|
|
226
|
+
"color.ColorGradient",
|
|
227
|
+
blank=True,
|
|
228
|
+
null=True,
|
|
229
|
+
on_delete=models.SET_NULL,
|
|
230
|
+
related_name="reports",
|
|
231
|
+
verbose_name="Color Palette",
|
|
232
|
+
help_text="The report color palette",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
parameters = models.JSONField(default=dict, encoder=DjangoJSONEncoder)
|
|
236
|
+
|
|
237
|
+
class Meta(PermissionObjectModelMixin.Meta):
|
|
238
|
+
verbose_name = "Report"
|
|
239
|
+
verbose_name_plural = "Reports"
|
|
240
|
+
constraints = [
|
|
241
|
+
models.UniqueConstraint(
|
|
242
|
+
name="unique_parent_report_and_report",
|
|
243
|
+
fields=["parent_report", "title"],
|
|
244
|
+
),
|
|
245
|
+
models.UniqueConstraint(
|
|
246
|
+
name="unique_content_object_and_key",
|
|
247
|
+
fields=["key", "content_type", "object_id"],
|
|
248
|
+
),
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
notification_types = [
|
|
252
|
+
create_notification_type(
|
|
253
|
+
code="wbreport.report.background_task",
|
|
254
|
+
title="Report Background Task Notification",
|
|
255
|
+
help_text="Sends you a notification when a background task regarding the Reports is done that you have triggered.",
|
|
256
|
+
),
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
class MPTTMeta:
|
|
260
|
+
parent_attr = "parent_report"
|
|
261
|
+
|
|
262
|
+
def __str__(self) -> str:
|
|
263
|
+
t = self.title
|
|
264
|
+
ancestors = self.get_ancestors()
|
|
265
|
+
|
|
266
|
+
# If the ascendants exist, make a pretty representation of the parent list.
|
|
267
|
+
if ancestors.exists():
|
|
268
|
+
t += " ["
|
|
269
|
+
separator = " - "
|
|
270
|
+
for parent_report in ancestors.order_by("-id"):
|
|
271
|
+
t += parent_report.title + separator
|
|
272
|
+
t = t[: t.rfind(separator)] + t[t.rfind(separator) + len(separator) :] # remove last separator.
|
|
273
|
+
t += "]"
|
|
274
|
+
return t
|
|
275
|
+
|
|
276
|
+
def save(self, *args, **kwargs):
|
|
277
|
+
if not self.namespace:
|
|
278
|
+
namespace = self.title
|
|
279
|
+
if self.parent_report:
|
|
280
|
+
namespace = f"{slugify(self.parent_report.namespace)}-{namespace}"
|
|
281
|
+
self.namespace = slugify(namespace)
|
|
282
|
+
if not self.key and self.parent_report and self.parent_report.key:
|
|
283
|
+
self.key = self.parent_report.key
|
|
284
|
+
|
|
285
|
+
return super().save(*args, **kwargs)
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def is_public(self) -> bool:
|
|
289
|
+
return self.permission_type == PermissionObjectModelMixin.PermissionType.PUBLIC
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def primary_version(self) -> Optional["ReportVersion"]:
|
|
293
|
+
"""property containing the primary version (must be unique)"""
|
|
294
|
+
if primary_version := self.versions.filter(is_primary=True).first():
|
|
295
|
+
return primary_version
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def last_version(self) -> Optional["ReportVersion"]:
|
|
300
|
+
"""property containing the latest version (must be unique)"""
|
|
301
|
+
if self.versions.exists():
|
|
302
|
+
return self.versions.latest("version_date")
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def earliest_version(self) -> Optional["ReportVersion"]:
|
|
307
|
+
"""property containing the latest version (must be unique)"""
|
|
308
|
+
if self.versions.exists():
|
|
309
|
+
return self.versions.earliest("version_date")
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
def is_accessible(self, user: Optional["User"] = None) -> bool:
|
|
313
|
+
"""
|
|
314
|
+
Check if this report is accessible by anybody or the given user
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
user: If None, check for global permission. Otherwise, check if the given user can access the report
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
True if report is accessible
|
|
321
|
+
"""
|
|
322
|
+
return (user and get_objects_for_user(user, self.view_perm_str).filter(id=self.id).exists()) or self.is_public
|
|
323
|
+
|
|
324
|
+
def get_permissions_for_user(self, user: "User", created: Optional[datetime] = None) -> Dict[str, bool]:
|
|
325
|
+
"""
|
|
326
|
+
Return a generator of allowed (view|change|delete) permission and its editable state
|
|
327
|
+
|
|
328
|
+
Get the default permissions from PermissionObjectMixin and extend it based on the attached report class
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
user: The user to which we get permission for the given object
|
|
332
|
+
created: The permission creation date. Defaults to None.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
A dictionary of string permission identifier, editable state key value pairs.
|
|
336
|
+
"""
|
|
337
|
+
base_permissions = super().get_permissions_for_user(user, created=created)
|
|
338
|
+
for perm_str in ["view", "change", "delete"]:
|
|
339
|
+
with suppress(NotImplementedError):
|
|
340
|
+
has_perm = getattr(self.report_class, f"has_{perm_str}_permission")(self, user)
|
|
341
|
+
dict_key = getattr(self, f"{perm_str}_perm_str")
|
|
342
|
+
if has_perm:
|
|
343
|
+
base_permissions[dict_key] = True
|
|
344
|
+
elif not has_perm and dict_key in base_permissions:
|
|
345
|
+
del base_permissions[dict_key]
|
|
346
|
+
return base_permissions
|
|
347
|
+
|
|
348
|
+
def get_or_create_version(
|
|
349
|
+
self, parameters: Dict[str, str], update_context: Optional[bool] = False, comment: Optional[str] = None
|
|
350
|
+
) -> "ReportVersion":
|
|
351
|
+
"""
|
|
352
|
+
Return the version corresponding to the specified parameters. Update its context if necessary
|
|
353
|
+
Args:
|
|
354
|
+
parameters: The parameters to filter against
|
|
355
|
+
update_context: If True, update the returned version context
|
|
356
|
+
comment: Optional comment
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
The found or created version
|
|
360
|
+
"""
|
|
361
|
+
if version := self.versions.filter(parameters=parameters).first():
|
|
362
|
+
pass
|
|
363
|
+
else:
|
|
364
|
+
version = ReportVersion.objects.create(
|
|
365
|
+
title=self.report_class.get_version_title(self.title, parameters),
|
|
366
|
+
parameters=parameters,
|
|
367
|
+
version_date=self.report_class.get_version_date(parameters),
|
|
368
|
+
report=self,
|
|
369
|
+
)
|
|
370
|
+
if comment:
|
|
371
|
+
version.save()
|
|
372
|
+
|
|
373
|
+
if update_context:
|
|
374
|
+
version.update_context()
|
|
375
|
+
if not version.disabled:
|
|
376
|
+
try:
|
|
377
|
+
# We try to check if the version generate a proper report
|
|
378
|
+
version.generate_html()
|
|
379
|
+
except Exception:
|
|
380
|
+
version.disabled = True
|
|
381
|
+
version.save()
|
|
382
|
+
return version
|
|
383
|
+
|
|
384
|
+
def set_primary_versions(self, parameters: Dict[str, str]):
|
|
385
|
+
"""
|
|
386
|
+
Set the version corresponding to these parameters as primary (will automatically unset the previous primary
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
parameters: The parameters to filter against
|
|
390
|
+
"""
|
|
391
|
+
for version in self.versions.filter(parameters=parameters):
|
|
392
|
+
if not version.is_primary: # if the version wasn't the primary one, we lock it initially
|
|
393
|
+
version.lock = True
|
|
394
|
+
version.is_primary = True
|
|
395
|
+
version.save()
|
|
396
|
+
|
|
397
|
+
def get_context(self, **kwargs) -> Dict[str, Any]:
|
|
398
|
+
"""
|
|
399
|
+
Get the base (default) context from the report.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
**kwargs: keyword argument context
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
A dictionary containing the default report context
|
|
406
|
+
"""
|
|
407
|
+
base_context = {
|
|
408
|
+
"report_title": self.title,
|
|
409
|
+
"slugify_report_title": slugify(self.title),
|
|
410
|
+
"report_base_color": self.base_color,
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if self.color_palette:
|
|
414
|
+
base_context["colors_palette"] = list(self.color_palette.get_gradient(self.base_color))
|
|
415
|
+
if self.logo_file:
|
|
416
|
+
# We need to store the id and retreive the file into a deserialization step to avoid pickle error
|
|
417
|
+
base_context["report_logo_file_id"] = self.id
|
|
418
|
+
return {**base_context, **kwargs}
|
|
419
|
+
|
|
420
|
+
def get_gradient(self) -> List[str]:
|
|
421
|
+
"""
|
|
422
|
+
Get the colors gradients based on the report base color
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
The gradient list of colors
|
|
426
|
+
"""
|
|
427
|
+
if self.color_palette and self.base_color:
|
|
428
|
+
return self.color_palette.get_gradient(self.base_color)
|
|
429
|
+
return []
|
|
430
|
+
|
|
431
|
+
def get_next_parameters(self, next_parameters: Optional[Dict[str, str]] = None) -> Optional[Dict[str, str]]:
|
|
432
|
+
"""
|
|
433
|
+
Get the next parameters if they are not explicitly defined and if report has previous versions.
|
|
434
|
+
Args:
|
|
435
|
+
next_parameters: explicit next parameters.
|
|
436
|
+
"""
|
|
437
|
+
if not next_parameters and self.versions.exists():
|
|
438
|
+
next_parameters = self.report_class.get_next_parameters(self.last_version.parameters)
|
|
439
|
+
return next_parameters
|
|
440
|
+
|
|
441
|
+
# Parent Report function
|
|
442
|
+
def generate_next_reports(
|
|
443
|
+
self,
|
|
444
|
+
next_parameters: Optional[Dict[str, str]] = None,
|
|
445
|
+
comment: Optional[str] = None,
|
|
446
|
+
max_depth_only: bool = False,
|
|
447
|
+
) -> None:
|
|
448
|
+
"""
|
|
449
|
+
Generate and update context of all descendant versions whose report inherits from this parent report.
|
|
450
|
+
Do not generate and update context for non-active reports.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
next_parameters: The parameters used to create versions. If None, the current report parameters is used.
|
|
454
|
+
comment: Optional report comment
|
|
455
|
+
max_depth_only: Boolean that allows to generate next reports for leaves node.
|
|
456
|
+
"""
|
|
457
|
+
self_next_parameters = self.get_next_parameters(next_parameters=next_parameters)
|
|
458
|
+
reports = self.get_descendants(include_self=True).filter(is_active=True)
|
|
459
|
+
if max_depth_only:
|
|
460
|
+
max_depth = reports.aggregate(max_depth=models.Max("level"))["max_depth"]
|
|
461
|
+
reports = reports.filter(level=max_depth)
|
|
462
|
+
|
|
463
|
+
for report in reports.order_by(models.F("parent_report").desc(nulls_last=True)):
|
|
464
|
+
# If self has already versions, we can calculate next parameters.
|
|
465
|
+
# If it does not have versions, but we explicitly choose next_parameters, it creates the first version of
|
|
466
|
+
# an empty report.
|
|
467
|
+
if report.versions.exists() or self_next_parameters is not None:
|
|
468
|
+
next_parameters = report.get_next_parameters(next_parameters=self_next_parameters)
|
|
469
|
+
report.get_or_create_version(next_parameters, update_context=True, comment=comment)
|
|
470
|
+
|
|
471
|
+
def bulk_create_child_reports(
|
|
472
|
+
self, start_parameters: Dict[str, str], end_parameters: Dict[str, str], max_iteration: int = 20
|
|
473
|
+
) -> None:
|
|
474
|
+
"""
|
|
475
|
+
Bootstrap utility function to generate multiple iterations of versions
|
|
476
|
+
Args:
|
|
477
|
+
start_parameters: The seed dictionary parameters to spinup the process
|
|
478
|
+
end_parameters: The end parameters where the loop stops
|
|
479
|
+
max_iteration: Max number of allowed iteration before exiting. Defaults to 20.
|
|
480
|
+
"""
|
|
481
|
+
print(f"Bootstrap Iteration with parameters {start_parameters}") # noqa: T201
|
|
482
|
+
iteration = 0
|
|
483
|
+
self.generate_next_reports(start_parameters)
|
|
484
|
+
while start_parameters != end_parameters and iteration < max_iteration and self.report_class:
|
|
485
|
+
start_parameters = self.report_class.get_next_parameters(start_parameters)
|
|
486
|
+
print(f"Iteration {iteration} with parameters {start_parameters}") # noqa: T201
|
|
487
|
+
self.generate_next_reports(start_parameters)
|
|
488
|
+
iteration += 1
|
|
489
|
+
|
|
490
|
+
def set_primary_report_version(self, parameters: Optional[Dict[str, str]] = None) -> None:
|
|
491
|
+
"""
|
|
492
|
+
Set all similar active children (all descendants including self) reports's versions as primary
|
|
493
|
+
Args:
|
|
494
|
+
parameters: Set all corresponding versions as primary. If None, use the report current parameters
|
|
495
|
+
"""
|
|
496
|
+
if not parameters and self.versions.exists():
|
|
497
|
+
parameters = self.last_version.parameters
|
|
498
|
+
|
|
499
|
+
for report in self.get_descendants(include_self=True).filter(is_active=True):
|
|
500
|
+
if report.versions.exists():
|
|
501
|
+
primary_parameters = report.last_version.parameters if not parameters else parameters
|
|
502
|
+
report.set_primary_versions(primary_parameters)
|
|
503
|
+
|
|
504
|
+
@classmethod
|
|
505
|
+
def get_representation_endpoint(cls) -> str:
|
|
506
|
+
return "wbreport:reportrepresentation-list"
|
|
507
|
+
|
|
508
|
+
@classmethod
|
|
509
|
+
def get_representation_value_key(cls) -> str:
|
|
510
|
+
return "id"
|
|
511
|
+
|
|
512
|
+
@classmethod
|
|
513
|
+
def get_representation_label_key(cls) -> str:
|
|
514
|
+
return "{{title}}"
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class ReportVersion(models.Model):
|
|
518
|
+
uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False)
|
|
519
|
+
lookup = models.CharField(max_length=256, default="", unique=True)
|
|
520
|
+
|
|
521
|
+
title = models.CharField(max_length=256)
|
|
522
|
+
parameters = models.JSONField(default=dict, encoder=DjangoJSONEncoder)
|
|
523
|
+
context = models.JSONField(default=dict, encoder=DjangoJSONEncoder)
|
|
524
|
+
|
|
525
|
+
version_date = models.DateField(blank=True, null=True)
|
|
526
|
+
creation_date = models.DateTimeField(auto_now_add=True)
|
|
527
|
+
update_date = models.DateTimeField(auto_now=True)
|
|
528
|
+
|
|
529
|
+
comment = models.TextField(default="")
|
|
530
|
+
|
|
531
|
+
is_primary = models.BooleanField(
|
|
532
|
+
default=False,
|
|
533
|
+
help_text="Only one Version from a report can be considered primary and is usually the last created one",
|
|
534
|
+
)
|
|
535
|
+
disabled = models.BooleanField(default=False, help_text="True if version needs to be disabled")
|
|
536
|
+
lock = models.BooleanField(default=False, help_text="True, the context cannot be regenerated")
|
|
537
|
+
|
|
538
|
+
report = models.ForeignKey(
|
|
539
|
+
"wbreport.Report", on_delete=models.CASCADE, verbose_name="Report", related_name="versions"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
class Meta:
|
|
543
|
+
verbose_name = "Report Version"
|
|
544
|
+
verbose_name_plural = "Report Versions"
|
|
545
|
+
|
|
546
|
+
@property
|
|
547
|
+
def slugify_title(self):
|
|
548
|
+
return slugify(self.title)
|
|
549
|
+
|
|
550
|
+
def __str__(self):
|
|
551
|
+
return f"{self.title} ({self.uuid})"
|
|
552
|
+
|
|
553
|
+
def save(self, *args, **kwargs):
|
|
554
|
+
"""Override save method to ensure uniqueness of primary among report's version"""
|
|
555
|
+
qs = ReportVersion.objects.filter(report=self.report, is_primary=True).exclude(id=self.id)
|
|
556
|
+
if self.is_primary:
|
|
557
|
+
qs.update(is_primary=False)
|
|
558
|
+
elif not qs.exists():
|
|
559
|
+
self.is_primary = True
|
|
560
|
+
if not self.version_date:
|
|
561
|
+
self.version_date = self.creation_date
|
|
562
|
+
if not self.lookup:
|
|
563
|
+
lookup = self.title
|
|
564
|
+
if self.report.parent_report:
|
|
565
|
+
lookup = f"{slugify(self.report.parent_report.namespace)}-{lookup}"
|
|
566
|
+
self.lookup = slugify(lookup)
|
|
567
|
+
return super().save(*args, **kwargs)
|
|
568
|
+
|
|
569
|
+
@property
|
|
570
|
+
def deserialized_context(self) -> Dict[str, Any]:
|
|
571
|
+
context = self.report.report_class.deserialize_context(self.context)
|
|
572
|
+
|
|
573
|
+
# we add possible report parameters into the context
|
|
574
|
+
for k, v in self.report.parameters.items():
|
|
575
|
+
context[f"report_{k}"] = v
|
|
576
|
+
return context
|
|
577
|
+
|
|
578
|
+
@property
|
|
579
|
+
def filename(self):
|
|
580
|
+
base_filename = self.report.parameters.get("filename", "report_{slugify_title}").format(
|
|
581
|
+
slugify_title=self.slugify_title, **model_to_dict(self)
|
|
582
|
+
)
|
|
583
|
+
return f"{base_filename}.{Report.FileContentType[self.report.file_content_type].name.lower()}"
|
|
584
|
+
|
|
585
|
+
def get_context(self, **kwargs) -> Dict[str, Any]:
|
|
586
|
+
"""
|
|
587
|
+
Get the base context and the ReportClass module get_context for the version.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
**kwargs: Divers keyword arguments to be injected in the get_context function
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
A dictionary containing the dynamic and base version context
|
|
594
|
+
"""
|
|
595
|
+
context = self.report.report_class.get_context(self)
|
|
596
|
+
base_context = self.report.get_context(**kwargs)
|
|
597
|
+
return {
|
|
598
|
+
"uuid": str(self.uuid),
|
|
599
|
+
"download_url": reverse("public_report:report_download_version_file", args=[self.uuid]),
|
|
600
|
+
"version_title": self.title,
|
|
601
|
+
"slugify_version_title": slugify(self.title),
|
|
602
|
+
"comment": self.comment,
|
|
603
|
+
**context,
|
|
604
|
+
**base_context,
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
def update_context(self, silent: bool | None = True, force_context_update: bool = False, **kwargs):
|
|
608
|
+
"""
|
|
609
|
+
Update version context. If an error is encounter, we disabled automatically this version
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
**kwargs: Divers keyword arguments to be injected in the get_context function
|
|
613
|
+
"""
|
|
614
|
+
if not self.lock or force_context_update:
|
|
615
|
+
if silent:
|
|
616
|
+
try:
|
|
617
|
+
self.context = self.get_context(**kwargs)
|
|
618
|
+
self.disabled = False
|
|
619
|
+
except Exception as e:
|
|
620
|
+
print(f"Error while updating Context for snap {self.id} {e}") # noqa: T201
|
|
621
|
+
self.disabled = True
|
|
622
|
+
else:
|
|
623
|
+
self.context = self.get_context(**kwargs)
|
|
624
|
+
self.context = self.report.report_class.serialize_context(self.context)
|
|
625
|
+
self.save()
|
|
626
|
+
|
|
627
|
+
def generate_file(self) -> BytesIO:
|
|
628
|
+
"""
|
|
629
|
+
Generate the file file (BytesIO object) given the context by calling the method generate_file in ReportClass
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
The BytesIO object containing the generated file
|
|
633
|
+
"""
|
|
634
|
+
file = self.report.report_class.generate_file(self.deserialized_context)
|
|
635
|
+
file.name = self.filename
|
|
636
|
+
return file
|
|
637
|
+
|
|
638
|
+
def generate_html(self) -> str:
|
|
639
|
+
"""
|
|
640
|
+
Generate the html given the context by calling the method generate_html in ReportClass
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
The generated html as string
|
|
644
|
+
"""
|
|
645
|
+
return self.report.report_class.generate_html(self.deserialized_context)
|
|
646
|
+
|
|
647
|
+
def send_mail(self, template: Optional[str] = None, base_message: Optional[str] = None):
|
|
648
|
+
"""
|
|
649
|
+
Send the version as a file in a email to the mailing list specified in the parent report.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
template: The template to use as mail body. Defaults to None (and the default module template)
|
|
653
|
+
base_message: The base mail message. Defaults to None
|
|
654
|
+
"""
|
|
655
|
+
if not base_message:
|
|
656
|
+
base_message = """
|
|
657
|
+
<p>The monthly report for <strong>{{ version_title }}</strong> has just been updated. You can find it <a href={{ report_version_url }}>here</a>.</p>
|
|
658
|
+
<p>You can download it as a file by clicking on the "save" button at its bottom right.</p>
|
|
659
|
+
"""
|
|
660
|
+
if not template:
|
|
661
|
+
global_preferences = global_preferences_registry.manager()
|
|
662
|
+
report_template_id = global_preferences["report__report_mail_template_id"]
|
|
663
|
+
template = MailTemplate.objects.get(id=report_template_id)
|
|
664
|
+
|
|
665
|
+
endpoint = reverse("public_report:report_version", args=[self.lookup])
|
|
666
|
+
|
|
667
|
+
context = {"report_version_url": f"{settings.BASE_ENDPOINT_URL}{endpoint}", **self.deserialized_context}
|
|
668
|
+
body = Template(base_message).render(Context(context))
|
|
669
|
+
mass_mail = MassMail.objects.create(
|
|
670
|
+
template=template, from_email=settings.DEFAULT_FROM_EMAIL, subject=f"{self.title} report update", body=body
|
|
671
|
+
)
|
|
672
|
+
mass_mail.mailing_lists.add(self.report.mailing_list)
|
|
673
|
+
mass_mail.submit()
|
|
674
|
+
mass_mail.send()
|
|
675
|
+
mass_mail.save()
|
|
676
|
+
|
|
677
|
+
@classmethod
|
|
678
|
+
def get_representation_endpoint(cls) -> str:
|
|
679
|
+
return "wbreport:reportversionrepresentation-list"
|
|
680
|
+
|
|
681
|
+
@classmethod
|
|
682
|
+
def get_representation_value_key(cls) -> str:
|
|
683
|
+
return "id"
|
|
684
|
+
|
|
685
|
+
@classmethod
|
|
686
|
+
def get_representation_label_key(cls) -> str:
|
|
687
|
+
return "{{title}}"
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
@receiver(models.signals.post_save, sender="wbreport.ReportVersion")
|
|
691
|
+
def generate_version_context_if_null(sender, instance, created, raw, **kwargs):
|
|
692
|
+
"""
|
|
693
|
+
Generate report version context on save
|
|
694
|
+
"""
|
|
695
|
+
if created and not instance.context:
|
|
696
|
+
update_context_as_task.apply_async((instance.id,), countdown=30)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
@shared_task()
|
|
700
|
+
def generate_next_reports_as_task(report_id, parameters=None, user=None, comment=None, max_depth_only=False):
|
|
701
|
+
"""
|
|
702
|
+
Trigger the Report generate_next_reports as a task
|
|
703
|
+
"""
|
|
704
|
+
report = Report.objects.get(id=report_id)
|
|
705
|
+
report.generate_next_reports(next_parameters=parameters, comment=comment, max_depth_only=max_depth_only)
|
|
706
|
+
if user:
|
|
707
|
+
send_notification(
|
|
708
|
+
code="wbreport.report.background_task",
|
|
709
|
+
title="The next reports generation task is complete",
|
|
710
|
+
body="You can now refresh the report widget to display its new parameters state",
|
|
711
|
+
user=user,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
@shared_task()
|
|
716
|
+
def bulk_create_child_reports_as_task(report_id, start_parameters, end_parameters, user=None):
|
|
717
|
+
"""
|
|
718
|
+
Trigger the Report generate_next_reports as a task
|
|
719
|
+
"""
|
|
720
|
+
report = Report.objects.get(id=report_id)
|
|
721
|
+
report.bulk_create_child_reports(start_parameters, end_parameters)
|
|
722
|
+
if user:
|
|
723
|
+
send_notification(
|
|
724
|
+
code="wbreport.report.background_task",
|
|
725
|
+
title="The bulk reports creation task is complete",
|
|
726
|
+
body="You can now refresh the report widget to access to all generated reports",
|
|
727
|
+
user=user,
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
@shared_task()
|
|
732
|
+
def update_context_as_task(report_version_id, user=None, comment=None):
|
|
733
|
+
"""
|
|
734
|
+
Trigger the Report Version update_report_context as a task
|
|
735
|
+
"""
|
|
736
|
+
version = ReportVersion.objects.get(id=report_version_id)
|
|
737
|
+
if comment:
|
|
738
|
+
version.comment = comment
|
|
739
|
+
version.save()
|
|
740
|
+
version.update_context()
|
|
741
|
+
if user:
|
|
742
|
+
send_notification(
|
|
743
|
+
code="wbreport.report.background_task",
|
|
744
|
+
title="The report version context refresh task is complete",
|
|
745
|
+
body="You can now acces the report html page to see the updated context",
|
|
746
|
+
user=user,
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
@shared_task()
|
|
751
|
+
def update_version_context_as_task(report_id, parameters=None, user=None):
|
|
752
|
+
"""
|
|
753
|
+
Trigger the Report Version update_report_context as a task
|
|
754
|
+
"""
|
|
755
|
+
for report in Report.objects.filter(
|
|
756
|
+
models.Q(is_active=True) & (models.Q(id=report_id) | models.Q(parent_report=report_id))
|
|
757
|
+
).distinct():
|
|
758
|
+
print( # noqa: T201
|
|
759
|
+
f'Updating context for report {str(report)} and version parameters {parameters if parameters else "{all}"}'
|
|
760
|
+
)
|
|
761
|
+
versions = report.versions.filter(disabled=False)
|
|
762
|
+
if parameters:
|
|
763
|
+
versions = versions.filter(parameters=parameters)
|
|
764
|
+
for version in versions.all():
|
|
765
|
+
version.update_context()
|
|
766
|
+
if user:
|
|
767
|
+
send_notification(
|
|
768
|
+
code="wbreport.report.background_task",
|
|
769
|
+
title="The context update of all the report versions is complete",
|
|
770
|
+
body="You can now acces the report html page to see the updated context",
|
|
771
|
+
user=user,
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@shared_task()
|
|
776
|
+
def set_primary_report_version_as_task(report_id, parameters=None, user=None):
|
|
777
|
+
"""
|
|
778
|
+
Trigger the Report set_primary_versions as a task
|
|
779
|
+
"""
|
|
780
|
+
report = Report.objects.get(id=report_id)
|
|
781
|
+
report.set_primary_report_version(parameters)
|