Passed
Push — master ( 38c298...28ef25 )
by Ramon
04:39
created

AnalysesView._folder_item_fieldicons()   A

Complexity

Conditions 5

Size

Total Lines 17
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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