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

Last change on this file since 15257 was 14217, checked in by Henrik Bettermann, 8 years ago

Flash reportlab error message.

  • Property svn:keywords set to Id
File size: 20.1 KB
Line 
1## $Id: utils.py 14217 2016-10-19 10:09:20Z henrik $
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"""
20import re
21import os
22import grok
23from cgi import escape
24from copy import deepcopy
25from StringIO import StringIO
26from reportlab.lib import colors
27from reportlab.lib.units import cm
28from reportlab.lib.pagesizes import A4
29from reportlab.lib.styles import getSampleStyleSheet
30from reportlab.platypus import Paragraph, Image, Table, Spacer
31from reportlab.platypus.flowables import PageBreak
32from reportlab.platypus.doctemplate import LayoutError
33from PyPDF2 import PdfFileMerger, PdfFileReader, PdfFileWriter
34from zope.event import notify
35from zope.schema.interfaces import ConstraintNotSatisfied
36from zope.component import getUtility, createObject
37from zope.formlib.form import setUpEditWidgets
38from zope.i18n import translate
39from waeup.ikoba.interfaces import MessageFactory as _
40from waeup.ikoba.interfaces import (
41    STARTED, CREATED, REQUESTED, APPROVED, SUBMITTED, VERIFIED, REJECTED,
42    EXPIRED, PROVISIONALLY, AWAITING,
43    IIkobaUtils, IExtFileStore)
44from waeup.ikoba.customers.interfaces import ICustomersUtils
45from waeup.ikoba.browser.interfaces import IPDFCreator
46from waeup.ikoba.browser.pdf import (
47    ENTRY1_STYLE, format_html, NOTE_STYLE, HEADING_STYLE, HEADLINE1_STYLE,
48    get_signature_tables, get_qrcode)
49from waeup.ikoba.payments.interfaces import STATE_PAID
50
51RE_CUSTID_NON_NUM = re.compile('[^\d]+')
52
53def generate_customer_id():
54    customers = grok.getSite()['customers']
55    new_id = customers.unique_customer_id
56    return new_id
57
58def path_from_custid(customer_id):
59    """Convert a customer_id into a predictable relative folder path.
60
61    Used for storing files.
62
63    Returns the name of folder in which files for a particular customer
64    should be stored. This is a relative path, relative to any general
65    customers folder with 5 zero-padded digits (except when customer_id
66    is overlong).
67
68    We normally map 1,000 different customer ids into one single
69    path. For instance ``K1000000`` will give ``01000/K1000000``,
70    ``K1234567`` will give ``0123/K1234567`` and ``K12345678`` will
71    result in ``1234/K12345678``.
72
73    For lower numbers < 10**6 we return the same path for up to 10,000
74    customer_ids. So for instance ``KM123456`` will result in
75    ``00120/KM123456`` (there will be no path starting with
76    ``00123``).
77
78    Works also with overlong number: here the leading zeros will be
79    missing but ``K123456789`` will give reliably
80    ``12345/K123456789`` as expected.
81    """
82    # remove all non numeric characters and turn this into an int.
83    num = int(RE_CUSTID_NON_NUM.sub('', customer_id))
84    if num < 10**6:
85        # store max. of 10000 custs per folder and correct num for 5 digits
86        num = num / 10000 * 10
87    else:
88        # store max. of 1000 custs per folder
89        num = num / 1000
90    # format folder name to have 5 zero-padded digits
91    folder_name = u'%05d' % num
92    folder_name = os.path.join(folder_name, customer_id)
93    return folder_name
94
95SLIP_STYLE = [
96    ('VALIGN',(0,0),(-1,-1),'TOP'),
97    #('FONT', (0,0), (-1,-1), 'Helvetica', 11),
98    ]
99
100CONTENT_STYLE = [
101    ('VALIGN',(0,0),(-1,-1),'TOP'),
102    #('FONT', (0,0), (-1,-1), 'Helvetica', 8),
103    #('TEXTCOLOR',(0,0),(-1,0),colors.white),
104    #('BACKGROUND',(0,0),(-1,0),colors.black),
105    ('INNERGRID', (0,0), (-1,-1), 0.25, colors.black),
106    ('BOX', (0,0), (-1,-1), 1, colors.black),
107
108    ]
109
110FONT_SIZE = 10
111FONT_COLOR = 'black'
112
113def trans(text, lang):
114    # shortcut
115    return translate(text, 'waeup.ikoba', target_language=lang)
116
117
118def formatted_text(text, color=FONT_COLOR, lang='en'):
119    """Turn `text`, `color` and `size` into an HTML snippet.
120
121    The snippet is suitable for use with reportlab and generating PDFs.
122    Wraps the `text` into a ``<font>`` tag with passed attributes.
123
124    Also non-strings are converted. Raw strings are expected to be
125    utf-8 encoded (usually the case for widgets etc.).
126
127    Finally, a br tag is added if widgets contain div tags
128    which are not supported by reportlab.
129
130    The returned snippet is unicode type.
131    """
132    if not isinstance(text, unicode):
133        if isinstance(text, basestring):
134            text = text.decode('utf-8')
135        else:
136            text = unicode(text)
137    if text == 'None':
138        text = ''
139    # Mainly for boolean values we need our customized
140    # localisation of the zope domain
141    text = translate(text, 'zope', target_language=lang)
142    text = text.replace('</div>', '<br /></div>')
143    tag1 = u'<font color="%s">' % (color)
144    return tag1 + u'%s</font>' % text
145
146def render_customer_data(customerview, context, omit_fields=(),
147                        lang='en', slipname=None):
148    """Render customer table for an existing frame.
149    """
150    width, height = A4
151    set_up_widgets(customerview, ignore_request=True)
152    data_left = []
153    data_middle = []
154    style = getSampleStyleSheet()
155    img = getUtility(IExtFileStore).getFileByContext(
156        customerview.context, attr='passport.jpg')
157    if img is None:
158        from waeup.ikoba.browser import DEFAULT_PASSPORT_IMAGE_PATH
159        img = open(DEFAULT_PASSPORT_IMAGE_PATH, 'rb')
160    doc_img = Image(img.name, width=4*cm, height=4*cm, kind='bound')
161    data_left.append([doc_img])
162    #data.append([Spacer(1, 12)])
163
164    f_label = trans(_('Name:'), lang)
165    f_label = Paragraph(f_label, ENTRY1_STYLE)
166    f_text = formatted_text(customerview.context.display_fullname)
167    f_text = Paragraph(f_text, ENTRY1_STYLE)
168    data_middle.append([f_label,f_text])
169
170    for widget in customerview.widgets:
171        if 'name' in widget.name:
172            continue
173        f_label = translate(
174            widget.label.strip(), 'waeup.ikoba',
175            target_language=lang)
176        f_label = Paragraph('%s:' % f_label, ENTRY1_STYLE)
177        f_text = formatted_text(widget(), lang=lang)
178        f_text = Paragraph(f_text, ENTRY1_STYLE)
179        data_middle.append([f_label,f_text])
180
181    if getattr(customerview.context, 'certcode', None):
182        if not 'date_of_birth' in omit_fields:
183            f_label = trans(_('Date of Birth:'), lang)
184            f_label = Paragraph(f_label, ENTRY1_STYLE)
185            date_of_birth = customerview.context.date_of_birth
186            tz = getUtility(IIkobaUtils).tzinfo
187            date_of_birth = to_timezone(date_of_birth, tz)
188            if date_of_birth is not None:
189                date_of_birth = date_of_birth.strftime("%d/%m/%Y")
190            f_text = formatted_text(date_of_birth)
191            f_text = Paragraph(f_text, ENTRY1_STYLE)
192            data_middle.append([f_label,f_text])
193
194    # append QR code to the right
195    if slipname:
196        url = customerview.url(context, slipname)
197        data_right = [[get_qrcode(url, width=70.0)]]
198        table_right = Table(data_right,style=SLIP_STYLE)
199    else:
200        table_right = None
201
202    table_left = Table(data_left,style=SLIP_STYLE)
203    table_middle = Table(data_middle,style=SLIP_STYLE, colWidths=[5*cm, 5*cm])
204    table = Table([[table_left, table_middle, table_right],],style=SLIP_STYLE)
205    return table
206
207def render_table_data(tableheader, tabledata, lang='en'):
208    """Render children table for an existing frame.
209    """
210    data = []
211    #data.append([Spacer(1, 12)])
212    line = []
213    style = getSampleStyleSheet()
214    for element in tableheader:
215        field = '<strong>%s</strong>' % formatted_text(element[0], lang=lang)
216        field = Paragraph(field, style["Normal"])
217        line.append(field)
218    data.append(line)
219    for ticket in tabledata:
220        line = []
221        for element in tableheader:
222              field = formatted_text(getattr(ticket,element[1],u' '))
223              field = Paragraph(field, style["Normal"])
224              line.append(field)
225        data.append(line)
226    table = Table(data,colWidths=[
227        element[2]*cm for element in tableheader], style=CONTENT_STYLE)
228    return table
229
230def docs_as_flowables(view, lang='en'):
231    """Create reportlab flowables out of scanned docs.
232    """
233    # XXX: fix circular import problem
234    from waeup.ikoba.browser.fileviewlets import FileManager
235    from waeup.ikoba.browser import DEFAULT_IMAGE_PATH
236    style = getSampleStyleSheet()
237    data = []
238
239    # Collect viewlets
240    fm = FileManager(view.context, view.request, view)
241    fm.update()
242    if fm.viewlets:
243        sc_translation = trans(_('Connected Files'), lang)
244        data.append(Paragraph(sc_translation, HEADING_STYLE))
245        # Insert list of scanned documents
246        table_data = []
247        for viewlet in fm.viewlets:
248            if viewlet.file_exists:
249                # Show viewlet only if file exists
250                f_label = Paragraph(trans(viewlet.label, lang), ENTRY1_STYLE)
251                img_path = getattr(getUtility(IExtFileStore).getFileByContext(
252                    view.context, attr=viewlet.download_name), 'name', None)
253                #f_text = Paragraph(trans(_('(not provided)'),lang), ENTRY1_STYLE)
254                if img_path is None:
255                    pass
256                elif img_path[-4:] == '.pdf':
257                    f_text = Paragraph('%s (see file attached)' % (
258                        viewlet.title,), ENTRY1_STYLE)
259                elif not img_path[-4:] in ('.jpg', '.JPG'):
260                    # reportlab requires jpg images, I think.
261                    f_text = Paragraph('%s (no preview available)' % (
262                        viewlet.title,), ENTRY1_STYLE)
263                else:
264                    f_text = Image(img_path, width=2*cm, height=1*cm, kind='bound')
265                table_data.append([f_label, f_text])
266        if table_data:
267            # safety belt; empty tables lead to problems.
268            data.append(Table(table_data, style=SLIP_STYLE))
269    return data
270
271
272def set_up_widgets(view, ignore_request=False):
273    view.adapters = {}
274    view.widgets = setUpEditWidgets(
275        view.form_fields, view.prefix, view.context, view.request,
276        adapters=view.adapters, for_display=True,
277        ignore_request=ignore_request
278        )
279
280def generate_contract_id():
281    new_id = grok.getSite().unique_contract_id
282    return new_id
283
284class CustomersUtils(grok.GlobalUtility):
285    """A collection of methods subject to customization.
286    """
287    grok.implements(ICustomersUtils)
288
289    #: A prefix used when generating new customer ids. Each customer id will
290    #: start with this string. The default is 'K'.
291    CUSTOMER_ID_PREFIX = u'K'
292
293    CUSTMANAGE_CUSTOMER_STATES = (STARTED,)
294
295    DOCMANAGE_CUSTOMER_STATES = (REQUESTED, APPROVED, PROVISIONALLY)
296
297    DOCMANAGE_DOCUMENT_STATES = (CREATED,)
298
299    CONMANAGE_CUSTOMER_STATES = deepcopy(DOCMANAGE_CUSTOMER_STATES)
300
301    CONMANAGE_CONTRACT_STATES = (CREATED,)
302
303    TRANSLATED_CUSTOMER_STATES = {
304        CREATED: _('created'),
305        STARTED: _('started'),
306        REQUESTED: _('requested'),
307        APPROVED: _('approved'),
308        PROVISIONALLY: _('provisionally approved'),
309        }
310
311    TRANSLATED_CONTRACT_STATES = {
312        CREATED: _('created'),
313        AWAITING: _('awaiting payment'),
314        SUBMITTED: _('submitted for approval'),
315        APPROVED: _('approved'),
316        REJECTED: _('rejected'),
317        EXPIRED:_('expired')
318        }
319
320    TRANSLATED_DOCUMENT_STATES = {
321        CREATED: _('created'),
322        SUBMITTED: _('submitted for verification'),
323        VERIFIED: _('verified'),
324        REJECTED: _('rejected'),
325        EXPIRED:_('expired')
326        }
327
328    DOCTYPES_DICT = {
329        'CustomerSampleDocument': _('Sample Document'),
330        'CustomerPDFDocument': _('PDF Document'),
331        }
332
333    CONTYPES_DICT = {
334        'SampleContract': _('Sample Contract'),
335        }
336
337    SELECTABLE_DOCTYPES_DICT = deepcopy(DOCTYPES_DICT)
338
339    SELECTABLE_CONTYPES_DICT = deepcopy(CONTYPES_DICT)
340
341    EXPORTER_NAMES = ('customers', 'customersampledocuments', 'samplecontracts')
342
343    #: A dictionary which maps widget names to headlines. The headline
344    #: is rendered in forms and on pdf slips above the respective
345    #: display or input widget. There are no separating headlines
346    #: in the base package.
347    SEPARATORS_DICT = {}
348
349    def getPDFCreator(self, context=None):
350        """Get a pdf creator suitable for `context`.
351
352        The default implementation always returns the default creator.
353        """
354        return getUtility(IPDFCreator)
355
356    def renderPDF(self, view, filename='slip.pdf', customer=None,
357                  customerview=None,
358                  tableheader=[], tabledata=[],
359                  note=None, signatures=None, sigs_in_footer=(),
360                  show_scans=True, show_history=True, topMargin=1.5,
361                  omit_fields=(), mergefiles=None, watermark=None):
362        """Render pdf slips for various pages.
363        """
364        portal_language = getUtility(IIkobaUtils).PORTAL_LANGUAGE
365        style = getSampleStyleSheet()
366        creator = self.getPDFCreator()
367        data = []
368        doc_title = view.label
369        author = '%s (%s)' % (view.request.principal.title,
370                              view.request.principal.id)
371        footer_text = view.label.split('\n')
372        separators = getattr(self, 'SEPARATORS_DICT', {})
373        if len(footer_text) > 2:
374            # We can add a department in first line
375            footer_text = footer_text[1]
376        else:
377            # Only the first line is used for the footer
378            footer_text = footer_text[0]
379        if getattr(customer, 'customer_id', None) is not None:
380            footer_text = "%s - %s - " % (customer.customer_id, footer_text)
381
382        # Insert customer data table
383        if customer is not None:
384            if view.__name__ == 'contract_slip.pdf':
385                bd_translation = trans(_('Contractor'), portal_language)
386                data.append(Paragraph(bd_translation, HEADING_STYLE))
387            if view.__name__ == 'document_slip.pdf':
388                bd_translation = trans(_('Document Owner'), portal_language)
389                data.append(Paragraph(bd_translation, HEADING_STYLE))
390            data.append(render_customer_data(
391                customerview, view.context, omit_fields, lang=portal_language,
392                slipname=filename))
393
394        # Insert widgets
395        if view.form_fields:
396            if view.__name__.startswith('contract'):
397                data_header = trans(_('Contract Data'), portal_language)
398                data.append(Paragraph(data_header, HEADING_STYLE))
399            table = creator.getWidgetsTable(
400                view.form_fields, view.context, None, lang=portal_language,
401                separators=separators)
402            data.append(table)
403
404        # Insert payment data
405        if getattr(view, 'payment_tuples', None) is not None:
406            data_header = trans(_('Payments'), portal_language)
407            data.append(Paragraph(data_header, HEADING_STYLE))
408            payment_number = 0
409            for payment_tuple in getattr(view, 'payment_tuples', None):
410                payment_number += 1
411                payment = payment_tuple[0]
412                payment_form_fields = payment_tuple[1]
413                if payment.state != STATE_PAID:
414                    continue
415                data.append(
416                    Paragraph("Payment %s" % payment_number, HEADLINE1_STYLE))
417                table = creator.getWidgetsTable(
418                    payment_form_fields, payment, None, lang=portal_language,
419                    separators=separators)
420                data.append(table)
421
422        # Insert scanned docs
423        if show_scans:
424            data.extend(docs_as_flowables(view, portal_language))
425
426        # Insert history
427        if show_history:
428            if getattr(view.context, 'history', None):
429                hist_translation = trans(_('Transaction History'),
430                    portal_language)
431                data.append(Paragraph(hist_translation, HEADING_STYLE))
432                data.extend(
433                    creator.fromStringList(view.context.history.messages))
434            else:
435                hist_translation = trans(_('Registration History'),
436                    portal_language)
437                data.append(Paragraph(hist_translation, HEADING_STYLE))
438                data.extend(creator.fromStringList(customer.history.messages))
439
440        # Insert validity period
441        if getattr(view, 'validity_period', None):
442            vp_translation = trans(_('Validity Period'), portal_language)
443            data.append(Paragraph(vp_translation, HEADING_STYLE))
444            vp = view.validity_period
445            data.append(Paragraph(vp, NOTE_STYLE))
446
447        # Insert content tables (optionally on second page)
448        if getattr(view, 'tabletitle', None):
449            for i in range(len(view.tabletitle)):
450                if tabledata[i] and tableheader[i]:
451                    #data.append(PageBreak())
452                    #data.append(Spacer(1, 20))
453                    data.append(Paragraph(view.tabletitle[i], HEADING_STYLE))
454                    data.append(Spacer(1, 8))
455                    contenttable = render_table_data(tableheader[i],tabledata[i])
456                    data.append(contenttable)
457
458        # Insert terms and conditions
459        if getattr(view, 'terms_and_conditions', None):
460            tc_translation = trans(_('Terms and Conditions'), portal_language)
461            data.append(Paragraph(tc_translation, HEADING_STYLE))
462            tc = format_html(view.terms_and_conditions)
463            data.append(Paragraph(tc, NOTE_STYLE))
464
465        # Insert signatures
466        # XXX: We are using only sigs_in_footer in waeup.ikoba, so we
467        # do not have a test for the following lines.
468        if signatures and not sigs_in_footer:
469            data.append(Spacer(1, 20))
470            # Render one signature table per signature to
471            # get date and signature in line.
472            for signature in signatures:
473                signaturetables = get_signature_tables(signature)
474                data.append(signaturetables[0])
475
476        view.response.setHeader(
477            'Content-Type', 'application/pdf')
478        try:
479            pdf_stream = creator.create_pdf(
480                data, None, doc_title, author=author, footer=footer_text,
481                note=note, sigs_in_footer=sigs_in_footer, topMargin=topMargin)
482        except IOError:
483            view.flash('Error in image file.')
484            return view.redirect(view.url(view.context))
485        except LayoutError, err:
486            view.flash(
487                'PDF file could not be created. Reportlab error message: %s'
488                % escape(err.message),
489                type="danger")
490            return view.redirect(view.url(view.context))
491        if mergefiles:
492            merger = PdfFileMerger()
493            merger.append(StringIO(pdf_stream))
494            if watermark:
495                watermark = PdfFileReader(watermark)
496            for file in mergefiles:
497                if watermark:
498                    # Pass through all pages of each file
499                    # and merge with watermark page.
500                    marked_file = PdfFileWriter()
501                    orig_file = PdfFileReader(file[1])
502                    num_pages = orig_file.getNumPages()
503                    for num in range(num_pages):
504                        page = orig_file.getPage(num)
505                        page.mergePage(watermark.getPage(0))
506                        marked_file.addPage(page)
507                    # Save into a file-like object
508                    tmp1 = StringIO()
509                    marked_file.write(tmp1)
510                    # Append the file-like object
511                    merger.append(tmp1)
512                else:
513                    # Just append the file object
514                    merger.append(file[1])
515            # Save into a file-like object
516            tmp2 = StringIO()
517            merger.write(tmp2)
518            return tmp2.getvalue()
519
520        return pdf_stream
Note: See TracBrowser for help on using the repository browser.