source: main/waeup.ikoba/trunk/src/waeup/ikoba/customers/utils.py @ 12183

Last change on this file since 12183 was 12182, checked in by Henrik Bettermann, 10 years ago

Add PDFMergeDocumentSlipPage which merges the pdf slip with pdf files connected to the document.

  • Property svn:keywords set to Id
File size: 16.1 KB
RevLine 
[12018]1## $Id: utils.py 12182 2014-12-09 11:04:53Z henrik $
[11956]2##
3## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
18"""General helper functions and utilities for the customer section.
19"""
[12035]20import re
21import os
[11956]22import grok
[12182]23from StringIO import StringIO
[12051]24from reportlab.lib import colors
25from reportlab.lib.units import cm
26from reportlab.lib.pagesizes import A4
27from reportlab.lib.styles import getSampleStyleSheet
28from reportlab.platypus import Paragraph, Image, Table, Spacer
[12182]29from reportlab.platypus.flowables import PageBreak
30from PyPDF2 import PdfFileMerger, PdfFileReader, PdfFileWriter
[12051]31from zope.event import notify
32from zope.schema.interfaces import ConstraintNotSatisfied
33from zope.component import getUtility, createObject
34from zope.formlib.form import setUpEditWidgets
35from zope.i18n import translate
[12032]36from waeup.ikoba.interfaces import MessageFactory as _
[12051]37from waeup.ikoba.interfaces import (
[12090]38    STARTED, CREATED, REQUESTED, APPROVED, SUBMITTED, VERIFIED, REJECTED,
39    EXPIRED,
40    IIkobaUtils, IExtFileStore)
[11956]41from waeup.ikoba.customers.interfaces import ICustomersUtils
[12051]42from waeup.ikoba.browser.interfaces import IPDFCreator
43from waeup.ikoba.browser.pdf import (
44    ENTRY1_STYLE, format_html, NOTE_STYLE, HEADING_STYLE,
45    get_signature_tables, get_qrcode)
[11956]46
[12035]47RE_CUSTID_NON_NUM = re.compile('[^\d]+')
[11985]48
[11956]49def generate_customer_id():
50    customers = grok.getSite()['customers']
51    new_id = customers.unique_customer_id
52    return new_id
53
[12035]54def path_from_custid(customer_id):
55    """Convert a customer_id into a predictable relative folder path.
[11985]56
[12035]57    Used for storing files.
58
59    Returns the name of folder in which files for a particular customer
60    should be stored. This is a relative path, relative to any general
61    customers folder with 5 zero-padded digits (except when customer_id
62    is overlong).
63
64    We normally map 1,000 different customer ids into one single
65    path. For instance ``K1000000`` will give ``01000/K1000000``,
66    ``K1234567`` will give ``0123/K1234567`` and ``K12345678`` will
67    result in ``1234/K12345678``.
68
69    For lower numbers < 10**6 we return the same path for up to 10,000
70    customer_ids. So for instance ``KM123456`` will result in
71    ``00120/KM123456`` (there will be no path starting with
72    ``00123``).
73
74    Works also with overlong number: here the leading zeros will be
75    missing but ``K123456789`` will give reliably
76    ``12345/K123456789`` as expected.
77    """
78    # remove all non numeric characters and turn this into an int.
79    num = int(RE_CUSTID_NON_NUM.sub('', customer_id))
80    if num < 10**6:
81        # store max. of 10000 custs per folder and correct num for 5 digits
82        num = num / 10000 * 10
83    else:
84        # store max. of 1000 custs per folder
85        num = num / 1000
86    # format folder name to have 5 zero-padded digits
87    folder_name = u'%05d' % num
88    folder_name = os.path.join(folder_name, customer_id)
89    return folder_name
90
[12051]91SLIP_STYLE = [
92    ('VALIGN',(0,0),(-1,-1),'TOP'),
93    #('FONT', (0,0), (-1,-1), 'Helvetica', 11),
94    ]
95
96CONTENT_STYLE = [
97    ('VALIGN',(0,0),(-1,-1),'TOP'),
98    #('FONT', (0,0), (-1,-1), 'Helvetica', 8),
99    #('TEXTCOLOR',(0,0),(-1,0),colors.white),
100    #('BACKGROUND',(0,0),(-1,0),colors.black),
101    ('INNERGRID', (0,0), (-1,-1), 0.25, colors.black),
102    ('BOX', (0,0), (-1,-1), 1, colors.black),
103
104    ]
105
106FONT_SIZE = 10
107FONT_COLOR = 'black'
108
109def trans(text, lang):
110    # shortcut
111    return translate(text, 'waeup.kofa', target_language=lang)
112
113
114def formatted_text(text, color=FONT_COLOR, lang='en'):
115    """Turn `text`, `color` and `size` into an HTML snippet.
116
117    The snippet is suitable for use with reportlab and generating PDFs.
118    Wraps the `text` into a ``<font>`` tag with passed attributes.
119
120    Also non-strings are converted. Raw strings are expected to be
121    utf-8 encoded (usually the case for widgets etc.).
122
123    Finally, a br tag is added if widgets contain div tags
124    which are not supported by reportlab.
125
126    The returned snippet is unicode type.
127    """
128    if not isinstance(text, unicode):
129        if isinstance(text, basestring):
130            text = text.decode('utf-8')
131        else:
132            text = unicode(text)
133    if text == 'None':
134        text = ''
135    # Mainly for boolean values we need our customized
136    # localisation of the zope domain
137    text = translate(text, 'zope', target_language=lang)
138    text = text.replace('</div>', '<br /></div>')
139    tag1 = u'<font color="%s">' % (color)
140    return tag1 + u'%s</font>' % text
141
142def render_customer_data(customerview, context, omit_fields=(),
143                        lang='en', slipname=None):
144    """Render customer table for an existing frame.
145    """
146    width, height = A4
147    set_up_widgets(customerview, ignore_request=True)
148    data_left = []
149    data_middle = []
150    style = getSampleStyleSheet()
151    img = getUtility(IExtFileStore).getFileByContext(
152        customerview.context, attr='passport.jpg')
153    if img is None:
154        from waeup.ikoba.browser import DEFAULT_PASSPORT_IMAGE_PATH
155        img = open(DEFAULT_PASSPORT_IMAGE_PATH, 'rb')
156    doc_img = Image(img.name, width=4*cm, height=4*cm, kind='bound')
157    data_left.append([doc_img])
158    #data.append([Spacer(1, 12)])
159
160    f_label = trans(_('Name:'), lang)
161    f_label = Paragraph(f_label, ENTRY1_STYLE)
162    f_text = formatted_text(customerview.context.display_fullname)
163    f_text = Paragraph(f_text, ENTRY1_STYLE)
164    data_middle.append([f_label,f_text])
165
166    for widget in customerview.widgets:
167        if 'name' in widget.name:
168            continue
169        f_label = translate(
170            widget.label.strip(), 'waeup.ikoba',
171            target_language=lang)
172        f_label = Paragraph('%s:' % f_label, ENTRY1_STYLE)
173        f_text = formatted_text(widget(), lang=lang)
174        f_text = Paragraph(f_text, ENTRY1_STYLE)
175        data_middle.append([f_label,f_text])
176
177    if getattr(customerview.context, 'certcode', None):
178        if not 'date_of_birth' in omit_fields:
179            f_label = trans(_('Date of Birth:'), lang)
180            f_label = Paragraph(f_label, ENTRY1_STYLE)
181            date_of_birth = customerview.context.date_of_birth
182            tz = getUtility(IIkobaUtils).tzinfo
183            date_of_birth = to_timezone(date_of_birth, tz)
184            if date_of_birth is not None:
185                date_of_birth = date_of_birth.strftime("%d/%m/%Y")
186            f_text = formatted_text(date_of_birth)
187            f_text = Paragraph(f_text, ENTRY1_STYLE)
188            data_middle.append([f_label,f_text])
189
190    # append QR code to the right
191    if slipname:
192        url = customerview.url(context, slipname)
193        data_right = [[get_qrcode(url, width=70.0)]]
194        table_right = Table(data_right,style=SLIP_STYLE)
195    else:
196        table_right = None
197
198    table_left = Table(data_left,style=SLIP_STYLE)
199    table_middle = Table(data_middle,style=SLIP_STYLE, colWidths=[5*cm, 5*cm])
200    table = Table([[table_left, table_middle, table_right],],style=SLIP_STYLE)
201    return table
202
203def render_table_data(tableheader, tabledata, lang='en'):
204    """Render children table for an existing frame.
205    """
206    data = []
207    #data.append([Spacer(1, 12)])
208    line = []
209    style = getSampleStyleSheet()
210    for element in tableheader:
211        field = '<strong>%s</strong>' % formatted_text(element[0], lang=lang)
212        field = Paragraph(field, style["Normal"])
213        line.append(field)
214    data.append(line)
215    for ticket in tabledata:
216        line = []
217        for element in tableheader:
218              field = formatted_text(getattr(ticket,element[1],u' '))
219              field = Paragraph(field, style["Normal"])
220              line.append(field)
221        data.append(line)
222    table = Table(data,colWidths=[
223        element[2]*cm for element in tableheader], style=CONTENT_STYLE)
224    return table
225
226def docs_as_flowables(view, lang='en'):
227    """Create reportlab flowables out of scanned docs.
228    """
229    # XXX: fix circular import problem
230    from waeup.ikoba.customers.fileviewlets import FileManager
231    from waeup.ikoba.browser import DEFAULT_IMAGE_PATH
232    style = getSampleStyleSheet()
233    data = []
234
235    # Collect viewlets
236    fm = FileManager(view.context, view.request, view)
237    fm.update()
238    if fm.viewlets:
[12062]239        sc_translation = trans(_('Connected Files'), lang)
[12051]240        data.append(Paragraph(sc_translation, HEADING_STYLE))
241        # Insert list of scanned documents
242        table_data = []
243        for viewlet in fm.viewlets:
244            if viewlet.file_exists:
245                # Show viewlet only if file exists
246                f_label = Paragraph(trans(viewlet.label, lang), ENTRY1_STYLE)
247                img_path = getattr(getUtility(IExtFileStore).getFileByContext(
248                    view.context, attr=viewlet.download_name), 'name', None)
249                #f_text = Paragraph(trans(_('(not provided)'),lang), ENTRY1_STYLE)
250                if img_path is None:
251                    pass
252                elif not img_path[-4:] in ('.jpg', '.JPG'):
253                    # reportlab requires jpg images, I think.
254                    f_text = Paragraph('%s (not displayable)' % (
255                        viewlet.title,), ENTRY1_STYLE)
256                else:
257                    f_text = Image(img_path, width=2*cm, height=1*cm, kind='bound')
258                table_data.append([f_label, f_text])
259        if table_data:
260            # safety belt; empty tables lead to problems.
261            data.append(Table(table_data, style=SLIP_STYLE))
262    return data
263
264
265def set_up_widgets(view, ignore_request=False):
266    view.adapters = {}
267    view.widgets = setUpEditWidgets(
268        view.form_fields, view.prefix, view.context, view.request,
269        adapters=view.adapters, for_display=True,
270        ignore_request=ignore_request
271        )
272
[12097]273def generate_contract_id():
274    new_id = grok.getSite().unique_contract_id
[12089]275    return new_id
276
[11956]277class CustomersUtils(grok.GlobalUtility):
278    """A collection of methods subject to customization.
279    """
280    grok.implements(ICustomersUtils)
281
282    #: A prefix used when generating new customer ids. Each customer id will
283    #: start with this string. The default is 'K'.
284    CUSTOMER_ID_PREFIX = u'K'
[11971]285
[12088]286    CUSTMANAGE_CUSTOMER_STATES = (STARTED,)
[12018]287
[12088]288    DOCMANAGE_CUSTOMER_STATES = (APPROVED,)
[12032]289
[12088]290    DOCMANAGE_DOCUMENT_STATES = (CREATED,)
291
[12099]292    CONMANAGE_CUSTOMER_STATES = DOCMANAGE_CUSTOMER_STATES
[12088]293
[12099]294    CONMANAGE_CONTRACT_STATES = (CREATED,)
[12088]295
[12035]296    SKIP_UPLOAD_VIEWLETS = ()
297
[12087]298    TRANSLATED_CUSTOMER_STATES = {
[12032]299        CREATED: _('created'),
300        STARTED: _('started'),
301        REQUESTED: _('requested'),
302        APPROVED: _('approved'),
303        }
[12051]304
[12089]305
[12097]306    TRANSLATED_CONTRACT_STATES = {
[12090]307        CREATED: _('created'),
308        SUBMITTED: _('submitted for approval'),
309        APPROVED: _('approved'),
310        REJECTED: _('rejected'),
311        EXPIRED:_('expired')
[12089]312        }
313
[12053]314    DOCTYPES_DICT = {
[12057]315        'CustomerSampleDocument': 'Sample Document',
[12053]316        'CustomerPDFDocument': 'PDF Document',
317        }
318
[12099]319    CONTYPES_DICT = {
[12097]320        'SampleContract': 'Sample Contract',
[12090]321        }
322
[12053]323    SELECTABLE_DOCTYPES_DICT = DOCTYPES_DICT
324
[12099]325    SELECTABLE_CONTYPES_DICT = CONTYPES_DICT
[12090]326
[12051]327    def getPDFCreator(self, context=None):
328        """Get a pdf creator suitable for `context`.
329
330        The default implementation always returns the default creator.
331        """
332        return getUtility(IPDFCreator)
333
334    def renderPDF(self, view, filename='slip.pdf', customer=None,
335                  customerview=None,
336                  tableheader=[], tabledata=[],
337                  note=None, signatures=None, sigs_in_footer=(),
338                  show_scans=True, show_history=True, topMargin=1.5,
[12182]339                  omit_fields=(), mergefiles=None):
[12051]340        """Render pdf slips for various pages.
341        """
342        portal_language = getUtility(IIkobaUtils).PORTAL_LANGUAGE
343        style = getSampleStyleSheet()
344        creator = self.getPDFCreator()
345        data = []
346        doc_title = view.label
347        author = '%s (%s)' % (view.request.principal.title,
348                              view.request.principal.id)
349        footer_text = view.label.split('\n')
350        if len(footer_text) > 2:
351            # We can add a department in first line
352            footer_text = footer_text[1]
353        else:
354            # Only the first line is used for the footer
355            footer_text = footer_text[0]
356        if getattr(customer, 'customer_id', None) is not None:
357            footer_text = "%s - %s - " % (customer.customer_id, footer_text)
358
359        # Insert customer data table
360        if customer is not None:
361            bd_translation = trans(_('Base Data'), portal_language)
362            data.append(Paragraph(bd_translation, HEADING_STYLE))
363            data.append(render_customer_data(
364                customerview, view.context, omit_fields, lang=portal_language,
365                slipname=filename))
366
367        # Insert widgets
368        if view.form_fields:
369            data.append(Paragraph(view.title, HEADING_STYLE))
370            separators = getattr(self, 'SEPARATORS_DICT', {})
371            table = creator.getWidgetsTable(
372                view.form_fields, view.context, None, lang=portal_language,
373                separators=separators)
374            data.append(table)
375
376        # Insert scanned docs
377        if show_scans:
378            data.extend(docs_as_flowables(view, portal_language))
379
380        # Insert history
381        if show_history:
[12062]382            if getattr(view.context, 'history', None):
[12091]383                hist_translation = trans(_('${a} Workflow History',
384                                           mapping = {'a':view.context.translated_class_name}),
385                                        portal_language)
[12062]386                data.append(Paragraph(hist_translation, HEADING_STYLE))
387                data.extend(creator.fromStringList(view.context.history.messages))
388            else:
389                hist_translation = trans(_('Customer Workflow History'), portal_language)
390                data.append(Paragraph(hist_translation, HEADING_STYLE))
391                data.extend(creator.fromStringList(customer.history.messages))
[12051]392
393        # Insert content tables (optionally on second page)
394        if hasattr(view, 'tabletitle'):
395            for i in range(len(view.tabletitle)):
396                if tabledata[i] and tableheader[i]:
397                    #data.append(PageBreak())
398                    #data.append(Spacer(1, 20))
399                    data.append(Paragraph(view.tabletitle[i], HEADING_STYLE))
400                    data.append(Spacer(1, 8))
401                    contenttable = render_table_data(tableheader[i],tabledata[i])
402                    data.append(contenttable)
403
404        # Insert signatures
405        # XXX: We are using only sigs_in_footer in waeup.ikoba, so we
406        # do not have a test for the following lines.
407        if signatures and not sigs_in_footer:
408            data.append(Spacer(1, 20))
409            # Render one signature table per signature to
410            # get date and signature in line.
411            for signature in signatures:
412                signaturetables = get_signature_tables(signature)
413                data.append(signaturetables[0])
414
415        view.response.setHeader(
416            'Content-Type', 'application/pdf')
417        try:
418            pdf_stream = creator.create_pdf(
419                data, None, doc_title, author=author, footer=footer_text,
420                note=note, sigs_in_footer=sigs_in_footer, topMargin=topMargin)
421        except IOError:
422            view.flash('Error in image file.')
423            return view.redirect(view.url(view.context))
[12182]424
425        if mergefiles:
426            merger = PdfFileMerger()
427            merger.append(StringIO(pdf_stream))
428            for file in mergefiles:
429                merger.append(file[1])
430            tmp = StringIO()
431            merger.write(tmp)
432            return tmp.getvalue()
433
[12051]434        return pdf_stream
Note: See TracBrowser for help on using the repository browser.