From 5fabcceb7091fa963c71dda3571630b175562d85 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Mon, 23 Oct 2017 00:45:41 -0500 Subject: [PATCH] Generar PDF con reportlab --- requirements.txt | 1 + source/app/controllers/helper.py | 267 ++++++++++++++++++++++-- source/app/controllers/util.py | 54 +++-- source/app/models/main.py | 5 +- source/templates/plantilla_factura.json | 26 +++ 5 files changed, 319 insertions(+), 34 deletions(-) create mode 100644 source/templates/plantilla_factura.json diff --git a/requirements.txt b/requirements.txt index 8d5e242..68fa627 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ python-dateutil zeep chardet pyqrcode +reportlab diff --git a/source/app/controllers/helper.py b/source/app/controllers/helper.py index 32fe513..55d9056 100644 --- a/source/app/controllers/helper.py +++ b/source/app/controllers/helper.py @@ -1,18 +1,6 @@ #!/usr/bin/env python3 #~ import falcon -#~ from models.main import get_cp - - -#~ class AppPostalCode(object): - - #~ def on_get(self, req, resp): - #~ values = req.params - #~ req.context['result'] = get_cp(values['cp']) - #~ resp.status = falcon.HTTP_200 - -#~ https://github.com/kennethreitz/requests/blob/v1.2.3/requests/structures.py#L37 - import re import smtplib import collections @@ -24,7 +12,17 @@ from email.mime.text import MIMEText from email import encoders from email.utils import formatdate +from reportlab.platypus import BaseDocTemplate, Frame, PageTemplate +from reportlab.lib import colors +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import cm +from reportlab.lib.pagesizes import letter +from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT +from reportlab.platypus import Paragraph, Table, TableStyle, Spacer +from reportlab.pdfgen import canvas + +#~ https://github.com/kennethreitz/requests/blob/v1.2.3/requests/structures.py#L37 class CaseInsensitiveDict(collections.MutableMapping): """A case-insensitive ``dict``-like object. Implements all methods and operations of @@ -307,3 +305,248 @@ class SendMail(object): except: pass return + + +class NumberedCanvas(canvas.Canvas): + X = 20.59 * cm + Y = 1 * cm + + def __init__(self, *args, **kwargs): + canvas.Canvas.__init__(self, *args, **kwargs) + self._saved_page_states = [] + + def showPage(self): + self._saved_page_states.append(dict(self.__dict__)) + self._startPage() + return + + def save(self): + """add page info to each page (page x of y)""" + page_count = len(self._saved_page_states) + for state in self._saved_page_states: + self.__dict__.update(state) + self.draw_page_number(page_count) + canvas.Canvas.showPage(self) + canvas.Canvas.save(self) + return + + def draw_page_number(self, page_count): + self.setFont('Helvetica', 8) + self.setFillColor(colors.darkred) + text = 'Página {} de {}'.format(self._pageNumber, page_count) + self.drawRightString(self.X, self.Y, text) + return + + +class TemplateInvoice(BaseDocTemplate): + + def __init__(self, *args, **kwargs): + # letter 21.6 x 27.9 + kwargs['pagesize'] = letter + kwargs['rightMargin'] = 1 * cm + kwargs['leftMargin'] = 1 * cm + kwargs['topMargin'] = 1.5 * cm + kwargs['bottomMargin'] = 1.5 * cm + BaseDocTemplate.__init__(self, *args, **kwargs) + self._data = {} + self._rows = [] + + def _set_rect(self, style): + color = style.pop('color', 'black') + if isinstance(color, str): + self.canv.setFillColor(getattr(colors, color)) + else: + self.canv.setFillColorRGB(*color) + + keys = ('x', 'y', 'width', 'height', 'radius') + for k in keys: + style[k] = style[k] * cm + + self.canv.roundRect(**style) + return + + def _set_text(self, styles, value): + rect = styles['rectangulo'] + if value: + self.canv.setFillColor(colors.white) + self.canv.setStrokeColor(colors.white) + ps = ParagraphStyle(**styles['estilo']) + p = Paragraph(value, ps) + p.wrap(rect['width'] * cm, rect['height'] * cm) + p.drawOn(self.canv, rect['x'] * cm, rect['y'] * cm) + + self._set_rect(rect) + return + + def _emisor(self, styles, data): + for k, v in styles.items(): + self._set_text(styles[k], data.get(k, '')) + return + + def afterPage(self): + encabezado = self._custom_styles['encabezado'] + self.canv.saveState() + self._emisor(encabezado['emisor'], self._data['emisor']) + self.canv.restoreState() + return + + @property + def custom_styles(self): + return self._custom_styles + @custom_styles.setter + def custom_styles(self, values): + self._custom_styles = values + + @property + def data(self): + return self._data + @data.setter + def data(self, values): + self._data = values + text = 'Total este reporte = $ {}'.format('1,000.00') + ps = ParagraphStyle( + name='Total', + fontSize=12, + fontName='Helvetica-BoldOblique', + textColor=colors.darkred, + spaceBefore=0.5 * cm, + spaceAfter=0.5 * cm) + p1 = Paragraph(text, ps) + text = 'Nota: esta suma no incluye documentos cancelados' + ps = ParagraphStyle( + name='Note', + fontSize=7, + fontName='Helvetica-BoldOblique') + p2 = Paragraph('', ps) + self._rows = [p2] + + def render(self): + frame = Frame(self.leftMargin, self.bottomMargin, + self.width, self.height, id='normal') + template = PageTemplate(id='invoice', frames=frame) + self.addPageTemplates([template]) + self.build(self._rows, canvasmaker=NumberedCanvas) + return + + +class ReportTemplate(BaseDocTemplate): + """Override the BaseDocTemplate class to do custom handle_XXX actions""" + + def __init__(self, *args, **kwargs): + # letter 21.6 x 27.9 + kwargs['pagesize'] = letter + kwargs['rightMargin'] = 1 * cm + kwargs['leftMargin'] = 1 * cm + kwargs['topMargin'] = 1.5 * cm + kwargs['bottomMargin'] = 1.5 * cm + BaseDocTemplate.__init__(self, *args, **kwargs) + self.styles = getSampleStyleSheet() + self.header = {} + self.data = [] + + def afterPage(self): + """Called after each page has been processed""" + self.canv.saveState() + date = datetime.datetime.today().strftime('%A, %d de %B del %Y') + self.canv.setStrokeColorRGB(0.5, 0, 0) + self.canv.setFont("Helvetica", 8) + self.canv.drawRightString(20.59 * cm, 26.9 * cm, date) + self.canv.line(1 * cm, 26.4 * cm, 20.6 * cm, 26.4 * cm) + + path_cur = os.path.dirname(os.path.realpath(__file__)) + path_img = os.path.join(path_cur, 'logo.png') + try: + self.canv.drawImage(path_img, 1 * cm, 24.2 * cm, 4 * cm, 2 * cm) + except: + pass + + self.canv.roundRect( + 5 * cm, 25.4 * cm, 15.5 * cm, 0.6 * cm, 0.15 * cm, + stroke=True, fill=False) + self.canv.setFont('Helvetica-BoldOblique', 10) + self.canv.drawCentredString(12.75 * cm, 25.6 * cm, self.header['emisor']) + + self.canv.roundRect( + 5 * cm, 24.4 * cm, 15.5 * cm, 0.6 * cm, 0.15 * cm, + stroke=True, fill=False) + self.canv.setFont('Helvetica-BoldOblique', 9) + self.canv.drawCentredString(12.75 * cm, 24.6 * cm, self.header['title']) + + self.canv.line(1 * cm, 1.5 * cm, 20.6 * cm, 1.5 * cm) + self.canv.restoreState() + return + + def set_data(self, data): + self.header['emisor'] = data['emisor'] + self.header['title'] = data['title'] + cols = len(data['rows'][0]) + widths = [] + for w in data['widths']: + widths.append(float(w) * cm) + t_styles = [ + ('GRID', (0, 0), (-1, -1), 0.25, colors.darkred), + ('FONTSIZE', (0, 0), (-1, 0), 9), + ('BOX', (0, 0), (-1, 0), 1, colors.darkred), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.darkred), + ('FONTSIZE', (0, 1), (-1, -1), 8), + ('ALIGN', (0, 0), (-1, 0), 'CENTER'), + ('ALIGN', (0, 0), (0, -1), 'RIGHT'), + ] + if cols == 6: + t_styles += [ + ('ALIGN', (1, 1), (1, -1), 'CENTER'), + ('ALIGN', (3, 1), (3, -1), 'CENTER'), + ('ALIGN', (4, 1), (4, -1), 'RIGHT'), + ] + elif cols == 3: + t_styles += [ + ('ALIGN', (-1, 0), (-1, -1), 'RIGHT'), + ('ALIGN', (-2, 0), (-2, -1), 'RIGHT'), + ('ALIGN', (0, 0), (-1, 0), 'CENTER'), + ] + elif cols == 2: + t_styles += [ + ('ALIGN', (-1, 0), (-1, -1), 'RIGHT'), + ('ALIGN', (0, 0), (-1, 0), 'CENTER'), + ] + rows = [] + for i, r in enumerate(data['rows']): + n = i + 1 + rows.append(('{}.-'.format(n),) + r) + if cols == 6: + if r[4] == 'Cancelado': + t_styles += [ + ('GRID', (0, n), (-1, n), 0.25, colors.red), + ('TEXTCOLOR', (0, n), (-1, n), colors.red), + ] + rows.insert(0, data['titles']) + t = Table(rows, colWidths=widths, repeatRows=1) + t.setStyle(TableStyle(t_styles)) + + text = 'Total este reporte = $ {}'.format(data['total']) + ps = ParagraphStyle( + name='Total', + fontSize=12, + fontName='Helvetica-BoldOblique', + textColor=colors.darkred, + spaceBefore=0.5 * cm, + spaceAfter=0.5 * cm) + p1 = Paragraph(text, ps) + text = 'Nota: esta suma no incluye documentos cancelados' + ps = ParagraphStyle( + name='Note', + fontSize=7, + fontName='Helvetica-BoldOblique') + p2 = Paragraph(text, ps) + self.data = [t, p1, p2] + return + + def render(self): + frame = Frame(self.leftMargin, self.bottomMargin, + self.width, self.height, id='normal') + template = PageTemplate(id='report', frames=frame) + self.addPageTemplates([template]) + self.build(self.data, canvasmaker=NumberedCanvas) + return + + diff --git a/source/app/controllers/util.py b/source/app/controllers/util.py index b951864..0ec5d31 100644 --- a/source/app/controllers/util.py +++ b/source/app/controllers/util.py @@ -20,14 +20,14 @@ from io import BytesIO from smtplib import SMTPException, SMTPAuthenticationError from xml.etree import ElementTree as ET -import uno -from com.sun.star.beans import PropertyValue -from com.sun.star.awt import Size +#~ import uno +#~ from com.sun.star.beans import PropertyValue +#~ from com.sun.star.awt import Size import pyqrcode from dateutil import parser -from .helper import CaseInsensitiveDict, NumLet, SendMail +from .helper import CaseInsensitiveDict, NumLet, SendMail, TemplateInvoice from settings import DEBUG, log, template_lookup, COMPANIES, DB_SAT, \ PATH_XSLT, PATH_XSLTPROC, PATH_OPENSSL, PATH_TEMPLATES, PRE @@ -168,6 +168,10 @@ def get_file(path): return open(path, 'rb') +def read_file(path, mode='rb'): + return open(path, mode).read() + + def get_size(path): return os.path.getsize(path) @@ -178,16 +182,18 @@ def get_template(name, data={}): return template.render(**data) -def get_path_template(name, default='plantilla_factura.ods'): +def get_custom_styles(name, default='plantilla_factura.json'): path = _join(PATH_TEMPLATES, name) if is_file(path): - return path + with open(path) as fh: + return loads(fh.read()) path = _join(PATH_TEMPLATES, default) if is_file(path): - return path + with open(path) as fh: + return loads(fh.read()) - return '' + return {} def dumps(data): @@ -719,13 +725,17 @@ class LIBO(object): return self._read(path) -def to_pdf(path, data): - app = LIBO() - - if not app.is_running: - return b'' - - return app.pdf(path, data) +def to_pdf(styles, data): + #~ app = LIBO() + #~ if not app.is_running: + #~ return b'' + #~ return app.pdf(path, data) + path = get_path_temp() + pdf = TemplateInvoice(path) + pdf.custom_styles = styles + pdf.data = data + pdf.render() + return read_file(path) def parse_xml(xml): @@ -780,7 +790,7 @@ def _emisor(doc, version, values): data = CaseInsensitiveDict(node.attrib.copy()) node = node.find('{}DomicilioFiscal'.format(PRE[version])) if not node is None: - data.update(node.attrib.copy()) + data.update(CaseInsensitiveDict(node.attrib.copy())) data['regimenfiscal'] = values['regimenfiscal'] return data @@ -897,9 +907,9 @@ def _timbre(doc, version, values): return data -def get_data(invoice, rfc, values): - name = '{}_factura.ods'.format(rfc.lower()) - path = get_path_template(name) +def get_data_from_xml(invoice, rfc, values): + name = '{}_factura.json'.format(rfc.lower()) + custom_styles = get_custom_styles(name) data = {'cancelada': invoice.cancelada} doc = parse_xml(invoice.xml) @@ -917,7 +927,7 @@ def get_data(invoice, rfc, values): } data['timbre'] = _timbre(doc, version, options) - return path, data + return custom_styles, data def to_zip(*files): @@ -966,6 +976,10 @@ def get_path_info(path): return (path, filename, name, extension) +def get_path_temp(): + return tempfile.mkstemp()[1] + + class ImportFacturaLibre(object): def __init__(self, path): diff --git a/source/app/models/main.py b/source/app/models/main.py index 9944678..392d58c 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -16,6 +16,7 @@ if __name__ == '__main__': from controllers import util from settings import log, VERSION, PATH_CP, COMPANIES, PRE + FORMAT = '{0:.2f}' @@ -1083,8 +1084,8 @@ class Facturas(BaseModel): return b'', name values = cls._get_not_in_xml(cls, obj) - path, data = util.get_data(obj, rfc, values) - doc = util.to_pdf(path, data) + custom_styles, data = util.get_data_from_xml(obj, rfc, values) + doc = util.to_pdf(custom_styles, data) return doc, name @classmethod diff --git a/source/templates/plantilla_factura.json b/source/templates/plantilla_factura.json new file mode 100644 index 0000000..9f17882 --- /dev/null +++ b/source/templates/plantilla_factura.json @@ -0,0 +1,26 @@ +{ +"encabezado": { + "emisor": { + "direccion": { + "rectangulo": {"x": 1.0, "y": 26.9, "width": 19.6, "height": 0.4, + "radius": 0.1, "stroke": false, "fill": true, "color": "darkred"} + }, + "nombre": { + "rectangulo": {"x": 10.6, "y": 25.9, "width": 10.0, "height": 0.4, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", "fontSize": 12, + "alignment": 2, "textColor": "darkred", "backColor": "white"} + }, + "rfc": { + "rectangulo": {"x": 10.6, "y": 25.1, "width": 10.0, "height": 0.4, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "rfc", "fontName": "Helvetica-Bold", "fontSize": 11, + "alignment": 2, "textColor": "darkred", "backColor": "white"} + } + } +}, +"conceptos": { +}, +"comprobante": { +} +}