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

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

Prepare contract payment receipt.

  • Property svn:keywords set to Id
File size: 19.2 KB
Line 
1## $Id: utils.py 12772 2015-03-16 10:02:14Z 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    def getPDFCreator(self, context=None):
342        """Get a pdf creator suitable for `context`.
343
344        The default implementation always returns the default creator.
345        """
346        return getUtility(IPDFCreator)
347
348    def renderPDF(self, view, filename='slip.pdf', customer=None,
349                  customerview=None,
350                  tableheader=[], tabledata=[],
351                  note=None, signatures=None, sigs_in_footer=(),
352                  show_scans=True, show_history=True, topMargin=1.5,
353                  omit_fields=(), mergefiles=None, watermark=None):
354        """Render pdf slips for various pages.
355        """
356        portal_language = getUtility(IIkobaUtils).PORTAL_LANGUAGE
357        style = getSampleStyleSheet()
358        creator = self.getPDFCreator()
359        data = []
360        doc_title = view.label
361        author = '%s (%s)' % (view.request.principal.title,
362                              view.request.principal.id)
363        footer_text = view.label.split('\n')
364        if len(footer_text) > 2:
365            # We can add a department in first line
366            footer_text = footer_text[1]
367        else:
368            # Only the first line is used for the footer
369            footer_text = footer_text[0]
370        if getattr(customer, 'customer_id', None) is not None:
371            footer_text = "%s - %s - " % (customer.customer_id, footer_text)
372
373        # Insert customer data table
374        if customer is not None:
375            if view.__name__ == 'contract_slip.pdf':
376                bd_translation = trans(_('Contractor'), portal_language)
377                data.append(Paragraph(bd_translation, HEADING_STYLE))
378            if view.__name__ == 'document_slip.pdf':
379                bd_translation = trans(_('Document Owner'), portal_language)
380                data.append(Paragraph(bd_translation, HEADING_STYLE))
381            data.append(render_customer_data(
382                customerview, view.context, omit_fields, lang=portal_language,
383                slipname=filename))
384
385        # Insert widgets
386        if view.form_fields:
387            if view.__name__.startswith('contract'):
388                data_header = trans(_('Contract Data'), portal_language)
389                data.append(Paragraph(data_header, HEADING_STYLE))
390            separators = getattr(self, 'SEPARATORS_DICT', {})
391            table = creator.getWidgetsTable(
392                view.form_fields, view.context, None, lang=portal_language,
393                separators=separators)
394            data.append(table)
395
396        # Insert payment data
397        if getattr(view, 'payment', None) is not None:
398            data_header = trans(_('Payment Data'), portal_language)
399            data.append(Paragraph(data_header, HEADING_STYLE))
400            for payment in getattr(view, 'payment', None):
401                if payment.state != STATE_PAID:
402                    continue
403                data.append(Paragraph('Payment', HEADLINE1_STYLE))
404                # XXX: Collect and render payment data
405
406        # Insert scanned docs
407        if show_scans:
408            data.extend(docs_as_flowables(view, portal_language))
409
410        # Insert history
411        if show_history:
412            if getattr(view.context, 'history', None):
413                hist_translation = trans(_('Transaction History'),
414                    portal_language)
415                data.append(Paragraph(hist_translation, HEADING_STYLE))
416                data.extend(
417                    creator.fromStringList(view.context.history.messages))
418            else:
419                hist_translation = trans(_('Registration History'),
420                    portal_language)
421                data.append(Paragraph(hist_translation, HEADING_STYLE))
422                data.extend(creator.fromStringList(customer.history.messages))
423
424        # Insert validity period
425        if getattr(view, 'validity_period', None):
426            vp_translation = trans(_('Validity Period'), portal_language)
427            data.append(Paragraph(vp_translation, HEADING_STYLE))
428            vp = view.validity_period
429            data.append(Paragraph(vp, NOTE_STYLE))
430
431        # Insert content tables (optionally on second page)
432        if getattr(view, 'tabletitle', None):
433            for i in range(len(view.tabletitle)):
434                if tabledata[i] and tableheader[i]:
435                    #data.append(PageBreak())
436                    #data.append(Spacer(1, 20))
437                    data.append(Paragraph(view.tabletitle[i], HEADING_STYLE))
438                    data.append(Spacer(1, 8))
439                    contenttable = render_table_data(tableheader[i],tabledata[i])
440                    data.append(contenttable)
441
442        # Insert terms and conditions
443        if getattr(view, 'terms_and_conditions', None):
444            tc_translation = trans(_('Terms and Conditions'), portal_language)
445            data.append(Paragraph(tc_translation, HEADING_STYLE))
446            tc = format_html(view.terms_and_conditions)
447            data.append(Paragraph(tc, NOTE_STYLE))
448
449        # Insert signatures
450        # XXX: We are using only sigs_in_footer in waeup.ikoba, so we
451        # do not have a test for the following lines.
452        if signatures and not sigs_in_footer:
453            data.append(Spacer(1, 20))
454            # Render one signature table per signature to
455            # get date and signature in line.
456            for signature in signatures:
457                signaturetables = get_signature_tables(signature)
458                data.append(signaturetables[0])
459
460        view.response.setHeader(
461            'Content-Type', 'application/pdf')
462        try:
463            pdf_stream = creator.create_pdf(
464                data, None, doc_title, author=author, footer=footer_text,
465                note=note, sigs_in_footer=sigs_in_footer, topMargin=topMargin)
466        except IOError:
467            view.flash('Error in image file.')
468            return view.redirect(view.url(view.context))
469
470        if mergefiles:
471            merger = PdfFileMerger()
472            merger.append(StringIO(pdf_stream))
473            if watermark:
474                watermark = PdfFileReader(watermark)
475            for file in mergefiles:
476                if watermark:
477                    # Pass through all pages of each file
478                    # and merge with watermark page.
479                    marked_file = PdfFileWriter()
480                    orig_file = PdfFileReader(file[1])
481                    num_pages = orig_file.getNumPages()
482                    for num in range(num_pages):
483                        page = orig_file.getPage(num)
484                        page.mergePage(watermark.getPage(0))
485                        marked_file.addPage(page)
486                    # Save into a file-like object
487                    tmp1 = StringIO()
488                    marked_file.write(tmp1)
489                    # Append the file-like object
490                    merger.append(tmp1)
491                else:
492                    # Just append the file object
493                    merger.append(file[1])
494            # Save into a file-like object
495            tmp2 = StringIO()
496            merger.write(tmp2)
497            return tmp2.getvalue()
498
499        return pdf_stream
Note: See TracBrowser for help on using the repository browser.