Passed
Push — master ( 90ae0b...f7940d )
by Jordi
04:19
created

AnalysesView.get_object()   A

Complexity

Conditions 3

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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