Passed
Push — master ( 719ce4...8d2e48 )
by Ramon
06:34
created

AnalysisRequestDigester.__call__()   A

Complexity

Conditions 1

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 7
dl 0
loc 10
rs 10
c 0
b 0
f 0
cc 1
nop 3
1
# -*- coding: utf-8 -*-
2
#
3
# This file is part of SENAITE.CORE
4
#
5
# Copyright 2018 by it's authors.
6
# Some rights reserved. See LICENSE.rst, CONTRIBUTORS.rst.
7
8
import os
9
import re
10
import tempfile
11
import traceback
12
from copy import copy
13
from email.mime.multipart import MIMEMultipart
14
from email.mime.text import MIMEText
15
from email.utils import formataddr
16
from operator import itemgetter
17
from smtplib import SMTPAuthenticationError
18
from smtplib import SMTPRecipientsRefused, SMTPServerDisconnected
19
20
import App
21
import transaction
22
from DateTime import DateTime
23
from Products.Archetypes.interfaces import IDateTimeField, IFileField, \
24
    ILinesField, IReferenceField, IStringField, ITextField
25
from Products.CMFCore.WorkflowCore import WorkflowException
26
from Products.CMFCore.utils import getToolByName
27
from Products.CMFPlone.utils import _createObjectByType, safe_unicode
28
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
29
from bika.lims import api
30
from bika.lims import POINTS_OF_CAPTURE, bikaMessageFactory as _, t
31
from bika.lims import logger
32
from bika.lims.api.analysis import is_out_of_range
33
from bika.lims.browser import BrowserView, ulocalized_time
34
from bika.lims.catalog.analysis_catalog import CATALOG_ANALYSIS_LISTING
35
from bika.lims.idserver import renameAfterCreation
36
from bika.lims.interfaces import IAnalysisRequest
37
from bika.lims.interfaces.field import IUIDReferenceField
38
from bika.lims.utils import attachPdf, createPdf, encode_header, \
39
    format_supsub, \
40
    isnumber
41
from bika.lims.utils import formatDecimalMark, to_utf8
42
from bika.lims.utils.analysis import format_uncertainty
43
from bika.lims.vocabularies import getARReportTemplates
44
from bika.lims.workflow import wasTransitionPerformed
45
from plone.api.portal import get_registry_record
46
from plone.api.portal import set_registry_record
47
from plone.app.blob.interfaces import IBlobField
48
from plone.memoize import view as viewcache
49
from plone.registry import Record
50
from plone.registry import field
51
from plone.registry.interfaces import IRegistry
52
from plone.resource.utils import queryResourceDirectory
53
from zope.component import getUtility
54
55
56
class AnalysisRequestPublishView(BrowserView):
57
    template = ViewPageTemplateFile("templates/analysisrequest_publish.pt")
58
    _ars = []
59
    _arsbyclient = []
60
    _current_ar_index = 0
61
    _current_arsbyclient_index = 0
62
    _publish = False
63
64
    def __init__(self, context, request, publish=False):
65
        BrowserView.__init__(self, context, request)
66
        self.context = context
67
        self.request = request
68
        self._publish = publish
69
        self._ars = [self.context]
70
        self._digester = AnalysisRequestDigester()
71
72
    @property
73
    def _DEFAULT_TEMPLATE(self):
74
        registry = getUtility(IRegistry)
75
        return registry.get(
76
            'bika.lims.analysisrequest.default_arreport_template', 'default.pt')
77
78
    def next_certificate_number(self):
79
        """Get a new certificate id.  These are throwaway IDs, until the
80
        publication is actually done.  So each preview gives us a new ID.
81
        """
82
        key = 'bika.lims.current_coa_number'
83
        registry = getUtility(IRegistry)
84
        if key not in registry:
85
            registry.records[key] = \
86
                Record(field.Int(title=u"Current COA number"), 0)
87
        val = get_registry_record(key) + 1
88
        set_registry_record(key, val)
89
        return "%05d" % int(val)
90
91
    def __call__(self):
92
        if self.context.portal_type == 'AnalysisRequest':
93
            self._ars = [self.context]
94
        elif self.context.portal_type in ('AnalysisRequestsFolder', 'Client') \
95
                and self.request.get('items', ''):
96
            uids = self.request.get('items').split(',')
97
            uc = getToolByName(self.context, 'uid_catalog')
98
            self._ars = [obj.getObject() for obj in uc(UID=uids)]
99
        else:
100
            # Do nothing
101
            self.destination_url = self.request.get_header(
102
                "referer", self.context.absolute_url())
103
104
        # Group ARs by client
105
        groups = {}
106
        for ar in self._ars:
107
            idclient = ar.aq_parent.id
108
            if idclient not in groups:
109
                groups[idclient] = [ar]
110
            else:
111
                groups[idclient].append(ar)
112
        self._arsbyclient = [group for group in groups.values()]
113
114
        # Report may want to print current date
115
        self.current_date = self.ulocalized_time(DateTime(), long_format=True)
116
117
        # Do publish?
118
        if self.request.form.get('publish', '0') == '1':
119
            self.publishFromPOST()
120
        else:
121
            return self.template()
122
123
    def showOptions(self):
124
        """Returns true if the options top panel will be displayed
125
        in the template
126
        """
127
        return self.request.get('pub', '1') == '1'
128
129
    def getAvailableFormats(self):
130
        """Returns the available formats found in templates/reports
131
        """
132
        return getARReportTemplates()
133
134
    def getAnalysisRequests(self):
135
        """Returns a dict with the analysis requests to manage
136
        """
137
        return self._ars
138
139
    def getAnalysisRequestsCount(self):
140
        """Returns the number of analysis requests to manage
141
        """
142
        return len(self._ars)
143
144
    def getGroupedAnalysisRequestsCount(self):
145
        """Returns the number of groups of analysis requests to manage when
146
        a multi-ar template is selected. The ARs are grouped by client
147
        """
148
        return len(self._arsbyclient)
149
150
    def getAnalysisRequestObj(self):
151
        """Returns the analysis request objects to be managed
152
        """
153
        return self._ars[self._current_ar_index]
154
155
    def getAnalysisRequest(self, analysisrequest=None):
156
        """Returns the dict for the Analysis Request specified. If no AR set,
157
        returns the current analysis request
158
        """
159
        if analysisrequest:
160
            return self._digester(analysisrequest)
161
        else:
162
            return self._digester(self._ars[self._current_ar_index])
163
164
    def getAnalysisRequestGroup(self):
165
        """Returns the current analysis request group to be managed
166
        """
167
        return self._arsbyclient[self._current_arsbyclient_index]
168
169
    def getAnalysisRequestGroupData(self):
170
        """Returns an array that contains the dicts (ar_data) for each
171
        analysis request from the current group
172
        """
173
        return [self._digester(ar) for ar in self.getAnalysisRequestGroup()]
174
175
    def _nextAnalysisRequest(self):
176
        """Move to the next analysis request
177
        """
178
        if self._current_ar_index < len(self._ars):
179
            self._current_ar_index += 1
180
181
    def _nextAnalysisRequestGroup(self):
182
        """Move to the next analysis request group
183
        """
184
        if self._current_arsbyclient_index < len(self._arsbyclient):
185
            self._current_arsbyclient_index += 1
186
187
    def _renderTemplate(self):
188
        """Returns the html template to be rendered in accordance with the
189
        template specified in the request ('template' parameter)
190
        """
191
        templates_dir = 'templates/reports'
192
        embedt = self.request.form.get('template', self._DEFAULT_TEMPLATE)
193
        if embedt.find(':') >= 0:
194
            prefix, template = embedt.split(':')
195
            templates_dir = queryResourceDirectory('reports', prefix).directory
196
            embedt = template
197
        embed = ViewPageTemplateFile(os.path.join(templates_dir, embedt))
198
        return embedt, embed(self)
199
200
    def getReportTemplate(self):
201
        """Returns the html template for the current ar and moves to
202
        the next ar to be processed. Uses the selected template
203
        specified in the request ('template' parameter)
204
        """
205
        embedt = ""
206
        try:
207
            embedt, reptemplate = self._renderTemplate()
208
        except:
209
            tbex = traceback.format_exc()
210
            arid = self._ars[self._current_ar_index].id
211
            reptemplate = \
212
                "<div class='error-report'>%s - %s '%s':<pre>%s</pre></div>" \
213
                % (arid, _("Unable to load the template"), embedt, tbex)
214
        self._nextAnalysisRequest()
215
        return reptemplate
216
217
    def getGroupedReportTemplate(self):
218
        """Returns the html template for the current group of ARs and moves to
219
        the next group to be processed. Uses the selected template
220
        specified in the request ('template' parameter)
221
        """
222
        embedt = ""
223
        try:
224
            embedt, reptemplate = self._renderTemplate()
225
        except:
226
            tbex = traceback.format_exc()
227
            reptemplate = \
228
                "<div class='error-report'>%s '%s':<pre>%s</pre></div>" \
229
                % (_("Unable to load the template"), embedt, tbex)
230
        self._nextAnalysisRequestGroup()
231
        return reptemplate
232
233 View Code Duplication
    def getReportStyle(self):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
234
        """Returns the css style to be used for the current template.
235
        If the selected template is 'default.pt', this method will
236
        return the content from 'default.css'. If no css file found
237
        for the current template, returns empty string
238
        """
239
        template = self.request.form.get('template', self._DEFAULT_TEMPLATE)
240
        content = ''
241
        if template.find(':') >= 0:
242
            prefix, template = template.split(':')
243
            resource = queryResourceDirectory('reports', prefix)
244
            css = '{0}.css'.format(template[:-3])
245
            if css in resource.listDirectory():
246
                content = resource.readFile(css)
247
        else:
248
            this_dir = os.path.dirname(os.path.abspath(__file__))
249
            templates_dir = os.path.join(this_dir, 'templates/reports/')
250
            path = '%s/%s.css' % (templates_dir, template[:-3])
251
            with open(path, 'r') as content_file:
252
                content = content_file.read()
253
        return content
254
255
    def isSingleARTemplate(self):
256
        seltemplate = self.request.form.get('template', self._DEFAULT_TEMPLATE)
257
        seltemplate = seltemplate.split(':')[-1].strip()
258
        return not seltemplate.lower().startswith('multi')
259
260
    def isQCAnalysesVisible(self):
261
        """Returns if the QC Analyses must be displayed
262
        """
263
        return self.request.form.get('qcvisible', '0').lower() in ['true', '1']
264
265
    def isHiddenAnalysesVisible(self):
266
        """Returns true if hidden analyses are visible
267
        """
268
        return self.request.form.get('hvisible', '0').lower() in ['true', '1']
269
270
    def isLandscape(self):
271
        """ Returns if the layout is landscape
272
        """
273
        return self.request.form.get('landscape', '0').lower() in ['true', '1']
274
275
    def localise_images(self, htmlreport):
276
        """WeasyPrint will attempt to retrieve attachments directly from the URL
277
        referenced in the HTML report, which may refer back to a single-threaded
278
        (and currently occupied) zeoclient, hanging it.  All "attachments"
279
        using urls ending with at_download/AttachmentFile must be converted
280
        to local files.
281
282
        Returns a list of files which were created, and a modified copy
283
        of htmlreport.
284
        """
285
        cleanup = []
286
287
        _htmltext = to_utf8(htmlreport)
288
        # first regular image tags
289
        for match in re.finditer(
290
                """http.*at_download/AttachmentFile""", _htmltext, re.I):
291
            url = match.group()
292
            att_path = url.replace(self.portal_url + "/", "")
293
            attachment = self.portal.unrestrictedTraverse(att_path)
294
            af = attachment.getAttachmentFile()
295
            filename = af.filename
296
            extension = "." + filename.split(".")[-1]
297
            outfile, outfilename = tempfile.mkstemp(suffix=extension)
298
            outfile = open(outfilename, 'wb')
299
            outfile.write(str(af.data))
300
            outfile.close()
301
            _htmltext.replace(url, outfilename)
302
            cleanup.append(outfilename)
303
        return cleanup, _htmltext
304
305
    def publishFromPOST(self):
306
        html = self.request.form.get('html')
307
        style = self.request.form.get('style')
308
        uids = self.request.form.get('uid').split(':')
309
        reporthtml = "<html><head>%s</head><body><div " \
310
                     "id='report'>%s</body></html>" % (style, html)
311
        publishedars = []
312
        for uid in uids:
313
            ars = self.publishFromHTML(
314
                uid, safe_unicode(reporthtml).encode('utf-8'))
315
            publishedars.extend(ars)
316
        return publishedars
317
318
    def publishFromHTML(self, aruid, results_html):
319
        # The AR can be published only and only if allowed
320
        uc = getToolByName(self.context, 'uid_catalog')
321
        ars = uc(UID=aruid)
322
        if not ars or len(ars) != 1:
323
            return []
324
325
        ar = ars[0].getObject()
326
        wf = getToolByName(self.context, 'portal_workflow')
327
        allowed_states = ['verified', 'published']
328
        # Publish/Republish allowed?
329
        if wf.getInfoFor(ar, 'review_state') not in allowed_states:
330
            # Pre-publish allowed?
331
            if not ar.getAnalyses(review_state=allowed_states):
332
                return []
333
334
        # HTML written to debug file
335
        debug_mode = App.config.getConfiguration().debug_mode
336
        if debug_mode:
337
            tmp_fn = tempfile.mktemp(suffix=".html")
338
            logger.debug("Writing HTML for %s to %s" % (ar.Title(), tmp_fn))
339
            open(tmp_fn, "wb").write(results_html)
340
341
        # Create the pdf report (will always be attached to the AR)
342
        # we must supply the file ourself so that createPdf leaves it alone.
343
        pdf_fn = tempfile.mktemp(suffix=".pdf")
344
        pdf_report = createPdf(htmlreport=results_html, outfile=pdf_fn)
345
346
        # PDF written to debug file
347
        if debug_mode:
348
            logger.debug("Writing PDF for %s to %s" % (ar.Title(), pdf_fn))
349
        else:
350
            os.remove(pdf_fn)
351
352
        recipients = []
353
        contact = ar.getContact()
354
        lab = ar.bika_setup.laboratory
355
        if pdf_report:
356
            if contact:
357
                recipients = [{
358
                    'UID': contact.UID(),
359
                    'Username': to_utf8(contact.getUsername()),
360
                    'Fullname': to_utf8(contact.getFullname()),
361
                    'EmailAddress': to_utf8(contact.getEmailAddress()),
362
                    'PublicationModes': contact.getPublicationPreference()
363
                }]
364
            reportid = ar.generateUniqueId('ARReport')
365
            report = _createObjectByType("ARReport", ar, reportid)
366
            report.edit(
367
                AnalysisRequest=ar.UID(),
368
                Pdf=pdf_report,
369
                Recipients=recipients
370
            )
371
            report.unmarkCreationFlag()
372
            renameAfterCreation(report)
373
374
            # Set status to prepublished/published/republished
375
            status = wf.getInfoFor(ar, 'review_state')
376
            transitions = {'verified': 'publish',
377
                           'published': 'republish'}
378
            transition = transitions.get(status, 'prepublish')
379
            try:
380
                wf.doActionFor(ar, transition)
381
            except WorkflowException:
382
                pass
383
384
            # compose and send email.
385
            # The managers of the departments for which the current AR has
386
            # at least one AS must receive always the pdf report by email.
387
            # https://github.com/bikalabs/Bika-LIMS/issues/1028
388
            mime_msg = MIMEMultipart('related')
389
            mime_msg['Subject'] = self.get_mail_subject(ar)[0]
390
            mime_msg['From'] = formataddr(
391
                (encode_header(lab.getName()), lab.getEmailAddress()))
392
            mime_msg.preamble = 'This is a multi-part MIME message.'
393
            msg_txt = MIMEText(results_html, _subtype='html')
394
            mime_msg.attach(msg_txt)
395
396
            to = []
397
            mngrs = ar.getResponsible()
398
            for mngrid in mngrs['ids']:
399
                name = mngrs['dict'][mngrid].get('name', '')
400
                email = mngrs['dict'][mngrid].get('email', '')
401
                if email:
402
                    to.append(formataddr((encode_header(name), email)))
403
404
            if len(to) > 0:
405
                # Send the email to the managers
406
                mime_msg['To'] = ','.join(to)
407
                attachPdf(mime_msg, pdf_report, ar.id)
408
409
                try:
410
                    host = getToolByName(self.context, 'MailHost')
411
                    host.send(mime_msg.as_string(), immediate=True)
412
                except SMTPServerDisconnected as msg:
413
                    logger.warn("SMTPServerDisconnected: %s." % msg)
414
                except SMTPRecipientsRefused as msg:
415
                    raise WorkflowException(str(msg))
416
                except SMTPAuthenticationError as msg:
417
                    logger.warn("SMTPAuthenticationFailed: %s." % msg)
418
419
        # Send report to recipients
420
        recips = self.get_recipients(ar)
421
        for recip in recips:
422
            if 'email' not in recip.get('pubpref', []) \
423
                    or not recip.get('email', ''):
424
                continue
425
426
            title = encode_header(recip.get('title', ''))
427
            email = recip.get('email')
428
            formatted = formataddr((title, email))
429
430
            # Create the new mime_msg object, cause the previous one
431
            # has the pdf already attached
432
            mime_msg = MIMEMultipart('related')
433
            mime_msg['Subject'] = self.get_mail_subject(ar)[0]
434
            mime_msg['From'] = formataddr(
435
                (encode_header(lab.getName()), lab.getEmailAddress()))
436
            mime_msg.preamble = 'This is a multi-part MIME message.'
437
            msg_txt = MIMEText(results_html, _subtype='html')
438
            mime_msg.attach(msg_txt)
439
            mime_msg['To'] = formatted
440
441
            # Attach the pdf to the email if requested
442
            if pdf_report and 'pdf' in recip.get('pubpref'):
443
                attachPdf(mime_msg, pdf_report, ar.id)
444
445
            # For now, I will simply ignore mail send under test.
446
            if hasattr(self.portal, 'robotframework'):
447
                continue
448
449
            msg_string = mime_msg.as_string()
450
451
            # content of outgoing email written to debug file
452
            if debug_mode:
453
                tmp_fn = tempfile.mktemp(suffix=".email")
454
                logger.debug(
455
                    "Writing MIME message for %s to %s" % (ar.Title(), tmp_fn))
456
                open(tmp_fn, "wb").write(msg_string)
457
458
            try:
459
                host = getToolByName(self.context, 'MailHost')
460
                host.send(msg_string, immediate=True)
461
            except SMTPServerDisconnected as msg:
462
                logger.warn("SMTPServerDisconnected: %s." % msg)
463
            except SMTPRecipientsRefused as msg:
464
                raise WorkflowException(str(msg))
465
            except SMTPAuthenticationError as msg:
466
                logger.warn("SMTPAuthenticationFailed: %s." % msg)
467
468
        return [ar]
469
470
    def publish(self):
471
        """Publish the AR report/s. Generates a results pdf file associated
472
        to each AR, sends an email with the report to the lab manager and
473
        sends a notification (usually an email with the PDF attached) to the
474
        AR's contact and CCs. Transitions each published AR to statuses
475
        'published', 'prepublished' or 'republished'. Returns a list with the
476
        AR identifiers that have been published/prepublished/republished
477
        (only those 'verified', 'published' or at least have one 'verified'
478
        result).
479
        """
480
        if len(self._ars) > 1:
481
            published_ars = []
482
            for ar in self._ars:
483
                arpub = AnalysisRequestPublishView(
484
                    ar, self.request, publish=True)
485
                ar = arpub.publish()
486
                published_ars.extend(ar)
487
            published_ars = [par.id for par in published_ars]
488
            return published_ars
489
490
        results_html = safe_unicode(self.template()).encode('utf-8')
491
        return self.publishFromHTML(results_html)
0 ignored issues
show
Bug introduced by
It seems like a value for argument results_html is missing in the method call.
Loading history...
492
493
    def get_recipients(self, ar):
494
        """Returns a list with the recipients and all its publication prefs
495
        """
496
        recips = []
497
498
        # Contact and CC's
499
        contact = ar.getContact()
500
        if contact:
501
            recips.append({'title': to_utf8(contact.Title()),
502
                           'email': contact.getEmailAddress(),
503
                           'pubpref': contact.getPublicationPreference()})
504
        for cc in ar.getCCContact():
505
            recips.append({'title': to_utf8(cc.Title()),
506
                           'email': cc.getEmailAddress(),
507
                           'pubpref': cc.getPublicationPreference()})
508
509
        # CC Emails
510
        # https://github.com/senaite/bika.lims/issues/361
511
        plone_utils = getToolByName(self.context, "plone_utils")
512
        ccemails = map(lambda x: x.strip(), ar.getCCEmails().split(","))
513
        for ccemail in ccemails:
514
            # Better do that with a field validator
515
            if not plone_utils.validateSingleEmailAddress(ccemail):
516
                logger.warn(
517
                    "Skipping invalid email address '{}'".format(ccemail))
518
                continue
519
            recips.append({
520
                'title': ccemail,
521
                'email': ccemail,
522
                'pubpref': ('email', 'pdf',), })
523
524
        return recips
525
526
    def get_mail_subject(self, ar):
527
        """Returns the email subject in accordance with the client
528
        preferences
529
        """
530
        client = ar.aq_parent
531
        subject_items = client.getEmailSubject()
532
        ai = co = cr = cs = False
533
        if 'ar' in subject_items:
534
            ai = True
535
        if 'co' in subject_items:
536
            co = True
537
        if 'cr' in subject_items:
538
            cr = True
539
        if 'cs' in subject_items:
540
            cs = True
541
        ais = []
542
        cos = []
543
        crs = []
544
        css = []
545
        blanks_found = False
546
        if ai:
547
            ais.append(ar.getId())
548
        if co:
549
            if ar.getClientOrderNumber():
550
                if not ar.getClientOrderNumber() in cos:
551
                    cos.append(ar.getClientOrderNumber())
552
            else:
553
                blanks_found = True
554
        if cr or cs:
555
            sample = ar.getSample()
556
            if cr:
557
                if sample.getClientReference():
558
                    if not sample.getClientReference() in crs:
559
                        crs.append(sample.getClientReference())
560
                else:
561
                    blanks_found = True
562
            if cs:
563
                if sample.getClientSampleID():
564
                    if not sample.getClientSampleID() in css:
565
                        css.append(sample.getClientSampleID())
566
                else:
567
                    blanks_found = True
568
        line_items = []
569
        if ais:
570
            ais.sort()
571
            li = t(_('ARs: ${ars}', mapping={'ars': ', '.join(ais)}))
572
            line_items.append(li)
573
        if cos:
574
            cos.sort()
575
            li = t(_('Orders: ${orders}', mapping={'orders': ', '.join(cos)}))
576
            line_items.append(li)
577
        if crs:
578
            crs.sort()
579
            li = t(_(
580
                'Refs: ${references}', mapping={'references': ', '.join(crs)}))
581
            line_items.append(li)
582
        if css:
583
            css.sort()
584
            li = t(_(
585
                'Samples: ${samples}', mapping={'samples': ', '.join(css)}))
586
            line_items.append(li)
587
        tot_line = ' '.join(line_items)
588
        if tot_line:
589
            subject = t(_('Analysis results for ${subject_parts}',
590
                          mapping={'subject_parts': tot_line}))
591
            if blanks_found:
592
                subject += (' ' + t(_('and others')))
593
        else:
594
            subject = t(_('Analysis results'))
595
        return subject, tot_line
596
597
    def sorted_by_sort_key(self, category_keys):
598
        """Sort categories via catalog lookup on title. """
599
        bsc = getToolByName(self.context, "bika_setup_catalog")
600
        analysis_categories = bsc(
601
            portal_type="AnalysisCategory", sort_on="sortable_title")
602
        sort_keys = dict([(b.Title, "{:04}".format(a))
603
                          for a, b in enumerate(analysis_categories)])
604
        return sorted(category_keys,
605
                      key=lambda title, sk=sort_keys: sk.get(title))
606
607
    def getAnaysisBasedTransposedMatrix(self, ars):
608
        """Returns a dict with the following structure:
609
        {'category_1_name':
610
            {'service_1_title':
611
                {'service_1_uid':
612
                    {'service': <AnalysisService-1>,
613
                     'ars': {'ar1_id': [<Analysis (for as-1)>,
614
                                       <Analysis (for as-1)>],
615
                             'ar2_id': [<Analysis (for as-1)>]
616
                            },
617
                    },
618
                },
619
            {'service_2_title':
620
                 {'service_2_uid':
621
                    {'service': <AnalysisService-2>,
622
                     'ars': {'ar1_id': [<Analysis (for as-2)>,
623
                                       <Analysis (for as-2)>],
624
                             'ar2_id': [<Analysis (for as-2)>]
625
                            },
626
                    },
627
                },
628
            ...
629
            },
630
        }
631
        """
632
        analyses = {}
633
        for ar in ars:
634
            ans = [an.getObject() for an in ar.getAnalyses()]
635
            for an in ans:
636
                cat = an.getCategoryTitle()
637
                an_title = an.Title()
638
                if cat not in analyses:
639
                    analyses[cat] = {
640
                        an_title: {
641
                            # The report should not mind receiving 'an'
642
                            # here - service fields are all inside!
643
                            'service': an,
644
                            'accredited': an.getAccredited(),
645
                            'ars': {ar.id: an.getFormattedResult()}
646
                        }
647
                    }
648
                elif an_title not in analyses[cat]:
649
                    analyses[cat][an_title] = {
650
                        'service': an,
651
                        'accredited': an.getAccredited(),
652
                        'ars': {ar.id: an.getFormattedResult()}
653
                    }
654
                else:
655
                    d = analyses[cat][an_title]
656
                    d['ars'][ar.id] = an.getFormattedResult()
657
                    analyses[cat][an_title] = d
658
        return analyses
659
660
    def _lab_address(self, lab):
661
        lab_address = lab.getPostalAddress() \
662
            or lab.getBillingAddress() \
663
            or lab.getPhysicalAddress()
664
        return _format_address(lab_address)
665
666
    def explode_data(self, data, padding=''):
667
        out = ''
668
        for k, v in data.items():
669
            if type(v) is dict:
670
                pad = '%s&nbsp;&nbsp;&nbsp;&nbsp;' % padding
671
                exploded = self.explode_data(v, pad)
672
                out = "%s<br/>%s'%s':{%s}" % (out, padding, str(k), exploded)
673
            elif type(v) is list:
674
                out = "%s<br/>%s'%s':[]" % (out, padding, str(k))
675
            elif type(v) is str:
676
                out = "%s<br/>%s'%s':''" % (out, padding, str(k))
677
        return out
678
679
    def currentDate(self):
680
        """
681
        This method returns the current time. It is useful if you want to
682
        get the current time in a report.
683
        :return: DateTime()
684
        """
685
        return DateTime()
686
687
688
class AnalysisRequestDigester:
689
    """Read AR data which could be useful during publication, into a data
690
    dictionary. This class should be instantiated once, and the instance
691
    called for all subsequent digestion.  This allows the instance to cache
692
    data for objects that may be read multiple times for different ARs.
693
694
    Note: ProxyFields are not included in the reading of the schema.  If you
695
    want to access sample fields in the report template, you must refer
696
    directly to the correct field in the Sample data dictionary.
697
698
    Note: ComputedFields are removed from the schema while creating the dict.
699
    XXX: Add all metadata columns for the AR into the dict.
700
701
    """
702
703
    def __init__(self):
704
        # By default we don't care about these schema fields when creating
705
        # dictionaries from the schemas of objects.
706
        self.SKIP_FIELDNAMES = [
707
            'allowDiscussion', 'subject', 'location', 'contributors',
708
            'creators', 'effectiveDate', 'expirationDate', 'language', 'rights',
709
            'relatedItems', 'modification_date', 'immediatelyAddableTypes',
710
            'locallyAllowedTypes', 'nextPreviousEnabled', 'constrainTypesMode',
711
            'RestrictedCategories',
712
        ]
713
714
    def __call__(self, ar, overwrite=False):
715
        # cheating
716
        self.context = ar
717
        self.request = ar.REQUEST
718
719
        logger.info("=========== creating new data for %s" % ar)
720
        # Set data to the AR schema field, and return it.
721
        data = self._ar_data(ar)
722
        logger.info("=========== new data for %s created." % ar)
723
        return data
724
725
    def _schema_dict(self, instance, skip_fields=None, recurse=True):
726
        """Return a dict of all mutated field values for all schema fields.
727
        This isn't used, as right now the digester just uses old code directly
728
        for BBB purposes.  But I'm keeping it here for future use.
729
        :param instance: The item who's schema will be exploded into a dict.
730
        :param skip_fields: A list of fieldnames which will not be rendered.
731
        :param recurse: If true, reference values will be recursed into.
732
        """
733
        data = {
734
            'obj': instance,
735
        }
736
737
        fields = instance.Schema().fields()
738
        for fld in fields:
739
            fieldname = fld.getName()
740
            if fieldname in self.SKIP_FIELDNAMES \
741
                    or (skip_fields and fieldname in skip_fields) \
742
                    or fld.type == 'computed':
743
                continue
744
745
            rawvalue = fld.get(instance)
746
747
            if rawvalue is True or rawvalue is False:
748
                # Booleans are special; we'll str and return them.
749
                data[fieldname] = str(rawvalue)
750
751
            elif rawvalue is 0:
752
                # Zero is special: it's false-ish, but the value is important.
753
                data[fieldname] = 0
754
755
            elif not rawvalue:
756
                # Other falsy values can simply return an empty string.
757
                data[fieldname] = ''
758
759
            elif fld.type == 'analyses':
760
                # AR.Analyses field is handled separately of course.
761
                data[fieldname] = ''
762
763
            elif IDateTimeField.providedBy(fld):
764
                # Date fields get stringed to rfc8222
765
                data[fieldname] = rawvalue.rfc822() if rawvalue else ''
766
767
            elif IReferenceField.providedBy(fld) \
768
                    or IUIDReferenceField.providedBy(fld):
769
                # mutate all reference targets into dictionaries
770
                # Assume here that allowed_types excludes incompatible types.
771
                if recurse and fld.multiValued:
772
                    v = [self._schema_dict(x, recurse=False) for x in rawvalue]
773
                elif recurse and not fld.multiValued:
774
                    v = self._schema_dict(rawvalue, recurse=False)
775
                elif not recurse and fld.multiValued:
776
                    v = [val.Title() for val in rawvalue if val]
777
                else:
778
                    v = rawvalue.Title() if rawvalue else ''
779
                data[fieldname] = v
780
781
                # Include a [fieldname]Title[s] field containing the title
782
                # or titles of referenced objects.
783
                if fld.multiValued:
784
                    data[fieldname + "Titles"] = [x.Title() for x in rawvalue]
785
                else:
786
                    data[fieldname + "Title"] = rawvalue.Title()
787
788
            # Text/String comes after UIDReferenceField.
789
            elif ITextField.providedBy(fld) or IStringField.providedBy(fld):
790
                rawvalue = str(rawvalue).strip()
791
                data[fieldname] = rawvalue
792
793
            # FileField comes after StringField.
794
            elif IFileField.providedBy(fld) or IBlobField.providedBy(fld):
795
                # We ignore file field values; we'll add the ones we want.
796
                data[fieldname] = ''
797
798
            elif ILinesField.providedBy(fld):
799
                # LinesField turns into a single string of lines
800
                data[fieldname] = "<br/>".join(rawvalue)
801
802
            elif fld.type == 'record':
803
                # Record returns a dictionary.
804
                data[fieldname] = rawvalue
805
806
            elif fld.type == 'records':
807
                # Record returns a list of dictionaries.
808
                data[fieldname] = rawvalue
809
810
            elif fld.type == 'address':
811
                # This is just a Record field
812
                data[fieldname + "_formatted"] = _format_address(rawvalue)
813
                # Also include un-formatted address
814
                data[fieldname] = rawvalue
815
816
            elif fld.type == 'duration':
817
                # Duration returns a formatted string like 1d 3h 1m.
818
                data[fieldname + "_formatted"] = \
819
                    ' '.join(["%s%s" % (rawvalue[key], key[0])
820
                              for key in ('days', 'hours', 'minutes')])
821
                # Also include unformatted duration.
822
                data[fieldname] = rawvalue
823
824
            else:
825
                data[fieldname] = rawvalue
826
827
        return data
828
829
    def getDimension(self):
830
        """ Returns the dimension of the report
831
        """
832
        return self.request.form.get("layout", "A4")
833
834
    def isLandscape(self):
835
        """ Returns if the layout is landscape
836
        """
837
        return self.request.form.get('landscape', '0').lower() in ['true', '1']
838
839
    def getDirection(self):
840
        """ Return landscape or horizontal
841
        """
842
        return self.isLandscape() and "landscape" or "horizontal"
843
844
    def getLayout(self):
845
        """ Returns the layout of the report
846
        """
847
        mapping = {
848
            "A4": (210, 297),
849
            "letter": (216, 279)
850
        }
851
        dimension = self.getDimension()
852
        layout = mapping.get(dimension, mapping.get("A4"))
853
        if self.isLandscape():
854
            layout = tuple(reversed(layout))
855
        return layout
856
857
    def _workflow_data(self, instance):
858
        """Add some workflow information for all actions performed against
859
        this instance. Only values for the last action event for any
860
        transition will be set here, previous transitions will be ignored.
861
862
        The default format for review_history is a list of lists; this function
863
        returns rather a dictionary of dictionaries, keyed by action_id
864
        """
865
        workflow = getToolByName(self.context, 'portal_workflow')
866
        history = copy(list(workflow.getInfoFor(instance, 'review_history')))
867
        data = {e['action']: {
868
            'actor': e['actor'],
869
            'time': ulocalized_time(e['time'], long_format=True)
870
        } for e in history if e['action']}
871
        return data
872
873
    def _ar_data(self, ar, excludearuids=None):
874
        """ Creates an ar dict, accessible from the view and from each
875
            specific template.
876
        """
877
        if not excludearuids:
878
            excludearuids = []
879
        bs = ar.bika_setup
880
        data = {'obj': ar,
881
                'id': ar.getId(),
882
                'client_order_num': ar.getClientOrderNumber(),
883
                'client_reference': ar.getClientReference(),
884
                'client_sampleid': ar.getClientSampleID(),
885
                'adhoc': ar.getAdHoc(),
886
                'composite': ar.getComposite(),
887
                'invoice_exclude': ar.getInvoiceExclude(),
888
                'date_received': ulocalized_time(ar.getDateReceived(),
889
                                                 long_format=1),
890
                'member_discount': ar.getMemberDiscount(),
891
                'date_sampled': ulocalized_time(
892
                    ar.getDateSampled(), long_format=1),
893
                'date_published': ulocalized_time(DateTime(), long_format=1),
894
                'invoiced': ar.getInvoiced(),
895
                'late': ar.getLate(),
896
                'subtotal': ar.getSubtotal(),
897
                'vat_amount': ar.getVATAmount(),
898
                'totalprice': ar.getTotalPrice(),
899
                'invalid': ar.isInvalid(),
900
                'url': ar.absolute_url(),
901
                'remarks': to_utf8(ar.getRemarks()),
902
                'footer': to_utf8(bs.getResultFooter()),
903
                'prepublish': False,
904
                'child_analysisrequest': None,
905
                'parent_analysisrequest': None,
906
                'resultsinterpretation': ar.getResultsInterpretation(),
907
                'ar_attachments': self._get_ar_attachments(ar),
908
                'an_attachments': self._get_an_attachments(ar),
909
                }
910
911
        # Sub-objects
912
        excludearuids.append(ar.UID())
913
        puid = ar.getRawParentAnalysisRequest()
914
        if puid and puid not in excludearuids:
915
            data['parent_analysisrequest'] = self._ar_data(
916
                ar.getParentAnalysisRequest(), excludearuids)
917
        cuid = ar.getRawChildAnalysisRequest()
918
        if cuid and cuid not in excludearuids:
919
            data['child_analysisrequest'] = self._ar_data(
920
                ar.getChildAnalysisRequest(), excludearuids)
921
922
        wf = ar.portal_workflow
923
        allowed_states = ['verified', 'published']
924
        data['prepublish'] = wf.getInfoFor(ar,
925
                                           'review_state') not in allowed_states
926
927
        data['contact'] = self._contact_data(ar)
928
        data['client'] = self._client_data(ar)
929
        data['sample'] = self._sample_data(ar)
930
        data['batch'] = self._batch_data(ar)
931
        data['specifications'] = self._specs_data(ar)
932
        data['analyses'] = self._analyses_data(ar, ['verified', 'published'])
933
        data['qcanalyses'] = self._qcanalyses_data(ar,
934
                                                   ['verified', 'published'])
935
        data['points_of_capture'] = sorted(
936
            set([an['point_of_capture'] for an in data['analyses']]))
937
        data['categories'] = sorted(
938
            set([an['category'] for an in data['analyses']]))
939
        data['haspreviousresults'] = len(
940
            [an['previous_results'] for an in data['analyses'] if
941
             an['previous_results']]) > 0
942
        data['hasblanks'] = len([an['reftype'] for an in data['qcanalyses'] if
943
                                 an['reftype'] == 'b']) > 0
944
        data['hascontrols'] = len([an['reftype'] for an in data['qcanalyses'] if
945
                                   an['reftype'] == 'c']) > 0
946
        data['hasduplicates'] = len(
947
            [an['reftype'] for an in data['qcanalyses'] if
948
             an['reftype'] == 'd']) > 0
949
950
        # Categorize analyses
951
        data['categorized_analyses'] = {}
952
        data['department_analyses'] = {}
953
        for an in data['analyses']:
954
            poc = an['point_of_capture']
955
            cat = an['category']
956
            pocdict = data['categorized_analyses'].get(poc, {})
957
            catlist = pocdict.get(cat, [])
958
            catlist.append(an)
959
            pocdict[cat] = catlist
960
            data['categorized_analyses'][poc] = pocdict
961
962
            # Group by department too
963
            anobj = an['obj']
964
            dept = anobj.getDepartment()
965
            if dept:
966
                dept = dept.UID()
967
                dep = data['department_analyses'].get(dept, {})
968
                dep_pocdict = dep.get(poc, {})
969
                dep_catlist = dep_pocdict.get(cat, [])
970
                dep_catlist.append(an)
971
                dep_pocdict[cat] = dep_catlist
972
                dep[poc] = dep_pocdict
973
                data['department_analyses'][dept] = dep
974
975
        # Categorize qcanalyses
976
        data['categorized_qcanalyses'] = {}
977
        for an in data['qcanalyses']:
978
            qct = an['reftype']
979
            poc = an['point_of_capture']
980
            cat = an['category']
981
            qcdict = data['categorized_qcanalyses'].get(qct, {})
982
            pocdict = qcdict.get(poc, {})
983
            catlist = pocdict.get(cat, [])
984
            catlist.append(an)
985
            pocdict[cat] = catlist
986
            qcdict[poc] = pocdict
987
            data['categorized_qcanalyses'][qct] = qcdict
988
989
        data['reporter'] = self._reporter_data(ar)
990
        data['managers'] = self._managers_data(ar)
991
992
        portal = self.context.portal_url.getPortalObject()
993
        data['portal'] = {'obj': portal,
994
                          'url': portal.absolute_url()}
995
        data['laboratory'] = self._lab_data()
996
997
        # results interpretation
998
        data = self._set_results_interpretation(ar, data)
999
1000
        return data
1001
1002
    def _get_attachment_info(self, attachment):
1003
        attachment_file = attachment.getAttachmentFile()
1004
        attachment_size = attachment.get_size()
1005
        attachment_type = attachment.getAttachmentType()
1006
        attachment_mime = attachment_file.getContentType()
1007
1008
        def get_kb_size():
1009
            size = attachment_size / 1024
1010
            if size < 1:
1011
                return 1
1012
            return size
1013
1014
        info = {
1015
            "obj": attachment,
1016
            "uid": attachment.UID(),
1017
            "keywords": attachment.getAttachmentKeys(),
1018
            "type": attachment_type and attachment_type.Title() or "",
1019
            "file": attachment_file,
1020
            "filename": attachment_file.filename,
1021
            "filesize": attachment_size,
1022
            "size": "{} Kb".format(get_kb_size()),
1023
            "download": "{}/at_download/AttachmentFile".format(
1024
                attachment.absolute_url()),
1025
            "mimetype": attachment_mime,
1026
            "title": attachment_file.Title(),
1027
            "icon": attachment_file.icon(),
1028
            "inline": "<embed src='{}/AttachmentFile' class='inline-attachment inline-attachment-{}'/>".format(
1029
                attachment.absolute_url(), self.getDirection()),
1030
            "renderoption": attachment.getReportOption(),
1031
        }
1032
        if attachment_mime.startswith("image"):
1033
            info["inline"] = "<img src='{}/AttachmentFile' class='inline-attachment inline-attachment-{}'/>".format(
1034
                attachment.absolute_url(), self.getDirection())
1035
        return info
1036
1037
    def _sorted_attachments(self, ar, attachments=[]):
1038
        """Sorter to return the attachments in the same order as the user
1039
        defined in the attachments viewlet
1040
        """
1041
        inf = float("inf")
1042
        view = ar.restrictedTraverse("attachments_view")
1043
        order = view.get_attachments_order()
1044
1045
        def att_cmp(att1, att2):
1046
            _n1 = att1.get('uid')
1047
            _n2 = att2.get('uid')
1048
            _i1 = _n1 in order and order.index(_n1) + 1 or inf
1049
            _i2 = _n2 in order and order.index(_n2) + 1 or inf
1050
            return cmp(_i1, _i2)
1051
1052
        return sorted(attachments, cmp=att_cmp)
1053
1054
    def _get_ar_attachments(self, ar):
1055
        attachments = []
1056
        for attachment in ar.getAttachment():
1057
            # Skip attachments which have the (i)gnore flag set
1058
            if attachment.getReportOption() == "i":
1059
                continue
1060
            attachments.append(self._get_attachment_info(attachment))
1061
1062
        return self._sorted_attachments(ar, attachments)
1063
1064
    def _get_an_attachments(self, ar):
1065
        attachments = []
1066
        for analysis in ar.getAnalyses(full_objects=True):
1067
            for attachment in analysis.getAttachment():
1068
                # Skip attachments which have the (i)gnore flag set
1069
                if attachment.getReportOption() == "i":
1070
                    continue
1071
                attachments.append(self._get_attachment_info(attachment))
1072
        return self._sorted_attachments(ar, attachments)
1073
1074
    def _batch_data(self, ar):
1075
        data = {}
1076
        batch = ar.getBatch()
1077
        if batch:
1078
            data = {'obj': batch,
1079
                    'id': batch.id,
1080
                    'url': batch.absolute_url(),
1081
                    'title': to_utf8(batch.Title()),
1082
                    'date': batch.getBatchDate(),
1083
                    'client_batchid': to_utf8(batch.getClientBatchID()),
1084
                    'remarks': to_utf8(batch.getRemarks())}
1085
1086
            uids = batch.Schema()['BatchLabels'].getAccessor(batch)()
1087
            uc = getToolByName(self.context, 'uid_catalog')
1088
            data['labels'] = [to_utf8(p.getObject().Title()) for p in
1089
                              uc(UID=uids)]
1090
1091
        return data
1092
1093
    def _sample_data(self, ar):
1094
        data = {}
1095
        sample = ar.getSample()
1096
        if sample:
1097
            data = {'obj': sample,
1098
                    'id': sample.id,
1099
                    'url': sample.absolute_url(),
1100
                    'client_sampleid': sample.getClientSampleID(),
1101
                    'date_sampled': sample.getDateSampled(),
1102
                    'sampling_date': sample.getSamplingDate(),
1103
                    'sampler': self._sampler_data(sample),
1104
                    'date_received': sample.getDateReceived(),
1105
                    'composite': sample.getComposite(),
1106
                    'date_expired': sample.getDateExpired(),
1107
                    'date_disposal': sample.getDisposalDate(),
1108
                    'date_disposed': sample.getDateDisposed(),
1109
                    'adhoc': sample.getAdHoc(),
1110
                    'remarks': sample.getRemarks(),
1111
                    'sample_type': self._sample_type(sample),
1112
                    'sample_point': self._sample_point(sample)}
1113
        return data
1114
1115
    def _sampler_data(self, sample=None):
1116
        data = {}
1117
        if not sample or not sample.getSampler():
1118
            return data
1119
        sampler = sample.getSampler()
1120
        mtool = getToolByName(self.context, 'portal_membership')
1121
        member = mtool.getMemberById(sampler)
1122
        if member:
1123
            mfullname = member.getProperty('fullname')
1124
            memail = member.getProperty('email')
1125
            mhomepage = member.getProperty('home_page')
1126
            pc = getToolByName(self.context, 'portal_catalog')
1127
            contact = pc(portal_type='LabContact', getUsername=member.getId())
1128
            # Only one LabContact should be found
1129
            if len(contact) > 1:
1130
                logger.warn(
1131
                    "Incorrect number of user with the same "
1132
                    "memberID. '{0}' users found with {1} as ID"
1133
                    .format(len(contact), member.id))
1134
            contact = contact[0].getObject() if contact else None
1135
            cfullname = contact.getFullname() if contact else None
1136
            cemail = contact.getEmailAddress() if contact else None
1137
            physical_address = _format_address(
1138
                contact.getPhysicalAddress()) if contact else ''
1139
            postal_address =\
1140
                _format_address(contact.getPostalAddress())\
1141
                if contact else ''
1142
            data = {'id': member.id,
1143
                    'fullname': to_utf8(cfullname) if cfullname else to_utf8(
1144
                        mfullname),
1145
                    'email': cemail if cemail else memail,
1146
                    'business_phone': contact.getBusinessPhone() if contact else '',
1147
                    'business_fax': contact.getBusinessFax() if contact else '',
1148
                    'home_phone': contact.getHomePhone() if contact else '',
1149
                    'mobile_phone': contact.getMobilePhone() if contact else '',
1150
                    'job_title': to_utf8(contact.getJobTitle()) if contact else '',
1151
                    'physical_address': physical_address,
1152
                    'postal_address': postal_address,
1153
                    'home_page': to_utf8(mhomepage)}
1154
        return data
1155
1156
    def _sample_type(self, sample=None):
1157
        data = {}
1158
        sampletype = sample.getSampleType() if sample else None
1159
        if sampletype:
1160
            data = {'obj': sampletype,
1161
                    'id': sampletype.id,
1162
                    'title': sampletype.Title(),
1163
                    'url': sampletype.absolute_url()}
1164
        return data
1165
1166
    def _sample_point(self, sample=None):
1167
        samplepoint = sample.getSamplePoint() if sample else None
1168
        data = {}
1169
        if samplepoint:
1170
            data = {'obj': samplepoint,
1171
                    'id': samplepoint.id,
1172
                    'title': samplepoint.Title(),
1173
                    'url': samplepoint.absolute_url()}
1174
        return data
1175
1176
    def _lab_address(self, lab):
1177
        lab_address = lab.getPostalAddress() \
1178
            or lab.getBillingAddress() \
1179
            or lab.getPhysicalAddress()
1180
        return _format_address(lab_address)
1181
1182
    def _lab_data(self):
1183
        portal = getToolByName(self.context, 'portal_url').getPortalObject()
1184
        lab = self.context.bika_setup.laboratory
1185
        sv = lab.getSupervisor()
1186
        sv = sv.getFullname() if sv else ""
1187
        return {'obj': lab,
1188
                'title': to_utf8(lab.Title()),
1189
                'url': to_utf8(lab.getLabURL()),
1190
                'supervisor': to_utf8(sv),
1191
                'address': to_utf8(self._lab_address(lab)),
1192
                'confidence': lab.getConfidence(),
1193
                'accredited': lab.getLaboratoryAccredited(),
1194
                'accreditation_body': to_utf8(lab.getAccreditationBody()),
1195
                'accreditation_logo': lab.getAccreditationBodyLogo(),
1196
                'logo': "%s/logo_print.png" % portal.absolute_url()}
1197
1198
    def _contact_data(self, ar):
1199
        data = {}
1200
        contact = ar.getContact()
1201
        if contact:
1202
            data = {'obj': contact,
1203
                    'fullname': to_utf8(contact.getFullname()),
1204
                    'email': to_utf8(contact.getEmailAddress()),
1205
                    'pubpref': contact.getPublicationPreference()}
1206
        return data
1207
1208
    def _client_data(self, ar):
1209
        data = {}
1210
        client = ar.aq_parent
1211
        if client:
1212
            data['obj'] = client
1213
            data['id'] = client.id
1214
            data['url'] = client.absolute_url()
1215
            data['name'] = to_utf8(client.getName())
1216
            data['phone'] = to_utf8(client.getPhone())
1217
            data['fax'] = to_utf8(client.getFax())
1218
1219
            data['address'] = to_utf8(get_client_address(ar))
1220
        return data
1221
1222
    def _specs_data(self, ar):
1223
        data = {}
1224
        specs = ar.getPublicationSpecification()
1225
        if not specs:
1226
            specs = ar.getSpecification()
1227
1228
        if specs:
1229
            data['obj'] = specs
1230
            data['id'] = specs.id
1231
            data['url'] = specs.absolute_url()
1232
            data['title'] = to_utf8(specs.Title())
1233
            data['resultsrange'] = specs.getResultsRangeDict()
1234
1235
        return data
1236
1237
    def _analyses_data(self, ar, analysis_states=None):
1238
        if not analysis_states:
1239
            analysis_states = ['verified', 'published']
1240
        analyses = []
1241
        dm = ar.aq_parent.getDecimalMark()
1242
        batch = ar.getBatch()
1243
        incl_prev_results = self.context.bika_setup.getIncludePreviousFromBatch()
1244
        workflow = getToolByName(self.context, 'portal_workflow')
1245
        showhidden = self.isHiddenAnalysesVisible()
1246
1247
        catalog = getToolByName(self.context, CATALOG_ANALYSIS_LISTING)
1248
        brains = catalog({'getRequestUID': ar.UID(),
1249
                          'review_state': analysis_states,
1250
                          'sort_on': 'sortable_title'})
1251
        for brain in brains:
1252
            an = brain.getObject()
1253
            # Omit hidden analyses?
1254
            if not showhidden and an.getHidden():
1255
                continue
1256
1257
            # Build the analysis-specific dict
1258
            andict = self._analysis_data(an, dm)
1259
1260
            # Are there previous results for the same AS and batch?
1261
            andict['previous'] = []
1262
            andict['previous_results'] = ""
1263
            if batch and incl_prev_results:
1264
                keyword = an.getKeyword()
1265
                bars = [bar for bar in batch.getAnalysisRequests()
1266
                        if an.aq_parent.UID() != bar.UID() and keyword in bar]
1267
                for bar in bars:
1268
                    pan = bar[keyword]
1269
                    pan_state = workflow.getInfoFor(pan, 'review_state')
1270
                    if pan.getResult() and pan_state in analysis_states:
1271
                        pandict = self._analysis_data(pan)
1272
                        andict['previous'].append(pandict)
1273
1274
                andict['previous'] = sorted(
1275
                    andict['previous'], key=itemgetter("capture_date"))
1276
                andict['previous_results'] = ", ".join(
1277
                    [p['formatted_result'] for p in andict['previous'][-5:]])
1278
1279
            analyses.append(andict)
1280
        return analyses
1281
1282
    def _analysis_data(self, analysis, decimalmark=None):
1283
1284
        andict = {'obj': analysis,
1285
                  'id': analysis.id,
1286
                  'title': analysis.Title(),
1287
                  'keyword': analysis.getKeyword(),
1288
                  'scientific_name': analysis.getScientificName(),
1289
                  'accredited': analysis.getAccredited(),
1290
                  'point_of_capture': to_utf8(
1291
                      POINTS_OF_CAPTURE.getValue(analysis.getPointOfCapture())),
1292
                  'category': to_utf8(analysis.getCategoryTitle()),
1293
                  'result': analysis.getResult(),
1294
                  'isnumber': isnumber(analysis.getResult()),
1295
                  'unit': to_utf8(analysis.getUnit()),
1296
                  'formatted_unit': format_supsub(to_utf8(analysis.getUnit())),
1297
                  'capture_date': analysis.getResultCaptureDate(),
1298
                  'request_id': analysis.aq_parent.getId(),
1299
                  'formatted_result': '',
1300
                  'uncertainty': analysis.getUncertainty(),
1301
                  'formatted_uncertainty': '',
1302
                  'retested': analysis.getRetested(),
1303
                  'remarks': to_utf8(analysis.getRemarks()),
1304
                  'outofrange': False,
1305
                  'type': analysis.portal_type,
1306
                  'reftype': analysis.getReferenceType() \
1307
                      if hasattr(analysis, 'getReferenceType') \
1308
                      else None,
1309
                  'worksheet': None,
1310
                  'specs': {},
1311
                  'formatted_specs': ''}
1312
1313
        if analysis.portal_type == 'DuplicateAnalysis':
1314
            andict['reftype'] = 'd'
1315
1316
        ws = analysis.getBackReferences('WorksheetAnalysis')
1317
        andict['worksheet'] = ws[0].id if ws and len(ws) > 0 else None
1318
        andict['worksheet_url'] = ws[0].absolute_url() \
1319
            if ws and len(ws) > 0 else None
1320
        andict['refsample'] = analysis.getSample().id \
1321
            if analysis.portal_type == 'Analysis' \
1322
            else '%s - %s' % (analysis.aq_parent.id, analysis.aq_parent.Title())
1323
1324
        specs = analysis.getResultsRange()
1325
        andict['specs'] = specs
1326
        scinot = self.context.bika_setup.getScientificNotationReport()
1327
        fresult = analysis.getFormattedResult(
1328
            specs=specs, sciformat=int(scinot), decimalmark=decimalmark)
1329
1330
        # We don't use here cgi.encode because results fields must be rendered
1331
        # using the 'structure' wildcard. The reason is that the result can be
1332
        # expressed in sci notation, that may include <sup></sup> html tags.
1333
        # Please note the default value for the 'html' parameter from
1334
        # getFormattedResult signature is set to True, so the service will
1335
        # already take into account LDLs and UDLs symbols '<' and '>' and escape
1336
        # them if necessary.
1337
        andict['formatted_result'] = fresult
1338
1339
        fs = ''
1340
        if specs.get('min', None) and specs.get('max', None):
1341
            fs = '%s - %s' % (specs['min'], specs['max'])
1342
        elif specs.get('min', None):
1343
            fs = '> %s' % specs['min']
1344
        elif specs.get('max', None):
1345
            fs = '< %s' % specs['max']
1346
        andict['formatted_specs'] = formatDecimalMark(fs, decimalmark)
1347
        andict['formatted_uncertainty'] = format_uncertainty(
1348
            analysis, analysis.getResult(), decimalmark=decimalmark,
1349
            sciformat=int(scinot))
1350
1351
        # Out of range? Note is_out_of_range returns a tuple of two elements,
1352
        # were the first returned value is a bool that indicates if the result
1353
        # is out of range. The second value (dismissed here) is a bool that
1354
        # indicates if the result is out of shoulders
1355
        andict['outofrange'] = is_out_of_range(analysis)[0]
1356
        return andict
1357
1358
    def _qcanalyses_data(self, ar, analysis_states=None):
1359
        if not analysis_states:
1360
            analysis_states = ['verified', 'published']
1361
        analyses = []
1362
1363
        for an in ar.getQCAnalyses(review_state=analysis_states):
1364
1365
            # Build the analysis-specific dict
1366
            andict = self._analysis_data(an)
1367
1368
            # Are there previous results for the same AS and batch?
1369
            andict['previous'] = []
1370
            andict['previous_results'] = ""
1371
1372
            analyses.append(andict)
1373
        analyses.sort(
1374
            lambda x, y: cmp(x.get('title').lower(), y.get('title').lower()))
1375
        return analyses
1376
1377
    def _reporter_data(self, ar):
1378
        data = {}
1379
        bsc = getToolByName(self.context, 'bika_setup_catalog')
1380
        mtool = getToolByName(self.context, 'portal_membership')
1381
        member = mtool.getAuthenticatedMember()
1382
        username = member.getUserName()
1383
        data['username'] = username
1384
        brains = [x for x in bsc(portal_type='LabContact')
1385
                  if x.getObject().getUsername() == username]
1386
        if brains:
1387
            contact = brains[0].getObject()
1388
            data['fullname'] = contact.getFullname()
1389
            data['email'] = contact.getEmailAddress()
1390
            sf = contact.getSignature()
1391
            if sf:
1392
                data['signature'] = sf.absolute_url() + "/Signature"
1393
        else:
1394
            data['signature'] = ''
1395
            data['fullname'] = username
1396
            data['email'] = ''
1397
1398
        return data
1399
1400
    def _managers_data(self, ar):
1401
        managers = {'ids': [], 'dict': {}}
1402
        departments = {}
1403
        ar_mngrs = self._verifiers_data(ar.UID())
1404
        for id in ar_mngrs['ids']:
1405
            new_depts = ar_mngrs['dict'][id]['departments'].split(',')
1406
            if id in managers['ids']:
1407
                for dept in new_depts:
1408
                    if dept not in departments[id]:
1409
                        departments[id].append(dept)
1410
            else:
1411
                departments[id] = new_depts
1412
                managers['ids'].append(id)
1413
                managers['dict'][id] = ar_mngrs['dict'][id]
1414
1415
        mngrs = departments.keys()
1416
        for mngr in mngrs:
1417
            final_depts = ''
1418
            for dept in departments[mngr]:
1419
                if final_depts:
1420
                    final_depts += ', '
1421
                final_depts += to_utf8(dept)
1422
            managers['dict'][mngr]['departments'] = final_depts
1423
1424
        return managers
1425
1426
    @viewcache.memoize
1427
    def _verifiers_data(self, ar_uid):
1428
        verifiers = dict()
1429
        for brain in self.get_analyses(ar_uid):
1430
            an_verifiers = brain.getVerificators or ''
1431
            an_verifiers = an_verifiers.split(',')
1432
            for user_id in an_verifiers:
1433
                user_data = self._user_contact_data(user_id)
1434
                if not user_data:
1435
                    continue
1436
                verifiers[user_id] = user_data
1437
        return {'ids': verifiers.keys(),
1438
                'dict': verifiers}
1439
1440
    @viewcache.memoize
1441
    def get_analyses(self, ar_uid):
1442
        query = dict(getRequestUID=ar_uid,
1443
                     portal_type='Analysis',
1444
                     cancellation_state='active',
1445
                     review_state=['verified', 'published'])
1446
        return api.search(query, CATALOG_ANALYSIS_LISTING)
1447
1448
    @viewcache.memoize
1449
    def _user_contact_data(self, user_id):
1450
        user = self.get_user_contact(user_id)
1451
        if not user:
1452
            return None
1453
        signature = user.getSignature()
1454
        if signature:
1455
            signature = '{}/Signature'.format(user.absolute_url())
1456
        return dict(salutation=safe_unicode(user.getSalutation()),
1457
                    name=safe_unicode(user.getFullname()),
1458
                    email=safe_unicode(user.getEmailAddress()),
1459
                    phone=safe_unicode(user.getBusinessPhone()),
1460
                    job_title=safe_unicode(user.getJobTitle()),
1461
                    signature=signature or '',
1462
                    departments='')
1463
1464
    @viewcache.memoize
1465
    def get_user_contact(self, user_id):
1466
        query = dict(getUsername=user_id,
1467
                     portal_type=['LabContact', 'Contact'])
1468
        users = api.search(query, 'portal_catalog')
1469
        if len(users) == 1:
1470
            return api.get_object(users[0])
1471
        return None
1472
1473
    def _set_results_interpretation(self, ar, data):
1474
        """
1475
        This function updates the 'results interpretation' data.
1476
        :param ar: an AnalysisRequest object.
1477
        :param data: The data dictionary.
1478
        :return: The 'data' dictionary with the updated values.
1479
        """
1480
        # General interpretation
1481
        data['resultsinterpretation'] = ar.getResultsInterpretation()
1482
        # Interpretations by departments
1483
        ri = {}
1484
        if ar.getResultsInterpretationByDepartment(None):
1485
            ri[''] = ar.getResultsInterpretationByDepartment(None)
1486
        depts = ar.getDepartments()
1487
        for dept in depts:
1488
            ri[dept.Title()] = ar.getResultsInterpretationByDepartment(dept)
1489
        data['resultsinterpretationdepts'] = ri
1490
        return data
1491
1492
    def isHiddenAnalysesVisible(self):
1493
        """Returns true if hidden analyses are visible
1494
        """
1495
        return self.request.form.get('hvisible', '0').lower() in ['true', '1']
1496
1497
1498
def get_client_address(context):
1499
    if context.portal_type == 'AnalysisRequest':
1500
        client = context.aq_parent
1501
    else:
1502
        client = context
1503
    client_address = client.getPostalAddress()
1504
    if not client_address:
1505
        ar = context
1506
        if not IAnalysisRequest.providedBy(ar):
1507
            return ""
1508
        # Data from the first contact
1509
        contact = ar.getContact()
1510
        if contact and contact.getBillingAddress():
1511
            client_address = contact.getBillingAddress()
1512
        elif contact and contact.getPhysicalAddress():
1513
            client_address = contact.getPhysicalAddress()
1514
    return _format_address(client_address)
1515
1516
1517
def _format_address(address):
1518
    """Takes a value from an AddressField, returns a div class=address
1519
    with spans inside, containing the address field values.
1520
    """
1521
    addr = ''
1522
    if address:
1523
        # order of divs in output html
1524
        keys = ['address', 'city', 'district', 'state', 'zip', 'country']
1525
        addr = ''.join(["<span>%s</span>" % address.get(v) for v in keys
1526
                        if address.get(v, None)])
1527
    return "<div class='address'>%s</div>" % addr
1528