Skip to content

Commit 48ecd12

Browse files
mamicoplone@umbar.hetzner.redturtle.itcekk
authored
add pdf features (#23)
* add pdf features * separatore csv * black * pdf descr * black * logo * typo * footer * dt * typo * test * remove file * ignore * rimosso supporto svg * fix pdf css * Preparing release 1.0.8 * Back to development: 1.0.9 --------- Co-authored-by: plone@umbar.hetzner.redturtle.it <plone@umbar.hetzner.redturtle.it> Co-authored-by: Andrea Cecchi <andrea.cecchi85@gmail.com>
1 parent 5c939ed commit 48ecd12

File tree

8 files changed

+402
-59
lines changed

8 files changed

+402
-59
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,5 @@ reports/
3939
venv/
4040
# excludes
4141
.python-version
42+
*.bak
43+
nohup.out

CHANGES.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ Changelog
22
=========
33

44

5-
1.0.8 (unreleased)
5+
1.0.9 (unreleased)
6+
------------------
7+
8+
- Nothing changed yet.
9+
10+
11+
1.0.8 (2025-08-29)
612
------------------
713

814
- Blocco search / variante table - aggiunte le proprietà dei campi nella colonna nel serializer

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
setup(
1818
name="iosanita.contenttypes",
19-
version="1.0.8.dev0",
19+
version="1.0.9.dev0",
2020
description="An add-on for Plone",
2121
long_description=long_description,
2222
# Get more from https://pypi.org/classifiers/

src/iosanita/contenttypes/browser/export_view.py

Lines changed: 114 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22
from io import BytesIO
33
from io import StringIO
44
from iosanita.contenttypes import _
5+
from PIL import Image
56
from plone import api
7+
from plone.memoize import forever
68
from Products.Five.browser import BrowserView
79
from weasyprint import HTML
8-
from zExceptions import BadRequest
10+
from zExceptions import NotFound
911
from zope.interface import implementer
1012
from zope.publisher.interfaces import IPublishTraverse
1113

14+
import base64
1215
import csv
16+
import imghdr
1317
import importlib.resources
1418
import logging
1519
import re
@@ -21,11 +25,58 @@
2125
fontools_logger.setLevel(logging.WARNING)
2226

2327

24-
CONTENT_TYPES_MAPPING = {
25-
"csv": "text/comma-separated-values",
26-
"pdf": "application/pdf",
27-
"html": "text/html",
28-
}
28+
@forever.memoize
29+
def image_to_html(input_string):
30+
"""
31+
Convert image data to a base64 string formatted for HTML.
32+
33+
Args:
34+
- input_string: The string containing the filename and base64 encoded image data.
35+
36+
Returns:
37+
- HTML.
38+
"""
39+
40+
if not input_string:
41+
return ""
42+
43+
# Split the input string to extract the filename and base64 data
44+
parts = input_string.split(";")
45+
datab64 = parts[1].split(":")[1]
46+
47+
# Decode the image data from base64
48+
image_data = base64.b64decode(datab64)
49+
50+
if image_data[:5] == b"<?xml":
51+
# https://github.com/Kozea/WeasyPrint/issues/75
52+
# anche se il ticket risulta chiuso gli svg non risultano correttamente gestiti
53+
# return image_data
54+
# return f'<img src="data:image/svg+xml;charset=utf-8;base64,{datab64}">'
55+
# XXX: se non si va decode/encode il b64 non risulta corretto (!)
56+
# return f'<img src="data:image/svg+xml;charset=utf-8;base64,{base64.b64encode(image_data).decode()}">'
57+
# weasyprint gli svg non li gestisce comunque correttamente
58+
return None
59+
60+
# Guess the image format
61+
image_format = imghdr.what(None, image_data)
62+
63+
if not image_format:
64+
# raise ValueError("Unable to determine image format")
65+
logger.warning("site logo, unable to determine image format")
66+
return ""
67+
68+
# Open the image from the decoded data
69+
img = Image.open(BytesIO(image_data))
70+
71+
# Create a buffer to hold the image data
72+
buffered = BytesIO()
73+
img.save(buffered, format=image_format)
74+
75+
# Encode the image data to base64
76+
img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
77+
78+
# Format the base64 string for HTML
79+
return f'<img class="logo" src="data:{image_format};base64,{img_base64}">'
2980

3081

3182
class IExportViewTraverser(IPublishTraverse):
@@ -50,6 +101,8 @@ class ExportViewDownload(BrowserView):
50101
51102
"""
52103

104+
with_footer = True
105+
53106
def __init__(self, context, request):
54107
super().__init__(context, request)
55108
self.export_type = "csv"
@@ -65,7 +118,7 @@ def publishTraverse(self, request, name):
65118
def __call__(self):
66119
""" """
67120
if self.export_type not in ["csv", "pdf", "html"]:
68-
raise BadRequest(
121+
raise NotFound(
69122
api.portal.translate(
70123
_(
71124
"invalid_export_type",
@@ -74,18 +127,21 @@ def __call__(self):
74127
)
75128
)
76129
)
77-
self.set_headers()
78130
data = self.get_data()
79-
if not data:
80-
return ""
81-
resp_data = ""
82131
if self.export_type == "csv":
83-
resp_data = self.get_csv(data)
132+
# default per locales di riferimento (perr l'encoding, al momento, lasciamo
133+
# il generico utf-8 con BOM che potrebbe funzionare per tutti,
134+
# MS Excel incluso)
135+
lang = api.portal.get_current_language(self.context)
136+
if lang == "it":
137+
sep = ";"
138+
else:
139+
sep = ","
140+
return self.get_csv(data, sep=sep)
84141
elif self.export_type == "pdf":
85-
resp_data = self.get_pdf(data)
142+
return self.get_pdf(data)
86143
elif self.export_type == "html":
87-
resp_data = self.get_html_for_pdf(data)
88-
return resp_data
144+
return self.get_html_for_pdf(data)
89145

90146
def get_filename(self):
91147
"""
@@ -94,37 +150,46 @@ def get_filename(self):
94150
now = datetime.now().strftime("%Y_%m_%d_%H_%M")
95151
return f"export_{now}.{self.export_type}"
96152

97-
def set_headers(self):
98-
"""
99-
Set the headers for the response.
100-
"""
101-
if self.export_type in ["pdf", "csv"]:
102-
self.request.response.setHeader(
103-
"Content-Disposition", f"attachment;filename={self.get_filename()}"
104-
)
105-
self.request.response.setHeader(
106-
"Content-Type", CONTENT_TYPES_MAPPING[self.export_type]
107-
)
108-
109-
def get_csv(self, data):
153+
def get_csv(self, data, encoding="utf-8-sig", sep=","):
110154
"""
111155
Generate CSV data from the provided data.
112156
"""
157+
# 1. Crea uno StringIO per il CSV
158+
csv_buffer = StringIO()
159+
# 2. Aggiungi l'header per il separatore (specifico per Excel)
160+
# In Libreoffice viene aggiunta una riga, per ora evitiamo
161+
# csv_buffer.write(f"sep={sep}\n")
162+
# 3. Scrittura dei dati CSV
113163
columns = self.get_columns(data)
114-
115-
csv_data = StringIO()
116-
csv_writer = csv.writer(csv_data, quoting=csv.QUOTE_ALL)
164+
csv_writer = csv.writer(csv_buffer, delimiter=sep, quoting=csv.QUOTE_ALL)
117165
csv_writer.writerow([c["title"] for c in columns])
118-
119166
for item in data:
120167
csv_writer.writerow(self.format_row(item))
121-
return csv_data.getvalue().encode("utf-8")
168+
# 4. Prepara i bytes con BOM (UTF-8-sig)
169+
csv_data = csv_buffer.getvalue()
170+
if encoding == "utf-8-sig":
171+
csv_bytes = b"\xef\xbb\xbf" + csv_data.encode("utf-8") # Aggiunge BOM
172+
else:
173+
csv_bytes = csv_data.encode(encoding)
174+
# 5. Crea la risposta con gli header corretti
175+
response = self.request.response
176+
response.setHeader(
177+
"Content-Disposition", f"attachment;filename={self.get_filename()}"
178+
)
179+
response.setHeader("Content-Type", f"text/csv; charset={encoding}")
180+
return csv_bytes
122181

123182
def get_pdf(self, data):
124183
html_str = self.get_html_for_pdf(data=data)
125184
pdf_file = BytesIO()
126185
HTML(string=html_str).write_pdf(pdf_file)
127186
pdf_file.seek(0)
187+
# 5. Crea la risposta con gli header corretti
188+
response = self.request.response
189+
response.setHeader(
190+
"Content-Disposition", f"attachment;filename={self.get_filename()}"
191+
)
192+
response.setHeader("Content-Type", "application/pdf")
128193
return pdf_file.read()
129194

130195
def get_data(self):
@@ -176,6 +241,7 @@ def get_html_for_pdf(self, data):
176241
context=self,
177242
request=self.request,
178243
)
244+
179245
return view(rows=data, columns=columns)
180246

181247
def pdf_styles(self):
@@ -184,7 +250,11 @@ def pdf_styles(self):
184250
)
185251

186252
def pdf_title(self):
187-
return None
253+
context = self.context.context
254+
site_title = api.portal.get_registry_record("plone.site_title")
255+
if site_title:
256+
return f"{site_title}: {context.Title()}"
257+
return context.Title()
188258

189259
def pdf_description(self):
190260
return None
@@ -203,3 +273,13 @@ def pdf_cell_format(self, column, value):
203273
if re.match(r"^\d{4}-\d{2}-\d{2}T00:00:00$", value):
204274
return {"type": "str", "value": value.split("T")[0]}
205275
return {"type": "str", "value": str(value)}
276+
277+
def pdf_logo(self):
278+
site_logo = api.portal.get_registry_record("plone.site_logo")
279+
if site_logo:
280+
return image_to_html(site_logo.decode())
281+
return None
282+
283+
def pdf_datetime(self):
284+
# TODO: valutare localizzazione della data
285+
return datetime.now().strftime("%d/%m/%Y %H:%M")

src/iosanita/contenttypes/browser/searchblock.py

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@
44
from .export_view import IExportViewTraverser
55
from copy import deepcopy
66
from iosanita.contenttypes import _
7+
from plone.app.querystring.interfaces import IQuerystringRegistryReader
8+
from plone.intelligenttext.transforms import convertWebIntelligentPlainTextToHtml
9+
from plone.memoize import view
10+
from plone.registry.interfaces import IRegistry
711
from plone.restapi.interfaces import ISerializeToJson
812
from zExceptions import BadRequest
913
from zExceptions import NotFound
1014
from zope.component import getMultiAdapter
15+
from zope.component import getUtility
1116
from zope.interface import implementer
1217

1318
import logging
@@ -86,11 +91,16 @@ def _query_from_facets(self):
8691
}
8792
)
8893
elif facet["type"] == "daterangeFacet":
94+
daterange = self.request.form[facet["field"]["value"]].split(",")
95+
if not daterange[0]:
96+
daterange[0] = "1970-01-01"
97+
if not daterange[1]:
98+
daterange[1] = "2500-01-01"
8999
query.append(
90100
{
91101
"i": facet["field"]["value"],
92102
"o": "plone.app.querystring.operation.date.between",
93-
"v": self.request.form[facet["field"]["value"]].split(","),
103+
"v": daterange,
94104
}
95105
)
96106
elif facet["type"] == "selectFacet" and not facet["multiple"]:
@@ -110,7 +120,7 @@ def _query_from_facets(self):
110120
}
111121
)
112122
else:
113-
logger.warning("DEBUG: filter %s not implemnted", facet)
123+
logger.warning("DEBUG: filter %s not implemented", facet)
114124
query.append(
115125
{
116126
"i": facet["field"]["value"],
@@ -128,7 +138,6 @@ def get_data(self):
128138
# 4. fare la ricerca
129139
# 5. fare export in csv/pdf a seconda del formato
130140
"""
131-
132141
# 2. Get columns, base filters and sorting
133142
columns = self.block_data.get("columns", [])
134143

@@ -185,13 +194,14 @@ def get_data(self):
185194
# XXX: consideriamo però che senza usare il serializzatore un utente potrebbe
186195
# chiedere qualsiasi atttributo degli oggetti, senza un controllo fine
187196
# sullo schema
188-
fullobjects = True
189-
self.request.form["b_size"] = 9999
190-
results = getMultiAdapter((results, self.request), ISerializeToJson)(
191-
fullobjects=fullobjects
192-
)
193-
for obj in results["items"]:
194-
yield [obj["title"]] + [obj.get(c["field"]) for c in columns]
197+
if results:
198+
fullobjects = True
199+
self.request.form["b_size"] = 9999
200+
results = getMultiAdapter((results, self.request), ISerializeToJson)(
201+
fullobjects=fullobjects
202+
)
203+
for obj in results["items"]:
204+
yield [obj["title"]] + [obj.get(c["field"]) for c in columns]
195205

196206
def get_columns(self, data):
197207
# Il titolo va aggiunto di default come prima colonna ?
@@ -200,3 +210,52 @@ def get_columns(self, data):
200210
return [{"key": "title", "title": _("Titolo")}] + [
201211
{"key": c["field"], "title": c["title"]} for c in columns
202212
]
213+
214+
@view.memoize
215+
def _get_querystring(self):
216+
# @querystring endpoint
217+
context = self.context.context
218+
registry = getUtility(IRegistry)
219+
reader = getMultiAdapter((registry, self.request), IQuerystringRegistryReader)
220+
reader.vocab_context = context
221+
result = reader()
222+
return result
223+
224+
# TODO: valutare eventuale titolo impostato sul blocco
225+
# def pdf_title(self):
226+
227+
pdf_description_as_html = True
228+
229+
def pdf_description(self) -> str:
230+
query = []
231+
querystring_registry = self._get_querystring()
232+
searchtext = self._query_from_searchtext()
233+
if searchtext and searchtext[0].get("v"):
234+
# TODO: translate
235+
query.append(f"Ricerca per: {searchtext[0]['v']}")
236+
for facet in self.block_data.get("facets") or []:
237+
if "field" not in facet:
238+
logger.warning("invalid facet %s", facet)
239+
continue
240+
if facet["field"]["value"] in self.request.form:
241+
value = self.request.form[facet["field"]["value"]]
242+
if value in ["null"]:
243+
continue
244+
# TODO: gestire campi particolari come: multipli, date, ...
245+
index = querystring_registry["indexes"].get(facet["field"]["value"])
246+
if index:
247+
if "values" in index:
248+
# TODO: per i valori multipli ?
249+
# TODO: facciamo constraint o fallback come ora?
250+
if value in index["values"] and index["values"][value].get(
251+
"title"
252+
):
253+
query.append(
254+
f'{facet["field"]["label"]}: {index["values"][value]["title"]}'
255+
)
256+
continue
257+
query.append(f'{facet["field"]["label"]}: {value}')
258+
if query:
259+
# TODO: translate
260+
txt = "Filtri applicati:\n- " + ",\n- ".join(query)
261+
return convertWebIntelligentPlainTextToHtml(txt)

0 commit comments

Comments
 (0)