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

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

Make EXPORTER_NAMES tuples customizable. We have many new subobject classes in custom packages.

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