diff --git a/.chameleon-cache/.gitkeep b/.chameleon-cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc7be25..8902b5b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: - id: untranslated-messages files: '\.po$' - repo: https://github.com/PyCQA/flake8 - rev: 7.0.0 + rev: 7.1.0 hooks: - id: flake8 types: [file] diff --git a/requirements.txt b/requirements.txt index 42225c6..2b03780 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ # This file was autogenerated by uv via the following command: # uv pip compile setup.cfg - -o requirements.txt --no-emit-package setuptools -e . - # via -r - alembic==1.13.1 # via # privatim (setup.cfg) @@ -22,7 +21,7 @@ bcrypt==4.1.3 # privatim beaker==1.13.0 # via pyramid-beaker -certifi==2024.2.2 +certifi==2024.6.2 # via # requests # sentry-sdk @@ -36,7 +35,7 @@ click==8.1.7 # privatim dnspython==2.6.1 # via email-validator -email-validator==2.1.1 +email-validator==2.1.2 # via # privatim (setup.cfg) # privatim @@ -60,7 +59,7 @@ idna==3.7 # via # email-validator # requests -mako==1.3.3 +mako==1.3.5 # via # alembic # pyramid-mako @@ -79,7 +78,7 @@ nh3==0.2.17 # via # privatim (setup.cfg) # privatim -packaging==24.0 +packaging==24.1 # via zope-sqlalchemy pastedeploy==3.1.0 # via plaster-pastedeploy @@ -102,6 +101,10 @@ psycopg2==2.9.9 # privatim pygments==2.18.0 # via pyramid-debugtoolbar +pypdf==4.2.0 + # via + # privatim (setup.cfg) + # privatim pyramid==2.0.2 # via # privatim (setup.cfg) @@ -147,13 +150,13 @@ python-magic==0.4.27 # privatim pytz==2024.1 # via sedate -requests==2.31.0 +requests==2.32.3 # via apache-libcloud -sedate==1.0.3.post1 +sedate==1.1.0 # via # privatim (setup.cfg) # privatim -sentry-sdk==2.1.1 +sentry-sdk==2.5.1 # via # privatim (setup.cfg) # privatim @@ -185,13 +188,14 @@ translationstring==1.4 # via pyramid types-python-dateutil==2.9.0.20240316 # via arrow -typing-extensions==4.11.0 +typing-extensions==4.12.2 # via # privatim (setup.cfg) # alembic # privatim + # pypdf # sqlalchemy -urllib3==2.2.1 +urllib3==2.2.2 # via # requests # sentry-sdk @@ -225,7 +229,7 @@ zope-event==5.0 # privatim (setup.cfg) # privatim # zope-schema -zope-interface==6.3 +zope-interface==6.4.post2 # via # privatim (setup.cfg) # privatim diff --git a/setup.cfg b/setup.cfg index 8297bf2..567a35f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,6 +42,7 @@ install_requires = pyramid_tm pyramid_retry python-magic + pypdf Pillow sentry_sdk sedate @@ -53,6 +54,7 @@ install_requires = typing_extensions WebOb waitress + WeasyPrint WTForms werkzeug plaster_pastedeploy @@ -116,6 +118,7 @@ test = bandit[toml] flake8 flake8-bugbear + freezegun hypothesis pytest pytest-cov diff --git a/src/privatim/__init__.py b/src/privatim/__init__.py index a3b6d00..5dc09f2 100644 --- a/src/privatim/__init__.py +++ b/src/privatim/__init__.py @@ -1,4 +1,6 @@ +from functools import partial from fanstatic import Fanstatic +from privatim.layouts.action_menu import ActionMenuEntry from pyramid.config import Configurator from pyramid_beaker import session_factory_from_settings from sqlalchemy import Table, MetaData, Column, ForeignKey @@ -25,6 +27,7 @@ if TYPE_CHECKING: from _typeshed.wsgi import WSGIApplication from privatim.cli.upgrade import UpgradeContext + from pyramid.interfaces import IRequest def includeme(config: Configurator) -> None: @@ -68,6 +71,24 @@ def includeme(config: Configurator) -> None: config.add_request_method(user_pic_url, 'user_pic', property=True) config.add_request_method(MessageQueue, 'messages', reify=True) + def add_action_menu_entry( + request: 'IRequest', + title: str, + url: str, + ) -> None: + """ The entries are temporarily stored on the request object. They are + then retrieved in action_menu.py + """ + if not hasattr(request, 'action_menu_entries'): + request.action_menu_entries = [] + request.action_menu_entries.append(ActionMenuEntry(title, url)) + + config.add_request_method( + lambda request: partial(add_action_menu_entry, request), + 'add_action_menu_entry', + reify=True + ) + def main( global_config: Any, **settings: Any diff --git a/src/privatim/cli/initialize_db.py b/src/privatim/cli/initialize_db.py index d1a68f5..141f4fc 100644 --- a/src/privatim/cli/initialize_db.py +++ b/src/privatim/cli/initialize_db.py @@ -95,9 +95,8 @@ def add_example_content( status = Status(name='In Überprüfung') tags = [Tag(name=n) for n in ['AG', 'ZH']] here = Path(__file__).parent - pdfname = ('sample-pdf-for-initialize-db/privatim_Vernehmlassung_VEMZ' - '.pdf') - pdf = here / pdfname + pdfname = 'privatim_Vernehmlassung_VEMZ.pdf' + pdf = here / 'sample-pdf-for-initialize-db/' / pdfname content = pdf.read_bytes() consultation = Consultation( documents=[ConsultationDocument(name=pdfname, content=content)], diff --git a/src/privatim/layouts/__init__.py b/src/privatim/layouts/__init__.py index 021428b..dcdfc59 100644 --- a/src/privatim/layouts/__init__.py +++ b/src/privatim/layouts/__init__.py @@ -1,5 +1,7 @@ from typing import TYPE_CHECKING +from privatim.layouts.action_menu import action_menu + from .flash import flash from .layout import Layout from .navbar import navbar @@ -33,3 +35,9 @@ def includeme(config: 'Configurator') -> None: name='footer', renderer='footer.pt' ) + + config.add_panel( + panel=action_menu, + name='action_menu', + renderer='action_menu.pt' + ) diff --git a/src/privatim/layouts/action_menu.pt b/src/privatim/layouts/action_menu.pt new file mode 100644 index 0000000..aad30c5 --- /dev/null +++ b/src/privatim/layouts/action_menu.pt @@ -0,0 +1,18 @@ +
+ +
diff --git a/src/privatim/layouts/action_menu.py b/src/privatim/layouts/action_menu.py new file mode 100644 index 0000000..cb7cf3c --- /dev/null +++ b/src/privatim/layouts/action_menu.py @@ -0,0 +1,27 @@ +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from pyramid.interfaces import IRequest + from privatim.types import RenderData + + +class ActionMenuEntry: + def __init__(self, title: str, url: str): + self.title = title + self.url = url + + def __call__(self) -> str: + return f'{self.title}' + + def __str__(self) -> str: + return self.__call__() + + def __html__(self) -> str: + return self.__call__() + + def __repr__(self) -> str: + return f'' + + +def action_menu(context: object, request: 'IRequest') -> 'RenderData': + action_menu_entries = getattr(request, 'action_menu_entries', []) + return {'action_menu_entries': action_menu_entries} diff --git a/src/privatim/locale/de/LC_MESSAGES/privatim.mo b/src/privatim/locale/de/LC_MESSAGES/privatim.mo index 6c0bdcf..b49734d 100644 Binary files a/src/privatim/locale/de/LC_MESSAGES/privatim.mo and b/src/privatim/locale/de/LC_MESSAGES/privatim.mo differ diff --git a/src/privatim/locale/de/LC_MESSAGES/privatim.po b/src/privatim/locale/de/LC_MESSAGES/privatim.po index cd77888..ee88425 100644 --- a/src/privatim/locale/de/LC_MESSAGES/privatim.po +++ b/src/privatim/locale/de/LC_MESSAGES/privatim.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2024-06-17 23:23+0200\n" +"POT-Creation-Date: 2024-06-19 22:52+0200\n" "PO-Revision-Date: 2024-05-21 21:20+0200\n" "Last-Translator: cyrill \n" "Language-Team: German \n" @@ -41,6 +41,10 @@ msgstr "Kontakt" msgid "Close" msgstr "Schliessen" +#: src/privatim/layouts/action_menu.pt +msgid "Actions" +msgstr "Aktionen" + #: src/privatim/layouts/macros.pt msgid "Message" msgstr "Nachricht" @@ -98,6 +102,10 @@ msgstr "Profilbild erfolgreich geändert" msgid "Successfully deleted file \"${title}\"" msgstr "Datei \"${title}\" erfolgreich gelöscht" +#: src/privatim/views/meetings.py +msgid "Export meeting protocol" +msgstr "Sitzungsprotokoll exportieren" + #: src/privatim/views/meetings.py src/privatim/forms/agenda_item_form.py msgid "Edit Agenda Item" msgstr "Traktandum bearbeiten" @@ -226,10 +234,12 @@ msgid "Save" msgstr "Speichern" #: src/privatim/views/templates/meeting.pt +#: src/privatim/reporting/template/report.pt msgid "Date / Time:" msgstr "Datum / Zeit:" #: src/privatim/views/templates/meeting.pt +#: src/privatim/reporting/template/report.pt msgid "Attendees:" msgstr "Teilnehmende:" @@ -239,6 +249,7 @@ msgid "Working Group:" msgstr "Gremium:" #: src/privatim/views/templates/meeting.pt +#: src/privatim/reporting/template/report.pt msgid "Agenda Items" msgstr "Traktanden" @@ -248,19 +259,20 @@ msgid "Add Agenda Item" msgstr "Traktandum hinzufügen" #: src/privatim/views/templates/working_group.pt +#: src/privatim/reporting/template/report.pt msgid "Working Group" msgstr "Gremium" -#: src/privatim/views/templates/working_group.pt -#: src/privatim/forms/meeting_form.py -msgid "Add Meeting" -msgstr "Sitzung hinzufügen" - #: src/privatim/views/templates/working_group.pt #: src/privatim/forms/working_group_forms.py msgid "Leader" msgstr "Leiter" +#: src/privatim/views/templates/working_group.pt +#: src/privatim/forms/meeting_form.py +msgid "Add Meeting" +msgstr "Sitzung hinzufügen" + #: src/privatim/views/templates/working_group.pt msgid "" "Here you can add meetings in the context of a working group. Click \"Add " @@ -578,5 +590,9 @@ msgstr "Weitere Dokumente hochladen" msgid "Select..." msgstr "Auswählen" +#: src/privatim/reporting/template/report.pt +msgid "Protocol of meeting ${document.title}" +msgstr "Protokoll der Sitzung ${document.title}" + #~ msgid "Do you really wish to delete \"${item_title}\"?" #~ msgstr "Möchten Sie \"${item_title}\" wirklich löschen?" diff --git a/src/privatim/locale/fr/LC_MESSAGES/privatim.mo b/src/privatim/locale/fr/LC_MESSAGES/privatim.mo index 44f7c90..64dc0ba 100644 Binary files a/src/privatim/locale/fr/LC_MESSAGES/privatim.mo and b/src/privatim/locale/fr/LC_MESSAGES/privatim.mo differ diff --git a/src/privatim/locale/fr/LC_MESSAGES/privatim.po b/src/privatim/locale/fr/LC_MESSAGES/privatim.po index dbdabb8..0303676 100644 --- a/src/privatim/locale/fr/LC_MESSAGES/privatim.po +++ b/src/privatim/locale/fr/LC_MESSAGES/privatim.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2024-06-17 23:23+0200\n" +"POT-Creation-Date: 2024-06-19 22:52+0200\n" "PO-Revision-Date: 2024-04-11 15:53+0200\n" "Last-Translator: cyrill \n" "Language-Team: French \n" @@ -38,6 +38,10 @@ msgstr "Contact" msgid "Close" msgstr "fermer" +#: src/privatim/layouts/action_menu.pt +msgid "Actions" +msgstr "Actions" + #: src/privatim/layouts/macros.pt msgid "Message" msgstr "Message" @@ -95,6 +99,10 @@ msgstr "Image de profil mise à jour avec succès" msgid "Successfully deleted file \"${title}\"" msgstr "Fichier supprimé avec succès \"${titre}\"" +#: src/privatim/views/meetings.py +msgid "Export meeting protocol" +msgstr "Exporter le protocole de la réunion" + #: src/privatim/views/meetings.py src/privatim/forms/agenda_item_form.py msgid "Edit Agenda Item" msgstr "Traiter un point de l'ordre du jour" @@ -225,10 +233,12 @@ msgid "Save" msgstr "Sauver" #: src/privatim/views/templates/meeting.pt +#: src/privatim/reporting/template/report.pt msgid "Date / Time:" msgstr "Date / Heure:" #: src/privatim/views/templates/meeting.pt +#: src/privatim/reporting/template/report.pt msgid "Attendees:" msgstr "Participants:" @@ -238,6 +248,7 @@ msgid "Working Group:" msgstr "Comité:" #: src/privatim/views/templates/meeting.pt +#: src/privatim/reporting/template/report.pt msgid "Agenda Items" msgstr "ordres du jour" @@ -247,19 +258,20 @@ msgid "Add Agenda Item" msgstr "Ajouter un élément de l'ordre du jour" #: src/privatim/views/templates/working_group.pt +#: src/privatim/reporting/template/report.pt msgid "Working Group" msgstr "Comité" -#: src/privatim/views/templates/working_group.pt -#: src/privatim/forms/meeting_form.py -msgid "Add Meeting" -msgstr "Ajouter une session" - #: src/privatim/views/templates/working_group.pt #: src/privatim/forms/working_group_forms.py msgid "Leader" msgstr "Chef de file" +#: src/privatim/views/templates/working_group.pt +#: src/privatim/forms/meeting_form.py +msgid "Add Meeting" +msgstr "Ajouter une session" + #: src/privatim/views/templates/working_group.pt msgid "" "Here you can add meetings in the context of a working group. Click \"Add " @@ -576,5 +588,9 @@ msgstr "Télécharger des fichiers supplémentaires" msgid "Select..." msgstr "Sélectionner..." +#: src/privatim/reporting/template/report.pt +msgid "Protocol of meeting ${document.title}" +msgstr "Procès-verbal de la réunion ${document.title}" + #~ msgid "Do you really wish to delete \"${item_title}\"?" #~ msgstr "Souhaitez-vous vraiment supprimer \"${item_title}\" ?" diff --git a/src/privatim/locale/privatim.pot b/src/privatim/locale/privatim.pot index 666c04d..c895443 100644 --- a/src/privatim/locale/privatim.pot +++ b/src/privatim/locale/privatim.pot @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2024-06-17 23:23+0200\n" +"POT-Creation-Date: 2024-06-19 22:52+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -38,6 +38,10 @@ msgstr "" msgid "Close" msgstr "" +#: ./src/privatim/layouts/action_menu.pt +msgid "Actions" +msgstr "" + #: ./src/privatim/layouts/macros.pt msgid "Message" msgstr "" @@ -95,6 +99,10 @@ msgstr "" msgid "Successfully deleted file \"${title}\"" msgstr "" +#: ./src/privatim/views/meetings.py +msgid "Export meeting protocol" +msgstr "" + #: ./src/privatim/views/meetings.py ./src/privatim/forms/agenda_item_form.py msgid "Edit Agenda Item" msgstr "" @@ -217,10 +225,12 @@ msgid "Save" msgstr "" #: ./src/privatim/views/templates/meeting.pt +#: ./src/privatim/reporting/template/report.pt msgid "Date / Time:" msgstr "" #: ./src/privatim/views/templates/meeting.pt +#: ./src/privatim/reporting/template/report.pt msgid "Attendees:" msgstr "" @@ -230,6 +240,7 @@ msgid "Working Group:" msgstr "" #: ./src/privatim/views/templates/meeting.pt +#: ./src/privatim/reporting/template/report.pt msgid "Agenda Items" msgstr "" @@ -239,17 +250,18 @@ msgid "Add Agenda Item" msgstr "" #: ./src/privatim/views/templates/working_group.pt +#: ./src/privatim/reporting/template/report.pt msgid "Working Group" msgstr "" #: ./src/privatim/views/templates/working_group.pt -#: ./src/privatim/forms/meeting_form.py -msgid "Add Meeting" +#: ./src/privatim/forms/working_group_forms.py +msgid "Leader" msgstr "" #: ./src/privatim/views/templates/working_group.pt -#: ./src/privatim/forms/working_group_forms.py -msgid "Leader" +#: ./src/privatim/forms/meeting_form.py +msgid "Add Meeting" msgstr "" #: ./src/privatim/views/templates/working_group.pt @@ -565,3 +577,7 @@ msgstr "" #: ./src/privatim/forms/fields/fields.py msgid "Select..." msgstr "" + +#: ./src/privatim/reporting/template/report.pt +msgid "Protocol of meeting ${document.title}" +msgstr "" diff --git a/src/privatim/reporting/__init__.py b/src/privatim/reporting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/privatim/reporting/report.py b/src/privatim/reporting/report.py new file mode 100644 index 0000000..5fd2c37 --- /dev/null +++ b/src/privatim/reporting/report.py @@ -0,0 +1,152 @@ +from dataclasses import dataclass +from datetime import datetime +from io import BytesIO +from pathlib import Path + +from babel.dates import format_datetime + +from privatim.layouts.layout import DEFAULT_TIMEZONE, Layout +from privatim.utils import datetime_format +from pyramid.renderers import render +from weasyprint import HTML # type: ignore + + +from typing import TYPE_CHECKING, Protocol +if TYPE_CHECKING: + from privatim.models import Meeting + from pytz import BaseTzInfo + from pyramid.interfaces import IRequest + + +@dataclass +class ReportOptions: + + language: str = 'de' + """ Translate report into language. """ + + tz: 'BaseTzInfo' = DEFAULT_TIMEZONE + """ Use timezone for all timestamps. """ + + +@dataclass +class PDFDocument: + + data: bytes + """ Raw PDF content. """ + + filename: str + """ Document's filename when stored or downloaded. """ + + +class ReportRenderer(Protocol): + + def render( + self, + meeting: 'Meeting', + timestamp: str, + request: 'IRequest' + ) -> bytes: + ... + + +class MeetingReport: + + def __init__( + self, + request: 'IRequest', + meeting: 'Meeting', + options: ReportOptions, + renderer: ReportRenderer + ) -> None: + self.request = request + self.meeting = meeting + self.renderer = renderer + self.issued_at = datetime.utcnow() + self.options = options + + @property + def created_at(self) -> str: + """ Returns formatted report date. """ + return format_datetime( + self.issued_at, + format='short', + locale=self.options.language, + tzinfo=self.options.tz + ).replace('\u202f', ' ') + + @property + def filename(self) -> str: + datestr = format_datetime( + self.issued_at, + format='YYMMdd', + locale=self.options.language, + tzinfo=self.options.tz + ) + return f'{datestr}_{self.meeting.name}.pdf' + + def build(self) -> PDFDocument: + """ Render report as PDF. """ + + pdf = self.renderer.render( + self.meeting, + self.created_at, + self.request + ) + + return PDFDocument(pdf, self.filename) + + +class HTMLReportRenderer: + """ + Render meeting report with WeasyPrint, using HTML and CSS. + + You can turn on logging for weasyprint to debug issues: + + >>> import logging, sys + >>> logger = logging.getLogger('weasyprint') + >>> logger.setLevel(logging.DEBUG) + >>> logger.addHandler(logging.StreamHandler(sys.stdout)) + + """ + + css = 'privatim/reporting/template/report.css' + template = 'privatim:reporting/template/report.pt' + macros = 'privatim:reporting/template/macros.pt' + + def render( + self, + meeting: 'Meeting', + timestamp: str, + request: 'IRequest' + ) -> bytes: + html = self.render_template(meeting, timestamp, request) + return self.render_pdf(html) + + def render_template( + self, + meeting: 'Meeting', + timestamp: str, + request: 'IRequest' + ) -> str: + """Render chameleon report template.""" + + document_context = {'title': meeting.name, 'created_at': timestamp} + ctx = { + 'meeting': meeting, + 'meeting_time': datetime_format(meeting.time), + 'document': document_context, + 'layout': Layout(meeting, request) + } + return render(self.template, ctx) + + def render_pdf(self, html: str) -> bytes: + """ + Render processed chameleon template as PDF. + """ + + # todo: use resource_base_url + resource_base_url = Path.cwd() / 'privatim' / 'reporting' + + buffer = BytesIO() + HTML(string=html, base_url=str(resource_base_url)).write_pdf(buffer) + return buffer.getvalue() diff --git a/src/privatim/reporting/template/report.pt b/src/privatim/reporting/template/report.pt new file mode 100644 index 0000000..131089c --- /dev/null +++ b/src/privatim/reporting/template/report.pt @@ -0,0 +1,63 @@ + + + + + + + ${document.title} + + + + + + + + + + + + + +
+
+ +

Protocol of meeting ${document.title}

+ +

+ Date / Time: ${meeting_time} +

+

+ Working Group ${meeting.working_group.name} +

+

+ Attendees: +

+ +
    +
  • + ${user.first_name} ${user.last_name} + ${user.email} +
  • +
+ +

Agenda Items

+
    +
  1. +

    ${item.title}

    +

    ${item.description}

    +
  2. +
+ +
+
+ + + \ No newline at end of file diff --git a/src/privatim/static/css/custom.css b/src/privatim/static/css/custom.css index a22efd7..44cc040 100644 --- a/src/privatim/static/css/custom.css +++ b/src/privatim/static/css/custom.css @@ -86,6 +86,7 @@ :root { --primary-color: #FF545F; /* privatim's primary color used globally*/ --hover-color: #e64c56; /* hover = 10% darker */ + --secondary-hover-color: #e9ecef; } .btn-primary { @@ -103,6 +104,17 @@ a { color: var(--primary-color); } + +.btn-secondary { + background: white; + color: var(--primary-color); + border-color: var(--primary-color); +} +.btn-secondary:hover { + background: var(--secondary-hover-color); + color: white; +} + a:hover { color: var(--hover-color) !important; text-decoration: none; @@ -216,9 +228,11 @@ main { display: flex; align-items: center; } + .fas { margin-right: 10px; /* Add space between the icon and the text */ } + .icon-container { display: flex; /* Makes the file icon be on the same height as the text */ align-items: center; /* Ensure the icon is centered vertically */ diff --git a/src/privatim/static/css/report.css b/src/privatim/static/css/report.css new file mode 100644 index 0000000..9c3e70d --- /dev/null +++ b/src/privatim/static/css/report.css @@ -0,0 +1,103 @@ +/* + * CSS stylesheet for the printable report. + * + * Uses the CSS Paged Media Module (https://www.w3.org/TR/css-page-3/). This + * module allows to design documents for a paged presentation (unlike the web, + * where the content is mostly continuous). Most regular CSS directives apply + * here too, but there are some extra directives. + * + * In addition, Weasyprint might not (yet) support all Paged Media Module + * extensions. A good overview can be found here: https://print-css.rocks/lessons + * + * For further reading and reference: + * - Live Preview: https://printcss.live/ + * - Header and footers: https://css4.pub/2022/running-headers/ + * - CSS at-rules: https://www.qhmit.com/css/at-rules/ + */ + +/* dm-sans-regular - latin-ext_latin */ +@font-face { + font-family: 'DM Sans'; + font-style: normal; + font-weight: 400; + src: local(''), + url(../fonts/dm-sans-v6-latin-ext_latin-regular.woff2) format('woff2'), + url(../fonts/dm-sans-v6-latin-ext_latin-regular.woff) format('woff'); +} + +/* dm-sans-italic - latin-ext_latin */ +@font-face { + font-family: 'DM Sans'; + font-style: italic; + font-weight: 400; + src: local(''), + url(../fonts/dm-sans-v6-latin-ext_latin-italic.woff2) format('woff2'), + url(../fonts/dm-sans-v6-latin-ext_latin-italic.woff) format('woff'); +} + +/* dm-sans-500 - latin-ext_latin */ +@font-face { + font-family: 'DM Sans'; + font-style: normal; + font-weight: 500; + src: local(''), + url(../fonts/dm-sans-v6-latin-ext_latin-500.woff2) format('woff2'), + url(../fonts/dm-sans-v6-latin-ext_latin-500.woff) format('woff'); +} + +/* Global page settings. */ +@page { + size: A4; + + /* Configure inner margins */ + margin: 2cm; + + /* Font for the headers and footers. */ + font-family: DM Sans; + + /* Setup header and footer */ + @top-left { + /* NOTE: We use a running element, i.e., taking an element from the page + * and display it in the header. Alternatively, you can set the logo as + * the background-image and scale it down, however, we have experienced + * image quality loss when doing that. + */ + content: element(header); + } + + @top-center { + font-weight: bold; + font-size: 16pt; + content: string(title); + } + + @top-right { + font-size: 10pt; + content: string(doc-creation-date); + } + + @bottom-right { + font-size: 10pt; + content: "Seite " counter(page) "/" counter(pages); + } +} + + +/* || CSS resets and global styles */ +* { + /*padding: 0;*/ + /*margin: 0;*/ +} + +img { + min-width: 5cm; + max-width: 5cm; + max-height: 3.5cm; + +} + +body { + font-family: DM Sans; + font-size: 10pt; +} + diff --git a/src/privatim/types.py b/src/privatim/types.py index e5df569..c1019a9 100644 --- a/src/privatim/types.py +++ b/src/privatim/types.py @@ -4,7 +4,12 @@ from decimal import Decimal from fractions import Fraction - from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPError + from pyramid.httpexceptions import ( + HTTPFound, + HTTPForbidden, + HTTPError, + HTTPNotFound + ) from pyramid.interfaces import IResponse, IRequest from typing import Any, Literal, TypeVar, Protocol @@ -40,10 +45,12 @@ RenderData: TypeAlias = dict[str, Any] RenderDataOrRedirect: TypeAlias = RenderData | HTTPFound + RenderDataOrNotFound: TypeAlias = RenderData | HTTPNotFound RenderDataOrRedirectOrForbidden: TypeAlias = ( RenderData | HTTPFound | HTTPForbidden | HTTPError ) RenderDataOrResponse: TypeAlias = RenderData | IResponse + ResponseOrNotFound: TypeAlias = IResponse | HTTPNotFound # NOTE: For now we only allow complex return types if we return JSON # If you want to return a scalar type as JSON you need to be diff --git a/src/privatim/utils.py b/src/privatim/utils.py index f4b3ffa..e735bf9 100644 --- a/src/privatim/utils.py +++ b/src/privatim/utils.py @@ -4,9 +4,12 @@ from PIL import Image import magic from io import BytesIO +from pytz import timezone, BaseTzInfo from sedate import to_timezone from markupsafe import escape +from privatim.layouts.layout import DEFAULT_TIMEZONE + from typing import Any, TYPE_CHECKING, overload if TYPE_CHECKING: @@ -16,6 +19,19 @@ from privatim.models.commentable import Comment +def datetime_format( + dt: 'datetime', + format: str = '%d.%m.%y %H:%M', + tz: BaseTzInfo = DEFAULT_TIMEZONE +) -> str: + + if not dt.tzinfo: + # If passed datetime does not carry any timezone information, we + # assume (and force) it to be UTC, as all timestamps should be. + dt = timezone('UTC').localize(dt) + return dt.astimezone(tz).strftime(format) + + def first(iterable: 'Iterable[Any] | None', default: Any | None = None) -> Any: """ Returns first item in given iterable or a default value. diff --git a/src/privatim/views/__init__.py b/src/privatim/views/__init__.py index fe7c2b5..6cb5e8d 100644 --- a/src/privatim/views/__init__.py +++ b/src/privatim/views/__init__.py @@ -24,7 +24,8 @@ from privatim.views.home import home_view from privatim.views.login import login_view from privatim.views.logout import logout_view -from privatim.views.meetings import add_meeting_view +from privatim.views.meetings import add_meeting_view, \ + export_meeting_as_pdf_view from privatim.views.meetings import delete_meeting_view from privatim.views.meetings import edit_meeting_view from privatim.views.meetings import meeting_view @@ -219,6 +220,17 @@ def includeme(config: 'Configurator') -> None: request_method='DELETE', xhr=True ) + # download the meeting report + config.add_route( + 'export_meeting_as_pdf_view', + '/meetings/{id}/export', + factory=meeting_factory + ) + config.add_view( + export_meeting_as_pdf_view, + route_name='export_meeting_as_pdf_view', + request_method='GET', + ) # Agenda items diff --git a/src/privatim/views/meetings.py b/src/privatim/views/meetings.py index e6b5b66..72ef8eb 100644 --- a/src/privatim/views/meetings.py +++ b/src/privatim/views/meetings.py @@ -1,12 +1,15 @@ -from pytz import timezone from markupsafe import Markup, escape -from pyramid.httpexceptions import HTTPFound - +from pyramid.httpexceptions import HTTPFound, HTTPNotFound +from pyramid.response import Response +from privatim.reporting.report import ( + MeetingReport, + ReportOptions, + HTMLReportRenderer, +) from privatim.controls.controls import Button, Icon, IconStyle -from privatim.utils import maybe_escape +from privatim.utils import maybe_escape, datetime_format from sqlalchemy import select -from privatim.layouts.layout import DEFAULT_TIMEZONE from privatim.utils import fix_utc_to_local_time from privatim.forms.meeting_form import MeetingForm from privatim.models import Meeting, User, WorkingGroup @@ -19,25 +22,10 @@ from pyramid.interfaces import IRequest from sqlalchemy.orm import Query from privatim.types import RenderData, XHRDataOrRedirect - from datetime import datetime - from pytz import BaseTzInfo _Q = TypeVar("_Q", bound=Query[Any]) from privatim.types import MixedDataOrRedirect -def datetime_format( - dt: 'datetime', - format: str = '%d.%m.%y %H:%M', - tz: 'BaseTzInfo' = DEFAULT_TIMEZONE -) -> str: - - if not dt.tzinfo: - # If passed datetime does not carry any timezone information, we - # assume (and force) it to be UTC, as all timestamps should be. - dt = timezone('UTC').localize(dt) - return dt.astimezone(tz).strftime(format) - - def meeting_view( context: Meeting, request: 'IRequest' @@ -47,6 +35,11 @@ def meeting_view( assert isinstance(context, Meeting) formatted_time = datetime_format(context.time) + request.add_action_menu_entry( + translate(_('Export meeting protocol')), + request.route_url('export_meeting_as_pdf_view', id=context.id), + ) + items = [] for item in context.agenda_items: items.append( @@ -113,8 +106,7 @@ def get_generic_user_list( users: Sequence[User], title: str ) -> Markup: - """Returns an HTML list of users with links to their profiles within a - container.""" + """Returns an HTML list of users with links to their profiles """ user_items = tuple( Markup( '
  • {} ' @@ -139,6 +131,25 @@ def get_generic_user_list( ).format(title, Markup('').join(user_items)) +def export_meeting_as_pdf_view( + context: Meeting, request: 'IRequest', +) -> Response: + + session = request.dbsession + meeting_id = context.id + meeting = session.get(Meeting, meeting_id) + + if meeting is None: + return HTTPNotFound() + renderer = HTMLReportRenderer() + options = ReportOptions(language=request.locale_name) + report = MeetingReport(request, meeting, options, renderer).build() + response = Response(report.data) + response.content_type = 'application/pdf' + response.content_disposition = f'inline;filename={report.filename}' + return response + + def meetings_view( context: WorkingGroup, request: 'IRequest' diff --git a/src/privatim/views/templates/meeting.pt b/src/privatim/views/templates/meeting.pt index 1a10293..882e6b0 100644 --- a/src/privatim/views/templates/meeting.pt +++ b/src/privatim/views/templates/meeting.pt @@ -1,64 +1,71 @@ + i18n:domain="privatim">
    -
    -
    -
    -

    Sitzung ${meeting.name}

    -
    - -
    -
    -

    - Date / Time: ${time} -

    -

    - Attendees: -

      -
    • - ${user.first_name} ${user.last_name} - ${user.email} -
    • -
    -

    -

    - Working Group: ${meeting.working_group.name} -

    -
    + +
    +
    +

    Sitzung ${meeting.name}

    +
    + +
    + ${panel('action_menu')} +
    + + +
    +
    +

    + Date / Time: ${time} +

    +

    + Attendees: +

      +
    • + ${user.first_name} ${user.last_name} + ${user.email} +
    • +
    +

    +

    + Working Group: ${meeting.working_group.name} +

    +
    -
    +
    -

    Agenda Items

    +

    Agenda Items

    -
    -
    -

    - -

    -
    -
    + + +
    +
    ${item.description} -
    +
    - - +
    +
    diff --git a/tests/reporting/__init__.py b/tests/reporting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/reporting/test_report.py b/tests/reporting/test_report.py new file mode 100644 index 0000000..0387f26 --- /dev/null +++ b/tests/reporting/test_report.py @@ -0,0 +1,37 @@ +from io import BytesIO +import pypdf +from privatim.reporting.report import ( + MeetingReport, + ReportOptions, + HTMLReportRenderer, +) +from weasyprint import HTML, CSS +from weasyprint.text.fonts import FontConfiguration + +from shared.utils import create_meeting + + +def test_simple_report(): + + font_config = FontConfiguration() + html = HTML(string='

    The title

    ') + css = CSS(string=''' + @font-face { + font-family: Gentium; + src: url(https://example.com/fonts/Gentium.otf); + } + h1 { font-family: Gentium }''', font_config=font_config) + html.write_pdf( + 'example.pdf', stylesheets=[css], + font_config=font_config) + + +def test_generate_meeting_report(config): + meeting = create_meeting() + + renderer = HTMLReportRenderer() + report = MeetingReport(meeting, ReportOptions(language='de'), renderer) + pdf = report.build() + document = pypdf.PdfReader(BytesIO(pdf.data)) + + assert len(document.pages) > 0 diff --git a/tests_requirements.txt b/tests_requirements.txt deleted file mode 100644 index 9c6977c..0000000 --- a/tests_requirements.txt +++ /dev/null @@ -1,292 +0,0 @@ -# This file was autogenerated by uv via the following command: -# uv pip compile setup.cfg --extra testing --extra typing -o tests_requirements.txt -c requirements.txt -alembic==1.13.1 - # via - # -c requirements.txt - # privatim (setup.cfg) -apache-libcloud==3.8.0 - # via - # -c requirements.txt - # sqlalchemy-file -arrow==1.3.0 - # via - # -c requirements.txt - # privatim (setup.cfg) -babel==2.15.0 - # via - # -c requirements.txt - # privatim (setup.cfg) -bcrypt==4.1.3 - # via - # -c requirements.txt - # privatim (setup.cfg) -beaker==1.13.0 - # via - # -c requirements.txt - # pyramid-beaker -certifi==2024.2.2 - # via - # -c requirements.txt - # requests - # sentry-sdk -chameleon==4.5.4 - # via - # -c requirements.txt - # pyramid-chameleon -charset-normalizer==3.3.2 - # via - # -c requirements.txt - # requests -click==8.1.7 - # via - # -c requirements.txt - # privatim (setup.cfg) -dnspython==2.6.1 - # via - # -c requirements.txt - # email-validator -email-validator==2.1.1 - # via - # -c requirements.txt - # privatim (setup.cfg) -fanstatic==1.4 - # via - # -c requirements.txt - # privatim (setup.cfg) -fasteners==0.19 - # via - # -c requirements.txt - # privatim (setup.cfg) -greenlet==3.0.3 - # via - # -c requirements.txt - # sqlalchemy -humanize==4.9.0 - # via - # -c requirements.txt - # privatim (setup.cfg) -hupper==1.12.1 - # via - # -c requirements.txt - # pyramid -idna==3.7 - # via - # -c requirements.txt - # email-validator - # requests -mako==1.3.3 - # via - # -c requirements.txt - # alembic - # pyramid-mako -markdown==3.6 - # via - # -c requirements.txt - # privatim (setup.cfg) -markupsafe==2.1.5 - # via - # -c requirements.txt - # privatim (setup.cfg) - # mako - # werkzeug - # wtforms -nh3==0.2.17 - # via - # -c requirements.txt - # privatim (setup.cfg) -packaging==24.0 - # via - # -c requirements.txt - # zope-sqlalchemy -pastedeploy==3.1.0 - # via - # -c requirements.txt - # plaster-pastedeploy -pillow==10.3.0 - # via - # -c requirements.txt - # privatim (setup.cfg) -plaster==1.1.2 - # via - # -c requirements.txt - # plaster-pastedeploy - # pyramid -plaster-pastedeploy==1.0.1 - # via - # -c requirements.txt - # privatim (setup.cfg) - # pyramid -psycopg2==2.9.9 - # via - # -c requirements.txt - # privatim (setup.cfg) -pygments==2.18.0 - # via - # -c requirements.txt - # pyramid-debugtoolbar -pyramid==2.0.2 - # via - # -c requirements.txt - # privatim (setup.cfg) - # pyramid-beaker - # pyramid-chameleon - # pyramid-debugtoolbar - # pyramid-layout - # pyramid-mako - # pyramid-retry - # pyramid-tm -pyramid-beaker==0.9 - # via - # -c requirements.txt - # privatim (setup.cfg) -pyramid-chameleon==0.3 - # via - # -c requirements.txt - # privatim (setup.cfg) -pyramid-debugtoolbar==4.12.1 - # via - # -c requirements.txt - # privatim (setup.cfg) -pyramid-layout==1.0 - # via - # -c requirements.txt - # privatim (setup.cfg) -pyramid-mako==1.1.0 - # via - # -c requirements.txt - # pyramid-debugtoolbar -pyramid-retry==2.1.1 - # via - # -c requirements.txt - # privatim (setup.cfg) -pyramid-tm==2.5 - # via - # -c requirements.txt - # privatim (setup.cfg) -python-dateutil==2.9.0.post0 - # via - # -c requirements.txt - # arrow -python-magic==0.4.27 - # via - # -c requirements.txt - # privatim (setup.cfg) -pytz==2024.1 - # via - # -c requirements.txt - # sedate -requests==2.31.0 - # via - # -c requirements.txt - # apache-libcloud -sedate==1.0.3.post1 - # via - # -c requirements.txt - # privatim (setup.cfg) -sentry-sdk==2.1.1 - # via - # -c requirements.txt - # privatim (setup.cfg) -setuptools==69.5.1 - # via - # fanstatic - # pyramid - # zope-deprecation - # zope-event - # zope-interface - # zope-schema - # zope-sqlalchemy -six==1.16.0 - # via - # -c requirements.txt - # python-dateutil -sqlalchemy==2.0.30 - # via - # -c requirements.txt - # privatim (setup.cfg) - # alembic - # sqlalchemy-file - # sqlalchemy-utils - # zope-sqlalchemy -sqlalchemy-file==0.6.0 - # via - # -c requirements.txt - # privatim (setup.cfg) -sqlalchemy-utils==0.41.2 - # via - # -c requirements.txt - # privatim (setup.cfg) -transaction==4.0 - # via - # -c requirements.txt - # privatim (setup.cfg) - # pyramid-tm - # zope-sqlalchemy -translationstring==1.4 - # via - # -c requirements.txt - # pyramid -types-python-dateutil==2.9.0.20240316 - # via - # -c requirements.txt - # arrow -typing-extensions==4.11.0 - # via - # -c requirements.txt - # privatim (setup.cfg) - # alembic - # sqlalchemy -urllib3==2.2.1 - # via - # -c requirements.txt - # requests - # sentry-sdk -venusian==3.1.0 - # via - # -c requirements.txt - # pyramid -waitress==3.0.0 - # via - # -c requirements.txt - # privatim (setup.cfg) -webob==1.8.7 - # via - # -c requirements.txt - # privatim (setup.cfg) - # fanstatic - # pyramid -werkzeug==3.0.3 - # via - # -c requirements.txt - # privatim (setup.cfg) -wtforms==3.1.2 - # via - # -c requirements.txt - # privatim (setup.cfg) -zope-deprecation==5.0 - # via - # -c requirements.txt - # privatim (setup.cfg) - # pyramid -zope-event==5.0 - # via - # -c requirements.txt - # privatim (setup.cfg) - # zope-schema -zope-interface==6.3 - # via - # -c requirements.txt - # privatim (setup.cfg) - # pyramid - # pyramid-retry - # transaction - # zope-schema - # zope-sqlalchemy -zope-schema==7.0.1 - # via - # -c requirements.txt - # privatim (setup.cfg) -zope-sqlalchemy==3.1 - # via - # -c requirements.txt - # privatim (setup.cfg)