Merge pull request #824 from SchrodingersGat/reporting-app

Reporting app
This commit is contained in:
Oliver 2020-05-22 23:16:24 +10:00 committed by GitHub
commit 203062a67a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 561 additions and 52 deletions

View File

@ -72,6 +72,27 @@ if DEBUG:
format='%(asctime)s %(levelname)s %(message)s',
)
# Web URL endpoint for served static files
STATIC_URL = '/static/'
# The filesystem location for served static files
STATIC_ROOT = os.path.abspath(CONFIG.get('static_root', os.path.join(BASE_DIR, 'static')))
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'InvenTree', 'static'),
]
# Web URL endpoint for served media files
MEDIA_URL = '/media/'
# The filesystem location for served static files
MEDIA_ROOT = os.path.abspath(CONFIG.get('media_root', os.path.join(BASE_DIR, 'media')))
if DEBUG:
print("InvenTree running in DEBUG mode")
print("MEDIA_ROOT:", MEDIA_ROOT)
print("STATIC_ROOT:", STATIC_ROOT)
# Does the user wish to use the sentry.io integration?
sentry_opts = CONFIG.get('sentry', {})
@ -106,12 +127,13 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
# InvenTree apps
'common.apps.CommonConfig',
'part.apps.PartConfig',
'stock.apps.StockConfig',
'company.apps.CompanyConfig',
'build.apps.BuildConfig',
'common.apps.CommonConfig',
'company.apps.CompanyConfig',
'order.apps.OrderConfig',
'part.apps.PartConfig',
'report.apps.ReportConfig',
'stock.apps.StockConfig',
# Third part add-ons
'django_filters', # Extended filter functionality
@ -126,6 +148,7 @@ INSTALLED_APPS = [
'mptt', # Modified Preorder Tree Traversal
'markdownx', # Markdown editing
'markdownify', # Markdown template rendering
'django_tex', # LaTeX output
]
LOGGING = {
@ -160,7 +183,11 @@ ROOT_URLCONF = 'InvenTree.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'DIRS': [
os.path.join(BASE_DIR, 'templates'),
# Allow templates in the reporting directory to be accessed
os.path.join(MEDIA_ROOT, 'report'),
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@ -173,6 +200,14 @@ TEMPLATES = [
],
},
},
# Backend for LaTeX report rendering
{
'NAME': 'tex',
'BACKEND': 'django_tex.engine.TeXEngine',
'DIRS': [
os.path.join(MEDIA_ROOT, 'report'),
]
},
]
REST_FRAMEWORK = {
@ -315,31 +350,19 @@ DATE_INPUT_FORMATS = [
"%Y-%m-%d",
]
# LaTeX rendering settings (django-tex)
latex_settings = CONFIG.get('latex', {})
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
# Set the latex interpreter in the config.yaml settings file
LATEX_INTERPRETER = latex_settings.get('interpreter', 'pdflatex')
# Web URL endpoint for served static files
STATIC_URL = '/static/'
LATEX_INTERPRETER_OPTIONS = latex_settings.get('options', '')
# The filesystem location for served static files
STATIC_ROOT = os.path.abspath(CONFIG.get('static_root', os.path.join(BASE_DIR, 'static')))
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'InvenTree', 'static'),
LATEX_GRAPHICSPATH = [
# Allow LaTeX files to access the report assets directory
os.path.join(MEDIA_ROOT, "report", "assets"),
]
# Web URL endpoint for served media files
MEDIA_URL = '/media/'
# The filesystem location for served static files
MEDIA_ROOT = os.path.abspath(CONFIG.get('media_root', os.path.join(BASE_DIR, 'media')))
if DEBUG:
print("InvenTree running in DEBUG mode")
print("MEDIA_ROOT:", MEDIA_ROOT)
print("STATIC_ROOT:", STATIC_ROOT)
# crispy forms use the bootstrap templates
CRISPY_TEMPLATE_PACK = 'bootstrap3'

View File

@ -166,6 +166,13 @@ class AjaxMixin(object):
except AttributeError:
context = {}
# If no 'form' argument is supplied, look at the underlying class
if form is None:
try:
form = self.get_form()
except AttributeError:
pass
if form:
context['form'] = form
else:

View File

@ -73,3 +73,14 @@ log_queries: False
sentry:
enabled: False
# dsn: add-your-sentry-dsn-here
# LaTeX report rendering
# InvenTree uses the django-tex plugin to enable LaTeX report rendering
# Ref: https://pypi.org/project/django-tex/
latex:
# Select the LaTeX interpreter to use for PDF rendering
# Note: The intepreter needs to be installed on the system!
# e.g. to install pdflatex: apt-get texlive-latex-base
interpreter: pdflatex
# Extra options to pass through to the LaTeX interpreter
options: ''

View File

@ -41,6 +41,7 @@ from InvenTree.helpers import decimal2string, normalize
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from report import models as ReportModels
from build import models as BuildModels
from order import models as OrderModels
from company.models import SupplierPart
@ -358,6 +359,24 @@ class Part(MPTTModel):
self.category = category
self.save()
def get_test_report_templates(self):
"""
Return all the TestReport template objects which map to this Part.
"""
templates = []
for report in ReportModels.TestReport.objects.all():
if report.matches_part(self):
templates.append(report)
return templates
def has_test_report_templates(self):
""" Return True if this part has a TestReport defined """
return len(self.get_test_report_templates()) > 0
def get_absolute_url(self):
""" Return the web URL for viewing this part """
return reverse('part-detail', kwargs={'pk': self.id})

View File

22
InvenTree/report/admin.py Normal file
View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.contrib import admin
from .models import ReportTemplate, ReportAsset
from .models import TestReport
class ReportTemplateAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'template')
class ReportAssetAdmin(admin.ModelAdmin):
list_display = ('asset', 'description')
admin.site.register(ReportTemplate, ReportTemplateAdmin)
admin.site.register(TestReport, ReportTemplateAdmin)
admin.site.register(ReportAsset, ReportAssetAdmin)

5
InvenTree/report/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ReportConfig(AppConfig):
name = 'report'

View File

@ -0,0 +1,49 @@
# Generated by Django 3.0.5 on 2020-05-22 11:00
import django.core.validators
from django.db import migrations, models
import report.models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ReportAsset',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('asset', models.FileField(help_text='Report asset file', upload_to=report.models.rename_asset)),
('description', models.CharField(help_text='Asset file description', max_length=250)),
],
),
migrations.CreateModel(
name='ReportTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Template name', max_length=100, unique=True)),
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])),
('description', models.CharField(help_text='Report template description', max_length=250)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='TestReport',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Template name', max_length=100, unique=True)),
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])),
('description', models.CharField(help_text='Report template description', max_length=250)),
('part_filters', models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validateFilterString])),
],
options={
'abstract': False,
},
),
]

View File

250
InvenTree/report/models.py Normal file
View File

@ -0,0 +1,250 @@
"""
Report template model definitions
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
from django.db import models
from django.core.validators import FileExtensionValidator
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from part import models as PartModels
from django_tex.shortcuts import render_to_pdf
from django_weasyprint import WeasyTemplateResponseMixin
def rename_template(instance, filename):
filename = os.path.basename(filename)
return os.path.join('report', 'report_template', instance.getSubdir(), filename)
def validateFilterString(value):
"""
Validate that a provided filter string looks like a list of comma-separated key=value pairs
These should nominally match to a valid database filter based on the model being filtered.
e.g. "category=6, IPN=12"
e.g. "part__name=widget"
The ReportTemplate class uses the filter string to work out which items a given report applies to.
For example, an acceptance test report template might only apply to stock items with a given IPN,
so the string could be set to:
filters = "IPN = ACME0001"
Returns a map of key:value pairs
"""
# Empty results map
results = {}
value = str(value).strip()
if not value or len(value) == 0:
return results
groups = value.split(',')
for group in groups:
group = group.strip()
pair = group.split('=')
if not len(pair) == 2:
raise ValidationError(
"Invalid group: {g}".format(g=group)
)
k, v = pair
k = k.strip()
v = v.strip()
if not k or not v:
raise ValidationError(
"Invalid group: {g}".format(g=group)
)
results[k] = v
return results
class WeasyprintReportMixin(WeasyTemplateResponseMixin):
"""
Class for rendering a HTML template to a PDF.
"""
pdf_filename = 'report.pdf'
pdf_attachment = True
def __init__(self, request, template, **kwargs):
self.request = request
self.template_name = template
self.pdf_filename = kwargs.get('filename', 'report.pdf')
class ReportTemplateBase(models.Model):
"""
Reporting template model.
"""
def __str__(self):
return "{n} - {d}".format(n=self.name, d=self.description)
def getSubdir(self):
return ''
@property
def extension(self):
return os.path.splitext(self.template.name)[1].lower()
@property
def template_name(self):
return os.path.join('report_template', self.getSubdir(), os.path.basename(self.template.name))
def get_context_data(self, request):
"""
Supply context data to the template for rendering
"""
return {}
def render(self, request, **kwargs):
"""
Render the template to a PDF file.
Supported template formats:
.tex - Uses django-tex plugin to render LaTeX template against an installed LaTeX engine
.html - Uses django-weasyprint plugin to render HTML template against Weasyprint
"""
filename = kwargs.get('filename', 'report.pdf')
context = self.get_context_data(request)
context['request'] = request
if self.extension == '.tex':
# Render LaTeX template to PDF
return render_to_pdf(request, self.template_name, context, filename=filename)
elif self.extension in ['.htm', '.html']:
# Render HTML template to PDF
wp = WeasyprintReportMixin(request, self.template_name, **kwargs)
return wp.render_to_response(context, **kwargs)
name = models.CharField(
blank=False, max_length=100,
help_text=_('Template name'),
unique=True,
)
template = models.FileField(
upload_to=rename_template,
help_text=_("Report template file"),
validators=[FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])],
)
description = models.CharField(max_length=250, help_text=_("Report template description"))
class Meta:
abstract = True
class ReportTemplate(ReportTemplateBase):
"""
A simple reporting template which is used to upload template files,
which can then be used in other concrete template classes.
"""
pass
class PartFilterMixin(models.Model):
"""
A model mixin used for matching a report type against a Part object.
Used to assign a report to a given part using custom filters.
"""
class Meta:
abstract = True
def matches_part(self, part):
"""
Test if this report matches a given part.
"""
filters = self.get_part_filters()
parts = PartModels.Part.objects.filter(**filters)
parts = parts.filter(pk=part.pk)
return parts.exists()
def get_part_filters(self):
""" Return a map of filters to be used for Part filtering """
return validateFilterString(self.part_filters)
part_filters = models.CharField(
blank=True,
max_length=250,
help_text=_("Part query filters (comma-separated list of key=value pairs)"),
validators=[validateFilterString]
)
class TestReport(ReportTemplateBase, PartFilterMixin):
"""
Render a TestReport against a StockItem object.
"""
def getSubdir(self):
return 'test'
# Requires a stock_item object to be given to it before rendering
stock_item = None
def get_context_data(self, request):
return {
'stock_item': self.stock_item,
'part': self.stock_item.part,
'results': self.stock_item.testResultMap(),
'result_list': self.stock_item.testResultList()
}
def rename_asset(instance, filename):
filename = os.path.basename(filename)
return os.path.join('report', 'assets', filename)
class ReportAsset(models.Model):
"""
Asset file for use in report templates.
For example, an image to use in a header file.
Uploaded asset files appear in MEDIA_ROOT/report/assets,
and can be loaded in a template using the {% report_asset <filename> %} tag.
"""
def __str__(self):
return os.path.basename(self.asset.name)
asset = models.FileField(
upload_to=rename_asset,
help_text=_("Report asset file"),
)
description = models.CharField(max_length=250, help_text=_("Asset file description"))

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

View File

@ -142,6 +142,34 @@ class SerializeStockForm(HelperForm):
]
class TestReportFormatForm(HelperForm):
""" Form for selection a test report template """
class Meta:
model = StockItem
fields = [
'template',
]
def __init__(self, stock_item, *args, **kwargs):
self.stock_item = stock_item
super().__init__(*args, **kwargs)
self.fields['template'].choices = self.get_template_choices()
def get_template_choices(self):
""" Available choices """
choices = []
for report in self.stock_item.part.get_test_report_templates():
choices.append((report.pk, report))
return choices
template = forms.ChoiceField(label=_('Template'), help_text=_('Select test report template'))
class ExportOptionsForm(HelperForm):
""" Form for selecting stock export options """

View File

@ -963,6 +963,13 @@ class StockItem(MPTTModel):
return result_map
def testResultList(self, **kwargs):
"""
Return a list of test-result objects for this StockItem
"""
return self.testResultMap(**kwargs).values()
def requiredTestStatus(self):
"""
Return the status of the tests required for this StockItem.

View File

@ -93,8 +93,13 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<span class='fas fa-copy'/>
</button>
{% endif %}
{% if item.part.has_test_report_templates %}
<button type='button' class='btn btn-default' id='stock-test-report' title='Generate test report'>
<span class='fas fa-tasks'/>
</button>
{% endif %}
<button type='button' class='btn btn-default' id='stock-edit' title='Edit stock item'>
<span class='fas fa-edit'/>
<span class='fas fa-edit icon-blue'/>
</button>
{% if item.can_delete %}
<button type='button' class='btn btn-default' id='stock-delete' title='Edit stock item'>
@ -264,6 +269,17 @@ $("#stock-serialize").click(function() {
);
});
{% if item.part.has_test_report_templates %}
$("#stock-test-report").click(function() {
launchModalForm(
"{% url 'stock-item-test-report-select' item.id %}",
{
follow: true,
}
);
});
{% endif %}
$("#stock-duplicate").click(function() {
launchModalForm(
"{% url 'stock-item-create' %}",

View File

@ -25,6 +25,8 @@ stock_item_detail_urls = [
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
url(r'^test-report-select/', views.StockItemTestReportSelect.as_view(), name='stock-item-test-report-select'),
url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'),
url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'),
url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'),
@ -53,6 +55,8 @@ stock_urls = [
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
url(r'^item/test-report-download/', views.StockItemTestReportDownload.as_view(), name='stock-item-test-report-download'),
# URLs for StockItem attachments
url(r'^item/attachment/', include([
url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'),

View File

@ -27,19 +27,12 @@ from datetime import datetime
from company.models import Company, SupplierPart
from part.models import Part
from report.models import TestReport
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
from .admin import StockItemResource
from .forms import EditStockLocationForm
from .forms import CreateStockItemForm
from .forms import EditStockItemForm
from .forms import AdjustStockForm
from .forms import TrackingEntryForm
from .forms import SerializeStockForm
from .forms import ExportOptionsForm
from .forms import EditStockItemAttachmentForm
from .forms import EditStockItemTestResultForm
from . import forms as StockForms
class StockIndex(ListView):
@ -114,7 +107,7 @@ class StockLocationEdit(AjaxUpdateView):
"""
model = StockLocation
form_class = EditStockLocationForm
form_class = StockForms.EditStockLocationForm
context_object_name = 'location'
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Edit Stock Location')
@ -158,7 +151,7 @@ class StockItemAttachmentCreate(AjaxCreateView):
"""
model = StockItemAttachment
form_class = EditStockItemAttachmentForm
form_class = StockForms.EditStockItemAttachmentForm
ajax_form_title = _("Add Stock Item Attachment")
ajax_template_name = "modal_form.html"
@ -203,7 +196,7 @@ class StockItemAttachmentEdit(AjaxUpdateView):
"""
model = StockItemAttachment
form_class = EditStockItemAttachmentForm
form_class = StockForms.EditStockItemAttachmentForm
ajax_form_title = _("Edit Stock Item Attachment")
def get_form(self):
@ -271,7 +264,7 @@ class StockItemTestResultCreate(AjaxCreateView):
"""
model = StockItemTestResult
form_class = EditStockItemTestResultForm
form_class = StockForms.EditStockItemTestResultForm
ajax_form_title = _("Add Test Result")
def post_save(self, **kwargs):
@ -319,7 +312,7 @@ class StockItemTestResultEdit(AjaxUpdateView):
"""
model = StockItemTestResult
form_class = EditStockItemTestResultForm
form_class = StockForms.EditStockItemTestResultForm
ajax_form_title = _("Edit Test Result")
def get_form(self):
@ -343,12 +336,81 @@ class StockItemTestResultDelete(AjaxDeleteView):
context_object_name = "result"
class StockItemTestReportSelect(AjaxView):
"""
View for selecting a TestReport template,
and generating a TestReport as a PDF.
"""
model = StockItem
ajax_form_title = _("Select Test Report Template")
def get_form(self):
stock_item = StockItem.objects.get(pk=self.kwargs['pk'])
return StockForms.TestReportFormatForm(stock_item)
def post(self, request, *args, **kwargs):
template_id = request.POST.get('template', None)
try:
template = TestReport.objects.get(pk=template_id)
except (ValueError, TestReport.DoesNoteExist):
raise ValidationError({'template': _("Select valid template")})
stock_item = StockItem.objects.get(pk=self.kwargs['pk'])
url = reverse('stock-item-test-report-download')
url += '?stock_item={id}'.format(id=stock_item.pk)
url += '&template={id}'.format(id=template.pk)
data = {
'form_valid': True,
'url': url,
}
return self.renderJsonResponse(request, self.get_form(), data=data)
class StockItemTestReportDownload(AjaxView):
"""
Download a TestReport against a StockItem.
Requires the following arguments to be passed as URL params:
stock_item - Valid PK of a StockItem object
template - Valid PK of a TestReport template object
"""
def get(self, request, *args, **kwargs):
template = request.GET.get('template', None)
stock_item = request.GET.get('stock_item', None)
try:
template = TestReport.objects.get(pk=template)
except (ValueError, TestReport.DoesNotExist):
raise ValidationError({'template': 'Invalid template ID'})
try:
stock_item = StockItem.objects.get(pk=stock_item)
except (ValueError, StockItem.DoesNotExist):
raise ValidationError({'stock_item': 'Invalid StockItem ID'})
template.stock_item = stock_item
return template.render(request)
class StockExportOptions(AjaxView):
""" Form for selecting StockExport options """
model = StockLocation
ajax_form_title = _('Stock Export Options')
form_class = ExportOptionsForm
form_class = StockForms.ExportOptionsForm
def post(self, request, *args, **kwargs):
@ -491,7 +553,7 @@ class StockAdjust(AjaxView, FormMixin):
ajax_template_name = 'stock/stock_adjust.html'
ajax_form_title = _('Adjust Stock')
form_class = AdjustStockForm
form_class = StockForms.AdjustStockForm
stock_items = []
def get_GET_items(self):
@ -809,7 +871,7 @@ class StockItemEdit(AjaxUpdateView):
"""
model = StockItem
form_class = EditStockItemForm
form_class = StockForms.EditStockItemForm
context_object_name = 'item'
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Edit Stock Item')
@ -845,7 +907,7 @@ class StockLocationCreate(AjaxCreateView):
"""
model = StockLocation
form_class = EditStockLocationForm
form_class = StockForms.EditStockLocationForm
context_object_name = 'location'
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Create new Stock Location')
@ -870,7 +932,7 @@ class StockItemSerialize(AjaxUpdateView):
model = StockItem
ajax_template_name = 'stock/item_serialize.html'
ajax_form_title = _('Serialize Stock')
form_class = SerializeStockForm
form_class = StockForms.SerializeStockForm
def get_form(self):
@ -879,7 +941,7 @@ class StockItemSerialize(AjaxUpdateView):
# Pass the StockItem object through to the form
context['item'] = self.get_object()
form = SerializeStockForm(**context)
form = StockForms.SerializeStockForm(**context)
return form
@ -958,7 +1020,7 @@ class StockItemCreate(AjaxCreateView):
"""
model = StockItem
form_class = CreateStockItemForm
form_class = StockForms.CreateStockItemForm
context_object_name = 'item'
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Create new Stock Item')
@ -1265,7 +1327,7 @@ class StockItemTrackingEdit(AjaxUpdateView):
model = StockItemTracking
ajax_form_title = _('Edit Stock Tracking Entry')
form_class = TrackingEntryForm
form_class = StockForms.TrackingEntryForm
class StockItemTrackingCreate(AjaxCreateView):
@ -1274,7 +1336,7 @@ class StockItemTrackingCreate(AjaxCreateView):
model = StockItemTracking
ajax_form_title = _("Add Stock Tracking Entry")
form_class = TrackingEntryForm
form_class = StockForms.TrackingEntryForm
def post(self, request, *args, **kwargs):

View File

@ -51,12 +51,12 @@ style:
# Run unit tests
test:
cd InvenTree && python3 manage.py check
cd InvenTree && python3 manage.py test build common company order part stock
cd InvenTree && python3 manage.py test build common company order part report stock InvenTree
# Run code coverage
coverage:
cd InvenTree && python3 manage.py check
coverage run InvenTree/manage.py test build common company order part stock InvenTree
coverage run InvenTree/manage.py test build common company order part report stock InvenTree
coverage html
# Install packages required to generate code docs

View File

@ -19,4 +19,6 @@ flake8==3.3.0 # PEP checking
coverage==4.0.3 # Unit test coverage
python-coveralls==2.9.1 # Coveralls linking (for Travis)
rapidfuzz==0.7.6 # Fuzzy string matching
django-stdimage==5.1.1 # Advanced ImageField management
django-stdimage==5.1.1 # Advanced ImageField management
django-tex==1.1.7 # LaTeX PDF export
django-weasyprint==1.0.1 # HTML PDF export