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

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

Render terms and conditions on contract slip.

  • Property svn:keywords set to Id
File size: 17.9 KB
Line 
1## $Id: utils.py 12368 2015-01-03 06:33: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,
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.kofa', 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,)
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        }
306
307    TRANSLATED_CONTRACT_STATES = {
308        CREATED: _('created'),
309        SUBMITTED: _('submitted for approval'),
310        APPROVED: _('approved'),
311        REJECTED: _('rejected'),
312        EXPIRED:_('expired')
313        }
314
315    TRANSLATED_DOCUMENT_STATES = {
316        CREATED: _('created'),
317        SUBMITTED: _('submitted for verification'),
318        VERIFIED: _('verified'),
319        REJECTED: _('rejected'),
320        EXPIRED:_('expired')
321        }
322
323    DOCTYPES_DICT = {
324        'CustomerSampleDocument': _('Sample Document'),
325        'CustomerPDFDocument': _('PDF Document'),
326        }
327
328    CONTYPES_DICT = {
329        'SampleContract': _('Sample Contract'),
330        }
331
332    SELECTABLE_DOCTYPES_DICT = deepcopy(DOCTYPES_DICT)
333
334    SELECTABLE_CONTYPES_DICT = deepcopy(CONTYPES_DICT)
335
336    EXPORTER_NAMES = ('customers', 'customersampledocuments', 'samplecontracts')
337
338    def getPDFCreator(self, context=None):
339        """Get a pdf creator suitable for `context`.
340
341        The default implementation always returns the default creator.
342        """
343        return getUtility(IPDFCreator)
344
345    def renderPDF(self, view, filename='slip.pdf', customer=None,
346                  customerview=None,
347                  tableheader=[], tabledata=[],
348                  note=None, signatures=None, sigs_in_footer=(),
349                  show_scans=True, show_history=True, topMargin=1.5,
350                  omit_fields=(), mergefiles=None, watermark=None):
351        """Render pdf slips for various pages.
352        """
353        portal_language = getUtility(IIkobaUtils).PORTAL_LANGUAGE
354        style = getSampleStyleSheet()
355        creator = self.getPDFCreator()
356        data = []
357        doc_title = view.label
358        author = '%s (%s)' % (view.request.principal.title,
359                              view.request.principal.id)
360        footer_text = view.label.split('\n')
361        if len(footer_text) > 2:
362            # We can add a department in first line
363            footer_text = footer_text[1]
364        else:
365            # Only the first line is used for the footer
366            footer_text = footer_text[0]
367        if getattr(customer, 'customer_id', None) is not None:
368            footer_text = "%s - %s - " % (customer.customer_id, footer_text)
369
370        # Insert customer data table
371        if customer is not None:
372            bd_translation = trans(_('Base Data'), portal_language)
373            data.append(Paragraph(bd_translation, HEADING_STYLE))
374            data.append(render_customer_data(
375                customerview, view.context, omit_fields, lang=portal_language,
376                slipname=filename))
377
378        # Insert widgets
379        if view.form_fields:
380            data.append(Paragraph(view.title, HEADING_STYLE))
381            separators = getattr(self, 'SEPARATORS_DICT', {})
382            table = creator.getWidgetsTable(
383                view.form_fields, view.context, None, lang=portal_language,
384                separators=separators)
385            data.append(table)
386
387        # Insert scanned docs
388        if show_scans:
389            data.extend(docs_as_flowables(view, portal_language))
390
391        # Insert history
392        if show_history:
393            if getattr(view.context, 'history', None):
394                hist_translation = trans(_('${a} Workflow History',
395                                           mapping = {'a':view.context.translated_class_name}),
396                                        portal_language)
397                data.append(Paragraph(hist_translation, HEADING_STYLE))
398                data.extend(creator.fromStringList(view.context.history.messages))
399            else:
400                hist_translation = trans(_('Customer Workflow History'), portal_language)
401                data.append(Paragraph(hist_translation, HEADING_STYLE))
402                data.extend(creator.fromStringList(customer.history.messages))
403
404        # Insert content tables (optionally on second page)
405        if hasattr(view, 'tabletitle'):
406            for i in range(len(view.tabletitle)):
407                if tabledata[i] and tableheader[i]:
408                    #data.append(PageBreak())
409                    #data.append(Spacer(1, 20))
410                    data.append(Paragraph(view.tabletitle[i], HEADING_STYLE))
411                    data.append(Spacer(1, 8))
412                    contenttable = render_table_data(tableheader[i],tabledata[i])
413                    data.append(contenttable)
414
415        # Insert terms and conditions
416        if hasattr(view, 'terms_and_conditions'):
417            tc_translation = trans(_('Terms and Conditions'), portal_language)
418            data.append(Paragraph(tc_translation, HEADING_STYLE))
419            tc = format_html(view.terms_and_conditions)
420            data.append(Paragraph(tc, NOTE_STYLE))
421
422        # Insert signatures
423        # XXX: We are using only sigs_in_footer in waeup.ikoba, so we
424        # do not have a test for the following lines.
425        if signatures and not sigs_in_footer:
426            data.append(Spacer(1, 20))
427            # Render one signature table per signature to
428            # get date and signature in line.
429            for signature in signatures:
430                signaturetables = get_signature_tables(signature)
431                data.append(signaturetables[0])
432
433        view.response.setHeader(
434            'Content-Type', 'application/pdf')
435        try:
436            pdf_stream = creator.create_pdf(
437                data, None, doc_title, author=author, footer=footer_text,
438                note=note, sigs_in_footer=sigs_in_footer, topMargin=topMargin)
439        except IOError:
440            view.flash('Error in image file.')
441            return view.redirect(view.url(view.context))
442
443        if mergefiles:
444            merger = PdfFileMerger()
445            merger.append(StringIO(pdf_stream))
446            if watermark:
447                watermark = PdfFileReader(watermark)
448            for file in mergefiles:
449                if watermark:
450                    # Pass through all pages of each file
451                    # and merge with watermark page.
452                    marked_file = PdfFileWriter()
453                    orig_file = PdfFileReader(file[1])
454                    num_pages = orig_file.getNumPages()
455                    for num in range(num_pages):
456                        page = orig_file.getPage(num)
457                        page.mergePage(watermark.getPage(0))
458                        marked_file.addPage(page)
459                    # Save into a file-like object
460                    tmp1 = StringIO()
461                    marked_file.write(tmp1)
462                    # Append the file-like object
463                    merger.append(tmp1)
464                else:
465                    # Just append the file object
466                    merger.append(file[1])
467            # Save into a file-like object
468            tmp2 = StringIO()
469            merger.write(tmp2)
470            return tmp2.getvalue()
471
472        return pdf_stream
Note: See TracBrowser for help on using the repository browser.