diff --git a/.github/workflows/impress.yml b/.github/workflows/impress.yml index acbaa36e..5fdf3a97 100644 --- a/.github/workflows/impress.yml +++ b/.github/workflows/impress.yml @@ -198,7 +198,7 @@ jobs: - name: Install gettext (required to compile messages) run: | sudo apt-get update - sudo apt-get install -y gettext + sudo apt-get install -y gettext pandoc - name: Generate a MO file from strings extracted from the project run: python manage.py compilemessages diff --git a/Dockerfile b/Dockerfile index fce18ac7..9f212837 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,6 +75,7 @@ RUN apt-get update && \ libgdk-pixbuf2.0-0 \ libpango-1.0-0 \ libpangocairo-1.0-0 \ + pandoc \ shared-mime-info && \ rm -rf /var/lib/apt/lists/* diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 540a55f8..e43f7e76 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -180,6 +180,12 @@ class DocumentGenerationSerializer(serializers.Serializer): required=False, default="html", ) + format = serializers.ChoiceField( + choices=["pdf", "docx"], + label=_("Format"), + required=False, + default="pdf", + ) class InvitationSerializer(serializers.ModelSerializer): diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 3af432c1..07691042 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1,13 +1,11 @@ """API endpoints""" -from io import BytesIO - from django.contrib.postgres.aggregates import ArrayAgg from django.db.models import ( OuterRef, Q, Subquery, ) -from django.http import FileResponse, Http404 +from django.http import Http404 from botocore.exceptions import ClientError from rest_framework import ( @@ -460,7 +458,16 @@ class TemplateViewSet( # pylint: disable=unused-argument def generate_document(self, request, pk=None): """ - Generate and return pdf for this template with the content passed. + Generate and return a document for this template around the + body passed as argument. + + 2 types of body are accepted: + - HTML: body_type = "html" + - Markdown: body_type = "markdown" + + 2 types of documents can be generated: + - PDF: format = "pdf" + - Docx: format = "docx" """ serializer = serializers.DocumentGenerationSerializer(data=request.data) @@ -471,13 +478,10 @@ class TemplateViewSet( body = serializer.validated_data["body"] body_type = serializer.validated_data["body_type"] + export_format = serializer.validated_data["format"] template = self.get_object() - pdf_content = template.generate_document(body, body_type) - - response = FileResponse(BytesIO(pdf_content), content_type="application/pdf") - response["Content-Disposition"] = f"attachment; filename={template.title}.pdf" - return response + return template.generate_document(body, body_type, export_format) class TemplateAccessViewSet( diff --git a/src/backend/core/models.py b/src/backend/core/models.py index a1cb9efa..66631359 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -2,10 +2,13 @@ Declare and configure the models for the impress core application """ import hashlib +import os import smtplib +import tempfile import textwrap import uuid from datetime import timedelta +from io import BytesIO from logging import getLogger from django.conf import settings @@ -16,6 +19,7 @@ from django.core import exceptions, mail, validators from django.core.files.base import ContentFile from django.core.files.storage import default_storage from django.db import models +from django.http import FileResponse from django.template.base import Template as DjangoTemplate from django.template.context import Context from django.template.loader import render_to_string @@ -26,10 +30,10 @@ from django.utils.translation import override import frontmatter import markdown +import pypandoc +import weasyprint from botocore.exceptions import ClientError from timezone_field import TimeZoneField -from weasyprint import CSS, HTML -from weasyprint.text.fonts import FontConfiguration logger = getLogger(__name__) @@ -564,10 +568,90 @@ class Template(BaseModel): "retrieve": can_get, } - def generate_document(self, body, body_type): + def generate_pdf(self, body_html, metadata): """ - Generate and return a PDF document for this template around the + Generate and return a pdf document wrapped around the current template + """ + document_html = weasyprint.HTML( + string=DjangoTemplate(self.code).render( + Context({"body": html.format_html(body_html), **metadata}) + ) + ) + css = weasyprint.CSS( + string=self.css, + font_config=weasyprint.text.fonts.FontConfiguration(), + ) + + pdf_content = document_html.write_pdf(stylesheets=[css], zoom=1) + response = FileResponse(BytesIO(pdf_content), content_type="application/pdf") + response["Content-Disposition"] = f"attachment; filename={self.title}.pdf" + + return response + + def generate_word(self, body_html, metadata): + """ + Generate and return a docx document wrapped around the current template + """ + template_string = DjangoTemplate(self.code).render( + Context({"body": html.format_html(body_html), **metadata}) + ) + + html_string = f""" + + +
+ + + + {template_string} + + + """ + + reference_docx = "core/static/reference.docx" + + # Convert the HTML to a temporary docx file + with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as tmp_file: + output_path = tmp_file.name + + pypandoc.convert_text( + html_string, + "docx", + format="html", + outputfile=output_path, + extra_args=["--reference-doc", reference_docx], + ) + + # Create a BytesIO object to store the output of the temporary docx file + with open(output_path, "rb") as f: + output = BytesIO(f.read()) + + # Remove the temporary docx file + os.remove(output_path) + + output.seek(0) + + response = FileResponse( + output, + content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + response["Content-Disposition"] = f"attachment; filename={self.title}.docx" + return response + + def generate_document(self, body, body_type, export_format): + """ + Generate and return a document for this template around the body passed as argument. + + 2 types of body are accepted: + - HTML: body_type = "html" + - Markdown: body_type = "markdown" + + 2 types of documents can be generated: + - PDF: export_format = "pdf" + - Docx: export_format = "docx" """ document = frontmatter.loads(body) metadata = document.metadata @@ -580,16 +664,10 @@ class Template(BaseModel): markdown.markdown(textwrap.dedent(strip_body)) if strip_body else "" ) - document_html = HTML( - string=DjangoTemplate(self.code).render( - Context({"body": html.format_html(body_html), **metadata}) - ) - ) - css = CSS( - string=self.css, - font_config=FontConfiguration(), - ) - return document_html.write_pdf(stylesheets=[css], zoom=1) + if export_format == "pdf": + return self.generate_pdf(body_html, metadata) + + return self.generate_word(body_html, metadata) class TemplateAccess(BaseAccess): diff --git a/src/backend/core/static/reference.docx b/src/backend/core/static/reference.docx new file mode 100644 index 00000000..2192455d Binary files /dev/null and b/src/backend/core/static/reference.docx differ diff --git a/src/backend/core/tests/templates/test_api_templates_generate_document.py b/src/backend/core/tests/templates/test_api_templates_generate_document.py index c1f15af6..8f745ace 100644 --- a/src/backend/core/tests/templates/test_api_templates_generate_document.py +++ b/src/backend/core/tests/templates/test_api_templates_generate_document.py @@ -178,3 +178,26 @@ def test_api_templates_generate_document_type_unknown(): '"unknown" is not a valid choice.', ] } + + +def test_api_templates_generate_document_export_docx(): + """Generate pdf document with the body type html.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + template = factories.TemplateFactory(is_public=True) + data = {"body": "Test body
", "body_type": "html", "format": "docx"} + + response = client.post( + f"/api/v1.0/templates/{template.id!s}/generate-document/", + data, + format="json", + ) + + assert response.status_code == 200 + assert ( + response.headers["content-type"] + == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) diff --git a/src/backend/demo/data/template/code.txt b/src/backend/demo/data/template/code.txt index 5fa0d27d..0ab83f60 100644 --- a/src/backend/demo/data/template/code.txt +++ b/src/backend/demo/data/template/code.txt @@ -1,7 +1,7 @@