Passed
Push — master ( d8e2ec...90ae0b )
by Jordi
10:07 queued 04:19
created

AnalysesView._folder_item_method()   A

Complexity

Conditions 4

Size

Total Lines 21
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 15
dl 0
loc 21
rs 9.65
c 0
b 0
f 0
cc 4
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 json
9
from collections import OrderedDict
10
from copy import copy
11
12
from DateTime import DateTime
13
from Products.Archetypes.config import REFERENCE_CATALOG
14
from Products.CMFPlone.utils import safe_unicode
15
from bika.lims import api
16
from bika.lims import bikaMessageFactory as _
17
from bika.lims import logger
18
from bika.lims.api.analysis import get_formatted_interval
19
from bika.lims.api.analysis import is_out_of_range
20
from bika.lims.browser.bika_listing import BikaListingView
21
from bika.lims.catalog import CATALOG_ANALYSIS_LISTING
22
from bika.lims.interfaces import IAnalysisRequest
23
from bika.lims.interfaces import IFieldIcons
24
from bika.lims.permissions import EditFieldResults, FieldEditAnalysisResult, \
25
    FieldEditAnalysisHidden, TransitionVerify
26
from bika.lims.permissions import EditResults
27
from bika.lims.permissions import ViewResults
28
from bika.lims.permissions import ViewRetractedAnalyses
29
from bika.lims.utils import check_permission
30
from bika.lims.utils import formatDecimalMark
31
from bika.lims.utils import format_supsub
32
from bika.lims.utils import getUsers
33
from bika.lims.utils import get_image
34
from bika.lims.utils import get_link
35
from bika.lims.utils import t
36
from bika.lims.utils.analysis import format_uncertainty
37
from plone.memoize import view as viewcache
38
from zope.component import getAdapters
39
40
41
class AnalysesView(BikaListingView):
42
    """Displays a list of Analyses in a table.
43
44
    Visible InterimFields from all analyses are added to self.columns[].
45
    Keyword arguments are passed directly to bika_analysis_catalog.
46
    """
47
48
    def __init__(self, context, request, **kwargs):
49
        super(AnalysesView, self).__init__(context, request, **kwargs)
50
51
        # prepare the content filter of this listing
52
        self.contentFilter = dict(kwargs)
53
        self.contentFilter.update({
54
            "portal_type": "Analysis",
55
            "sort_on": "sortable_title",
56
            "sort_order": "ascending",
57
        })
58
59
        # set the listing view config
60
        self.catalog = CATALOG_ANALYSIS_LISTING
61
        self.sort_order = "ascending"
62
        self.context_actions = {}
63
64
        self.show_select_row = False
65
        self.show_select_column = False
66
        self.show_column_toggles = False
67
        self.pagesize = 9999999
68
        self.form_id = "analyses_form"
69
        self.context_active = api.is_active(context)
70
        self.interim_fields = {}
71
        self.interim_columns = OrderedDict()
72
        self.specs = {}
73
        self.bsc = api.get_tool("bika_setup_catalog")
74
        self.portal = api.get_portal()
75
        self.portal_url = api.get_url(self.portal)
76
        self.rc = api.get_tool(REFERENCE_CATALOG)
77
        self.dmk = context.bika_setup.getResultsDecimalMark()
78
        self.scinot = context.bika_setup.getScientificNotationResults()
79
        self.categories = []
80
81
        # each editable item needs it's own allow_edit
82
        # which is a list of field names.
83
        self.allow_edit = False
84
85
        self.columns = OrderedDict((
86
            # Although 'created' column is not displayed in the list (see
87
            # review_states to check the columns that will be rendered), this
88
            # column is needed to sort the list by create date
89
            ("created", {
90
                "title": _("Date Created"),
91
                "toggle": False}),
92
            ("Service", {
93
                "title": _("Analysis"),
94
                "attr": "Title",
95
                "index": "sortable_title",
96
                "sortable": False}),
97
            ("Method", {
98
                "title": _("Method"),
99
                "sortable": False,
100
                "ajax": True,
101
                "toggle": True}),
102
            ("Instrument", {
103
                "title": _("Instrument"),
104
                "ajax": True,
105
                "sortable": False,
106
                "toggle": True}),
107
            ("Analyst", {
108
                "title": _("Analyst"),
109
                "sortable": False,
110
                "ajax": True,
111
                "toggle": True}),
112
            ("state_title", {
113
                "title": _("Status"),
114
                "sortable": False}),
115
            ("DetectionLimit", {
116
                "title": _("DL"),
117
                "sortable": False,
118
                "toggle": False}),
119
            ("Result", {
120
                "title": _("Result"),
121
                "input_width": "6",
122
                "input_class": "ajax_calculate numeric",
123
                "ajax": True,
124
                "sortable": False}),
125
            ("Specification", {
126
                "title": _("Specification"),
127
                "sortable": False}),
128
            ("Uncertainty", {
129
                "title": _("+-"),
130
                "sortable": False}),
131
            ("retested", {
132
                "title": _("Retested"),
133
                "type": "boolean",
134
                "sortable": False}),
135
            ("Attachments", {
136
                "title": _("Attachments"),
137
                "sortable": False}),
138
            ("CaptureDate", {
139
                "title": _("Captured"),
140
                "index": "getResultCaptureDate",
141
                "sortable": False}),
142
            ("DueDate", {
143
                "title": _("Due Date"),
144
                "index": "getDueDate",
145
                "sortable": False}),
146
            ("Hidden", {
147
                "title": _("Hidden"),
148
                "toggle": True,
149
                "sortable": False,
150
                "ajax": True,
151
                "type": "boolean"}),
152
        ))
153
154
        # Inject Remarks column for listing
155
        if self.analysis_remarks_enabled():
156
            self.columns["Remarks"] = {
157
                "title": "Remarks",
158
                "toggle": False,
159
                "sortable": False,
160
                "type": "remarks",
161
                "ajax": True,
162
            }
163
164
        self.review_states = [
165
            {
166
                "id": "default",
167
                "title": _("Valid"),
168
                "contentFilter": {
169
                    "review_state": [
170
                        "registered",
171
                        "unassigned",
172
                        "assigned",
173
                        "to_be_verified",
174
                        "verified",
175
                        "published",
176
                    ]
177
                },
178
                "columns": self.columns.keys()
179
            },
180
            {
181
                "id": "invalid",
182
                "contentFilter": {
183
                    "review_state": [
184
                        "cancelled",
185
                        "retracted",
186
                        "rejected",
187
                    ]
188
                },
189
                "title": _("Invalid"),
190
                "columns": self.columns.keys(),
191
            },
192
            {
193
                "id": "all",
194
                "title": _("All"),
195
                "contentFilter": {},
196
                "columns": self.columns.keys()
197
             },
198
        ]
199
200
        # This is used to display method and instrument columns if there is at
201
        # least one analysis to be rendered that allows the assignment of
202
        # method and/or instrument
203
        self.show_methodinstr_columns = False
204
205
    def update(self):
206
        """Update hook
207
        """
208
        super(AnalysesView, self).update()
209
        self.load_analysis_categories()
210
211
    def before_render(self):
212
        """Before render hook
213
        """
214
        super(AnalysesView, self).before_render()
215
        self.request.set("disable_plone.rightcolumn", 1)
216
217
    @viewcache.memoize
218
    def analysis_remarks_enabled(self):
219
        """Check if analysis remarks are enabled
220
        """
221
        return self.context.bika_setup.getEnableAnalysisRemarks()
222
223
    @viewcache.memoize
224
    def has_permission(self, permission, obj=None):
225
        """Returns if the current user has rights for the permission passed in
226
227
        :param permission: permission identifier
228
        :param obj: object to check the permission against
229
        :return: True if the user has rights for the permission passed in
230
        """
231
        if not permission:
232
            logger.warn("None permission is not allowed")
233
            return False
234
        if obj is None:
235
            return check_permission(permission, self.context)
236
        return check_permission(permission, api.get_object(obj))
237
238
    @viewcache.memoize
239
    def is_analysis_edition_allowed(self, analysis_brain):
240
        """Returns if the analysis passed in can be edited by the current user
241
242
        :param analysis_brain: Brain that represents an analysis
243
        :return: True if the user can edit the analysis, otherwise False
244
        """
245
        if not self.context_active:
246
            # The current context must be active. We cannot edit analyses from
247
            # inside a deactivated Analysis Request, for instance
248
            return False
249
250
        analysis_obj = api.get_object(analysis_brain)
251
        if analysis_obj.getPointOfCapture() == 'field':
252
            # This analysis must be captured on field, during sampling.
253
            if not self.has_permission(EditFieldResults, analysis_obj):
254
                # Current user cannot edit field analyses.
255
                return False
256
257
        elif not self.has_permission(EditResults, analysis_obj):
258
            # The Point of Capture is 'lab' and the current user cannot edit
259
            # lab analyses.
260
            return False
261
262
        # Check if the user is allowed to enter a value to to Result field
263
        if not self.has_permission(FieldEditAnalysisResult, analysis_obj):
264
            return False
265
266
        # Is the instrument out of date?
267
        # The user can assign a result to the analysis if it does not have any
268
        # instrument assigned or the instrument assigned is valid.
269
        if not self.is_analysis_instrument_valid(analysis_brain):
270
            # return if it is allowed to enter a manual result
271
            return analysis_obj.getManualEntryOfResults()
272
273
        return True
274
275
    @viewcache.memoize
276
    def is_analysis_instrument_valid(self, analysis_brain):
277
        """Return if the analysis has a valid instrument.
278
279
        If the analysis passed in is from ReferenceAnalysis type or does not
280
        have an instrument assigned, returns True
281
282
        :param analysis_brain: Brain that represents an analysis
283
        :return: True if the instrument assigned is valid or is None"""
284
        if analysis_brain.meta_type == 'ReferenceAnalysis':
285
            # If this is a ReferenceAnalysis, there is no need to check the
286
            # validity of the instrument, because this is a QC analysis and by
287
            # definition, it has the ability to promote an instrument to a
288
            # valid state if the result is correct.
289
            return True
290
        instrument = self.get_instrument(analysis_brain)
291
        return not instrument or instrument.isValid()
292
293
    def get_instrument(self, analysis_brain):
294
        """Returns the instrument assigned to the analysis passed in, if any
295
296
        :param analysis_brain: Brain that represents an analysis
297
        :return: Instrument object or None"""
298
        instrument_uid = analysis_brain.getInstrumentUID
299
        # Note we look for the instrument by using its UID, case we want the
300
        # instrument to be cached by UID so if same instrument is assigned to
301
        # several analyses, a single search for instrument will be required
302
        return self.get_object(instrument_uid)
303
304
    @viewcache.memoize
305
    def get_object(self, brain_or_object_or_uid):
306
        """Get the full content object. Returns None if the param passed in is
307
        not a valid, not a valid object or not found
308
309
        :param brain_or_object_or_uid: UID/Catalog brain/content object
310
        :returns: content object
311
        """
312
        if api.is_uid(brain_or_object_or_uid):
313
            return api.get_object_by_uid(brain_or_object_or_uid, default=None)
314
        if api.is_object(brain_or_object_or_uid):
315
            return api.get_object(brain_or_object_or_uid)
316
        return None
317
318
    @viewcache.memoize
319
    def get_methods_vocabulary(self, analysis_brain):
320
        """Returns a vocabulary with all the methods available for the passed in
321
        analysis, either those assigned to an instrument that are capable to
322
        perform the test (option "Allow Entry of Results") and those assigned
323
        manually in the associated Analysis Service.
324
325
        The vocabulary is a list of dictionaries. Each dictionary has the
326
        following structure:
327
328
            {'ResultValue': <method_UID>,
329
             'ResultText': <method_Title>}
330
331
        :param analysis_brain: A single Analysis brain
332
        :type analysis_brain: CatalogBrain
333
        :returns: A list of dicts
334
        """
335
        uids = analysis_brain.getAllowedMethodUIDs
336
        query = {'portal_type': 'Method',
337
                 'is_active': True,
338
                 'UID': uids}
339
        brains = api.search(query, 'bika_setup_catalog')
340
        if not brains:
341
            return [{'ResultValue': '', 'ResultText': _('None')}]
342
        return map(lambda brain: {'ResultValue': brain.UID,
343
                                  'ResultText': brain.Title}, brains)
344
345
    @viewcache.memoize
346
    def get_instruments_vocabulary(self, analysis_brain):
347
        """Returns a vocabulary with the valid and active instruments available
348
        for the analysis passed in.
349
350
        If the option "Allow instrument entry of results" for the Analysis
351
        is disabled, the function returns an empty vocabulary.
352
353
        If the analysis passed in is a Reference Analysis (Blank or Control),
354
        the vocabulary, the invalid instruments will be included in the
355
        vocabulary too.
356
357
        The vocabulary is a list of dictionaries. Each dictionary has the
358
        following structure:
359
360
            {'ResultValue': <instrument_UID>,
361
             'ResultText': <instrument_Title>}
362
363
        :param analysis_brain: A single Analysis or ReferenceAnalysis
364
        :type analysis_brain: Analysis or.ReferenceAnalysis
365
        :return: A vocabulary with the instruments for the analysis
366
        :rtype: A list of dicts: [{'ResultValue':UID, 'ResultText':Title}]
367
        """
368
        if not analysis_brain.getInstrumentEntryOfResults:
369
            # Instrument entry of results for this analysis is not allowed
370
            return list()
371
372
        # If the analysis is a QC analysis, display all instruments, including
373
        # those uncalibrated or for which the last QC test failed.
374
        meta_type = analysis_brain.meta_type
375
        uncalibrated = meta_type == 'ReferenceAnalysis'
376
        if meta_type == 'DuplicateAnalysis':
377
            base_analysis_type = analysis_brain.getAnalysisPortalType
378
            uncalibrated = base_analysis_type == 'ReferenceAnalysis'
379
380
        uids = analysis_brain.getAllowedInstrumentUIDs
381
        query = {'portal_type': 'Instrument',
382
                 'is_active': True,
383
                 'UID': uids}
384
        brains = api.search(query, 'bika_setup_catalog')
385
        vocab = [{'ResultValue': '', 'ResultText': _('None')}]
386
        for brain in brains:
387
            instrument = self.get_object(brain)
388
            if uncalibrated and not instrument.isOutOfDate():
389
                # Is a QC analysis, include instrument also if is not valid
390
                vocab.append({'ResultValue': instrument.UID(),
391
                              'ResultText': instrument.Title()})
392
            if instrument.isValid():
393
                # Only add the 'valid' instruments: certificate
394
                # on-date and valid internal calibration tests
395
                vocab.append({'ResultValue': instrument.UID(),
396
                              'ResultText': instrument.Title()})
397
        return vocab
398
399
    @viewcache.memoize
400
    def get_analysts(self):
401
        analysts = getUsers(self.context, ['Manager', 'LabManager', 'Analyst'])
402
        analysts = analysts.sortedByKey()
403
        results = list()
404
        for analyst_id, analyst_name in analysts.items():
405
            results.append({'ResultValue': analyst_id,
406
                            'ResultText': analyst_name})
407
        return results
408
409
    def load_analysis_categories(self):
410
        # Getting analysis categories
411
        bsc = api.get_tool('bika_setup_catalog')
412
        analysis_categories = bsc(portal_type="AnalysisCategory",
413
                                  sort_on="sortable_title")
414
        # Sorting analysis categories
415
        self.analysis_categories_order = dict([
416
            (b.Title, "{:04}".format(a)) for a, b in
417
            enumerate(analysis_categories)])
418
419
    def isItemAllowed(self, obj):
420
        """Checks if the passed in Analysis must be displayed in the list.
421
        :param obj: A single Analysis brain or content object
422
        :type obj: ATContentType/CatalogBrain
423
        :returns: True if the item can be added to the list.
424
        :rtype: bool
425
        """
426
        if not obj:
427
            return False
428
429
        # Does the user has enough privileges to see retracted analyses?
430
        if obj.review_state == 'retracted' and \
431
                not self.has_permission(ViewRetractedAnalyses):
432
            return False
433
        return True
434
435
    def folderitem(self, obj, item, index):
436
        """Prepare a data item for the listing.
437
438
        :param obj: The catalog brain or content object
439
        :param item: Listing item (dictionary)
440
        :param index: Index of the listing item
441
        :returns: Augmented listing data item
442
        """
443
444
        item['Service'] = obj.Title
445
        item['class']['service'] = 'service_title'
446
        item['service_uid'] = obj.getServiceUID
447
        item['Keyword'] = obj.getKeyword
448
        item['Unit'] = format_supsub(obj.getUnit) if obj.getUnit else ''
449
        item['retested'] = obj.getRetestOfUID and True or False
450
        item['class']['retested'] = 'center'
451
452
        # Append info link before the service
453
        # see: bika.lims.site.coffee for the attached event handler
454
        item["before"]["Service"] = get_link(
455
            "analysisservice_info?service_uid={}&analysis_uid={}"
456
            .format(obj.getServiceUID, obj.UID),
457
            value="<span class='glyphicon glyphicon-info-sign'></span>",
458
            css_class="service_info")
459
460
        # Note that getSampleTypeUID returns the type of the Sample, no matter
461
        # if the sample associated to the analysis is a regular Sample (routine
462
        # analysis) or if is a Reference Sample (Reference Analysis). If the
463
        # analysis is a duplicate, it returns the Sample Type of the sample
464
        # associated to the source analysis.
465
        item['st_uid'] = obj.getSampleTypeUID
466
467
        # Fill item's category
468
        self._folder_item_category(obj, item)
469
        # Fill item's row class
470
        self._folder_item_css_class(obj, item)
471
        # Fill result and/or result options
472
        self._folder_item_result(obj, item)
473
        # Fill calculation and interim fields
474
        self._folder_item_calculation(obj, item)
475
        # Fill method
476
        self._folder_item_method(obj, item)
477
        # Fill instrument
478
        self._folder_item_instrument(obj, item)
479
        # Fill analyst
480
        self._folder_item_analyst(obj, item)
481
        # Fill attachments
482
        self._folder_item_attachments(obj, item)
483
        # Fill uncertainty
484
        self._folder_item_uncertainty(obj, item)
485
        # Fill Detection Limits
486
        self._folder_item_detection_limits(obj, item)
487
        # Fill Specifications
488
        self._folder_item_specifications(obj, item)
489
        # Fill Due Date and icon if late/overdue
490
        self._folder_item_duedate(obj, item)
491
        # Fill verification criteria
492
        self._folder_item_verify_icons(obj, item)
493
        # Fill worksheet anchor/icon
494
        self._folder_item_assigned_worksheet(obj, item)
495
        # Fill reflex analysis icons
496
        self._folder_item_reflex_icons(obj, item)
497
        # Fill hidden field (report visibility)
498
        self._folder_item_report_visibility(obj, item)
499
        # Renders additional icons to be displayed
500
        self._folder_item_fieldicons(obj)
501
        # Renders remarks toggle button
502
        self._folder_item_remarks(obj, item)
503
504
        return item
505
506
    def folderitems(self):
507
        # This shouldn't be required here, but there are some views that calls
508
        # directly contents_table() instead of __call__, so before_render is
509
        # never called. :(
510
        self.before_render()
511
512
        # Gettin all the items
513
        items = super(AnalysesView, self).folderitems(classic=False)
514
515
        # the TAL requires values for all interim fields on all
516
        # items, so we set blank values in unused cells
517
        for item in items:
518
            for field in self.interim_columns:
519
                if field not in item:
520
                    item[field] = ""
521
522
        # XXX order the list of interim columns
523
        interim_keys = self.interim_columns.keys()
524
        interim_keys.reverse()
525
526
        # add InterimFields keys to columns
527
        for col_id in interim_keys:
528
            if col_id not in self.columns:
529
                self.columns[col_id] = {
530
                    "title": self.interim_columns[col_id],
531
                    "input_width": "6",
532
                    "input_class": "ajax_calculate numeric",
533
                    "sortable": False,
534
                    "toggle": True,
535
                    "ajax": True,
536
                }
537
538
        if self.allow_edit:
539
            new_states = []
540
            for state in self.review_states:
541
                # InterimFields are displayed in review_state
542
                # They are anyway available through View.columns though.
543
                # In case of hidden fields, the calcs.py should check
544
                # calcs/services
545
                # for additional InterimFields!!
546
                pos = "Result" in state["columns"] and \
547
                      state["columns"].index("Result") or len(state["columns"])
548
                for col_id in interim_keys:
549
                    if col_id not in state["columns"]:
550
                        state["columns"].insert(pos, col_id)
551
                # retested column is added after Result.
552
                pos = "Result" in state["columns"] and \
553
                      state["columns"].index("Uncertainty") + 1 or len(
554
                    state["columns"])
555
                if "retested" in state["columns"]:
556
                    state["columns"].remove("retested")
557
                state["columns"].insert(pos, "retested")
558
                new_states.append(state)
559
            self.review_states = new_states
560
            # Allow selecting individual analyses
561
            self.show_select_column = True
562
563
        if self.show_categories:
564
            self.categories = map(lambda x: x[0],
565
                                  sorted(self.categories, key=lambda x: x[1]))
566
        else:
567
            self.categories.sort()
568
569
        # self.json_specs = json.dumps(self.specs)
570
        self.json_interim_fields = json.dumps(self.interim_fields)
571
        self.items = items
572
573
        # Method and Instrument columns must be shown or hidden at the
574
        # same time, because the value assigned to one causes
575
        # a value reassignment to the other (one method can be performed
576
        # by different instruments)
577
        self.columns["Method"]["toggle"] = self.show_methodinstr_columns
578
        self.columns["Instrument"]["toggle"] = self.show_methodinstr_columns
579
580
        return items
581
582
    def _folder_item_category(self, analysis_brain, item):
583
        """Sets the category to the item passed in
584
585
        :param analysis_brain: Brain that represents an analysis
586
        :param item: analysis' dictionary counterpart that represents a row
587
        """
588
        if not self.show_categories:
589
            return
590
591
        cat = analysis_brain.getCategoryTitle
592
        item["category"] = cat
593
        cat_order = self.analysis_categories_order.get(cat)
594
        if (cat, cat_order) not in self.categories:
595
            self.categories.append((cat, cat_order))
596
597
    def _folder_item_css_class(self, analysis_brain, item):
598
        """Sets the suitable css class name(s) to `table_row_class` from the
599
        item passed in, depending on the properties of the analysis object
600
601
        :param analysis_brain: Brain that represents an analysis
602
        :param item: analysis' dictionary counterpart that represents a row
603
        """
604
        meta_type = analysis_brain.meta_type
605
606
        # Default css names for table_row_class
607
        css_names = item.get('table_row_class', '').split()
608
        css_names.extend(['state-{}'.format(analysis_brain.review_state),
609
                          'type-{}'.format(meta_type.lower())])
610
611
        if meta_type == 'ReferenceAnalysis':
612
            css_names.append('qc-analysis')
613
614
        elif meta_type == 'DuplicateAnalysis':
615
            if analysis_brain.getAnalysisPortalType == 'ReferenceAnalysis':
616
                css_names.append('qc-analysis')
617
618
        item['table_row_class'] = ' '.join(css_names)
619
620
    def _folder_item_duedate(self, analysis_brain, item):
621
        """Set the analysis' due date to the item passed in.
622
623
        :param analysis_brain: Brain that represents an analysis
624
        :param item: analysis' dictionary counterpart that represents a row
625
        """
626
627
        # Note that if the analysis is a Reference Analysis, `getDueDate`
628
        # returns the date when the ReferenceSample expires. If the analysis is
629
        # a duplicate, `getDueDate` returns the due date of the source analysis
630
        due_date = analysis_brain.getDueDate
631
        if not due_date:
632
            return None
633
        due_date_str = self.ulocalized_time(due_date, long_format=0)
634
        item['DueDate'] = due_date_str
635
636
        # If the Analysis is late/overdue, display an icon
637
        capture_date = analysis_brain.getResultCaptureDate
638
        capture_date = capture_date or DateTime()
639
        if capture_date > due_date:
640
            # The analysis is late or overdue
641
            img = get_image('late.png', title=t(_("Late Analysis")),
642
                            width='16px', height='16px')
643
            item['replace']['DueDate'] = '{} {}'.format(due_date_str, img)
644
645
    def _folder_item_result(self, analysis_brain, item):
646
        """Set the analysis' result to the item passed in.
647
648
        :param analysis_brain: Brain that represents an analysis
649
        :param item: analysis' dictionary counterpart that represents a row
650
        """
651
652
        item['Result'] = ''
653
        if not self.has_permission(ViewResults, analysis_brain):
654
            # If user has no permissions, don't display the result but an icon
655
            img = get_image('to_follow.png', width='16px', height='16px')
656
            item['before']['Result'] = img
657
            return
658
659
        result = analysis_brain.getResult
660
        capture_date = analysis_brain.getResultCaptureDate
661
        capture_date_str = self.ulocalized_time(capture_date, long_format=0)
662
        item['Result'] = result
663
        item['CaptureDate'] = capture_date_str
664
        item['result_captured'] = capture_date_str
665
666
        if self.is_analysis_edition_allowed(analysis_brain):
667
            item['allow_edit'].extend(['Result'])
668
669
            # If this analysis has a predefined set of options as result,
670
            # tell the template that selection list (choices) must be
671
            # rendered instead of an input field for the introduction of
672
            # result.
673
            choices = analysis_brain.getResultOptions
674
            if choices:
675
                # N.B.we copy here the list to avoid persistent changes
676
                choices = copy(choices)
677
                # By default set empty as the default selected choice
678
                choices.insert(0, dict(ResultValue="", ResultText=""))
679
                item['choices']['Result'] = choices
680
681
        # Wake up the object only if necessary. If there is no result set, then
682
        # there is no need to go further with formatted result
683
        if not result:
684
            return
685
686
        # TODO: Performance, we wake-up the full object here
687
        full_obj = self.get_object(analysis_brain)
688
        formatted_result = full_obj.getFormattedResult(
689
            sciformat=int(self.scinot), decimalmark=self.dmk)
690
        item['formatted_result'] = formatted_result
691
692
    def _folder_item_calculation(self, analysis_brain, item):
693
        """Set the analysis' calculation and interims to the item passed in.
694
695
        :param analysis_brain: Brain that represents an analysis
696
        :param item: analysis' dictionary counterpart that represents a row
697
        """
698
699
        is_editable = self.is_analysis_edition_allowed(analysis_brain)
700
        # Set interim fields. Note we add the key 'formatted_value' to the list
701
        # of interims the analysis has already assigned.
702
        interim_fields = analysis_brain.getInterimFields or list()
703
        for interim_field in interim_fields:
704
            interim_keyword = interim_field.get('keyword', '')
705
            if not interim_keyword:
706
                continue
707
            interim_value = interim_field.get('value', '')
708
            interim_formatted = formatDecimalMark(interim_value, self.dmk)
709
            interim_field['formatted_value'] = interim_formatted
710
            item[interim_keyword] = interim_field
711
            item['class'][interim_keyword] = 'interim'
712
713
            # Note: As soon as we have a separate content type for field
714
            #       analysis, we can solely rely on the field permission
715
            #       "senaite.core: Field: Edit Analysis Result"
716
            if is_editable:
717
                if self.has_permission(FieldEditAnalysisResult, analysis_brain):
718
                    item['allow_edit'].append(interim_keyword)
719
720
            # Add this analysis' interim fields to the interim_columns list
721
            interim_hidden = interim_field.get('hidden', False)
722
            if not interim_hidden:
723
                interim_title = interim_field.get('title')
724
                self.interim_columns[interim_keyword] = interim_title
725
726
        item['interimfields'] = interim_fields
727
        self.interim_fields[analysis_brain.UID] = interim_fields
728
729
        # Set calculation
730
        calculation_uid = analysis_brain.getCalculationUID
731
        has_calculation = calculation_uid and True or False
732
        item['calculation'] = has_calculation
733
734
    def _folder_item_method(self, analysis_brain, item):
735
        """Fills the analysis' method to the item passed in.
736
737
        :param analysis_brain: Brain that represents an analysis
738
        :param item: analysis' dictionary counterpart that represents a row
739
        """
740
741
        is_editable = self.is_analysis_edition_allowed(analysis_brain)
742
        method_title = analysis_brain.getMethodTitle
743
        item['Method'] = method_title or ''
744
        if is_editable:
745
            method_vocabulary = self.get_methods_vocabulary(analysis_brain)
746
            if method_vocabulary:
747
                item['Method'] = analysis_brain.getMethodUID
748
                item['choices']['Method'] = method_vocabulary
749
                item['allow_edit'].append('Method')
750
                self.show_methodinstr_columns = True
751
        elif method_title:
752
            item['replace']['Method'] = get_link(analysis_brain.getMethodURL,
753
                                                 method_title)
754
            self.show_methodinstr_columns = True
755
756
    def _folder_item_instrument(self, analysis_brain, item):
757
        """Fills the analysis' instrument to the item passed in.
758
759
        :param analysis_brain: Brain that represents an analysis
760
        :param item: analysis' dictionary counterpart that represents a row
761
        """
762
        item['Instrument'] = ''
763
        if not analysis_brain.getInstrumentEntryOfResults:
764
            # Manual entry of results, instrument is not allowed
765
            item['Instrument'] = _('Manual')
766
            item['replace']['Instrument'] = \
767
                '<a href="#">{}</a>'.format(t(_('Manual')))
768
            return
769
770
        # Instrument can be assigned to this analysis
771
        is_editable = self.is_analysis_edition_allowed(analysis_brain)
772
        self.show_methodinstr_columns = True
773
        instrument = self.get_instrument(analysis_brain)
774
        if is_editable:
775
            # Edition allowed
776
            voc = self.get_instruments_vocabulary(analysis_brain)
777
            if voc:
778
                # The service has at least one instrument available
779
                item['Instrument'] = instrument.UID() if instrument else ''
780
                item['choices']['Instrument'] = voc
781
                item['allow_edit'].append('Instrument')
782
                return
783
784
        if instrument:
785
            # Edition not allowed
786
            instrument_title = instrument and instrument.Title() or ''
787
            instrument_link = get_link(instrument.absolute_url(),
788
                                       instrument_title)
789
            item['Instrument'] = instrument_title
790
            item['replace']['Instrument'] = instrument_link
791
            return
792
793
    def _folder_item_analyst(self, obj, item):
794
        is_editable = self.is_analysis_edition_allowed(obj)
795
        if not is_editable:
796
            item['Analyst'] = obj.getAnalystName
797
            return
798
799
        # Analyst is editable
800
        item['Analyst'] = obj.getAnalyst or api.get_current_user().id
801
        item['choices']['Analyst'] = self.get_analysts()
802
803
    def _folder_item_attachments(self, obj, item):
804
        item['Attachments'] = ''
805
        attachment_uids = obj.getAttachmentUIDs
806
        if not attachment_uids:
807
            return
808
809
        if not self.has_permission(ViewResults, obj):
810
            return
811
812
        attachments_html = []
813
        attachments = api.search({'UID': attachment_uids}, 'uid_catalog')
814
        for attachment in attachments:
815
            attachment = api.get_object(attachment)
816
            uid = api.get_uid(attachment)
817
            html = '<span class="attachment" attachment_uid="{}">'.format(uid)
818
            attachments_html.append(html)
819
820
            at_file = attachment.getAttachmentFile()
821
            icon = at_file.icon
822
            if callable(icon):
823
                icon = icon()
824
            if icon:
825
                html = '<img src="{}/{}">'.format(self.portal_url, icon)
826
                attachments_html.append(html)
827
828
            url = '{}/at_download/AttachmentFile'
829
            url = url.format(attachment.absolute_url())
830
            link = get_link(url, at_file.filename)
831
            attachments_html.append(link)
832
833
            if not self.is_analysis_edition_allowed(obj):
834
                attachments_html.append('<br/></span>')
835
                continue
836
837
            img = '<img class="deleteAttachmentButton"' \
838
                  ' attachment_uid="{}" src="{}"/>'
839
            img = img.format(uid, '++resource++bika.lims.images/delete.png')
840
            attachments_html.append(img)
841
            attachments_html.append('<br/></span>')
842
843
        if attachments_html:
844
            # Remove the last <br/></span> and add only </span>
845
            attachments_html = attachments_html[:-1]
846
            attachments_html.append('</span>')
847
            item['replace']['Attachments'] = ''.join(attachments_html)
848
849
    def _folder_item_uncertainty(self, obj, item):
850
        item['Uncertainty'] = ''
851
        if not self.has_permission(ViewResults, obj):
852
            return
853
854
        result = obj.getResult
855
856
        # TODO: Performance, we wake-up the full object here
857
        full_obj = self.get_object(obj)
858
        formatted = format_uncertainty(full_obj, result, decimalmark=self.dmk,
859
                                       sciformat=int(self.scinot))
860
        if formatted:
861
            item['Uncertainty'] = formatted
862
            item['structure'] = True
863
            # Add before and after snippets
864
            after = '<em class="discreet" style="white-space:nowrap;"> {}</em>'
865
            item['before']['Uncertainty'] = '&plusmn;&nbsp;'
866
            item['after']['Uncertainty'] = after.format(obj.getUnit)
867
868
        is_editable = self.is_analysis_edition_allowed(obj)
869
        if is_editable and full_obj.getAllowManualUncertainty():
870
            # User can set the value of uncertainty manually
871
            uncertainty = full_obj.getUncertainty(result)
872
            item['Uncertainty'] = uncertainty or ''
873
            item['allow_edit'].append('Uncertainty')
874
            item['structure'] = False
875
            # Add before and after snippets
876
            after = '<em class="discreet" style="white-space:nowrap;"> {}</em>'
877
            item['before']['Uncertainty'] = '&plusmn;&nbsp;'
878
            item['after']['Uncertainty'] = after.format(obj.getUnit)
879
880
    def _folder_item_detection_limits(self, obj, item):
881
        item['DetectionLimit'] = ''
882
        is_editable = self.is_analysis_edition_allowed(obj)
883
        if not is_editable:
884
            return
885
886
        # TODO: Performance, we wake-up the full object here
887
        full_obj = self.get_object(obj)
888
        uid = api.get_uid(obj)
889
890
        is_below_ldl = full_obj.isBelowLowerDetectionLimit()
891
        is_above_udl = full_obj.isAboveUpperDetectionLimit()
892
893
        # Allow to use LDL and UDL in calculations.
894
        # Since LDL, UDL, etc. are wildcards that can be used in calculations,
895
        # these fields must be loaded always for 'live' calculations.
896
        dls = {
897
            'above_udl': is_above_udl,
898
            'below_ldl': is_below_ldl,
899
            'is_ldl': full_obj.isLowerDetectionLimit(),
900
            'is_udl': full_obj.isUpperDetectionLimit(),
901
            'default_ldl': full_obj.getLowerDetectionLimit(),
902
            'default_udl': full_obj.getUpperDetectionLimit(),
903
            'manual_allowed': full_obj.getAllowManualDetectionLimit(),
904
            'dlselect_allowed': full_obj.getDetectionLimitSelector()
905
        }
906
        dlsin = \
907
            '<input type="hidden" id="AnalysisDLS.%s" value=\'%s\'/>'
908
        dlsin = dlsin % (uid, json.dumps(dls))
909
        item['after']['Result'] = dlsin
910
911
        if not full_obj.getDetectionLimitSelector():
912
            # The user cannot manually set the Detection Limit
913
            return
914
915
        # User can manually set the Detection Limit for this analysis.
916
        # A selector with options '', '<' and '>' must be displayed.
917
        dl_operator = ''
918
        if is_below_ldl or is_above_udl:
919
            dl_operator = '<' if is_below_ldl else '>'
920
921
        item['allow_edit'].append('DetectionLimit')
922
        item['DetectionLimit'] = dl_operator
923
        item['choices']['DetectionLimit'] = [
924
            {'ResultValue': '<', 'ResultText': '<'},
925
            {'ResultValue': '>', 'ResultText': '>'}
926
        ]
927
        self.columns['DetectionLimit']['toggle'] = True
928
        defaults = {'min': full_obj.getLowerDetectionLimit(),
929
                    'max': full_obj.getUpperDetectionLimit(),
930
                    'manual': full_obj.getAllowManualDetectionLimit()}
931
        defin = \
932
            '<input type="hidden" id="DefaultDLS.%s" value=\'%s\'/>'
933
        defin = defin % (uid, json.dumps(defaults))
934
        item['after']['DetectionLimit'] = defin
935
936
    def _folder_item_specifications(self, analysis_brain, item):
937
        """Set the results range to the item passed in"""
938
        # Everyone can see valid-ranges
939
        item['Specification'] = ''
940
        results_range = analysis_brain.getResultsRange
941
        if not results_range:
942
            return
943
944
        # Display the specification interval
945
        item["Specification"] = get_formatted_interval(results_range, "")
946
947
        # Show an icon if out of range
948
        out_range, out_shoulders = is_out_of_range(analysis_brain)
949
        if not out_range:
950
            return
951
        # At least is out of range
952
        img = get_image("exclamation.png", title=_("Result out of range"))
953
        if not out_shoulders:
954
            img = get_image("warning.png", title=_("Result in shoulder range"))
955
        self._append_html_element(item, "Result", img)
956
957
    def _folder_item_verify_icons(self, analysis_brain, item):
958
        """Set the analysis' verification icons to the item passed in.
959
960
        :param analysis_brain: Brain that represents an analysis
961
        :param item: analysis' dictionary counterpart that represents a row
962
        """
963
        submitter = analysis_brain.getSubmittedBy
964
        if not submitter:
965
            # This analysis hasn't yet been submitted, no verification yet
966
            return
967
968
        if analysis_brain.review_state == 'retracted':
969
            # Don't display icons and additional info about verification
970
            return
971
972
        verifiers = analysis_brain.getVerificators
973
        in_verifiers = submitter in verifiers
974
        if in_verifiers:
975
            # If analysis has been submitted and verified by the same person,
976
            # display a warning icon
977
            msg = t(_("Submitted and verified by the same user: {}"))
978
            icon = get_image('warning.png', title=msg.format(submitter))
979
            self._append_html_element(item, 'state_title', icon)
980
981
        num_verifications = analysis_brain.getNumberOfRequiredVerifications
982
        if num_verifications > 1:
983
            # More than one verification required, place an icon and display
984
            # the number of verifications done vs. total required
985
            done = analysis_brain.getNumberOfVerifications
986
            pending = num_verifications - done
987
            ratio = float(done) / float(num_verifications) if done > 0 else 0
988
            ratio = int(ratio * 100)
989
            scale = ratio == 0 and 0 or (ratio / 25) * 25
990
            anchor = "<a href='#' title='{} &#13;{} {}' " \
991
                     "class='multi-verification scale-{}'>{}/{}</a>"
992
            anchor = anchor.format(t(_("Multi-verification required")),
993
                                   str(pending),
994
                                   t(_("verification(s) pending")),
995
                                   str(scale),
996
                                   str(done),
997
                                   str(num_verifications))
998
            self._append_html_element(item, 'state_title', anchor)
999
1000
        if analysis_brain.review_state != 'to_be_verified':
1001
            # The verification of analysis has already been done or first
1002
            # verification has not been done yet. Nothing to do
1003
            return
1004
1005
        # Check if the user has "Bika: Verify" privileges
1006
        if not self.has_permission(TransitionVerify):
1007
            # User cannot verify, do nothing
1008
            return
1009
1010
        username = api.get_current_user().id
1011
        if username not in verifiers:
1012
            # Current user has not verified this analysis
1013
            if submitter != username:
1014
                # Current user is neither a submitter nor a verifier
1015
                return
1016
1017
            # Current user is the same who submitted the result
1018
            if analysis_brain.isSelfVerificationEnabled:
1019
                # Same user who submitted can verify
1020
                title = t(_("Can verify, but submitted by current user"))
1021
                html = get_image('warning.png', title=title)
1022
                self._append_html_element(item, 'state_title', html)
1023
                return
1024
1025
            # User who submitted cannot verify
1026
            title = t(_("Cannot verify, submitted by current user"))
1027
            html = get_image('submitted-by-current-user.png', title=title)
1028
            self._append_html_element(item, 'state_title', html)
1029
            return
1030
1031
        # This user verified this analysis before
1032
        multi_verif = self.context.bika_setup.getTypeOfmultiVerification()
1033
        if multi_verif != 'self_multi_not_cons':
1034
            # Multi verification by same user is not allowed
1035
            title = t(_("Cannot verify, was verified by current user"))
1036
            html = get_image('submitted-by-current-user.png', title=title)
1037
            self._append_html_element(item, 'state_title', html)
1038
            return
1039
1040
        # Multi-verification by same user, but non-consecutively, is allowed
1041
        if analysis_brain.getLastVerificator != username:
1042
            # Current user was not the last user to verify
1043
            title = t(
1044
                _("Can verify, but was already verified by current user"))
1045
            html = get_image('warning.png', title=title)
1046
            self._append_html_element(item, 'state_title', html)
1047
            return
1048
1049
        # Last user who verified is the same as current user
1050
        title = t(_("Cannot verify, last verified by current user"))
1051
        html = get_image('submitted-by-current-user.png', title=title)
1052
        self._append_html_element(item, 'state_title', html)
1053
        return
1054
1055
    def _folder_item_assigned_worksheet(self, analysis_brain, item):
1056
        """Adds an icon to the item dict if the analysis is assigned to a
1057
        worksheet and if the icon is suitable for the current context
1058
1059
        :param analysis_brain: Brain that represents an analysis
1060
        :param item: analysis' dictionary counterpart that represents a row
1061
        """
1062
        if not IAnalysisRequest.providedBy(self.context):
1063
            # We want this icon to only appear if the context is an AR
1064
            return
1065
1066
        analysis_obj = self.get_object(analysis_brain)
1067
        worksheet = analysis_obj.getWorksheet()
1068
        if not worksheet:
1069
            # No worksheet assigned. Do nothing
1070
            return
1071
1072
        title = t(_("Assigned to: ${worksheet_id}",
1073
                    mapping={'worksheet_id': safe_unicode(worksheet.id)}))
1074
        img = get_image('worksheet.png', title=title)
1075
        anchor = get_link(worksheet.absolute_url(), img)
1076
        self._append_html_element(item, 'state_title', anchor)
1077
1078
    def _folder_item_reflex_icons(self, analysis_brain, item):
1079
        """Adds an icon to the item dictionary if the analysis has been
1080
        automatically generated due to a reflex rule
1081
1082
        :param analysis_brain: Brain that represents an analysis
1083
        :param item: analysis' dictionary counterpart that represents a row
1084
        """
1085
        if not analysis_brain.getIsReflexAnalysis:
1086
            # Do nothing
1087
            return
1088
        img = get_image('reflexrule.png',
1089
                        title=t(_('It comes form a reflex rule')))
1090
        self._append_html_element(item, 'Service', img)
1091
1092
    def _folder_item_report_visibility(self, analysis_brain, item):
1093
        """Set if the hidden field can be edited (enabled/disabled)
1094
1095
        :analysis_brain: Brain that represents an analysis
1096
        :item: analysis' dictionary counterpart to be represented as a row"""
1097
        # Users that can Add Analyses to an Analysis Request must be able to
1098
        # set the visibility of the analysis in results report, also if the
1099
        # current state of the Analysis Request (e.g. verified) does not allow
1100
        # the edition of other fields. Note that an analyst has no privileges
1101
        # by default to edit this value, cause this "visibility" field is
1102
        # related with results reporting and/or visibility from the client
1103
        # side. This behavior only applies to routine analyses, the visibility
1104
        # of QC analyses is managed in publish and are not visible to clients.
1105
        if 'Hidden' not in self.columns:
1106
            return
1107
1108
        full_obj = self.get_object(analysis_brain)
1109
        item['Hidden'] = full_obj.getHidden()
1110
        if self.has_permission(FieldEditAnalysisHidden, obj=full_obj):
1111
            item['allow_edit'].append('Hidden')
1112
1113
    def _folder_item_fieldicons(self, analysis_brain):
1114
        """Resolves if field-specific icons must be displayed for the object
1115
        passed in.
1116
1117
        :param analysis_brain: Brain that represents an analysis
1118
        """
1119
        full_obj = self.get_object(analysis_brain)
1120
        uid = api.get_uid(full_obj)
1121
        for name, adapter in getAdapters((full_obj,), IFieldIcons):
1122
            alerts = adapter()
1123
            if not alerts or uid not in alerts:
1124
                continue
1125
            alerts = alerts[uid]
1126
            if uid not in self.field_icons:
1127
                self.field_icons[uid] = alerts
1128
                continue
1129
            self.field_icons[uid].extend(alerts)
1130
1131
    def _folder_item_remarks(self, analysis_brain, item):
1132
        """Renders the Remarks field for the passed in analysis
1133
1134
        If the edition of the analysis is permitted, adds the field into the
1135
        list of editable fields.
1136
1137
        :param analysis_brain: Brain that represents an analysis
1138
        :param item: analysis' dictionary counterpart that represents a row
1139
        """
1140
1141
        if self.analysis_remarks_enabled():
1142
            item["Remarks"] = analysis_brain.getRemarks
1143
1144
        if self.is_analysis_edition_allowed(analysis_brain):
1145
            item["allow_edit"].extend(["Remarks"])
1146
1147
    def _append_html_element(self, item, element, html, glue="&nbsp;",
1148
                             after=True):
1149
        """Appends an html value after or before the element in the item dict
1150
1151
        :param item: dictionary that represents an analysis row
1152
        :param element: id of the element the html must be added thereafter
1153
        :param html: element to append
1154
        :param glue: glue to use for appending
1155
        :param after: if the html content must be added after or before"""
1156
        position = after and 'after' or 'before'
1157
        item[position] = item.get(position, {})
1158
        original = item[position].get(element, '')
1159
        if not original:
1160
            item[position][element] = html
1161
            return
1162
        item[position][element] = glue.join([original, html])
1163