Passed
Push — master ( b9e514...257903 )
by Jordi
11:51 queued 05:38
created

AnalysesView.has_permission()   A

Complexity

Conditions 3

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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