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,59 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from slugify import slugify
|
|
3
|
+
from wbcore.models import WBModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ArticleType(WBModel):
|
|
7
|
+
"""An arbitrary type for articles.
|
|
8
|
+
|
|
9
|
+
The type of an article helps filtering out undesired categories of
|
|
10
|
+
articles.
|
|
11
|
+
Furthermore, having the article type in a separate model allows adding,
|
|
12
|
+
removing, and changing types during runtime.
|
|
13
|
+
|
|
14
|
+
Attributes
|
|
15
|
+
----------
|
|
16
|
+
label : models.CharField
|
|
17
|
+
The unique label for this type.
|
|
18
|
+
This is meant to be displayed to users.
|
|
19
|
+
slug : models.SlugField
|
|
20
|
+
A slugified version fo the label.
|
|
21
|
+
This is used for searching ArticleTypes.
|
|
22
|
+
parsers : models.ManyToManyField
|
|
23
|
+
One or more optional parsers. The parser are used to convert the
|
|
24
|
+
`content` of an article and its associated in-editor template(s)
|
|
25
|
+
to something that can be published.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
label = models.CharField(max_length=255, unique=True)
|
|
29
|
+
slug = models.SlugField(max_length=255, null=True, blank=True, unique=True)
|
|
30
|
+
parsers = models.ManyToManyField("wbwriter.PublicationParser", related_name="publication_parsers", blank=True)
|
|
31
|
+
peer_reviewers = models.ManyToManyField("directory.Person", related_name="article_type_peer_reviewers")
|
|
32
|
+
qa_reviewers = models.ManyToManyField("directory.Person", related_name="article_type_qa_reviewers")
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def get_endpoint_basename(self):
|
|
36
|
+
return "wbwriter:articletyperepresentation"
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def get_representation_endpoint(cls):
|
|
40
|
+
return "wbwriter:articletyperepresentation-list"
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def get_representation_value_key(cls):
|
|
44
|
+
return "id"
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def get_representation_label_key(cls):
|
|
48
|
+
return "{{label}}"
|
|
49
|
+
|
|
50
|
+
def __str__(self):
|
|
51
|
+
return self.label
|
|
52
|
+
|
|
53
|
+
def save(self, *args, **kwargs):
|
|
54
|
+
self.slug = slugify(self.label)
|
|
55
|
+
super().save(*args, **kwargs)
|
|
56
|
+
|
|
57
|
+
class Meta:
|
|
58
|
+
verbose_name = "Article Type"
|
|
59
|
+
verbose_name_plural = "Article Types"
|
wbwriter/models/block.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from django.template import Context
|
|
3
|
+
from django.template import Template as DjangoTemplate
|
|
4
|
+
from slugify import slugify
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Block(models.Model):
|
|
8
|
+
title = models.CharField(max_length=512)
|
|
9
|
+
key = models.CharField(max_length=512, null=True, blank=True)
|
|
10
|
+
html = models.TextField(default="")
|
|
11
|
+
|
|
12
|
+
def __str__(self):
|
|
13
|
+
return self.title
|
|
14
|
+
|
|
15
|
+
def save(self, *args, **kwargs):
|
|
16
|
+
self.key = slugify(self.title)
|
|
17
|
+
super().save(*args, **kwargs)
|
|
18
|
+
|
|
19
|
+
def parse_parameters(self, parameters):
|
|
20
|
+
for index, parameter in enumerate(self.parameters.all().order_by("order")):
|
|
21
|
+
yield parameter.title, parameter.parse_parameter(parameters[index])
|
|
22
|
+
|
|
23
|
+
def render(self, parameters):
|
|
24
|
+
return DjangoTemplate(self.html).render(Context(dict(self.parse_parameters(parameters))))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from django.db import models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BlockParameter(models.Model):
|
|
7
|
+
block = models.ForeignKey(to="wbwriter.Block", related_name="parameters", on_delete=models.CASCADE)
|
|
8
|
+
|
|
9
|
+
order = models.PositiveIntegerField()
|
|
10
|
+
title = models.CharField(max_length=255)
|
|
11
|
+
many = models.BooleanField(default=False)
|
|
12
|
+
|
|
13
|
+
def __str__(self):
|
|
14
|
+
return f"{self.block.title}: {self.title}"
|
|
15
|
+
|
|
16
|
+
def parse_parameter(self, parameter):
|
|
17
|
+
if self.many:
|
|
18
|
+
return json.loads(parameter)
|
|
19
|
+
return parameter
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from wbcore.models import WBModel
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class InEditorTemplate(WBModel):
|
|
6
|
+
"""A template that the user can choose and configure in the context of
|
|
7
|
+
the WYSIWYG editor.
|
|
8
|
+
|
|
9
|
+
In-Editor templates define CSS styles that need to be applied in
|
|
10
|
+
combination with the HTML template, and a number of other
|
|
11
|
+
In-Editor templates that may be nested within the given In-Editor
|
|
12
|
+
template.
|
|
13
|
+
|
|
14
|
+
Note that templates can not allow themself to be nested inside them.
|
|
15
|
+
|
|
16
|
+
Attributes
|
|
17
|
+
----------
|
|
18
|
+
uuid : models.CharField
|
|
19
|
+
A unique identifier. The `uuid` must pass as a valid CSS class and
|
|
20
|
+
must less than 256 characters long.
|
|
21
|
+
|
|
22
|
+
The regular expression for valid CSS classes: "-?[_a-zA-Z]+[_a-zA-Z0-9-]*"
|
|
23
|
+
|
|
24
|
+
See section 4.1.3, second paragraph on
|
|
25
|
+
https://www.w3.org/TR/CSS21/syndata.html#characters
|
|
26
|
+
title : models.CharField
|
|
27
|
+
A human readable title.
|
|
28
|
+
description : models.TextField
|
|
29
|
+
A descriptive text that outlines the use case and possible
|
|
30
|
+
configurations of the template.
|
|
31
|
+
style : models.TextField, optional
|
|
32
|
+
The CSS that is required to properly display the HTML template.
|
|
33
|
+
template : models.TextField
|
|
34
|
+
The HTML that makes up the template.
|
|
35
|
+
|
|
36
|
+
TODO: WRITE ABOUT SCHEMA AND CONFIGURATION OPTIONS.
|
|
37
|
+
is_stand_alone_template : models.BooleanField, default=True
|
|
38
|
+
Signifies whether this template can be used without a surrounding
|
|
39
|
+
template (i.e. as root node of the article).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
uuid = models.CharField(max_length=255, unique=True)
|
|
43
|
+
title = models.CharField(
|
|
44
|
+
verbose_name="Title",
|
|
45
|
+
max_length=255,
|
|
46
|
+
help_text="The title should be unique but doesn't need to be.",
|
|
47
|
+
)
|
|
48
|
+
description = models.TextField(
|
|
49
|
+
verbose_name="Description",
|
|
50
|
+
default="",
|
|
51
|
+
help_text="A brief text that describes the use case for this template.",
|
|
52
|
+
)
|
|
53
|
+
style = models.TextField(
|
|
54
|
+
verbose_name="Template CSS",
|
|
55
|
+
default="",
|
|
56
|
+
blank=True,
|
|
57
|
+
null=True,
|
|
58
|
+
help_text="The CSS that styles the templates HTML.",
|
|
59
|
+
)
|
|
60
|
+
template = models.TextField(
|
|
61
|
+
verbose_name="Template HTML",
|
|
62
|
+
default="",
|
|
63
|
+
help_text="The HTML code of the template.",
|
|
64
|
+
)
|
|
65
|
+
# configuration_field_definitions = models.JSONField(
|
|
66
|
+
# verbose_name="Configuration Field Definitions",
|
|
67
|
+
# help_text="Maps configuration IDs to field definitions for rendering a form.",
|
|
68
|
+
# blank=True,
|
|
69
|
+
# null=True,
|
|
70
|
+
# )
|
|
71
|
+
# configuration_form_layout = models.JSONField(
|
|
72
|
+
# verbose_name="Configuration Form Layout",
|
|
73
|
+
# help_text="The field layout of the configuration form.",
|
|
74
|
+
# blank=True,
|
|
75
|
+
# null=True,
|
|
76
|
+
# )
|
|
77
|
+
modified = models.DateTimeField(
|
|
78
|
+
verbose_name="Last modification date and time",
|
|
79
|
+
auto_now=True,
|
|
80
|
+
help_text="The last time this template has been edited.",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
is_stand_alone_template = models.BooleanField(default=True)
|
|
84
|
+
|
|
85
|
+
def __str__(self):
|
|
86
|
+
return self.title
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def get_endpoint_basename(self):
|
|
90
|
+
return "wbwriter:in-editor-template"
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def get_representation_endpoint(cls):
|
|
94
|
+
return "wbwriter:in-editor-template-list"
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def get_representation_value_key(cls):
|
|
98
|
+
return "id"
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def get_representation_label_key(cls):
|
|
102
|
+
return "{{ title }}"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from django.dispatch import receiver
|
|
3
|
+
from wbcore.models import WBModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MetaInformation(WBModel):
|
|
7
|
+
class MetaInformationType(models.TextChoices):
|
|
8
|
+
# NOTE: This is just one value for now. However, this structure allows us to add more data type in the future.
|
|
9
|
+
BOOLEAN = "BOOLEAN", "Boolean"
|
|
10
|
+
|
|
11
|
+
article_type = models.ManyToManyField(to="wbwriter.ArticleType", related_name="meta_information")
|
|
12
|
+
name = models.CharField(max_length=255, null=False, blank=False, unique=True)
|
|
13
|
+
key = models.CharField(max_length=255, null=False, blank=False, unique=True)
|
|
14
|
+
meta_information_type = models.CharField(
|
|
15
|
+
max_length=24, choices=MetaInformationType.choices, default=MetaInformationType.BOOLEAN
|
|
16
|
+
)
|
|
17
|
+
boolean_default = models.BooleanField(null=True, blank=True)
|
|
18
|
+
|
|
19
|
+
def __str__(self) -> str:
|
|
20
|
+
return f"{self.name} ({self.key})"
|
|
21
|
+
|
|
22
|
+
class Meta:
|
|
23
|
+
verbose_name = "Meta Information"
|
|
24
|
+
verbose_name_plural = "Meta Information"
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def get_endpoint_basename(self):
|
|
28
|
+
return "wbwriter:metainformation"
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def get_representation_endpoint(cls):
|
|
32
|
+
return "wbwriter:metainformation-list"
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def get_representation_value_key(cls):
|
|
36
|
+
return "id"
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def get_representation_label_key(cls):
|
|
40
|
+
return "{{name}} ({{key}})"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MetaInformationInstance(WBModel):
|
|
44
|
+
article = models.ForeignKey(to="wbwriter.Article", related_name="meta_information", on_delete=models.CASCADE)
|
|
45
|
+
|
|
46
|
+
meta_information = models.ForeignKey(
|
|
47
|
+
to="wbwriter.MetaInformation", related_name="instances", on_delete=models.CASCADE
|
|
48
|
+
)
|
|
49
|
+
boolean_value = models.BooleanField(null=True, blank=True)
|
|
50
|
+
|
|
51
|
+
def __str__(self) -> str:
|
|
52
|
+
return f"{self.meta_information} / {self.article}: {self.boolean_value}"
|
|
53
|
+
|
|
54
|
+
class Meta:
|
|
55
|
+
verbose_name = "Meta Information Instance"
|
|
56
|
+
verbose_name_plural = "Meta Information Instances"
|
|
57
|
+
|
|
58
|
+
constraints = [
|
|
59
|
+
models.UniqueConstraint(fields=["meta_information", "article"], name="unique_meta_information_article")
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def get_endpoint_basename(self):
|
|
64
|
+
return "wbwriter:metainformationinstance"
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def get_representation_endpoint(cls):
|
|
68
|
+
return "wbwriter:metainformationinstance-list"
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def get_representation_value_key(cls):
|
|
72
|
+
return "id"
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def get_representation_label_key(cls):
|
|
76
|
+
return "{{meta_information.name}}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@receiver(models.signals.post_save, sender="wbwriter.Article")
|
|
80
|
+
def create_meta_information_instances(sender, instance, created, **kwargs):
|
|
81
|
+
if created and instance.type:
|
|
82
|
+
for meta_information in instance.type.meta_information.all():
|
|
83
|
+
MetaInformationInstance.objects.get_or_create(
|
|
84
|
+
meta_information=meta_information,
|
|
85
|
+
article=instance,
|
|
86
|
+
defaults={"boolean_value": meta_information.boolean_default},
|
|
87
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from importlib import import_module
|
|
3
|
+
|
|
4
|
+
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
5
|
+
from django.contrib.contenttypes.models import ContentType
|
|
6
|
+
from django.db import models
|
|
7
|
+
from django.utils.functional import cached_property
|
|
8
|
+
from rest_framework.reverse import reverse
|
|
9
|
+
from slugify import slugify
|
|
10
|
+
from wbcore.contrib.tags.models import TagModelMixin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Publication(TagModelMixin, models.Model):
|
|
14
|
+
"""A publication of anything.
|
|
15
|
+
|
|
16
|
+
A publication stores content prepared for a specific platform or use
|
|
17
|
+
case. For instance, it stores an HTML string for publishing something on
|
|
18
|
+
web.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
class Meta:
|
|
22
|
+
verbose_name = "Publication"
|
|
23
|
+
verbose_name_plural = "Publications"
|
|
24
|
+
|
|
25
|
+
title = models.CharField(max_length=1024)
|
|
26
|
+
|
|
27
|
+
slug = models.CharField(max_length=1024, blank=True)
|
|
28
|
+
|
|
29
|
+
target = models.CharField(max_length=256)
|
|
30
|
+
|
|
31
|
+
teaser_image = models.ImageField(blank=True, null=True, upload_to="writer/publication/teasers")
|
|
32
|
+
thumbnail_image = models.ImageField(blank=True, null=True, upload_to="writer/publication/thumbnails")
|
|
33
|
+
|
|
34
|
+
author = models.ForeignKey(
|
|
35
|
+
"directory.Person",
|
|
36
|
+
related_name="publication",
|
|
37
|
+
on_delete=models.PROTECT,
|
|
38
|
+
blank=True,
|
|
39
|
+
null=True,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
created = models.DateField(
|
|
43
|
+
verbose_name="Creation Date",
|
|
44
|
+
auto_now_add=True,
|
|
45
|
+
help_text="The date on which this has been created.",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
modified = models.DateTimeField(
|
|
49
|
+
verbose_name="Last Modification Datetime",
|
|
50
|
+
auto_now=True,
|
|
51
|
+
help_text="The date and time on which this has been modified last.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
description = models.TextField(default="", blank=True)
|
|
55
|
+
|
|
56
|
+
content = models.TextField(default="")
|
|
57
|
+
|
|
58
|
+
content_file = models.FileField(
|
|
59
|
+
max_length=256,
|
|
60
|
+
upload_to="writer/publication/content_files",
|
|
61
|
+
blank=True,
|
|
62
|
+
null=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
parser = models.ForeignKey(
|
|
66
|
+
"wbwriter.PublicationParser",
|
|
67
|
+
related_name="parsed_publication",
|
|
68
|
+
on_delete=models.PROTECT,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
additional_information = models.JSONField(default=dict, null=True, blank=True)
|
|
72
|
+
|
|
73
|
+
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
|
74
|
+
object_id = models.PositiveIntegerField()
|
|
75
|
+
content_object = GenericForeignKey("content_type", "object_id")
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def create_or_update_from_parser_and_object(cls, parser, generic_object):
|
|
79
|
+
if hasattr(generic_object, "_build_dto") and callable(generic_object._build_dto):
|
|
80
|
+
ctype = ContentType.objects.get_for_model(generic_object)
|
|
81
|
+
pub, created = cls.objects.get_or_create(parser=parser, content_type=ctype, object_id=generic_object.id)
|
|
82
|
+
publ_metadata = generic_object.get_publication_metadata()
|
|
83
|
+
for k, v in publ_metadata.items():
|
|
84
|
+
setattr(pub, k, v)
|
|
85
|
+
pub.parser.parser_class(
|
|
86
|
+
generic_object._build_dto(), datetime.date.today() if created else pub.created
|
|
87
|
+
).parse(pub)
|
|
88
|
+
pub.save()
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def get_endpoint_basename(self):
|
|
92
|
+
return "wbwriter:publication"
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def get_representation_endpoint(cls):
|
|
96
|
+
return "wbwriter:publicationrepresentation-list"
|
|
97
|
+
|
|
98
|
+
def save(self, *args, **kwargs):
|
|
99
|
+
self.slug = slugify(self.title)
|
|
100
|
+
super().save(*args, **kwargs)
|
|
101
|
+
|
|
102
|
+
def get_tag_detail_endpoint(self):
|
|
103
|
+
return reverse("wbwriter:publication-detail", [self.id])
|
|
104
|
+
|
|
105
|
+
def get_tag_representation(self):
|
|
106
|
+
return self.title
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PublicationParser(models.Model):
|
|
110
|
+
"""A parser meant to be used to parse something into a publication.
|
|
111
|
+
|
|
112
|
+
Attributes
|
|
113
|
+
----------
|
|
114
|
+
title : models.CharField
|
|
115
|
+
The unique title for this parser.
|
|
116
|
+
|
|
117
|
+
parser_path : models.CharField
|
|
118
|
+
A dotted path to the parser file relatoive to the projects root
|
|
119
|
+
(ROOT/projects/).
|
|
120
|
+
|
|
121
|
+
created : models.DateField
|
|
122
|
+
The date on which this publication has been created.
|
|
123
|
+
|
|
124
|
+
content : models.TextField
|
|
125
|
+
The content of this publication in text form.
|
|
126
|
+
|
|
127
|
+
content_file : models.FileField
|
|
128
|
+
An optional file attachment, that represents the publications content.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
class Meta:
|
|
132
|
+
verbose_name = "PublicationParser"
|
|
133
|
+
verbose_name_plural = "PublicationParsers"
|
|
134
|
+
|
|
135
|
+
title = models.CharField(
|
|
136
|
+
max_length=1024,
|
|
137
|
+
unique=True,
|
|
138
|
+
)
|
|
139
|
+
slug = models.CharField(max_length=1024, blank=True)
|
|
140
|
+
parser_path = models.CharField(
|
|
141
|
+
max_length=1024,
|
|
142
|
+
unique=True,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
@cached_property
|
|
146
|
+
def parser_class(self):
|
|
147
|
+
return import_module(self.parser_path).Parser
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def get_endpoint_basename(cls):
|
|
151
|
+
return "wbwriter:publicationparser"
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def get_representation_endpoint(cls):
|
|
155
|
+
return "wbwriter:publicationparserrepresentation-list"
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def get_representation_value_key(cls):
|
|
159
|
+
return "id"
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def get_representation_label_key(cls):
|
|
163
|
+
return "{{title}}"
|
|
164
|
+
|
|
165
|
+
def save(self, *args, **kwargs):
|
|
166
|
+
self.slug = slugify(self.title)
|
|
167
|
+
super().save(*args, **kwargs)
|
|
168
|
+
|
|
169
|
+
def __str__(self):
|
|
170
|
+
return f"{self.title}"
|
wbwriter/models/style.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Template(models.Model):
|
|
5
|
+
"""
|
|
6
|
+
A template consists of n styles and a template which holds a {{content}} templatetag where the content is rendered
|
|
7
|
+
into.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
title = models.CharField(max_length=255, unique=True)
|
|
11
|
+
template = models.TextField(default="")
|
|
12
|
+
|
|
13
|
+
header_template = models.ForeignKey(
|
|
14
|
+
"Template",
|
|
15
|
+
null=True,
|
|
16
|
+
blank=True,
|
|
17
|
+
related_name="header_templates",
|
|
18
|
+
on_delete=models.SET_NULL,
|
|
19
|
+
)
|
|
20
|
+
footer_template = models.ForeignKey(
|
|
21
|
+
"Template",
|
|
22
|
+
null=True,
|
|
23
|
+
blank=True,
|
|
24
|
+
related_name="footer_templates",
|
|
25
|
+
on_delete=models.SET_NULL,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
styles = models.ManyToManyField(to="wbwriter.Style", related_name="templates", blank=True)
|
|
29
|
+
|
|
30
|
+
side_margin = models.FloatField(default=2.5)
|
|
31
|
+
extra_vertical_margin = models.FloatField(default=10)
|
|
32
|
+
|
|
33
|
+
def __str__(self):
|
|
34
|
+
return self.title
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
|
|
3
|
+
from weasyprint import CSS, HTML
|
|
4
|
+
from weasyprint.text.fonts import FontConfiguration
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PdfGenerator:
|
|
8
|
+
"""
|
|
9
|
+
Generate a PDF out of a rendered template, with the possibility to integrate nicely
|
|
10
|
+
a header and a footer if provided.
|
|
11
|
+
|
|
12
|
+
Notes:
|
|
13
|
+
------
|
|
14
|
+
- When Weasyprint renders an html into a PDF, it goes though several intermediate steps.
|
|
15
|
+
Here, in this class, we deal mostly with a box representation: 1 `Document` have 1 `Page`
|
|
16
|
+
or more, each `Page` 1 `Box` or more. Each box can contain other box. Hence the recursive
|
|
17
|
+
method `get_element` for example.
|
|
18
|
+
For more, see:
|
|
19
|
+
https://weasyprint.readthedocs.io/en/stable/hacking.html#dive-into-the-source
|
|
20
|
+
https://weasyprint.readthedocs.io/en/stable/hacking.html#formatting-structure
|
|
21
|
+
- Warning: the logic of this class relies heavily on the internal Weasyprint API. This
|
|
22
|
+
snippet was written at the time of the release 47, it might break in the future.
|
|
23
|
+
- This generator draws its inspiration and, also a bit of its implementation, from this
|
|
24
|
+
discussion in the library github issues: https://github.com/Kozea/WeasyPrint/issues/92
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
OVERLAY_LAYOUT = "@page {size: A4 portrait; margin: 0;}"
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
main_html,
|
|
32
|
+
header_html=None,
|
|
33
|
+
footer_html=None,
|
|
34
|
+
custom_css=[],
|
|
35
|
+
base_url=None,
|
|
36
|
+
side_margin=2,
|
|
37
|
+
extra_vertical_margin=30,
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
main_html: str
|
|
43
|
+
An HTML file (most of the time a template rendered into a string) which represents
|
|
44
|
+
the core of the PDF to generate.
|
|
45
|
+
header_html: str
|
|
46
|
+
An optional header html.
|
|
47
|
+
footer_html: str
|
|
48
|
+
An optional footer html.
|
|
49
|
+
custom_css: list
|
|
50
|
+
An optional list of css objects. (see weasyprint.CSS)
|
|
51
|
+
base_url: str
|
|
52
|
+
An absolute url to the page which serves as a reference to Weasyprint to fetch assets,
|
|
53
|
+
required to get our media.
|
|
54
|
+
side_margin: int, interpreted in cm, by default 2cm
|
|
55
|
+
The margin to apply on the core of the rendered PDF (i.e. main_html).
|
|
56
|
+
extra_vertical_margin: int, interpreted in pixel, by default 30 pixels
|
|
57
|
+
An extra margin to apply between the main content and header and the footer.
|
|
58
|
+
The goal is to avoid having the content of `main_html` touching the header or the
|
|
59
|
+
footer.
|
|
60
|
+
"""
|
|
61
|
+
self.main_html = main_html
|
|
62
|
+
self.header_html = header_html
|
|
63
|
+
self.footer_html = footer_html
|
|
64
|
+
self.custom_css = custom_css
|
|
65
|
+
self.base_url = base_url
|
|
66
|
+
self.side_margin = side_margin
|
|
67
|
+
self.extra_vertical_margin = extra_vertical_margin
|
|
68
|
+
|
|
69
|
+
def _compute_overlay_element(self, element: str):
|
|
70
|
+
"""
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
element: str
|
|
74
|
+
Either 'header' or 'footer'
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
element_body: BlockBox
|
|
79
|
+
A Weasyprint pre-rendered representation of an html element
|
|
80
|
+
element_height: float
|
|
81
|
+
The height of this element, which will be then translated in a html height
|
|
82
|
+
"""
|
|
83
|
+
html = HTML(
|
|
84
|
+
string=getattr(self, f"{element}_html"),
|
|
85
|
+
base_url=self.base_url,
|
|
86
|
+
)
|
|
87
|
+
element_doc = html.render(stylesheets=[CSS(string=self.OVERLAY_LAYOUT)] + self.custom_css)
|
|
88
|
+
element_page = element_doc.pages[0]
|
|
89
|
+
element_body = PdfGenerator.get_element(element_page._page_box.all_children(), "body")
|
|
90
|
+
element_body = element_body.copy_with_children(element_body.all_children())
|
|
91
|
+
element_html = PdfGenerator.get_element(element_page._page_box.all_children(), element)
|
|
92
|
+
|
|
93
|
+
if element == "header":
|
|
94
|
+
element_height = element_html.height
|
|
95
|
+
if element == "footer":
|
|
96
|
+
element_height = element_page.height - element_html.position_y
|
|
97
|
+
|
|
98
|
+
return element_body, element_height
|
|
99
|
+
|
|
100
|
+
def _apply_overlay_on_main(self, main_doc, header_body=None, footer_body=None):
|
|
101
|
+
"""
|
|
102
|
+
Insert the header and the footer in the main document.
|
|
103
|
+
|
|
104
|
+
Parameters
|
|
105
|
+
----------
|
|
106
|
+
main_doc: Document
|
|
107
|
+
The top level representation for a PDF page in Weasyprint.
|
|
108
|
+
header_body: BlockBox
|
|
109
|
+
A representation for an html element in Weasyprint.
|
|
110
|
+
footer_body: BlockBox
|
|
111
|
+
A representation for an html element in Weasyprint.
|
|
112
|
+
"""
|
|
113
|
+
for page in main_doc.pages:
|
|
114
|
+
page_body = PdfGenerator.get_element(page._page_box.all_children(), "body")
|
|
115
|
+
|
|
116
|
+
if header_body:
|
|
117
|
+
page_body.children += header_body.all_children()
|
|
118
|
+
if footer_body:
|
|
119
|
+
page_body.children += footer_body.all_children()
|
|
120
|
+
|
|
121
|
+
def render_pdf(self):
|
|
122
|
+
"""
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
pdf: a bytes sequence
|
|
126
|
+
The rendered PDF.
|
|
127
|
+
"""
|
|
128
|
+
if self.header_html:
|
|
129
|
+
header_body, header_height = self._compute_overlay_element("header")
|
|
130
|
+
else:
|
|
131
|
+
header_body, header_height = None, 0
|
|
132
|
+
if self.footer_html:
|
|
133
|
+
footer_body, footer_height = self._compute_overlay_element("footer")
|
|
134
|
+
else:
|
|
135
|
+
footer_body, footer_height = None, 0
|
|
136
|
+
|
|
137
|
+
margins = "{header_size}px {side_margin} {footer_size}px {side_margin}".format(
|
|
138
|
+
header_size=header_height + self.extra_vertical_margin,
|
|
139
|
+
footer_size=footer_height + self.extra_vertical_margin,
|
|
140
|
+
side_margin=f"{self.side_margin}cm",
|
|
141
|
+
)
|
|
142
|
+
content_print_layout = "@page {size: A4 portrait; margin: %s;}" % margins
|
|
143
|
+
|
|
144
|
+
html = HTML(
|
|
145
|
+
string=self.main_html,
|
|
146
|
+
base_url=self.base_url,
|
|
147
|
+
)
|
|
148
|
+
font_config = FontConfiguration()
|
|
149
|
+
main_doc = html.render(
|
|
150
|
+
stylesheets=[CSS(string=content_print_layout)] + self.custom_css,
|
|
151
|
+
font_config=font_config,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if self.header_html or self.footer_html:
|
|
155
|
+
self._apply_overlay_on_main(main_doc, header_body, footer_body)
|
|
156
|
+
pdf = main_doc.write_pdf()
|
|
157
|
+
encoded = base64.b64encode(pdf)
|
|
158
|
+
encoded = encoded.decode("utf-8")
|
|
159
|
+
return pdf
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def get_element(boxes, element):
|
|
163
|
+
"""
|
|
164
|
+
Given a set of boxes representing the elements of a PDF page in a DOM-like way, find the
|
|
165
|
+
box which is named `element`.
|
|
166
|
+
|
|
167
|
+
Look at the notes of the class for more details on Weasyprint insides.
|
|
168
|
+
"""
|
|
169
|
+
for box in boxes:
|
|
170
|
+
if box.element_tag == element:
|
|
171
|
+
return box
|
|
172
|
+
return PdfGenerator.get_element(box.all_children(), element)
|