diff --git a/.gitignore b/.gitignore index 55251cc..46ece7e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ reports/ venv/ # excludes .python-version +*.bak +nohup.out diff --git a/src/iosanita/contenttypes/browser/export_view.py b/src/iosanita/contenttypes/browser/export_view.py index 78d2b84..993f8c0 100644 --- a/src/iosanita/contenttypes/browser/export_view.py +++ b/src/iosanita/contenttypes/browser/export_view.py @@ -2,14 +2,18 @@ from io import BytesIO from io import StringIO from iosanita.contenttypes import _ +from PIL import Image from plone import api +from plone.memoize import forever from Products.Five.browser import BrowserView from weasyprint import HTML -from zExceptions import BadRequest +from zExceptions import NotFound from zope.interface import implementer from zope.publisher.interfaces import IPublishTraverse +import base64 import csv +import imghdr import importlib.resources import logging import re @@ -21,11 +25,58 @@ fontools_logger.setLevel(logging.WARNING) -CONTENT_TYPES_MAPPING = { - "csv": "text/comma-separated-values", - "pdf": "application/pdf", - "html": "text/html", -} +@forever.memoize +def image_to_html(input_string): + """ + Convert image data to a base64 string formatted for HTML. + + Args: + - input_string: The string containing the filename and base64 encoded image data. + + Returns: + - HTML. + """ + + if not input_string: + return "" + + # Split the input string to extract the filename and base64 data + parts = input_string.split(";") + datab64 = parts[1].split(":")[1] + + # Decode the image data from base64 + image_data = base64.b64decode(datab64) + + if image_data[:5] == b"' + # XXX: se non si va decode/encode il b64 non risulta corretto (!) + # return f'' + # weasyprint gli svg non li gestisce comunque correttamente + return None + + # Guess the image format + image_format = imghdr.what(None, image_data) + + if not image_format: + # raise ValueError("Unable to determine image format") + logger.warning("site logo, unable to determine image format") + return "" + + # Open the image from the decoded data + img = Image.open(BytesIO(image_data)) + + # Create a buffer to hold the image data + buffered = BytesIO() + img.save(buffered, format=image_format) + + # Encode the image data to base64 + img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8") + + # Format the base64 string for HTML + return f'' class IExportViewTraverser(IPublishTraverse): @@ -50,6 +101,8 @@ class ExportViewDownload(BrowserView): """ + with_footer = True + def __init__(self, context, request): super().__init__(context, request) self.export_type = "csv" @@ -65,7 +118,7 @@ def publishTraverse(self, request, name): def __call__(self): """ """ if self.export_type not in ["csv", "pdf", "html"]: - raise BadRequest( + raise NotFound( api.portal.translate( _( "invalid_export_type", @@ -74,18 +127,21 @@ def __call__(self): ) ) ) - self.set_headers() data = self.get_data() - if not data: - return "" - resp_data = "" if self.export_type == "csv": - resp_data = self.get_csv(data) + # default per locales di riferimento (perr l'encoding, al momento, lasciamo + # il generico utf-8 con BOM che potrebbe funzionare per tutti, + # MS Excel incluso) + lang = api.portal.get_current_language(self.context) + if lang == "it": + sep = ";" + else: + sep = "," + return self.get_csv(data, sep=sep) elif self.export_type == "pdf": - resp_data = self.get_pdf(data) + return self.get_pdf(data) elif self.export_type == "html": - resp_data = self.get_html_for_pdf(data) - return resp_data + return self.get_html_for_pdf(data) def get_filename(self): """ @@ -94,37 +150,46 @@ def get_filename(self): now = datetime.now().strftime("%Y_%m_%d_%H_%M") return f"export_{now}.{self.export_type}" - def set_headers(self): - """ - Set the headers for the response. - """ - if self.export_type in ["pdf", "csv"]: - self.request.response.setHeader( - "Content-Disposition", f"attachment;filename={self.get_filename()}" - ) - self.request.response.setHeader( - "Content-Type", CONTENT_TYPES_MAPPING[self.export_type] - ) - - def get_csv(self, data): + def get_csv(self, data, encoding="utf-8-sig", sep=","): """ Generate CSV data from the provided data. """ + # 1. Crea uno StringIO per il CSV + csv_buffer = StringIO() + # 2. Aggiungi l'header per il separatore (specifico per Excel) + # In Libreoffice viene aggiunta una riga, per ora evitiamo + # csv_buffer.write(f"sep={sep}\n") + # 3. Scrittura dei dati CSV columns = self.get_columns(data) - - csv_data = StringIO() - csv_writer = csv.writer(csv_data, quoting=csv.QUOTE_ALL) + csv_writer = csv.writer(csv_buffer, delimiter=sep, quoting=csv.QUOTE_ALL) csv_writer.writerow([c["title"] for c in columns]) - for item in data: csv_writer.writerow(self.format_row(item)) - return csv_data.getvalue().encode("utf-8") + # 4. Prepara i bytes con BOM (UTF-8-sig) + csv_data = csv_buffer.getvalue() + if encoding == "utf-8-sig": + csv_bytes = b"\xef\xbb\xbf" + csv_data.encode("utf-8") # Aggiunge BOM + else: + csv_bytes = csv_data.encode(encoding) + # 5. Crea la risposta con gli header corretti + response = self.request.response + response.setHeader( + "Content-Disposition", f"attachment;filename={self.get_filename()}" + ) + response.setHeader("Content-Type", f"text/csv; charset={encoding}") + return csv_bytes def get_pdf(self, data): html_str = self.get_html_for_pdf(data=data) pdf_file = BytesIO() HTML(string=html_str).write_pdf(pdf_file) pdf_file.seek(0) + # 5. Crea la risposta con gli header corretti + response = self.request.response + response.setHeader( + "Content-Disposition", f"attachment;filename={self.get_filename()}" + ) + response.setHeader("Content-Type", "application/pdf") return pdf_file.read() def get_data(self): @@ -176,6 +241,7 @@ def get_html_for_pdf(self, data): context=self, request=self.request, ) + return view(rows=data, columns=columns) def pdf_styles(self): @@ -184,7 +250,11 @@ def pdf_styles(self): ) def pdf_title(self): - return None + context = self.context.context + site_title = api.portal.get_registry_record("plone.site_title") + if site_title: + return f"{site_title}: {context.Title()}" + return context.Title() def pdf_description(self): return None @@ -203,3 +273,13 @@ def pdf_cell_format(self, column, value): if re.match(r"^\d{4}-\d{2}-\d{2}T00:00:00$", value): return {"type": "str", "value": value.split("T")[0]} return {"type": "str", "value": str(value)} + + def pdf_logo(self): + site_logo = api.portal.get_registry_record("plone.site_logo") + if site_logo: + return image_to_html(site_logo.decode()) + return None + + def pdf_datetime(self): + # TODO: valutare localizzazione della data + return datetime.now().strftime("%d/%m/%Y %H:%M") diff --git a/src/iosanita/contenttypes/browser/searchblock.py b/src/iosanita/contenttypes/browser/searchblock.py index 1ea3d79..006716b 100644 --- a/src/iosanita/contenttypes/browser/searchblock.py +++ b/src/iosanita/contenttypes/browser/searchblock.py @@ -4,10 +4,15 @@ from .export_view import IExportViewTraverser from copy import deepcopy from iosanita.contenttypes import _ +from plone.app.querystring.interfaces import IQuerystringRegistryReader +from plone.intelligenttext.transforms import convertWebIntelligentPlainTextToHtml +from plone.memoize import view +from plone.registry.interfaces import IRegistry from plone.restapi.interfaces import ISerializeToJson from zExceptions import BadRequest from zExceptions import NotFound from zope.component import getMultiAdapter +from zope.component import getUtility from zope.interface import implementer import logging @@ -86,11 +91,16 @@ def _query_from_facets(self): } ) elif facet["type"] == "daterangeFacet": + daterange = self.request.form[facet["field"]["value"]].split(",") + if not daterange[0]: + daterange[0] = "1970-01-01" + if not daterange[1]: + daterange[1] = "2500-01-01" query.append( { "i": facet["field"]["value"], "o": "plone.app.querystring.operation.date.between", - "v": self.request.form[facet["field"]["value"]].split(","), + "v": daterange, } ) elif facet["type"] == "selectFacet" and not facet["multiple"]: @@ -110,7 +120,7 @@ def _query_from_facets(self): } ) else: - logger.warning("DEBUG: filter %s not implemnted", facet) + logger.warning("DEBUG: filter %s not implemented", facet) query.append( { "i": facet["field"]["value"], @@ -128,7 +138,6 @@ def get_data(self): # 4. fare la ricerca # 5. fare export in csv/pdf a seconda del formato """ - # 2. Get columns, base filters and sorting columns = self.block_data.get("columns", []) @@ -185,13 +194,14 @@ def get_data(self): # XXX: consideriamo perĂ² che senza usare il serializzatore un utente potrebbe # chiedere qualsiasi atttributo degli oggetti, senza un controllo fine # sullo schema - fullobjects = True - self.request.form["b_size"] = 9999 - results = getMultiAdapter((results, self.request), ISerializeToJson)( - fullobjects=fullobjects - ) - for obj in results["items"]: - yield [obj["title"]] + [obj.get(c["field"]) for c in columns] + if results: + fullobjects = True + self.request.form["b_size"] = 9999 + results = getMultiAdapter((results, self.request), ISerializeToJson)( + fullobjects=fullobjects + ) + for obj in results["items"]: + yield [obj["title"]] + [obj.get(c["field"]) for c in columns] def get_columns(self, data): # Il titolo va aggiunto di default come prima colonna ? @@ -200,3 +210,52 @@ def get_columns(self, data): return [{"key": "title", "title": _("Titolo")}] + [ {"key": c["field"], "title": c["title"]} for c in columns ] + + @view.memoize + def _get_querystring(self): + # @querystring endpoint + context = self.context.context + registry = getUtility(IRegistry) + reader = getMultiAdapter((registry, self.request), IQuerystringRegistryReader) + reader.vocab_context = context + result = reader() + return result + + # TODO: valutare eventuale titolo impostato sul blocco + # def pdf_title(self): + + pdf_description_as_html = True + + def pdf_description(self) -> str: + query = [] + querystring_registry = self._get_querystring() + searchtext = self._query_from_searchtext() + if searchtext and searchtext[0].get("v"): + # TODO: translate + query.append(f"Ricerca per: {searchtext[0]['v']}") + for facet in self.block_data.get("facets") or []: + if "field" not in facet: + logger.warning("invalid facet %s", facet) + continue + if facet["field"]["value"] in self.request.form: + value = self.request.form[facet["field"]["value"]] + if value in ["null"]: + continue + # TODO: gestire campi particolari come: multipli, date, ... + index = querystring_registry["indexes"].get(facet["field"]["value"]) + if index: + if "values" in index: + # TODO: per i valori multipli ? + # TODO: facciamo constraint o fallback come ora? + if value in index["values"] and index["values"][value].get( + "title" + ): + query.append( + f'{facet["field"]["label"]}: {index["values"][value]["title"]}' + ) + continue + query.append(f'{facet["field"]["label"]}: {value}') + if query: + # TODO: translate + txt = "Filtri applicati:\n- " + ",\n- ".join(query) + return convertWebIntelligentPlainTextToHtml(txt) diff --git a/src/iosanita/contenttypes/browser/static/export_pdf.css b/src/iosanita/contenttypes/browser/static/export_pdf.css index ada17e8..0b7a248 100644 --- a/src/iosanita/contenttypes/browser/static/export_pdf.css +++ b/src/iosanita/contenttypes/browser/static/export_pdf.css @@ -1,29 +1,81 @@ @page { + /* size: A4 portrait; */ size: landscape; + margin-bottom: 3cm; + @bottom-right { + content: counter(page); + font-size: 75%; + width: 20%; + } + @bottom-left { + content: element(footer); + width: 80%; + } + .description.first-page { + display: none; + } } +@page:first { + margin-top: 2cm; + @top-left { + content: element(header); + margin-top: 0; + } + .description.first-page { + display: block; + } +} + body { font-family: "Titillium Web", Geneva, Tahoma, sans-serif; - font-size: 16px; - margin: 10px auto; - padding: 0 10px; + font-size: 14px; color: #1C2024; } +header { + position: running(header); + width: 100%; + display: flex; + align-items: center; +} + +/* header .site_data { + display: flex; + width: 580px; + align-items: center; + justify-content: center; +} */ + +/* header hr { + display: flex; + width: 580px; + align-items: center; + justify-content: center; +} */ + +footer { + position: running(footer); + width: 100%; +} + a { color: #235295 } h1 { - font-weight: 700; + font-weight: 500; + font-size: 18px; +} +#logo { + padding-right: 30px; } -p.description { +/* p.description { font-size: 1.3333333333rem; -} +} */ table.export-table { table-layout: fixed; width: 100%; - margin-bottom: 1rem; box-sizing: border-box; border-spacing: 2px; } diff --git a/src/iosanita/contenttypes/browser/templates/export_pdf.pt b/src/iosanita/contenttypes/browser/templates/export_pdf.pt index 517c639..a67c349 100644 --- a/src/iosanita/contenttypes/browser/templates/export_pdf.pt +++ b/src/iosanita/contenttypes/browser/templates/export_pdf.pt @@ -2,21 +2,41 @@ + " + i18n:domain="iosanita.contenttypes" +>