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
+
+ -
+
${item.title}
+ ${item.description}
+
+
+
+
+
+
+
+
\ 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
-
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)