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.

Files changed (66) hide show
  1. wbreport/__init__.py +1 -0
  2. wbreport/admin.py +87 -0
  3. wbreport/apps.py +6 -0
  4. wbreport/defaults/__init__.py +0 -0
  5. wbreport/defaults/factsheets/__init__.py +0 -0
  6. wbreport/defaults/factsheets/base.py +990 -0
  7. wbreport/defaults/factsheets/menu.py +93 -0
  8. wbreport/defaults/factsheets/mixins.py +35 -0
  9. wbreport/defaults/factsheets/multitheme.py +947 -0
  10. wbreport/dynamic_preferences_registry.py +15 -0
  11. wbreport/factories/__init__.py +8 -0
  12. wbreport/factories/data_classes.py +48 -0
  13. wbreport/factories/reports.py +79 -0
  14. wbreport/filters.py +37 -0
  15. wbreport/migrations/0001_initial_squashed_squashed_0007_report_key.py +238 -0
  16. wbreport/migrations/0008_alter_report_file_content_type.py +25 -0
  17. wbreport/migrations/0009_alter_report_color_palette.py +27 -0
  18. wbreport/migrations/0010_auto_20240103_0947.py +43 -0
  19. wbreport/migrations/0011_auto_20240207_1629.py +35 -0
  20. wbreport/migrations/0012_reportversion_lock.py +17 -0
  21. wbreport/migrations/0013_alter_reportversion_context.py +18 -0
  22. wbreport/migrations/0014_alter_reportcategory_options_and_more.py +25 -0
  23. wbreport/migrations/__init__.py +0 -0
  24. wbreport/mixins.py +183 -0
  25. wbreport/models.py +781 -0
  26. wbreport/pdf/__init__.py +0 -0
  27. wbreport/pdf/charts/__init__.py +0 -0
  28. wbreport/pdf/charts/legend.py +15 -0
  29. wbreport/pdf/charts/pie.py +169 -0
  30. wbreport/pdf/charts/timeseries.py +77 -0
  31. wbreport/pdf/sandbox/__init__.py +0 -0
  32. wbreport/pdf/sandbox/run.py +17 -0
  33. wbreport/pdf/sandbox/templates/__init__.py +0 -0
  34. wbreport/pdf/sandbox/templates/basic_factsheet.py +908 -0
  35. wbreport/pdf/sandbox/templates/fund_factsheet.py +864 -0
  36. wbreport/pdf/sandbox/templates/long_industry_exposure_factsheet.py +898 -0
  37. wbreport/pdf/sandbox/templates/multistrat_factsheet.py +872 -0
  38. wbreport/pdf/sandbox/templates/testfile.pdf +434 -0
  39. wbreport/pdf/tables/__init__.py +0 -0
  40. wbreport/pdf/tables/aggregated_tables.py +156 -0
  41. wbreport/pdf/tables/data_tables.py +75 -0
  42. wbreport/serializers.py +191 -0
  43. wbreport/tasks.py +60 -0
  44. wbreport/templates/__init__.py +0 -0
  45. wbreport/templatetags/__init__.py +0 -0
  46. wbreport/templatetags/portfolio_tags.py +35 -0
  47. wbreport/tests/__init__.py +0 -0
  48. wbreport/tests/conftest.py +24 -0
  49. wbreport/tests/test_models.py +253 -0
  50. wbreport/tests/test_tasks.py +17 -0
  51. wbreport/tests/test_viewsets.py +0 -0
  52. wbreport/tests/tests.py +12 -0
  53. wbreport/urls.py +29 -0
  54. wbreport/urls_public.py +10 -0
  55. wbreport/viewsets/__init__.py +10 -0
  56. wbreport/viewsets/configs/__init__.py +18 -0
  57. wbreport/viewsets/configs/buttons.py +193 -0
  58. wbreport/viewsets/configs/displays.py +116 -0
  59. wbreport/viewsets/configs/endpoints.py +23 -0
  60. wbreport/viewsets/configs/menus.py +8 -0
  61. wbreport/viewsets/configs/titles.py +30 -0
  62. wbreport/viewsets/viewsets.py +330 -0
  63. wbreport-2.2.1.dist-info/METADATA +6 -0
  64. wbreport-2.2.1.dist-info/RECORD +66 -0
  65. wbreport-2.2.1.dist-info/WHEEL +5 -0
  66. 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)