wbwriter 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 wbwriter might be problematic. Click here for more details.

Files changed (70) hide show
  1. wbwriter/__init__.py +1 -0
  2. wbwriter/admin.py +142 -0
  3. wbwriter/apps.py +5 -0
  4. wbwriter/dynamic_preferences_registry.py +15 -0
  5. wbwriter/factories/__init__.py +13 -0
  6. wbwriter/factories/article.py +181 -0
  7. wbwriter/factories/meta_information.py +29 -0
  8. wbwriter/filters/__init__.py +2 -0
  9. wbwriter/filters/article.py +47 -0
  10. wbwriter/filters/metainformationinstance.py +24 -0
  11. wbwriter/migrations/0001_initial_squashed_squashed_0008_alter_article_author_alter_article_feedback_contact_and_more.py +653 -0
  12. wbwriter/migrations/0009_dependantarticle.py +41 -0
  13. wbwriter/migrations/0010_alter_article_options.py +20 -0
  14. wbwriter/migrations/0011_auto_20240103_0953.py +39 -0
  15. wbwriter/migrations/__init__.py +0 -0
  16. wbwriter/models/__init__.py +9 -0
  17. wbwriter/models/article.py +1179 -0
  18. wbwriter/models/article_type.py +59 -0
  19. wbwriter/models/block.py +24 -0
  20. wbwriter/models/block_parameter.py +19 -0
  21. wbwriter/models/in_editor_template.py +102 -0
  22. wbwriter/models/meta_information.py +87 -0
  23. wbwriter/models/mixins.py +9 -0
  24. wbwriter/models/publication_models.py +170 -0
  25. wbwriter/models/style.py +13 -0
  26. wbwriter/models/template.py +34 -0
  27. wbwriter/pdf_generator.py +172 -0
  28. wbwriter/publication_parser.py +258 -0
  29. wbwriter/serializers/__init__.py +28 -0
  30. wbwriter/serializers/article.py +359 -0
  31. wbwriter/serializers/article_type.py +14 -0
  32. wbwriter/serializers/in_editor_template.py +37 -0
  33. wbwriter/serializers/meta_information.py +67 -0
  34. wbwriter/serializers/publication.py +82 -0
  35. wbwriter/templatetags/__init__.py +0 -0
  36. wbwriter/templatetags/writer.py +72 -0
  37. wbwriter/tests/__init__.py +0 -0
  38. wbwriter/tests/conftest.py +32 -0
  39. wbwriter/tests/signals.py +23 -0
  40. wbwriter/tests/test_filter.py +58 -0
  41. wbwriter/tests/test_model.py +591 -0
  42. wbwriter/tests/test_writer.py +38 -0
  43. wbwriter/tests/tests.py +18 -0
  44. wbwriter/typings.py +23 -0
  45. wbwriter/urls.py +83 -0
  46. wbwriter/viewsets/__init__.py +22 -0
  47. wbwriter/viewsets/article.py +270 -0
  48. wbwriter/viewsets/article_type.py +49 -0
  49. wbwriter/viewsets/buttons.py +61 -0
  50. wbwriter/viewsets/display/__init__.py +6 -0
  51. wbwriter/viewsets/display/article.py +404 -0
  52. wbwriter/viewsets/display/article_type.py +27 -0
  53. wbwriter/viewsets/display/in_editor_template.py +39 -0
  54. wbwriter/viewsets/display/meta_information.py +37 -0
  55. wbwriter/viewsets/display/meta_information_instance.py +28 -0
  56. wbwriter/viewsets/display/publication.py +55 -0
  57. wbwriter/viewsets/endpoints/__init__.py +2 -0
  58. wbwriter/viewsets/endpoints/article.py +12 -0
  59. wbwriter/viewsets/endpoints/meta_information.py +14 -0
  60. wbwriter/viewsets/in_editor_template.py +68 -0
  61. wbwriter/viewsets/menu.py +42 -0
  62. wbwriter/viewsets/meta_information.py +51 -0
  63. wbwriter/viewsets/meta_information_instance.py +48 -0
  64. wbwriter/viewsets/publication.py +117 -0
  65. wbwriter/viewsets/titles/__init__.py +2 -0
  66. wbwriter/viewsets/titles/publication_title_config.py +18 -0
  67. wbwriter/viewsets/titles/reviewer_article_title_config.py +6 -0
  68. wbwriter-2.2.1.dist-info/METADATA +8 -0
  69. wbwriter-2.2.1.dist-info/RECORD +70 -0
  70. wbwriter-2.2.1.dist-info/WHEEL +5 -0
@@ -0,0 +1,1179 @@
1
+ import datetime
2
+ import json
3
+ import re
4
+ from contextlib import suppress
5
+ from datetime import date, timedelta
6
+
7
+ from celery import shared_task
8
+ from django import template
9
+ from django.contrib.auth import get_user_model
10
+ from django.contrib.contenttypes.fields import GenericRelation
11
+ from django.core.files.base import ContentFile
12
+ from django.db import models
13
+ from django.db.models import Count, Max, OuterRef, Subquery
14
+ from django.db.models.functions import Coalesce
15
+ from django_fsm import FSMField, transition
16
+ from dynamic_preferences.registries import global_preferences_registry
17
+ from rest_framework.reverse import reverse
18
+ from slugify import slugify
19
+ from wbcore.contrib.directory.models import Person
20
+ from wbcore.contrib.documents.models import Document, DocumentType
21
+ from wbcore.contrib.icons import WBIcon
22
+ from wbcore.contrib.notifications.dispatch import send_notification
23
+ from wbcore.contrib.tags.models import TagModelMixin
24
+ from wbcore.enums import RequestType
25
+ from wbcore.markdown.template import resolve_content
26
+ from wbcore.metadata.configs.buttons import ActionButton, ButtonDefaultColor
27
+ from wbcore.metadata.configs.display.instance_display.shortcuts import (
28
+ create_simple_display,
29
+ )
30
+ from wbcore.models import WBModel
31
+ from wbwriter.models.publication_models import Publication
32
+ from wbwriter.pdf_generator import PdfGenerator
33
+ from wbwriter.publication_parser import ParserValidationException
34
+ from wbwriter.typings import ArticleDTO
35
+ from weasyprint import CSS
36
+
37
+ from .mixins import PublishableMixin
38
+
39
+
40
+ @shared_task
41
+ def generate_publications(article_id):
42
+ with suppress(Article.DoesNotExist):
43
+ article = Article.objects.get(id=article_id)
44
+ if article.can_be_published():
45
+ for parser in article.type.parsers.all():
46
+ # Create the publication
47
+ Publication.create_or_update_from_parser_and_object(parser, article)
48
+
49
+
50
+ def can_administrate_article(instance, user):
51
+ """Allow only superusers and article admins to admin articles."""
52
+ is_superuser = user.is_superuser
53
+ is_article_admin = user.has_perm("wbwriter.administrate_article")
54
+
55
+ return is_superuser or is_article_admin
56
+
57
+
58
+ def can_access_article(instance, user):
59
+ """Allow access to superusers always, and to others when their position is appropriate."""
60
+ return (
61
+ can_administrate_article(instance, user)
62
+ or user.is_superuser
63
+ or user.profile.is_internal
64
+ or is_reviewer(instance, user)
65
+ or is_qa_reviewer(instance, user)
66
+ or is_peer_reviewer(instance, user)
67
+ )
68
+
69
+
70
+ def can_edit_article_author(instance, user) -> bool:
71
+ """Allow the author and admins to edit the author."""
72
+ if instance.author is None:
73
+ return True
74
+
75
+ return can_administrate_article(instance, user)
76
+
77
+
78
+ def can_edit_article_content(instance, user) -> bool:
79
+ """Allow the appropriate role to edit content based on the state of the article."""
80
+ if instance.author is None:
81
+ return True
82
+
83
+ if instance.status == Article.Status.DRAFT:
84
+ return is_author(instance, user)
85
+
86
+ if instance.status == Article.Status.FEEDBACK:
87
+ return is_reviewer(instance, user)
88
+
89
+ if instance.status == Article.Status.PEER_REVIEW:
90
+ return is_peer_reviewer(instance, user)
91
+
92
+ if instance.status == Article.Status.QA_REVIEW:
93
+ return is_qa_reviewer(instance, user)
94
+
95
+ return can_administrate_article(instance, user)
96
+
97
+
98
+ def can_edit_article_meta_data(instance, user) -> bool:
99
+ """Allow the author to change the meta data of the article."""
100
+ return (
101
+ is_author(instance, user)
102
+ or (is_reviewer(instance, user) and instance.status == Article.Status.FEEDBACK)
103
+ or (is_peer_reviewer(instance, user) and instance.status == Article.Status.PEER_REVIEW)
104
+ or (is_qa_reviewer(instance, user) and instance.status == Article.Status.QA_REVIEW)
105
+ )
106
+
107
+
108
+ def can_edit_article_type(instance, user) -> bool:
109
+ return is_author(instance, user) and instance.status == Article.Status.DRAFT
110
+
111
+
112
+ def can_request_peer_review(instance) -> bool:
113
+ return not instance.peer_reviewer_approved
114
+
115
+
116
+ def can_request_qa_review(instance) -> bool:
117
+ return instance.peer_reviewer_approved
118
+
119
+
120
+ def is_author(instance, user) -> bool:
121
+ """Confirm user is the author of the instance."""
122
+ return instance.author is None or instance.author == user.profile or can_administrate_article(instance, user)
123
+
124
+
125
+ def is_reviewer(instance, user) -> bool:
126
+ """Confirm user is the reviewer of the instance."""
127
+ return instance.reviewer == user.profile or can_administrate_article(instance, user)
128
+
129
+
130
+ def is_peer_reviewer(instance, user) -> bool:
131
+ """Confirm user is ine of the peer reviewer of the instance's type."""
132
+ if can_administrate_article(instance, user):
133
+ return True
134
+ return (
135
+ instance.peer_reviewer is not None and instance.peer_reviewer.id == user.profile.id
136
+ ) or instance.type.peer_reviewers.filter(id=user.profile.id).exists()
137
+
138
+
139
+ def is_qa_reviewer(instance, user) -> bool:
140
+ """Confirm user is the quality assurance reviewer of the instance's type."""
141
+ if can_administrate_article(instance, user):
142
+ return True
143
+ return (
144
+ instance.qa_reviewer is not None and instance.qa_reviewer.id == user.profile.id
145
+ ) or instance.type.qa_reviewers.filter(id=user.profile.id).exists()
146
+
147
+
148
+ class DependantArticle(WBModel):
149
+ article = models.ForeignKey(
150
+ to="wbwriter.Article",
151
+ related_name="dependant_article_connections",
152
+ on_delete=models.CASCADE,
153
+ )
154
+
155
+ dependant_article = models.ForeignKey(
156
+ to="wbwriter.Article",
157
+ related_name="used_article_connections",
158
+ on_delete=models.PROTECT,
159
+ )
160
+
161
+ @classmethod
162
+ def get_endpoint_basename(self):
163
+ return "wbwriter:dependantarticle"
164
+
165
+ @classmethod
166
+ def get_representation_endpoint(cls):
167
+ return "wbwriter:dependantarticle-list"
168
+
169
+ @classmethod
170
+ def get_representation_value_key(cls):
171
+ return "id"
172
+
173
+ @classmethod
174
+ def get_representation_label_key(cls):
175
+ return "{{ article }} - {{ dependant_article }}"
176
+
177
+
178
+ def create_dependencies(article):
179
+ if article.id:
180
+ for dependency in re.findall("{% load_article.* ([0-9]*) %}", json.dumps(article.content)):
181
+ DependantArticle.objects.get_or_create(article=article, dependant_article_id=dependency)
182
+
183
+
184
+ class Article(TagModelMixin, PublishableMixin, WBModel):
185
+ class Status(models.TextChoices):
186
+ DRAFT = ("draft", "Draft")
187
+ FEEDBACK = ("feedback", "Feedback")
188
+ PEER_REVIEW = ("peer_review", "Peer Review")
189
+ QA_REVIEW = ("qa_review", "QA Review")
190
+ AUTHOR_APPROVAL = ("author_approval", "Author approval")
191
+ APPROVED = ("approved", "Approved")
192
+ PUBLISHED = ("published", "Published")
193
+
194
+ class Meta:
195
+ verbose_name = "Article"
196
+ verbose_name_plural = "Articles"
197
+
198
+ permissions = [
199
+ ("administrate_article", "Can administrate Articles."),
200
+ ]
201
+
202
+ notification_types = [
203
+ (
204
+ "wbwriter.article.notify",
205
+ "Article Notification",
206
+ "Sends a notification when something happens in a relevant article.",
207
+ True,
208
+ True,
209
+ False,
210
+ ),
211
+ ]
212
+
213
+ """
214
+ /////////////////////////////////////////////////////////////////
215
+ /// FSM Transition Buttons ///
216
+ /////////////////////////////////////////////////////////////////
217
+ """
218
+ fsm_base_button_parameters = {
219
+ "method": RequestType.PATCH,
220
+ "identifiers": ("wbwriter:article",),
221
+ "description_fields": "<p>{{ title }}</p>",
222
+ }
223
+
224
+ request_feedback_button = ActionButton(
225
+ method=RequestType.PATCH,
226
+ identifiers=("wbwriter:article",),
227
+ description_fields="<p>Select a contact to ask for feedback.</p>",
228
+ key="requestfeedback",
229
+ label="Request Feedback",
230
+ action_label="Request Feedback",
231
+ color=ButtonDefaultColor.PRIMARY,
232
+ icon=WBIcon.FEEDBACK.icon,
233
+ instance_display=create_simple_display([["feedback_contact"]]),
234
+ )
235
+
236
+ submit_feedback_button = ActionButton(
237
+ method=RequestType.PATCH,
238
+ identifiers=("wbwriter:article",),
239
+ description_fields="<p>Are you sure you want to<br />submit your feedback?</p>",
240
+ key="submitfeedback",
241
+ label="Submit Feedback",
242
+ action_label="Submit Feedback",
243
+ color=ButtonDefaultColor.PRIMARY,
244
+ icon=WBIcon.SEND.icon,
245
+ )
246
+
247
+ request_peer_review_button = ActionButton(
248
+ method=RequestType.PATCH,
249
+ identifiers=("wbwriter:article",),
250
+ description_fields="<p>Are you sure you want to<br />request a peer review?</p>",
251
+ key="requestpeerreview",
252
+ label="Request Peer Review",
253
+ action_label="Request Peer Review",
254
+ color=ButtonDefaultColor.PRIMARY,
255
+ icon=WBIcon.PEOPLE.icon,
256
+ )
257
+
258
+ request_qa_review_button = ActionButton(
259
+ method=RequestType.PATCH,
260
+ identifiers=("wbwriter:article",),
261
+ description_fields="<p>Are you sure you want to<br />request a QA review?</p>",
262
+ key="requestqareview",
263
+ label="Request QA Review",
264
+ action_label="Request QA Review",
265
+ color=ButtonDefaultColor.PRIMARY,
266
+ icon=WBIcon.PEOPLE.icon,
267
+ )
268
+
269
+ peer_approve_button = ActionButton(
270
+ method=RequestType.PATCH,
271
+ identifiers=("wbwriter:article",),
272
+ description_fields="<p>Are you sure you want to<br />approve this draft?</p>",
273
+ key="peerapprove",
274
+ label="Approve",
275
+ action_label="Approve",
276
+ color=ButtonDefaultColor.SUCCESS,
277
+ icon=WBIcon.CONFIRM.icon,
278
+ )
279
+
280
+ peer_reject_button = ActionButton(
281
+ method=RequestType.PATCH,
282
+ identifiers=("wbwriter:article",),
283
+ description_fields="<p>Are you sure you want to<br />reject this draft?</p>",
284
+ key="peerreject",
285
+ label="Request Changes",
286
+ action_label="Request Changes",
287
+ color=ButtonDefaultColor.WARNING,
288
+ icon=WBIcon.REJECT.icon,
289
+ )
290
+
291
+ qa_approve_draft_button = ActionButton(
292
+ method=RequestType.PATCH,
293
+ identifiers=("wbwriter:article",),
294
+ description_fields="<p>Are you sure you want to<br />approve this draft?</p>",
295
+ key="qaapprove",
296
+ label="Approve",
297
+ action_label="Approve",
298
+ color=ButtonDefaultColor.SUCCESS,
299
+ icon=WBIcon.APPROVE.icon,
300
+ )
301
+
302
+ qa_reject_draft_button = ActionButton(
303
+ method=RequestType.PATCH,
304
+ identifiers=("wbwriter:article",),
305
+ description_fields="<p>Are you sure you want to<br />reject this draft?</p>",
306
+ key="qareject",
307
+ label="Request Changes",
308
+ action_label="Request Changes",
309
+ color=ButtonDefaultColor.WARNING,
310
+ icon=WBIcon.DENY.icon,
311
+ )
312
+
313
+ author_approve_button = ActionButton(
314
+ method=RequestType.PATCH,
315
+ identifiers=("wbwriter:article",),
316
+ description_fields="<p>Are you sure you want to<br />approve this article?</p>",
317
+ key="authorapprove",
318
+ label="Approve",
319
+ action_label="Approve",
320
+ color=ButtonDefaultColor.SUCCESS,
321
+ icon=WBIcon.APPROVE.icon,
322
+ )
323
+
324
+ author_reject_button = ActionButton(
325
+ method=RequestType.PATCH,
326
+ identifiers=("wbwriter:article",),
327
+ description_fields="<p>Are you sure you want to<br />reject this article?</p>",
328
+ key="authorreject",
329
+ label="Reject",
330
+ action_label="Reject",
331
+ color=ButtonDefaultColor.WARNING,
332
+ icon=WBIcon.DENY.icon,
333
+ )
334
+
335
+ authors_revise_button = ActionButton(
336
+ method=RequestType.PATCH,
337
+ identifiers=("wbwriter:article",),
338
+ description_fields="<p>Are you sure you want to<br />revise this article?</p>",
339
+ key="authorrevise",
340
+ label="Revise Article",
341
+ action_label="Revise Article",
342
+ color=ButtonDefaultColor.PRIMARY,
343
+ icon=WBIcon.SYNCHRONIZE.icon,
344
+ )
345
+
346
+ qas_revise_button = ActionButton(
347
+ method=RequestType.PATCH,
348
+ identifiers=("wbwriter:article",),
349
+ description_fields="<p>Are you sure you want to<br />revise this article's review?</p>",
350
+ key="qarevise",
351
+ label="Revise Review",
352
+ action_label="Revise Review",
353
+ color=ButtonDefaultColor.PRIMARY,
354
+ icon=WBIcon.SYNCHRONIZE.icon,
355
+ )
356
+
357
+ publish_button = ActionButton(
358
+ method=RequestType.PATCH,
359
+ identifiers=("wbwriter:article",),
360
+ description_fields="<p>Are you sure you want to<br />publish this article?</p>",
361
+ key="publish",
362
+ label="Publish",
363
+ action_label="Publish",
364
+ color=ButtonDefaultColor.PRIMARY,
365
+ icon=WBIcon.DOCUMENT.icon,
366
+ )
367
+
368
+ unpublish_button = ActionButton(
369
+ method=RequestType.PATCH,
370
+ identifiers=("wbwriter:article",),
371
+ description_fields="<p>Are you sure you want to<br />revise this article's publication?</p>",
372
+ key="unpublish",
373
+ label="Unpublish",
374
+ action_label="Unpublish",
375
+ color=ButtonDefaultColor.PRIMARY,
376
+ icon=WBIcon.UNDO.icon,
377
+ )
378
+
379
+ used_article_connections: models.QuerySet[DependantArticle]
380
+ dependant_article_connections: models.QuerySet[DependantArticle]
381
+
382
+ """
383
+ /////////////////////////////////////////////////////////////////
384
+ /// /FSM Transition Buttons ///
385
+ /////////////////////////////////////////////////////////////////
386
+ """
387
+
388
+ name = models.CharField(
389
+ max_length=1024,
390
+ unique=True,
391
+ help_text="A unique name to reference this article.",
392
+ )
393
+ slug = models.CharField(max_length=1024, null=True, blank=True)
394
+ title = models.CharField(
395
+ max_length=1024,
396
+ null=True,
397
+ blank=True,
398
+ help_text="The title of the article that is going to be used when imported into other articles."
399
+ + " Defaults to the name of the article when not set.",
400
+ )
401
+ teaser_image = models.ImageField(blank=True, null=True, upload_to="writer/article/teasers")
402
+ created = models.DateField(
403
+ verbose_name="Creation Date",
404
+ auto_now_add=True,
405
+ help_text="The date on which this article has been created.",
406
+ )
407
+ modified = models.DateTimeField(
408
+ verbose_name="Last modification date and time",
409
+ auto_now=True,
410
+ help_text="The last time this article has been edited.",
411
+ )
412
+ content = models.JSONField(
413
+ verbose_name="Content",
414
+ default=dict,
415
+ blank=False,
416
+ null=False,
417
+ )
418
+
419
+ type = models.ForeignKey(
420
+ "wbwriter.ArticleType",
421
+ related_name="article",
422
+ on_delete=models.PROTECT,
423
+ blank=False,
424
+ null=False,
425
+ )
426
+
427
+ publications = GenericRelation(Publication)
428
+
429
+ """
430
+ /////////////////////////////////////////////////////////////////
431
+ /// Access relevant fields ///
432
+ /////////////////////////////////////////////////////////////////
433
+ TODO:
434
+ - Protect the author field. Only the "admin role" can change this.
435
+ - We should have an adjustable value for the minimum number of required peer reviews.
436
+ """
437
+ author = models.ForeignKey(
438
+ "directory.Person",
439
+ related_name="author_articles",
440
+ blank=True,
441
+ null=True,
442
+ on_delete=models.PROTECT,
443
+ )
444
+ feedback_contact = models.ForeignKey(
445
+ "directory.Person",
446
+ related_name="feedback_contact_articles",
447
+ blank=True,
448
+ null=True,
449
+ on_delete=models.SET_NULL,
450
+ )
451
+ reviewer = models.ForeignKey(
452
+ "directory.Person",
453
+ related_name="review_articles",
454
+ help_text="The contact that is currently working on feedback.",
455
+ blank=True,
456
+ null=True,
457
+ on_delete=models.SET_NULL, # TODO: We should transition back to DRAFT when the contact gets deleted.
458
+ )
459
+
460
+ peer_reviewer = models.ForeignKey(
461
+ "directory.Person",
462
+ related_name="peer_review_articles",
463
+ help_text="The peer reviewer who reviewed this article.",
464
+ blank=True,
465
+ null=True,
466
+ on_delete=models.SET_NULL,
467
+ )
468
+ peer_reviewer_approved = models.BooleanField(default=False)
469
+ qa_reviewer = models.ForeignKey(
470
+ "directory.Person",
471
+ related_name="qa_review_articles",
472
+ help_text="The quality assurance (QA) reviewer who reviewed this article.",
473
+ blank=True,
474
+ null=True,
475
+ on_delete=models.PROTECT,
476
+ )
477
+
478
+ is_private = models.BooleanField(
479
+ blank=False,
480
+ null=False,
481
+ default=False,
482
+ help_text="Signifies whether this article can be seen by all customers on the homepage.",
483
+ )
484
+
485
+ """
486
+ /////////////////////////////////////////////////////////////////
487
+ /// /Access relevant fields ///
488
+ /////////////////////////////////////////////////////////////////
489
+ """
490
+
491
+ # QUESTION: What are we going to do with this when IETs become the norm?
492
+ template = models.ForeignKey(
493
+ "wbwriter.Template",
494
+ related_name="articles",
495
+ null=True,
496
+ blank=True,
497
+ on_delete=models.SET_NULL,
498
+ )
499
+
500
+ article_structure = models.JSONField(null=True, blank=True)
501
+
502
+ def get_publication_metadata(self) -> dict[str, str]:
503
+ """Returns the title, slug, author, and teasre image for the
504
+ publication as a dictionary.
505
+ """
506
+ return {
507
+ "title": self.title,
508
+ "slug": slugify(self.title),
509
+ "author": self.author,
510
+ }
511
+
512
+ def get_tag_detail_endpoint(self):
513
+ return reverse("wbwriter:article-detail", [self.id])
514
+
515
+ def get_tag_representation(self):
516
+ return self.name
517
+
518
+ """
519
+ /////////////////////////////////////////////////////////////////
520
+ /// FSM Transitions ///
521
+ /////////////////////////////////////////////////////////////////
522
+ """
523
+ status = FSMField(choices=Status.choices, default=Status.DRAFT)
524
+
525
+ @transition(
526
+ status,
527
+ source=[Status.DRAFT],
528
+ target=Status.FEEDBACK,
529
+ permission=is_author,
530
+ custom={"_transition_button": request_feedback_button},
531
+ )
532
+ def requestfeedback(self, by=None):
533
+ """Submit a draft to a reviewer (internal or external) that may give feedback."""
534
+ self.reviewer = self.feedback_contact
535
+ if user := getattr(self.reviewer, "user_account", None):
536
+ send_notification(
537
+ code="wbwriter.article.notify",
538
+ title="Feedback requested",
539
+ body=f"{by.profile} has requested feedback from you.",
540
+ user=user,
541
+ reverse_name="wbwriter:article-detail",
542
+ reverse_args=[self.id],
543
+ )
544
+
545
+ @transition(
546
+ status,
547
+ source=[Status.FEEDBACK],
548
+ target=Status.DRAFT,
549
+ permission=is_reviewer,
550
+ custom={"_transition_button": submit_feedback_button},
551
+ )
552
+ def submitfeedback(self, by=None):
553
+ """Submit the feedback to the author."""
554
+ if user := getattr(self.author, "user_account", None):
555
+ send_notification(
556
+ code="wbwriter.article.notify",
557
+ title="Feedback received",
558
+ body=f"{by.profile} has send your their feedback.",
559
+ user=user,
560
+ reverse_name="wbwriter:article-detail",
561
+ reverse_args=[self.id],
562
+ )
563
+
564
+ @transition(
565
+ status,
566
+ source=[Status.DRAFT],
567
+ target=Status.PEER_REVIEW,
568
+ permission=is_author,
569
+ custom={"_transition_button": request_peer_review_button},
570
+ conditions=[can_request_peer_review],
571
+ )
572
+ def requestpeerreview(self, by=None):
573
+ """Submit a draft to a peer reviewer that may approve or reject the draft."""
574
+ if user := getattr(self.peer_reviewer, "user_account", None):
575
+ send_notification(
576
+ code="wbwriter.article.notify",
577
+ title="Selected for peer review",
578
+ body=f'You have been selected to review the draft of "{self.title}" from {self.author}.',
579
+ user=user,
580
+ reverse_name="wbwriter:article-detail",
581
+ reverse_args=[self.id],
582
+ )
583
+
584
+ @transition(
585
+ status,
586
+ source=[Status.DRAFT],
587
+ target=Status.QA_REVIEW,
588
+ permission=is_author,
589
+ custom={"_transition_button": request_qa_review_button},
590
+ conditions=[can_request_qa_review],
591
+ )
592
+ def requestqareview(self, by=None):
593
+ """Submit a draft to a QA reviewer that may approve or reject the draft."""
594
+ if user := getattr(self.qa_reviewer, "user_account", None):
595
+ send_notification(
596
+ code="wbwriter.article.notify",
597
+ title="QA review requested",
598
+ body=f'{by.profile} has requested your review of "{self.title}".',
599
+ user=user,
600
+ reverse_name="wbwriter:article-detail",
601
+ reverse_args=[self.id],
602
+ )
603
+
604
+ @transition(
605
+ status,
606
+ source=[Status.PEER_REVIEW],
607
+ target=Status.QA_REVIEW,
608
+ permission=is_peer_reviewer,
609
+ custom={"_transition_button": peer_approve_button},
610
+ )
611
+ def peerapprove(self, by=None):
612
+ """Approve the article and send it to the QA reviewer."""
613
+ self.peer_reviewer_approved = True
614
+ if user := getattr(self.author, "user_account", None):
615
+ send_notification(
616
+ code="wbwriter.article.notify",
617
+ title="Peer approved your article",
618
+ body=f'{by.profile} has approved your article "{self.title}" and send it to {self.qa_reviewer} for approval.',
619
+ user=user,
620
+ reverse_name="wbwriter:article-detail",
621
+ reverse_args=[self.id],
622
+ )
623
+ if user := getattr(self.qa_reviewer, "user_account", None):
624
+ send_notification(
625
+ code="wbwriter.article.notify",
626
+ title="Selected for QA review",
627
+ body=f'{by.profile} has approved "{self.title}" from {self.author} and you have been selected to review it.',
628
+ user=user,
629
+ reverse_name="wbwriter:article-detail",
630
+ reverse_args=[self.id],
631
+ )
632
+
633
+ @transition(
634
+ status,
635
+ source=[Status.PEER_REVIEW],
636
+ target=Status.DRAFT,
637
+ permission=is_peer_reviewer,
638
+ custom={"_transition_button": peer_reject_button},
639
+ )
640
+ def peerreject(self, by=None):
641
+ """Reject the article and send it back to the author."""
642
+ self.peer_reviewer_approved = False
643
+ if user := getattr(self.author, "user_account", None):
644
+ send_notification(
645
+ code="wbwriter.article.notify",
646
+ title=f"{by.profile} rejected your article",
647
+ body=f'{by.profile} has requested changes to your article "{self.title}" and send you their feedback.',
648
+ user=user,
649
+ reverse_name="wbwriter:article-detail",
650
+ reverse_args=[self.id],
651
+ )
652
+
653
+ @transition(
654
+ status,
655
+ source=[Status.QA_REVIEW],
656
+ target=Status.DRAFT,
657
+ permission=is_qa_reviewer,
658
+ custom={"_transition_button": qa_reject_draft_button},
659
+ )
660
+ def qareject(self, by=None):
661
+ """Reject the article and return it to the peer reviewer."""
662
+ if user := getattr(self.author, "user_account", None):
663
+ send_notification(
664
+ code="wbwriter.article.notify",
665
+ title="Your article failed the QA review",
666
+ body=f'{by.profile} has rejected "{self.title}".',
667
+ user=user,
668
+ reverse_name="wbwriter:article-detail",
669
+ reverse_args=[self.id],
670
+ )
671
+
672
+ @transition(
673
+ status,
674
+ source=[Status.QA_REVIEW],
675
+ target=Status.AUTHOR_APPROVAL,
676
+ permission=is_qa_reviewer,
677
+ custom={"_transition_button": qa_approve_draft_button},
678
+ )
679
+ def qaapprove(self, by=None):
680
+ """Approve the article."""
681
+ if user := getattr(self.author, "user_account", None):
682
+ send_notification(
683
+ code="wbwriter.article.notify",
684
+ title=f"{by.profile} approved your article",
685
+ body=f'{by.profile} has approved "{self.title}". Please, review.',
686
+ user=user,
687
+ reverse_name="wbwriter:article-detail",
688
+ reverse_args=[self.id],
689
+ )
690
+
691
+ @transition(
692
+ status,
693
+ source=[Status.AUTHOR_APPROVAL],
694
+ target=Status.QA_REVIEW,
695
+ permission=is_author,
696
+ custom={"_transition_button": author_reject_button},
697
+ )
698
+ def authorreject(self, by=None):
699
+ """Reject the article and return it to the QA reviewer."""
700
+ if user := getattr(self.qa_reviewer, "user_account", None):
701
+ send_notification(
702
+ code="wbwriter.article.notify",
703
+ title=f"{by.profile} did not approve of your review",
704
+ body=f'{by.profile} has rejected your review of "{self.title}".',
705
+ user=user,
706
+ reverse_name="wbwriter:article-detail",
707
+ reverse_args=[self.id],
708
+ )
709
+
710
+ @transition(
711
+ status,
712
+ source=[Status.AUTHOR_APPROVAL],
713
+ target=Status.APPROVED,
714
+ permission=is_author,
715
+ custom={"_transition_button": author_approve_button},
716
+ # conditions=[can_qa_approve],
717
+ )
718
+ def authorapprove(self, by=None):
719
+ """Approve the reviewed article."""
720
+ if user := getattr(self.qa_reviewer, "user_account", None):
721
+ send_notification(
722
+ code="wbwriter.article.notify",
723
+ title=f"{by.profile} approved your review",
724
+ body=f'{by.profile} has approved "{self.title}". It is now in the "Approved" state.',
725
+ user=user,
726
+ reverse_name="wbwriter:article-detail",
727
+ reverse_args=[self.id],
728
+ )
729
+
730
+ @transition(
731
+ status,
732
+ source=[Status.APPROVED],
733
+ target=Status.PUBLISHED,
734
+ permission=can_administrate_article,
735
+ custom={"_transition_button": publish_button},
736
+ )
737
+ def publish(self, by=None):
738
+ """Publish the article."""
739
+ if self.publications.all().count() == 0 and (user := getattr(self.author, "user_account", None)):
740
+ # Send a notification only on the first time the article has been published.
741
+ # Re-publishing the article should not bother the author.
742
+ send_notification(
743
+ code="wbwriter.article.notify",
744
+ title="Your article has been published",
745
+ body=f'Your article "{self.title}" has been published.',
746
+ user=user,
747
+ reverse_name="wbwriter:article-detail",
748
+ reverse_args=[self.id],
749
+ )
750
+ if self.type is not None:
751
+ generate_publications.delay(self.id)
752
+
753
+ def can_publish(self) -> dict[str, str]:
754
+ """Allow only one-off and deep-dive articles to be published."""
755
+ errors = dict()
756
+ if self.status not in [self.Status.APPROVED, self.Status.PUBLISHED]:
757
+ errors["status"] = "Status needs to be approved in order to allow publication"
758
+ if not self.type or self.type.slug not in [
759
+ "one-off-article",
760
+ "deep-dive-article",
761
+ "mid-year-review",
762
+ "year-s-favorites",
763
+ ]:
764
+ errors["type"] = "unvalid type for publication"
765
+ # We ensure the article's parser can be published
766
+ if article_type := self.type:
767
+ for parser in article_type.parsers.all():
768
+ try:
769
+ parser.parser_class(
770
+ self._build_dto(),
771
+ date.today(),
772
+ ).is_valid()
773
+ except ParserValidationException as e:
774
+ errors["non_field_errors"] = ", ".join(e.errors)
775
+ except ModuleNotFoundError:
776
+ errors["non_field_errors"] = "invalid parser"
777
+ return errors
778
+
779
+ def can_be_published(self) -> bool:
780
+ return not bool(self.can_publish())
781
+
782
+ @transition(
783
+ status,
784
+ source=[Status.PUBLISHED],
785
+ target=Status.APPROVED,
786
+ permission=can_administrate_article,
787
+ custom={"_transition_button": unpublish_button},
788
+ )
789
+ def unpublish(self, by=None):
790
+ """Move the article back to the approved state."""
791
+ pass
792
+
793
+ # @transition(
794
+ # status,
795
+ # source=[Status.APPROVED, Status.PUBLISHED],
796
+ # target=Status.DRAFT,
797
+ # permission=can_revise,
798
+ # custom={"_transition_button": revise_button},
799
+ # )
800
+ # def revise(self, by=None):
801
+ # """Move the article back to the draft state."""
802
+ # self.peer_reviewer_approved = False
803
+ # self.peer_reviewer = None
804
+ # if self.type.slug == "ddq":
805
+ # self.author = None
806
+
807
+ @transition(
808
+ status,
809
+ source=[Status.APPROVED],
810
+ target=Status.DRAFT,
811
+ permission=is_author,
812
+ custom={"_transition_button": authors_revise_button},
813
+ )
814
+ def authorrevise(self, by=None):
815
+ """Move the article back to the draft state."""
816
+ pass
817
+
818
+ @transition(
819
+ status,
820
+ source=[Status.APPROVED],
821
+ target=Status.QA_REVIEW,
822
+ permission=is_qa_reviewer,
823
+ custom={"_transition_button": qas_revise_button},
824
+ )
825
+ def qarevise(self, by=None):
826
+ """Move the article back to the qa_review state."""
827
+ pass
828
+
829
+ """
830
+ /////////////////////////////////////////////////////////////////
831
+ /// /FSM Transitions ///
832
+ /////////////////////////////////////////////////////////////////
833
+ """
834
+
835
+ def __str__(self):
836
+ return self.title
837
+
838
+ def save(self, *args, **kwargs):
839
+ self.slug = slugify(self.name)
840
+ self.article_structure = self.get_sub_article_titles()
841
+ if self.title in ("", None):
842
+ self.title = self.name
843
+ # Set the reviewers
844
+ if not self.peer_reviewer:
845
+ self.peer_reviewer = self.roll_peer()
846
+ if not self.qa_reviewer:
847
+ self.qa_reviewer = self.roll_qa()
848
+
849
+ # if self.type and self.type.slug == "ddq":
850
+ # with suppress(Person.DoesNotExist):
851
+ # self.qa_reviewer = Person.objects.get(computed_str__icontains="Stefano Rodella")
852
+
853
+ create_dependencies(self)
854
+ super().save(*args, **kwargs)
855
+
856
+ def clone(self, user=None, **kwargs) -> "Article":
857
+ from wbwriter.models import MetaInformationInstance
858
+
859
+ object_copy = Article.objects.get(id=self.id)
860
+ object_copy.id = None
861
+ object_copy.name += "_clone"
862
+ object_copy.title += " (Clone)"
863
+ if user and (profile := user.profile):
864
+ object_copy.author = profile
865
+ object_copy.save()
866
+ for meta_rel in self.meta_information.all():
867
+ if not MetaInformationInstance.objects.filter(
868
+ article=object_copy, meta_information=meta_rel.meta_information
869
+ ).exists():
870
+ meta_rel.article = object_copy
871
+ meta_rel.id = None
872
+ meta_rel.save()
873
+ for dependant_article in self.dependant_article_connections.all():
874
+ dependant_article.article = object_copy
875
+ dependant_article.id = None
876
+ dependant_article.save()
877
+ for dependant_article_relationship in self.used_article_connections.all():
878
+ dependant_article_relationship.dependant_article = object_copy
879
+ dependant_article_relationship.id = None
880
+ dependant_article_relationship.save()
881
+
882
+ return object_copy
883
+
884
+ @property
885
+ def system_key(self):
886
+ return f"article-{self.id}"
887
+
888
+ def _build_dto(self, **kwargs):
889
+ meta_informations = kwargs.get("meta_information", {})
890
+ tags = kwargs.get("tags", [])
891
+ if self.id:
892
+ if hasattr(self, "meta_information"):
893
+ meta_informations = list(
894
+ self.meta_information.values(
895
+ "meta_information__key",
896
+ "meta_information__name",
897
+ "meta_information__meta_information_type",
898
+ "meta_information__boolean_default",
899
+ "boolean_value",
900
+ )
901
+ )
902
+ if hasattr(self, "tags"):
903
+ tags = list(self.tags.values_list("id", flat=True))
904
+ return ArticleDTO(
905
+ name=self.name,
906
+ slug=getattr(self, "slug", slugify(self.name)),
907
+ title=getattr(self, "title", self.name),
908
+ teaser_image=getattr(self, "teaser_image", None),
909
+ created=getattr(self, "created", datetime.datetime.now()),
910
+ modified=getattr(self, "modified", datetime.datetime.now()),
911
+ content=self.content,
912
+ is_private=self.is_private,
913
+ article_structure=getattr(
914
+ self, "article_structure", dict()
915
+ ), # todo make the logic behind article_structure agnostic to the data model.
916
+ status=self.status,
917
+ meta_information=meta_informations,
918
+ tags=tags,
919
+ )
920
+
921
+ def roll_peer(self):
922
+ """Return a randomly chosen user profile from the list of peer
923
+ reviewers of the type.
924
+
925
+ Returns the author if no type has been assigned or if there are no peer
926
+ reviewers listed on the assigned type.
927
+ """
928
+ if self.type:
929
+ days_into_the_past = global_preferences_registry.manager()["wbwriter__reviewer_roll_days_range"]
930
+ relevant_peers_query = self.type.article.filter(
931
+ peer_reviewer=OuterRef("pk"), created__gte=date.today() - timedelta(days=days_into_the_past)
932
+ ).values("peer_reviewer")
933
+ num_articles = Subquery(relevant_peers_query.annotate(c=Count("*")).values("c")[:1])
934
+
935
+ latest_article = Subquery(relevant_peers_query.annotate(c=Max("created")).values("c")[:1])
936
+
937
+ peers = (
938
+ self.type.peer_reviewers.exclude(id=self.author.id)
939
+ .annotate(
940
+ num_articles=Coalesce(num_articles, 0),
941
+ latest_article=latest_article,
942
+ )
943
+ .order_by("num_articles", "latest_article")
944
+ )
945
+ if peers.exists():
946
+ return peers.first()
947
+ return self.author
948
+
949
+ def roll_qa(self):
950
+ """Return a randomly chosen user profile from the list of QA
951
+ reviewers of the type.
952
+
953
+ Returns the author if no type has been assigned or if there are no QA
954
+ reviewers listed on the assigned type.
955
+ """
956
+ if self.type:
957
+ days_into_the_past = global_preferences_registry.manager()["wbwriter__reviewer_roll_days_range"]
958
+ relevant_qas_query = self.type.article.filter(
959
+ qa_reviewer=OuterRef("pk"), created__gte=date.today() - timedelta(days=days_into_the_past)
960
+ ).values("qa_reviewer")
961
+ num_articles = Subquery(relevant_qas_query.annotate(c=Count("*")).values("c")[:1])
962
+
963
+ latest_article = Subquery(relevant_qas_query.annotate(c=Max("created")).values("c")[:1])
964
+
965
+ qas = (
966
+ self.type.qa_reviewers.exclude(id=self.author.id)
967
+ .annotate(
968
+ num_articles=Coalesce(num_articles, 0),
969
+ latest_article=latest_article,
970
+ )
971
+ .order_by("num_articles", "latest_article")
972
+ )
973
+
974
+ if qas.exists():
975
+ return qas.first()
976
+ return self.author
977
+
978
+ qas = self.type.qa_reviewers.exclude(id=self.author.id) if self.author else self.type.qa_reviewers.all()
979
+ if self.type:
980
+ days_into_the_past = global_preferences_registry.manager()["wbwriter__reviewer_roll_days_range"]
981
+ qa_reviewer_id = (
982
+ Article.objects.filter(
983
+ qa_reviewer__in=qas, created__gte=date.today() - timedelta(days=days_into_the_past)
984
+ )
985
+ .values("qa_reviewer")
986
+ .annotate(num_reviews=Count("*"), latest_review=Max("created"))
987
+ .order_by("num_reviews", "latest_review")
988
+ .values_list("qa_reviewer", flat=True)
989
+ )
990
+ if qa_reviewer_id.exists():
991
+ return Person.objects.get(id=qa_reviewer_id.first())
992
+ return self.author
993
+
994
+ def reroll_peer(self):
995
+ """Rerolls the peer reviewer and saves the newly rolled profile as the
996
+ new peer reviewer.
997
+ """
998
+ self.peer_reviewer = self.roll_peer()
999
+
1000
+ def reroll_qa(self):
1001
+ """Rerolls the QA reviewer and saves the newly rolled profile as the
1002
+ new QA reviewer.
1003
+ """
1004
+ self.qa_reviewer = self.roll_qa()
1005
+
1006
+ def reroll_peer_and_qa(self):
1007
+ """Rerolls the peer and QA reviewers and saves the newly rolled
1008
+ profiles as the new peer and QA reviewers.
1009
+ """
1010
+ self.peer_reviewer = self.roll_peer()
1011
+ self.qa_reviewer = self.roll_qa()
1012
+
1013
+ def generate_pdf(self, user=None):
1014
+ empty_template = """<html>
1015
+ <head>
1016
+ <meta charset="UTF-8">
1017
+ <title>{{ self.title }}</title>
1018
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1019
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1020
+ <link
1021
+ href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,300;0,500;0,700;1,300&display=swap"
1022
+ rel="stylesheet"
1023
+ >
1024
+ </head>
1025
+ <body style="margin: 0">{{ content|safe }}</body>
1026
+ </html>"""
1027
+ styles = []
1028
+ pdf_content = None
1029
+ if not self.content or "sections" not in self.content:
1030
+ html = template.Template(empty_template).render(
1031
+ {"content": "<h1>There seems to be no content to render.</h1>"}
1032
+ )
1033
+ else:
1034
+ content = self.content["sections"][self.content["sectionOrder"][0]]["content"]["content"]
1035
+ resolved_content = resolve_content(content, extensions=["sane_lists"], article=self)
1036
+ template_context = template.Context({"content": resolved_content})
1037
+ if self.type:
1038
+ if "ddq" in self.type.slug:
1039
+ # TODO: Replace self.content with the raw_contents equivalent.
1040
+
1041
+ # If there is a template defined, then render that one and use its styles.
1042
+ if self.template and self.template.template:
1043
+ html = template.Template(self.template.template).render(template_context)
1044
+
1045
+ styles = [CSS(string=style.style) for style in self.template.styles.all()]
1046
+ if header := self.template.header_template:
1047
+ styles += [CSS(string=style.style) for style in header.styles.all()]
1048
+ if footer := self.template.footer_template:
1049
+ styles += [CSS(string=style.style) for style in footer.styles.all()]
1050
+
1051
+ pdf_content = PdfGenerator(
1052
+ main_html=html,
1053
+ header_html=self.template.header_template.template,
1054
+ footer_html=self.template.footer_template.template,
1055
+ custom_css=styles,
1056
+ side_margin=self.template.side_margin,
1057
+ extra_vertical_margin=self.template.extra_vertical_margin,
1058
+ ).render_pdf()
1059
+
1060
+ elif (
1061
+ "one-off" in self.type.slug
1062
+ or "deep-dive" in self.type.slug
1063
+ or "mid-year" in self.type.slug
1064
+ or "year-s-favorites" in self.type.slug
1065
+ ):
1066
+ parsers = self.type.parsers.all()
1067
+ if parsers.exists():
1068
+ parser = parsers.first().parser_class(self._build_dto(), datetime.date.today())
1069
+ if parser.is_valid(raise_exception=False):
1070
+ pdf_content = parser.get_file()
1071
+ else:
1072
+ template_context[
1073
+ "content"
1074
+ ] = f"""
1075
+ <p>To generate the PDF, please correct the following errors:</p>
1076
+ <ul>
1077
+ {''.join([f'<li>{error}</li>' for error in parser.errors])}
1078
+ </ul>
1079
+ """
1080
+ html = template.Template(empty_template).render(template_context)
1081
+ if not pdf_content:
1082
+ pdf_content = PdfGenerator(
1083
+ main_html=html,
1084
+ custom_css=styles,
1085
+ side_margin=0,
1086
+ extra_vertical_margin=0,
1087
+ ).render_pdf()
1088
+ document_type, created = DocumentType.objects.get_or_create(name="article")
1089
+ filename = f"{self.slug}.pdf"
1090
+ content_file = ContentFile(pdf_content, name=filename)
1091
+ document, created = Document.objects.update_or_create(
1092
+ system_created=True,
1093
+ system_key=f"article-{self.id}",
1094
+ defaults={
1095
+ "document_type": document_type,
1096
+ "file": content_file,
1097
+ "name": filename,
1098
+ "permission_type": Document.PermissionType.INTERNAL,
1099
+ },
1100
+ )
1101
+ document.link(self)
1102
+
1103
+ if not user:
1104
+ user = getattr(self.author, "user_account", None)
1105
+ if user:
1106
+ send_notification(
1107
+ code="wbwriter.article.notify",
1108
+ title="Your article has been generated",
1109
+ body=f'Your article "{self.title}" has been generated.',
1110
+ user=user,
1111
+ reverse_name="wbwriter:article-detail",
1112
+ reverse_args=[self.id],
1113
+ )
1114
+
1115
+ def re_find_articles(self) -> list:
1116
+ """Search in `content` for load_article template tags using regex and return the match."""
1117
+ # TODO: Fix the regex below. I belive that this regex doesn't represent
1118
+ # the full range of possible load_article tremplate tags.
1119
+ load_article_regex = "{%[ ]*load_article[ ]+([0-9]*)[ ]*[A-Za-z]*[ ]+(False True)?[ ]*%}"
1120
+
1121
+ if not self.content or "sectionOrder" not in self.content:
1122
+ return []
1123
+
1124
+ result = []
1125
+ for section_id in self.content["sectionOrder"]:
1126
+ for content in self.content["sections"][section_id]["content"].values():
1127
+ result.extend(re.findall(load_article_regex, content))
1128
+
1129
+ return result
1130
+
1131
+ def get_article_structure(self, level=0, enumerator=None):
1132
+ articles = {
1133
+ "title": self.title,
1134
+ "anchor": slugify(self.name),
1135
+ "enumerator": enumerator,
1136
+ "level": level,
1137
+ "articles": list(),
1138
+ }
1139
+
1140
+ for index, (sub_article_id, _) in enumerate(self.re_find_articles(), start=1):
1141
+ _enumerator = f"{enumerator or ''}{index}."
1142
+ articles["articles"].append(
1143
+ Article.objects.get(id=sub_article_id).get_article_structure(level + 1, _enumerator)
1144
+ )
1145
+
1146
+ return articles
1147
+
1148
+ def get_sub_article_titles(self, level=0, enumerator=None):
1149
+ mapping = dict()
1150
+
1151
+ for index, (sub_article_id, _) in enumerate(self.re_find_articles(), start=1):
1152
+ _enumerator = f"{enumerator or ''}{index}."
1153
+ mapping[sub_article_id] = {"enumerator": _enumerator, "level": level}
1154
+ _mapping = Article.objects.get(id=sub_article_id).get_sub_article_titles(level + 1, _enumerator)
1155
+ mapping.update(**_mapping)
1156
+ return mapping
1157
+
1158
+ @classmethod
1159
+ def get_endpoint_basename(self):
1160
+ return "wbwriter:article"
1161
+
1162
+ @classmethod
1163
+ def get_representation_endpoint(cls):
1164
+ return "wbwriter:article-list"
1165
+
1166
+ @classmethod
1167
+ def get_representation_value_key(cls):
1168
+ return "id"
1169
+
1170
+ @classmethod
1171
+ def get_representation_label_key(cls):
1172
+ return "{{ title }}"
1173
+
1174
+
1175
+ @shared_task
1176
+ def generate_pdf_as_task(article_id, user_id=None):
1177
+ article = Article.objects.get(id=article_id)
1178
+ user = get_user_model().objects.get(id=user_id) if user_id else None
1179
+ article.generate_pdf(user=user)