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.
- wbwriter/__init__.py +1 -0
- wbwriter/admin.py +142 -0
- wbwriter/apps.py +5 -0
- wbwriter/dynamic_preferences_registry.py +15 -0
- wbwriter/factories/__init__.py +13 -0
- wbwriter/factories/article.py +181 -0
- wbwriter/factories/meta_information.py +29 -0
- wbwriter/filters/__init__.py +2 -0
- wbwriter/filters/article.py +47 -0
- wbwriter/filters/metainformationinstance.py +24 -0
- wbwriter/migrations/0001_initial_squashed_squashed_0008_alter_article_author_alter_article_feedback_contact_and_more.py +653 -0
- wbwriter/migrations/0009_dependantarticle.py +41 -0
- wbwriter/migrations/0010_alter_article_options.py +20 -0
- wbwriter/migrations/0011_auto_20240103_0953.py +39 -0
- wbwriter/migrations/__init__.py +0 -0
- wbwriter/models/__init__.py +9 -0
- wbwriter/models/article.py +1179 -0
- wbwriter/models/article_type.py +59 -0
- wbwriter/models/block.py +24 -0
- wbwriter/models/block_parameter.py +19 -0
- wbwriter/models/in_editor_template.py +102 -0
- wbwriter/models/meta_information.py +87 -0
- wbwriter/models/mixins.py +9 -0
- wbwriter/models/publication_models.py +170 -0
- wbwriter/models/style.py +13 -0
- wbwriter/models/template.py +34 -0
- wbwriter/pdf_generator.py +172 -0
- wbwriter/publication_parser.py +258 -0
- wbwriter/serializers/__init__.py +28 -0
- wbwriter/serializers/article.py +359 -0
- wbwriter/serializers/article_type.py +14 -0
- wbwriter/serializers/in_editor_template.py +37 -0
- wbwriter/serializers/meta_information.py +67 -0
- wbwriter/serializers/publication.py +82 -0
- wbwriter/templatetags/__init__.py +0 -0
- wbwriter/templatetags/writer.py +72 -0
- wbwriter/tests/__init__.py +0 -0
- wbwriter/tests/conftest.py +32 -0
- wbwriter/tests/signals.py +23 -0
- wbwriter/tests/test_filter.py +58 -0
- wbwriter/tests/test_model.py +591 -0
- wbwriter/tests/test_writer.py +38 -0
- wbwriter/tests/tests.py +18 -0
- wbwriter/typings.py +23 -0
- wbwriter/urls.py +83 -0
- wbwriter/viewsets/__init__.py +22 -0
- wbwriter/viewsets/article.py +270 -0
- wbwriter/viewsets/article_type.py +49 -0
- wbwriter/viewsets/buttons.py +61 -0
- wbwriter/viewsets/display/__init__.py +6 -0
- wbwriter/viewsets/display/article.py +404 -0
- wbwriter/viewsets/display/article_type.py +27 -0
- wbwriter/viewsets/display/in_editor_template.py +39 -0
- wbwriter/viewsets/display/meta_information.py +37 -0
- wbwriter/viewsets/display/meta_information_instance.py +28 -0
- wbwriter/viewsets/display/publication.py +55 -0
- wbwriter/viewsets/endpoints/__init__.py +2 -0
- wbwriter/viewsets/endpoints/article.py +12 -0
- wbwriter/viewsets/endpoints/meta_information.py +14 -0
- wbwriter/viewsets/in_editor_template.py +68 -0
- wbwriter/viewsets/menu.py +42 -0
- wbwriter/viewsets/meta_information.py +51 -0
- wbwriter/viewsets/meta_information_instance.py +48 -0
- wbwriter/viewsets/publication.py +117 -0
- wbwriter/viewsets/titles/__init__.py +2 -0
- wbwriter/viewsets/titles/publication_title_config.py +18 -0
- wbwriter/viewsets/titles/reviewer_article_title_config.py +6 -0
- wbwriter-2.2.1.dist-info/METADATA +8 -0
- wbwriter-2.2.1.dist-info/RECORD +70 -0
- 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)
|