diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e0d1a93cd2c81c59a81b40fb1217d0e99a6b5904..116019ee190b6e294059cf14c3b15135bab57864 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,17 +14,22 @@ Added * Notification drawer in top nav bar * GraphQL queries and mutations for core data management +* [Dev] Provide plain PDF template without header/footer for special layouts. * Show also group ownerships on person detail page Changed ~~~~~~~ * Rewrite of frontend using Vuetify +* [Dev] Provide function to generate PDF files from fully-rendered templates. * OIDC scope "profile" now exposes the avatar instead of the official photo Fixed ~~~~~ +* The logo in the PDF files was displayed at the wrong position. +* Sometimes the PDF files were not generated correctly + and images were displayed only partially. * Error message in permission form was misleading. Removed diff --git a/Dockerfile b/Dockerfile index df38046b1aff30d14a480603c80d5d2987cf4cdd..914ee330f0d7e6646ba62c4d630747501b72d4f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ RUN apt-get -y update && \ eatmydata apt-get install -y --no-install-recommends \ build-essential \ chromium \ + chromium-driver \ curl \ dumb-init \ gettext \ diff --git a/aleksis/core/static/print-simple.css b/aleksis/core/static/print-simple.css new file mode 100644 index 0000000000000000000000000000000000000000..f0e6536b4d835b67ab0d519de13c561953be4ea5 --- /dev/null +++ b/aleksis/core/static/print-simple.css @@ -0,0 +1,21 @@ +@page { + padding: 0; + margin: 0; +} + +table.small-print, td.small-print, th.small-print { + font-size: 10pt; +} + +tr { + border-bottom: 1px solid rgba(0, 0, 0, 0.3); +} + +td, th { + padding: 1px; +} + +td.rotate, th.rotate { + text-align: center; + transform: rotate(-90deg); +} diff --git a/aleksis/core/static/print.css b/aleksis/core/static/print.css index cda82eacab1d51a39f7f455170eb6ff12121dc30..dad3abb5967e84b014769240ce000d33ced9f014 100644 --- a/aleksis/core/static/print.css +++ b/aleksis/core/static/print.css @@ -67,9 +67,10 @@ header .row, header .col { } #print-logo { - padding: 2mm; height: 22mm; width: auto; + margin-block: 0; + padding: 2mm 2mm 2mm 0; } .page-break { diff --git a/aleksis/core/templates/core/base_simple_print.html b/aleksis/core/templates/core/base_simple_print.html new file mode 100644 index 0000000000000000000000000000000000000000..6e66e28983679acebdd2df0993ae7b5fdf64e621 --- /dev/null +++ b/aleksis/core/templates/core/base_simple_print.html @@ -0,0 +1,52 @@ +{% load static i18n any_js sass_tags %} +{% get_current_language as LANGUAGE_CODE %} + +<!DOCTYPE html> +<html lang="{{ LANGUAGE_CODE }}"> +<head> + {% include "core/partials/meta.html" %} + + <title> + {% block no_browser_title %} + {% block browser_title %}{% endblock %} — + {% endblock %} + {{ SITE_PREFERENCES.general__title }} + </title> + + {% include_css "material-design-icons" %} + {% include_css "Roboto100" %} + {% include_css "Roboto300" %} + {% include_css "Roboto400" %} + {% include_css "Roboto500" %} + {% include_css "Roboto700" %} + {% include_css "Roboto900" %} + {% include_css "paper-css" %} + <link rel="stylesheet" href="{% sass_src 'public/style.scss' %}"/> + <link rel="stylesheet" href="{% static "print-simple.css" %}"/> + + {% block size %} + <style> + @page { + size: {{ width }}mm {{ height }}mm; + } + + @media print { + html, body { + width: {{ width }}mm; + } + } + + .sheet { + width: {{ width }}mm; + height: {{ height|add:-1 }}.83mm; + } + </style> + {% endblock %} + + {% block extra_head %}{% endblock %} +</head> + +<body> +{% block content %}{% endblock %} +</body> +</html> diff --git a/aleksis/core/util/pdf.py b/aleksis/core/util/pdf.py index a261017b3b2bb22decea1cc517b414858b14a68c..8721d8c3167ba8a01fd250a0967f56af495bdf78 100644 --- a/aleksis/core/util/pdf.py +++ b/aleksis/core/util/pdf.py @@ -1,8 +1,9 @@ +import base64 import os import subprocess # noqa from datetime import timedelta from tempfile import TemporaryDirectory -from typing import Optional, Tuple, Union +from typing import Callable, Optional, Tuple, Union from urllib.parse import urljoin from django.conf import settings @@ -19,6 +20,7 @@ from django.utils.translation import gettext as _ from celery.result import AsyncResult from celery_progress.backend import ProgressRecorder +from selenium import webdriver from aleksis.core.celery import app from aleksis.core.models import PDFFile @@ -26,6 +28,29 @@ from aleksis.core.util.celery_progress import recorded_task, render_progress_pag from aleksis.core.util.core_helpers import process_custom_context_processors +def _generate_pdf_with_chromium(temp_dir, pdf_path, html_url, lang): + """Generate a PDF file from a HTML file.""" + chrome_options = webdriver.ChromeOptions() + chrome_options.add_argument("--kiosk-printing") + chrome_options.add_argument("--headless") + chrome_options.add_argument("--no-sandbox") + chrome_options.add_argument("--disable-gpu") + chrome_options.add_argument("--disable-dev-shm-usage") + chrome_options.add_argument("--disable-setuid-sandbox") + chrome_options.add_argument("--dbus-stub") + chrome_options.add_argument("--temp-profile") + chrome_options.add_argument(f"--lang={lang}") + + driver = webdriver.Chrome(options=chrome_options) + driver.get(html_url) + pdf = driver.execute_cdp_cmd( + "Page.printToPDF", {"printBackground": True, "preferCSSPageSize": True} + ) + driver.close() + with open(pdf_path, "wb") as f: + f.write(base64.b64decode(pdf["data"])) + + @recorded_task def generate_pdf( file_pk: int, html_url: str, recorder: ProgressRecorder, lang: Optional[str] = None @@ -40,26 +65,7 @@ def generate_pdf( pdf_path = os.path.join(temp_dir, "print.pdf") lang = lang or get_language() - # Run PDF generation using a headless Chromium - cmd = [ - "chromium", - "--headless", - "--no-sandbox", - "--run-all-compositor-stages-before-draw", - "--temp-profile", - "--disable-dev-shm-usage", - "--disable-gpu", - "--disable-setuid-sandbox", - "--dbus-stub", - f"--home-dir={temp_dir}", - f"--lang={lang}", - f"--print-to-pdf={pdf_path}", - html_url, - ] - res = subprocess.run(cmd) # noqa - - # Let the task fail on a non-success return code - res.check_returncode() + _generate_pdf_with_chromium(temp_dir, pdf_path, html_url, lang) # Upload PDF file to media storage with open(pdf_path, "rb") as f: @@ -69,10 +75,8 @@ def generate_pdf( recorder.set_progress(1, 1) -def generate_pdf_from_template( - template_name: str, context: Optional[dict] = None, request: Optional[HttpRequest] = None -) -> Tuple[PDFFile, AsyncResult]: - """Start a PDF generation task and return the matching file object and Celery result.""" +def process_context_for_pdf(context: Optional[dict] = None, request: Optional[HttpRequest] = None): + context = context or {} if not request: processed_context = process_custom_context_processors( settings.NON_REQUEST_CONTEXT_PROCESSORS @@ -80,11 +84,14 @@ def generate_pdf_from_template( processed_context.update(context) else: processed_context = context - html_template = render_to_string(template_name, processed_context, request) + return processed_context - file_object = PDFFile.objects.create( - html_file=ContentFile(html_template.encode(), name="source.html") - ) + +def generate_pdf_from_html( + html: str, request: Optional[HttpRequest] = None +) -> Tuple[PDFFile, AsyncResult]: + """Start a PDF generation task and return the matching file object and Celery result.""" + file_object = PDFFile.objects.create(html_file=ContentFile(html.encode(), name="source.html")) # As this method may be run in background and there is no request available, # we have to use a predefined URL from settings then @@ -98,6 +105,23 @@ def generate_pdf_from_template( return file_object, result +def generate_pdf_from_template( + template_name: str, + context: Optional[dict] = None, + request: Optional[HttpRequest] = None, + render_method: Optional[Callable] = None, +) -> Tuple[PDFFile, AsyncResult]: + """Start a PDF generation task and return the matching file object and Celery result.""" + processed_context = process_context_for_pdf(context, request) + + if render_method: + html_template = render_method(processed_context, request) + else: + html_template = render_to_string(template_name, processed_context, request) + + return generate_pdf_from_html(html_template, request) + + def render_pdf( request: Union[HttpRequest, None], template_name: str, context: dict = None ) -> HttpResponse: diff --git a/docs/admin/10_install.rst b/docs/admin/10_install.rst index 3f350d5f9199ac265b6be906ad284a20705a5bf9..ee46193d93282c9e44016b3b0ebf8e1f1b9c4b36 100644 --- a/docs/admin/10_install.rst +++ b/docs/admin/10_install.rst @@ -53,6 +53,7 @@ Install some packages from the Debian package system. yarnpkg \ python3-virtualenv \ chromium \ + chromium-driver \ redis-server \ postgresql \ locales-all \ diff --git a/docs/dev/01_setup.rst b/docs/dev/01_setup.rst index 75cf677a7f43703dc5e6a389e97be28fcf813a02..1b82cf5e35c78585ee01b101120ceaff8fa19305 100644 --- a/docs/dev/01_setup.rst +++ b/docs/dev/01_setup.rst @@ -47,7 +47,7 @@ Install native dependencies Some system libraries are required to install AlekSIS. On Debian, for example, this would be done with:: - sudo apt install build-essential libpq-dev libpq5 libssl-dev python3-dev python3-pip python3-venv yarnpkg gettext chromium + sudo apt install build-essential libpq-dev libpq5 libssl-dev python3-dev python3-pip python3-venv yarnpkg gettext chromium chromium-driver Get Poetry ---------- diff --git a/pyproject.toml b/pyproject.toml index 8768704eba7cab5cc3b1c57eae6fcb97d900560c..b5349b77fb39aa4332e18660b86a63de540ade9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,6 +130,7 @@ django-iconify = "^0.3" customidenticon = "^0.1.5" graphene-django = "^3.0.0" django-webpack-loader = "^1.6.0" +selenium = "^4.4.3" [tool.poetry.extras] ldap = ["django-auth-ldap"]