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

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

Extend customer registration workflow.

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