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

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

Set SEPARATORS_DICT.

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