Passed
Push — 2.x ( 2b922f...08fd05 )
by Jordi
09:08
created

AnalysesView.get_interim_choices()   A

Complexity

Conditions 3

Size

Total Lines 9
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 7
dl 0
loc 9
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
# 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-2021 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
from copy import deepcopy
25
from operator import itemgetter
26
27
from bika.lims import api
28
from bika.lims import bikaMessageFactory as _
29
from bika.lims import deprecated
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.api.analysis import is_result_range_compliant
34
from bika.lims.config import LDL
35
from bika.lims.config import UDL
36
from bika.lims.interfaces import IAnalysisRequest
37
from bika.lims.interfaces import IFieldIcons
38
from bika.lims.interfaces import IReferenceAnalysis
39
from bika.lims.interfaces import IRoutineAnalysis
40
from senaite.core.permissions import EditFieldResults
41
from senaite.core.permissions import EditResults
42
from senaite.core.permissions import FieldEditAnalysisConditions
43
from senaite.core.permissions import FieldEditAnalysisHidden
44
from senaite.core.permissions import FieldEditAnalysisResult
45
from senaite.core.permissions import TransitionVerify
46
from senaite.core.permissions import ViewResults
47
from senaite.core.permissions import ViewRetractedAnalyses
48
from bika.lims.utils import check_permission
49
from bika.lims.utils import format_supsub
50
from bika.lims.utils import formatDecimalMark
51
from bika.lims.utils import get_image
52
from bika.lims.utils import get_link
53
from bika.lims.utils import get_link_for
54
from bika.lims.utils.analysis import format_uncertainty
55
from DateTime import DateTime
56
from plone.memoize import view as viewcache
57
from Products.Archetypes.config import REFERENCE_CATALOG
58
from Products.CMFPlone.utils import safe_unicode
59
from senaite.app.listing import ListingView
60
from senaite.core.catalog import ANALYSIS_CATALOG
61
from senaite.core.catalog import SETUP_CATALOG
62
from senaite.core.i18n import translate as t
63
from senaite.core.registry import get_registry_record
64
from zope.component import getAdapters
65
from zope.component import getMultiAdapter
66
67
68
class AnalysesView(ListingView):
69
    """Displays a list of Analyses in a table.
70
71
    Visible InterimFields from all analyses are added to self.columns[].
72
    Keyword arguments are passed directly to senaite_catalog_analysis.
73
    """
74
75
    def __init__(self, context, request, **kwargs):
76
        super(AnalysesView, self).__init__(context, request, **kwargs)
77
78
        # prepare the content filter of this listing
79
        self.contentFilter = dict(kwargs)
80
        self.contentFilter.update({
81
            "portal_type": "Analysis",
82
            "sort_on": "sortable_title",
83
            "sort_order": "ascending",
84
        })
85
86
        # set the listing view config
87
        self.catalog = ANALYSIS_CATALOG
88
        self.sort_order = "ascending"
89
        self.context_actions = {}
90
91
        self.show_select_row = False
92
        self.show_select_column = False
93
        self.show_column_toggles = False
94
        self.pagesize = 9999999
95
        self.form_id = "analyses_form"
96
        self.context_active = api.is_active(context)
97
        self.interim_fields = {}
98
        self.interim_columns = OrderedDict()
99
        self.specs = {}
100
        self.bsc = api.get_tool(SETUP_CATALOG)
101
        self.portal = api.get_portal()
102
        self.portal_url = api.get_url(self.portal)
103
        self.rc = api.get_tool(REFERENCE_CATALOG)
104
        self.dmk = context.bika_setup.getResultsDecimalMark()
105
        self.scinot = context.bika_setup.getScientificNotationResults()
106
        self.categories = []
107
        self.expand_all_categories = True
108
109
        # each editable item needs it's own allow_edit
110
        # which is a list of field names.
111
        self.allow_edit = False
112
113
        self.columns = OrderedDict((
114
            # Although 'created' column is not displayed in the list (see
115
            # review_states to check the columns that will be rendered), this
116
            # column is needed to sort the list by create date
117
            ("created", {
118
                "title": _("Date Created"),
119
                "toggle": False}),
120
            ("Service", {
121
                "title": _("Analysis"),
122
                "attr": "Title",
123
                "index": "sortable_title",
124
                "sortable": False}),
125
            ("DetectionLimitOperand", {
126
                "title": _("DL"),
127
                "sortable": False,
128
                "ajax": True,
129
                "autosave": True,
130
                "toggle": False}),
131
            ("Result", {
132
                "title": _("Result"),
133
                "input_width": "6",
134
                "input_class": "ajax_calculate numeric",
135
                "ajax": True,
136
                "sortable": False}),
137
            ("Uncertainty", {
138
                "title": _("+-"),
139
                "ajax": True,
140
                "sortable": False}),
141
            ("Unit", {
142
                "title": _("Unit"),
143
                "sortable": False,
144
                "ajax": True,
145
                "on_change": "_on_unit_change",
146
                "toggle": True}),
147
            ("Specification", {
148
                "title": _("Specification"),
149
                "sortable": False}),
150
            ("retested", {
151
                "title": _("Retested"),
152
                "type": "boolean",
153
                "sortable": False}),
154
            ("Method", {
155
                "title": _("Method"),
156
                "sortable": False,
157
                "ajax": True,
158
                "on_change": "_on_method_change",
159
                "toggle": True}),
160
            ("Instrument", {
161
                "title": _("Instrument"),
162
                "ajax": True,
163
                "sortable": False,
164
                "toggle": True}),
165
            ("Calculation", {
166
                "title": _("Calculation"),
167
                "sortable": False,
168
                "toggle": False}),
169
            ("Attachments", {
170
                "title": _("Attachments"),
171
                "sortable": False}),
172
            ("SubmittedBy", {
173
                "title": _("Submitter"),
174
                "sortable": False}),
175
            ("Analyst", {
176
                "title": _("Analyst"),
177
                "sortable": False,
178
                "ajax": True,
179
                "toggle": True}),
180
            ("CaptureDate", {
181
                "title": _("Captured"),
182
                "index": "getResultCaptureDate",
183
                "sortable": False}),
184
            ("DueDate", {
185
                "title": _("Due Date"),
186
                "index": "getDueDate",
187
                "sortable": False}),
188
            ("state_title", {
189
                "title": _("Status"),
190
                "sortable": False}),
191
            ("Hidden", {
192
                "title": _("Hidden"),
193
                "toggle": True,
194
                "sortable": False,
195
                "ajax": True,
196
                "type": "boolean"}),
197
        ))
198
199
        # Inject Remarks column for listing
200
        if self.analysis_remarks_enabled():
201
            self.columns["Remarks"] = {
202
                "title": "Remarks",
203
                "toggle": False,
204
                "sortable": False,
205
                "type": "remarks",
206
                "ajax": True,
207
            }
208
209
        self.review_states = [
210
            {
211
                "id": "default",
212
                "title": _("Valid"),
213
                "contentFilter": {
214
                    "review_state": [
215
                        "registered",
216
                        "unassigned",
217
                        "assigned",
218
                        "to_be_verified",
219
                        "verified",
220
                        "published",
221
                    ]
222
                },
223
                "columns": self.columns.keys()
224
            },
225
            {
226
                "id": "invalid",
227
                "contentFilter": {
228
                    "review_state": [
229
                        "cancelled",
230
                        "retracted",
231
                        "rejected",
232
                    ]
233
                },
234
                "title": _("Invalid"),
235
                "columns": self.columns.keys(),
236
            },
237
            {
238
                "id": "all",
239
                "title": _("All"),
240
                "contentFilter": {},
241
                "columns": self.columns.keys()
242
             },
243
        ]
244
245
    def update(self):
246
        """Update hook
247
        """
248
        super(AnalysesView, self).update()
249
        self.load_analysis_categories()
250
        self.append_partition_filters()
251
        if self.analysis_categories_enabled():
252
            self.show_categories = True
253
254
    def before_render(self):
255
        """Before render hook
256
        """
257
        super(AnalysesView, self).before_render()
258
        self.request.set("disable_plone.rightcolumn", 1)
259
260
    @viewcache.memoize
261
    def get_default_columns_order(self):
262
        """Return the default column order from the registry
263
264
        :returns: List of column keys
265
        """
266
        name = "sampleview_analysis_columns_order"
267
        columns_order = get_registry_record(name, default=[]) or []
268
        return columns_order
269
270
    def reorder_analysis_columns(self):
271
        """Reorder analysis columns based on registry configuration
272
        """
273
        columns_order = self.get_default_columns_order()
274
        if not columns_order:
275
            return
276
        # compute columns that are missing in the config
277
        missing_columns = filter(
278
            lambda col: col not in columns_order, self.columns.keys())
279
        # prepare the new sort order for the columns
280
        ordered_columns = columns_order + missing_columns
281
282
        # set the order in each review state
283
        for rs in self.review_states:
284
            # set a copy of the new ordered columns list
285
            rs["columns"] = ordered_columns[:]
286
287
    def calculate_interim_columns_position(self, review_state):
288
        """Calculate at which position the interim columns should be inserted
289
        """
290
        columns = review_state.get("columns", [])
291
        if "AdditionalValues" in columns:
292
            return columns.index("AdditionalValues")
293
        if "Result" in columns:
294
            return columns.index("Result")
295
        return len(columns)
296
297
    @property
298
    @viewcache.memoize
299
    def senaite_theme(self):
300
        return getMultiAdapter(
301
            (self.context, self.request),
302
            name="senaite_theme")
303
304
    @property
305
    @viewcache.memoize
306
    def show_partitions(self):
307
        """Returns whether the partitions must be displayed or not
308
        """
309
        if api.get_current_client():
310
            # Current user is a client contact
311
            return api.get_setup().getShowPartitions()
312
        return True
313
314
    @viewcache.memoize
315
    def analysis_remarks_enabled(self):
316
        """Check if analysis remarks are enabled
317
        """
318
        return self.context.bika_setup.getEnableAnalysisRemarks()
319
320
    @viewcache.memoize
321
    def analysis_categories_enabled(self):
322
        """Check if analyses should be grouped by category
323
        """
324
        # setting applies only for samples
325
        if not IAnalysisRequest.providedBy(self.context):
326
            return False
327
        setup = api.get_senaite_setup()
328
        return setup.getCategorizeSampleAnalyses()
329
330
    @viewcache.memoize
331
    def has_permission(self, permission, obj=None):
332
        """Returns if the current user has rights for the permission passed in
333
334
        :param permission: permission identifier
335
        :param obj: object to check the permission against
336
        :return: True if the user has rights for the permission passed in
337
        """
338
        if not permission:
339
            logger.warn("None permission is not allowed")
340
            return False
341
        if obj is None:
342
            return check_permission(permission, self.context)
343
        return check_permission(permission, self.get_object(obj))
344
345
    @viewcache.memoize
346
    def is_analysis_edition_allowed(self, analysis_brain):
347
        """Returns if the analysis passed in can be edited by the current user
348
349
        :param analysis_brain: Brain that represents an analysis
350
        :return: True if the user can edit the analysis, otherwise False
351
        """
352
        if not self.context_active:
353
            # The current context must be active. We cannot edit analyses from
354
            # inside a deactivated Analysis Request, for instance
355
            return False
356
357
        analysis_obj = self.get_object(analysis_brain)
358
        if analysis_obj.getPointOfCapture() == 'field':
359
            # This analysis must be captured on field, during sampling.
360
            if not self.has_permission(EditFieldResults, analysis_obj):
361
                # Current user cannot edit field analyses.
362
                return False
363
364
        elif not self.has_permission(EditResults, analysis_obj):
365
            # The Point of Capture is 'lab' and the current user cannot edit
366
            # lab analyses.
367
            return False
368
369
        # Check if the user is allowed to enter a value to to Result field
370
        if not self.has_permission(FieldEditAnalysisResult, analysis_obj):
371
            return False
372
373
        return True
374
375
    @viewcache.memoize
376
    def is_result_edition_allowed(self, analysis_brain):
377
        """Checks if the edition of the result field is allowed
378
379
        :param analysis_brain: Brain that represents an analysis
380
        :return: True if the user can edit the result field, otherwise False
381
        """
382
383
        # Always check general edition first
384
        if not self.is_analysis_edition_allowed(analysis_brain):
385
            return False
386
387
        # Get the ananylsis object
388
        obj = self.get_object(analysis_brain)
389
390
        if not obj.getDetectionLimitOperand():
391
            # This is a regular result (not a detection limit)
392
            return True
393
394
        # Detection limit selector is enabled in the Analysis Service
395
        if obj.getDetectionLimitSelector():
396
            # Manual detection limit entry is *not* allowed
397
            if not obj.getAllowManualDetectionLimit():
398
                return False
399
400
        return True
401
402
    @viewcache.memoize
403
    def is_uncertainty_edition_allowed(self, analysis_brain):
404
        """Checks if the edition of the uncertainty field is allowed
405
406
        :param analysis_brain: Brain that represents an analysis
407
        :return: True if the user can edit the result field, otherwise False
408
        """
409
410
        # Only allow to edit the uncertainty if result edition is allowed
411
        if not self.is_result_edition_allowed(analysis_brain):
412
            return False
413
414
        # Get the ananylsis object
415
        obj = self.get_object(analysis_brain)
416
417
        # Manual setting of uncertainty is not allowed
418
        if not obj.getAllowManualUncertainty():
419
            return False
420
421
        # Result is a detection limit -> uncertainty setting makes no sense!
422
        if obj.getDetectionLimitOperand() in [LDL, UDL]:
423
            return False
424
425
        return True
426
427
    @viewcache.memoize
428
    def is_analysis_conditions_edition_allowed(self, analysis_brain):
429
        """Returns whether the conditions of the analysis can be edited or not
430
        """
431
        # Check if permission is granted for the given analysis
432
        obj = self.get_object(analysis_brain)
433
434
        if IReferenceAnalysis.providedBy(obj):
435
            return False
436
437
        if not self.has_permission(FieldEditAnalysisConditions, obj):
438
            return False
439
440
        # Omit analysis does not have conditions set
441
        if not obj.getConditions(empties=True):
442
            return False
443
444
        return True
445
446
    def get_instrument(self, analysis_brain):
447
        """Returns the instrument assigned to the analysis passed in, if any
448
449
        :param analysis_brain: Brain that represents an analysis
450
        :return: Instrument object or None
451
        """
452
        obj = self.get_object(analysis_brain)
453
        return obj.getInstrument()
454
455
    def get_calculation(self, analysis_brain):
456
        """Returns the calculation assigned to the analysis passed in, if any
457
458
        :param analysis_brain: Brain that represents an analysis
459
        :return: Calculation object or None
460
        """
461
        obj = self.get_object(analysis_brain)
462
        return obj.getCalculation()
463
464
    @viewcache.memoize
465
    def get_object(self, brain_or_object_or_uid):
466
        """Get the full content object. Returns None if the param passed in is
467
        not a valid, not a valid object or not found
468
469
        :param brain_or_object_or_uid: UID/Catalog brain/content object
470
        :returns: content object
471
        """
472
        return api.get_object(brain_or_object_or_uid, default=None)
473
474
    def get_methods_vocabulary(self, analysis_brain):
475
        """Returns a vocabulary with all the methods available for the passed in
476
        analysis, either those assigned to an instrument that are capable to
477
        perform the test (option "Allow Entry of Results") and those assigned
478
        manually in the associated Analysis Service.
479
480
        The vocabulary is a list of dictionaries. Each dictionary has the
481
        following structure:
482
483
            {'ResultValue': <method_UID>,
484
             'ResultText': <method_Title>}
485
486
        :param analysis_brain: A single Analysis brain
487
        :type analysis_brain: CatalogBrain
488
        :returns: A list of dicts
489
        """
490
        vocab = []
491
        obj = self.get_object(analysis_brain)
492
        default_method = obj.getMethod()
493
        methods = obj.getAllowedMethods()
494
        empty_option = {"ResultValue": "", "ResultText": _("None")}
495
        for method in methods:
496
            vocab.append({
497
                "ResultValue": api.get_uid(method),
498
                "ResultText": api.get_title(method),
499
            })
500
        # allow empty option if we have no allowed methods
501
        if not methods:
502
            vocab = [empty_option]
503
        # allow empty option if the default method is set to "None"
504
        elif default_method is None:
505
            vocab.insert(0, empty_option)
506
        return vocab
507
508
    def get_unit_vocabulary(self, analysis_brain):
509
        """Returns a vocabulary with all the units available for the passed in
510
        analysis.
511
512
        The vocabulary is a list of dictionaries. Each dictionary has the
513
        following structure:
514
515
            {'ResultValue': <unit>,
516
             'ResultText': <unit>}
517
518
        :param analysis_brain: A single Analysis brain
519
        :type analysis_brain: CatalogBrain
520
        :returns: A list of dicts
521
        """
522
        obj = self.get_object(analysis_brain)
523
        # Get unit choices
524
        unit_choices = obj.getUnitChoices()
525
        vocab = []
526
        for unit in unit_choices:
527
            vocab.append({
528
                "ResultValue": unit['value'],
529
                "ResultText": unit['value'],
530
            })
531
        return vocab
532
533
    def get_instruments_vocabulary(self, analysis, method=None):
534
        """Returns a vocabulary with the valid and active instruments available
535
        for the analysis passed in.
536
537
        If the option "Allow instrument entry of results" for the Analysis
538
        is disabled, the function returns an empty vocabulary.
539
540
        If the analysis passed in is a Reference Analysis (Blank or Control),
541
        the vocabulary, the invalid instruments will be included in the
542
        vocabulary too.
543
544
        The vocabulary is a list of dictionaries. Each dictionary has the
545
        following structure:
546
547
            {'ResultValue': <instrument_UID>,
548
             'ResultText': <instrument_Title>}
549
550
        :param analysis: A single Analysis or ReferenceAnalysis
551
        :type analysis_brain: Analysis or.ReferenceAnalysis
552
        :return: A vocabulary with the instruments for the analysis
553
        :rtype: A list of dicts: [{'ResultValue':UID, 'ResultText':Title}]
554
        """
555
        obj = self.get_object(analysis)
556
        # get the allowed interfaces from the analysis service
557
        instruments = obj.getAllowedInstruments()
558
        # if no method is passed, get the assigned method of the analyis
559
        if method is None:
560
            method = obj.getMethod()
561
562
        # check if the analysis has a method
563
        if method:
564
            # supported instrument from the method
565
            method_instruments = method.getInstruments()
566
            # allow only method instruments that are set in service
567
            instruments = list(
568
                set(instruments).intersection(method_instruments))
569
570
        # If the analysis is a QC analysis, display all instruments, including
571
        # those uncalibrated or for which the last QC test failed.
572
        is_qc = api.get_portal_type(obj) == "ReferenceAnalysis"
573
574
        vocab = []
575
        for instrument in instruments:
576
            uid = api.get_uid(instrument)
577
            title = api.safe_unicode(api.get_title(instrument))
578
            # append all valid instruments
579
            if instrument.isValid():
580
                vocab.append({
581
                    "ResultValue": uid,
582
                    "ResultText": title,
583
                })
584
            elif is_qc:
585
                # Is a QC analysis, include instrument also if is not valid
586
                if instrument.isOutOfDate():
587
                    title = _(u"{} (Out of date)".format(title))
588
                vocab.append({
589
                    "ResultValue": uid,
590
                    "ResultText": title,
591
                })
592
            elif instrument.isOutOfDate():
593
                # disable out of date instruments
594
                title = _(u"{} (Out of date)".format(title))
595
                vocab.append({
596
                    "disabled": True,
597
                    "ResultValue": None,
598
                    "ResultText": title,
599
                })
600
601
        # sort the vocabulary
602
        vocab = list(sorted(vocab, key=itemgetter("ResultText")))
603
        # prepend empty item
604
        vocab = [{"ResultValue": "", "ResultText": _("None")}] + vocab
605
606
        return vocab
607
608
    def load_analysis_categories(self):
609
        # Getting analysis categories
610
        bsc = api.get_tool('senaite_catalog_setup')
611
        analysis_categories = bsc(portal_type="AnalysisCategory",
612
                                  sort_on="sortable_title")
613
        # Sorting analysis categories
614
        self.analysis_categories_order = dict([
615
            (b.Title, "{:04}".format(a)) for a, b in
616
            enumerate(analysis_categories)])
617
618
    def append_partition_filters(self):
619
        """Append additional review state filters for partitions
620
        """
621
622
        # view is used for instrument QC view as well
623
        if not IAnalysisRequest.providedBy(self.context):
624
            return
625
626
        # check if the sample has partitions
627
        partitions = self.context.getDescendants()
628
629
        # root partition w/o partitions
630
        if not partitions:
631
            return
632
633
        new_states = []
634
        valid_states = [
635
            "registered",
636
            "unassigned",
637
            "assigned",
638
            "to_be_verified",
639
            "verified",
640
            "published",
641
        ]
642
643
        if self.context.isRootAncestor():
644
            root_id = api.get_id(self.context)
645
            new_states.append({
646
                "id": root_id,
647
                "title": root_id,
648
                "contentFilter": {
649
                    "getAncestorsUIDs": api.get_uid(self.context),
650
                    "review_state": valid_states,
651
                    "path": {
652
                        "query": api.get_path(self.context),
653
                        "level": 0,
654
                    },
655
                },
656
                "columns": self.columns.keys(),
657
            })
658
659
        for partition in partitions:
660
            part_id = api.get_id(partition)
661
            new_states.append({
662
                "id": part_id,
663
                "title": part_id,
664
                "contentFilter": {
665
                    "getAncestorsUIDs": api.get_uid(partition),
666
                    "review_state": valid_states,
667
                },
668
                "columns": self.columns.keys(),
669
            })
670
671
        for state in sorted(new_states, key=lambda s: s.get("id")):
672
            if state not in self.review_states:
673
                self.review_states.append(state)
674
675
    def isItemAllowed(self, obj):
676
        """Checks if the passed in Analysis must be displayed in the list.
677
        :param obj: A single Analysis brain or content object
678
        :type obj: ATContentType/CatalogBrain
679
        :returns: True if the item can be added to the list.
680
        :rtype: bool
681
        """
682
        if not obj:
683
            return False
684
685
        # Does the user has enough privileges to see retracted analyses?
686
        if obj.review_state == 'retracted' and \
687
                not self.has_permission(ViewRetractedAnalyses):
688
            return False
689
        return True
690
691
    def folderitem(self, obj, item, index):
692
        """Prepare a data item for the listing.
693
694
        :param obj: The catalog brain or content object
695
        :param item: Listing item (dictionary)
696
        :param index: Index of the listing item
697
        :returns: Augmented listing data item
698
        """
699
700
        item['Service'] = obj.Title
701
        item['class']['service'] = 'service_title'
702
        item['service_uid'] = obj.getServiceUID
703
        item['Keyword'] = obj.getKeyword
704
        item['Unit'] = format_supsub(obj.getUnit) if obj.getUnit else ''
705
        item['retested'] = obj.getRetestOfUID and True or False
706
        item['replace']['Service'] = '<strong>{}</strong>'.format(obj.Title)
707
708
        # Append info link before the service
709
        # see: bika.lims.site.coffee for the attached event handler
710
        item["before"]["Service"] = get_link(
711
            "analysisservice_info?service_uid={}&analysis_uid={}"
712
            .format(obj.getServiceUID, obj.UID),
713
            value="<i class='fas fa-info-circle'></i>",
714
            css_class="overlay_panel", tabindex="-1")
715
716
        # Append conditions link before the analysis
717
        # see: bika.lims.site.coffee for the attached event handler
718
        if self.is_analysis_conditions_edition_allowed(obj):
719
            url = api.get_url(self.context)
720
            url = "{}/set_analysis_conditions?uid={}".format(url, obj.UID)
721
            ico = "<i class='fas fa-list' style='padding-top: 5px;'/>"
722
            conditions = get_link(url, value=ico, css_class="overlay_panel",
723
                                  tabindex="-1")
724
            info = item["before"]["Service"]
725
            item["before"]["Service"] = "<br/>".join([info, conditions])
726
727
        # Note that getSampleTypeUID returns the type of the Sample, no matter
728
        # if the sample associated to the analysis is a regular Sample (routine
729
        # analysis) or if is a Reference Sample (Reference Analysis). If the
730
        # analysis is a duplicate, it returns the Sample Type of the sample
731
        # associated to the source analysis.
732
        item['st_uid'] = obj.getSampleTypeUID
733
734
        # Fill item's category
735
        self._folder_item_category(obj, item)
736
        # Fill item's row class
737
        self._folder_item_css_class(obj, item)
738
        # Fill result and/or result options
739
        self._folder_item_result(obj, item)
740
        # Fill calculation and interim fields
741
        self._folder_item_calculation(obj, item)
742
        # Fill unit field
743
        self._folder_item_unit(obj, item)
744
        # Fill method
745
        self._folder_item_method(obj, item)
746
        # Fill instrument
747
        self._folder_item_instrument(obj, item)
748
        # Fill analyst
749
        self._folder_item_analyst(obj, item)
750
        # Fill submitted by
751
        self._folder_item_submitted_by(obj, item)
752
        # Fill attachments
753
        self._folder_item_attachments(obj, item)
754
        # Fill uncertainty
755
        self._folder_item_uncertainty(obj, item)
756
        # Fill Detection Limits
757
        self._folder_item_detection_limits(obj, item)
758
        # Fill Specifications
759
        self._folder_item_specifications(obj, item)
760
        self._folder_item_out_of_range(obj, item)
761
        self._folder_item_result_range_compliance(obj, item)
762
        # Fill Partition
763
        self._folder_item_partition(obj, item)
764
        # Fill Due Date and icon if late/overdue
765
        self._folder_item_duedate(obj, item)
766
        # Fill verification criteria
767
        self._folder_item_verify_icons(obj, item)
768
        # Fill worksheet anchor/icon
769
        self._folder_item_assigned_worksheet(obj, item)
770
        # Fill accredited icon
771
        self._folder_item_accredited_icon(obj, item)
772
        # Fill hidden field (report visibility)
773
        self._folder_item_report_visibility(obj, item)
774
        # Renders additional icons to be displayed
775
        self._folder_item_fieldicons(obj)
776
        # Renders remarks toggle button
777
        self._folder_item_remarks(obj, item)
778
        # Renders the analysis conditions
779
        self._folder_item_conditions(obj, item)
780
781
        return item
782
783
    def folderitems(self):
784
        # This shouldn't be required here, but there are some views that calls
785
        # directly contents_table() instead of __call__, so before_render is
786
        # never called. :(
787
        self.before_render()
788
789
        # Gettin all the items
790
        items = super(AnalysesView, self).folderitems()
791
792
        # the TAL requires values for all interim fields on all
793
        # items, so we set blank values in unused cells
794
        for item in items:
795
            for field in self.interim_columns:
796
                if field not in item:
797
                    item[field] = ""
798
799
            # Graceful handling of new item key introduced in
800
            # https://github.com/senaite/senaite.app.listing/pull/81
801
            item["help"] = item.get("help", {})
802
803
        # XXX order the list of interim columns
804
        interim_keys = self.interim_columns.keys()
805
        interim_keys.reverse()
806
807
        # add InterimFields keys to columns
808
        for col_id in interim_keys:
809
            if col_id not in self.columns:
810
                self.columns[col_id] = {
811
                    "title": self.interim_columns[col_id],
812
                    "input_width": "6",
813
                    "input_class": "ajax_calculate numeric",
814
                    "sortable": False,
815
                    "toggle": True,
816
                    "ajax": True,
817
                }
818
819
        if self.allow_edit:
820
            new_states = []
821
            for state in self.review_states:
822
                pos = self.calculate_interim_columns_position(state)
823
                for col_id in interim_keys:
824
                    if col_id not in state["columns"]:
825
                        state["columns"].insert(pos, col_id)
826
                new_states.append(state)
827
            self.review_states = new_states
828
            # Allow selecting individual analyses
829
            self.show_select_column = True
830
831
        if self.show_categories:
832
            self.categories = map(lambda x: x[0],
833
                                  sorted(self.categories, key=lambda x: x[1]))
834
        else:
835
            self.categories.sort()
836
837
        # self.json_specs = json.dumps(self.specs)
838
        self.json_interim_fields = json.dumps(self.interim_fields)
839
840
        # Display method and instrument columns only if at least one of the
841
        # analyses requires them to be displayed for selection
842
        show_method_column = self.is_method_column_required(items)
843
        if "Method" in self.columns:
844
            self.columns["Method"]["toggle"] = show_method_column
845
846
        show_instrument_column = self.is_instrument_column_required(items)
847
        if "Instrument" in self.columns:
848
            self.columns["Instrument"]["toggle"] = show_instrument_column
849
850
        # show unit selection column only if required
851
        show_unit_column = self.is_unit_selection_column_required(items)
852
        if "Unit" in self.columns:
853
            self.columns["Unit"]["toggle"] = show_unit_column
854
855
        return items
856
857
    def render_unit(self, unit, css_class=None):
858
        """Render HTML element for unit
859
        """
860
        if css_class is None:
861
            css_class = "unit d-inline-block py-2 small text-secondary text-nowrap"
862
        return "<span class='{css_class}'>{unit}</span>".format(
863
            unit=unit, css_class=css_class)
864
865
    def _folder_item_category(self, analysis_brain, item):
866
        """Sets the category to the item passed in
867
868
        :param analysis_brain: Brain that represents an analysis
869
        :param item: analysis' dictionary counterpart that represents a row
870
        """
871
        if not self.show_categories:
872
            return
873
874
        cat = analysis_brain.getCategoryTitle
875
        item["category"] = cat
876
        cat_order = self.analysis_categories_order.get(cat)
877
        if (cat, cat_order) not in self.categories:
878
            self.categories.append((cat, cat_order))
879
880
    def _folder_item_css_class(self, analysis_brain, item):
881
        """Sets the suitable css class name(s) to `table_row_class` from the
882
        item passed in, depending on the properties of the analysis object
883
884
        :param analysis_brain: Brain that represents an analysis
885
        :param item: analysis' dictionary counterpart that represents a row
886
        """
887
        meta_type = analysis_brain.meta_type
888
889
        # Default css names for table_row_class
890
        css_names = item.get('table_row_class', '').split()
891
        css_names.extend(['state-{}'.format(analysis_brain.review_state),
892
                          'type-{}'.format(meta_type.lower())])
893
894
        if meta_type == 'ReferenceAnalysis':
895
            css_names.append('qc-analysis')
896
897
        elif meta_type == 'DuplicateAnalysis':
898
            if analysis_brain.getAnalysisPortalType == 'ReferenceAnalysis':
899
                css_names.append('qc-analysis')
900
901
        item['table_row_class'] = ' '.join(css_names)
902
903
    def _folder_item_duedate(self, analysis_brain, item):
904
        """Set the analysis' due date to the item passed in.
905
906
        :param analysis_brain: Brain that represents an analysis
907
        :param item: analysis' dictionary counterpart that represents a row
908
        """
909
910
        # Note that if the analysis is a Reference Analysis, `getDueDate`
911
        # returns the date when the ReferenceSample expires. If the analysis is
912
        # a duplicate, `getDueDate` returns the due date of the source analysis
913
        due_date = analysis_brain.getDueDate
914
        if not due_date:
915
            return None
916
        due_date_str = self.ulocalized_time(due_date, long_format=0)
917
        item['DueDate'] = due_date_str
918
919
        # If the Analysis is late/overdue, display an icon
920
        capture_date = analysis_brain.getResultCaptureDate
921
        capture_date = capture_date or DateTime()
922
        if capture_date > due_date:
923
            # The analysis is late or overdue
924
            img = get_image('late.png', title=t(_("Late Analysis")),
925
                            width='16px', height='16px')
926
            item['replace']['DueDate'] = '{} {}'.format(due_date_str, img)
927
928
    def _folder_item_result(self, analysis_brain, item):
929
        """Set the analysis' result to the item passed in.
930
931
        :param analysis_brain: Brain that represents an analysis
932
        :param item: analysis' dictionary counterpart that represents a row
933
        """
934
935
        item["Result"] = ""
936
937
        if not self.has_permission(ViewResults, analysis_brain):
938
            # If user has no permissions, don"t display the result but an icon
939
            img = get_image("to_follow.png", width="16px", height="16px")
940
            item["before"]["Result"] = img
941
            return
942
943
        result = analysis_brain.getResult
944
        capture_date = analysis_brain.getResultCaptureDate
945
        capture_date_str = self.ulocalized_time(capture_date, long_format=0)
946
947
        item["Result"] = result
948
        item["CaptureDate"] = capture_date_str
949
        item["result_captured"] = capture_date_str
950
951
        # Add the unit after the result
952
        unit = item.get("Unit")
953
        if unit:
954
            item["after"]["Result"] = self.render_unit(unit)
955
956
        # Get the analysis object
957
        obj = self.get_object(analysis_brain)
958
959
        # Edit mode enabled of this Analysis
960
        if self.is_analysis_edition_allowed(analysis_brain):
961
            # Allow to set Remarks
962
            item["allow_edit"].append("Remarks")
963
964
            # Set the results field editable
965
            if self.is_result_edition_allowed(analysis_brain):
966
                item["allow_edit"].append("Result")
967
968
            # Display the DL operand (< or >) in the results entry field if
969
            # the manual entry of DL is set, but DL selector is hidden
970
            allow_manual = obj.getAllowManualDetectionLimit()
971
            selector = obj.getDetectionLimitSelector()
972
            if allow_manual and not selector:
973
                operand = obj.getDetectionLimitOperand()
974
                item["Result"] = "{} {}".format(operand, result).strip()
975
976
            # Prepare result options
977
            choices = self.get_result_options(obj)
978
            if choices:
979
                choices_type = obj.getResultOptionsType()
980
                if choices_type == "select":
981
                    # By default set empty as the default selected choice
982
                    choices.insert(0, dict(ResultValue="", ResultText=""))
983
                item["choices"]["Result"] = choices
984
                item["result_type"] = choices_type
985
986
            elif obj.getStringResult():
987
                item["result_type"] = "string"
988
989
            else:
990
                item["result_type"] = "numeric"
991
                item["help"]["Result"] = _(
992
                    "Enter the result either in decimal or scientific "
993
                    "notation, e.g. 0.00005 or 1e-5, 10000 or 1e5")
994
995
        if not result:
996
            return
997
998
        formatted_result = obj.getFormattedResult(
999
            sciformat=int(self.scinot), decimalmark=self.dmk)
1000
        item["formatted_result"] = formatted_result
1001
1002
    def get_result_options(self, analysis):
1003
        """Returns the result options of the analysis to be rendered or empty
1004
        """
1005
        options = copy(analysis.getResultOptions())
1006
        sort_by = analysis.getResultOptionsSorting()
1007
        if not sort_by:
1008
            return options
1009
1010
        sort_by, sort_order = sort_by.split("-")
1011
        reverse = sort_order == "desc"
1012
        return sorted(options, key=itemgetter(sort_by), reverse=reverse)
1013
1014
    def is_multi_interim(self, interim):
1015
        """Returns whether the interim stores a list of values instead of a
1016
        single value
1017
        """
1018
        result_type = interim.get("result_type", "")
1019
        return result_type.startswith("multi")
1020
1021
    @deprecated("Use api.to_list instead")
1022
    def to_list(self, value):
1023
        """Converts the value to a list
1024
        """
1025
        return api.to_list(value)
1026
1027
    def get_interim_choices(self, interim):
1028
        """Parse the interim choices field
1029
        """
1030
        choices = interim.get("choices")
1031
        if not choices:
1032
            return None
1033
        items = choices.split("|")
1034
        pairs = map(lambda item: item.strip().split(":"), items)
1035
        return OrderedDict(pairs)
1036
1037
    def _folder_item_calculation(self, analysis_brain, item):
1038
        """Set the analysis' calculation and interims to the item passed in.
1039
1040
        :param analysis_brain: Brain that represents an analysis
1041
        :param item: analysis' dictionary counterpart that represents a row
1042
        """
1043
1044
        if not self.has_permission(ViewResults, analysis_brain):
1045
            # Hide interims and calculation if user cannot view results
1046
            return
1047
1048
        is_editable = self.is_analysis_edition_allowed(analysis_brain)
1049
1050
        # calculation
1051
        calculation = self.get_calculation(analysis_brain)
1052
        calculation_uid = api.get_uid(calculation) if calculation else ""
1053
        calculation_title = api.get_title(calculation) if calculation else ""
1054
        calculation_link = get_link_for(calculation) if calculation else ""
1055
        item["calculation"] = calculation_uid
1056
        item["Calculation"] = calculation_title
1057
        item["replace"]["Calculation"] = calculation_link or _("Manual")
1058
1059
        # Set interim fields. Note we add the key 'formatted_value' to the list
1060
        # of interims the analysis has already assigned.
1061
        analysis_obj = self.get_object(analysis_brain)
1062
        interim_fields = analysis_obj.getInterimFields() or list()
1063
1064
        # Copy to prevent to avoid persistent changes
1065
        interim_fields = deepcopy(interim_fields)
1066
        for interim_field in interim_fields:
1067
            interim_keyword = interim_field.get("keyword", "")
1068
            if not interim_keyword:
1069
                continue
1070
1071
            interim_value = interim_field.get("value", "")
1072
            interim_allow_empty = interim_field.get("allow_empty") == "on"
1073
            interim_unit = interim_field.get("unit", "")
1074
            interim_formatted = formatDecimalMark(interim_value, self.dmk)
1075
            interim_field["formatted_value"] = interim_formatted
1076
            item[interim_keyword] = interim_field
1077
            item["class"][interim_keyword] = "interim"
1078
1079
            # render the unit after the interim field
1080
            if interim_unit:
1081
                formatted_interim_unit = format_supsub(interim_unit)
1082
                item["after"][interim_keyword] = self.render_unit(
1083
                    formatted_interim_unit)
1084
1085
            # Note: As soon as we have a separate content type for field
1086
            #       analysis, we can solely rely on the field permission
1087
            #       "senaite.core: Field: Edit Analysis Result"
1088
            if is_editable:
1089
                if self.has_permission(
1090
                        FieldEditAnalysisResult, analysis_brain):
1091
                    item["allow_edit"].append(interim_keyword)
1092
1093
            # Add this analysis' interim fields to the interim_columns list
1094
            interim_hidden = interim_field.get("hidden", False)
1095
            if not interim_hidden:
1096
                interim_title = interim_field.get("title")
1097
                self.interim_columns[interim_keyword] = interim_title
1098
1099
            # Does interim's results list needs to be rendered?
1100
            choices = self.get_interim_choices(interim_field)
1101
            if choices:
1102
                multi = self.is_multi_interim(interim_field)
1103
1104
                # Ensure empty option is available if no default value is set
1105
                if not interim_value and not multi:
1106
                    # allow empty selection and flush default value
1107
                    interim_value = ""
1108
                    interim_allow_empty = True
1109
1110
                # Generate the display list
1111
                # [{"ResultValue": value, "ResultText": text},]
1112
                headers = ["ResultValue", "ResultText"]
1113
                dl = map(lambda it: dict(zip(headers, it)), choices.items())
0 ignored issues
show
introduced by
The variable headers does not seem to be defined for all execution paths.
Loading history...
1114
1115
                # Allow empty selection if allowed
1116
                if interim_allow_empty:
1117
                    empty = {"ResultValue": "", "ResultText": ""}
1118
                    dl = [empty] + list(dl)
1119
1120
                item.setdefault("choices", {})[interim_keyword] = dl
1121
1122
                # Set the text as the formatted value
1123
                texts = [choices.get(v, "") for v in api.to_list(interim_value)]
1124
                text = "<br/>".join(filter(None, texts))
1125
                interim_field["formatted_value"] = text
1126
1127
                if not is_editable:
1128
                    # Display the text instead of the value
1129
                    interim_field["value"] = text
1130
1131
                item[interim_keyword] = interim_field
1132
1133
        item["interimfields"] = interim_fields
1134
        self.interim_fields[analysis_brain.UID] = interim_fields
1135
1136
    def _folder_item_unit(self, analysis_brain, item):
1137
        """Fills the analysis' unit to the item passed in.
1138
1139
        :param analysis_brain: Brain that represents an analysis
1140
        :param item: analysis' dictionary counterpart that represents a row
1141
        """
1142
        if not self.is_analysis_edition_allowed(analysis_brain):
1143
            return
1144
1145
        # Edition allowed
1146
        voc = self.get_unit_vocabulary(analysis_brain)
1147
        if voc:
1148
            item["choices"]["Unit"] = voc
1149
            item["allow_edit"].append("Unit")
1150
1151
    def _folder_item_method(self, analysis_brain, item):
1152
        """Fills the analysis' method to the item passed in.
1153
1154
        :param analysis_brain: Brain that represents an analysis
1155
        :param item: analysis' dictionary counterpart that represents a row
1156
        """
1157
        obj = self.get_object(analysis_brain)
1158
        is_editable = self.is_analysis_edition_allowed(analysis_brain)
1159
        if is_editable:
1160
            method_vocabulary = self.get_methods_vocabulary(analysis_brain)
1161
            item["Method"] = obj.getRawMethod()
1162
            item["choices"]["Method"] = method_vocabulary
1163
            item["allow_edit"].append("Method")
1164
        else:
1165
            item["Method"] = _("Manual")
1166
            method = obj.getMethod()
1167
            if method:
1168
                item["Method"] = api.get_title(method)
1169
                item["replace"]["Method"] = get_link_for(method, tabindex="-1")
1170
1171
    def _on_method_change(self, uid=None, value=None, item=None, **kw):
1172
        """Update instrument and calculation when the method changes
1173
1174
        :param uid: object UID
1175
        :value: UID of the new method
1176
        :item: old folderitem
1177
1178
        :returns: updated folderitem
1179
        """
1180
        obj = api.get_object_by_uid(uid, None)
1181
        method = api.get_object_by_uid(value, None)
1182
1183
        if not all([obj, method, item]):
1184
            return None
1185
1186
        # update the available instruments
1187
        inst_vocab = self.get_instruments_vocabulary(obj, method=method)
1188
        item["choices"]["Instrument"] = inst_vocab
1189
1190
        return item
1191
1192
    def _folder_item_instrument(self, analysis_brain, item):
1193
        """Fills the analysis' instrument to the item passed in.
1194
1195
        :param analysis_brain: Brain that represents an analysis
1196
        :param item: analysis' dictionary counterpart that represents a row
1197
        """
1198
        item["Instrument"] = ""
1199
1200
        # Instrument can be assigned to this analysis
1201
        is_editable = self.is_analysis_edition_allowed(analysis_brain)
1202
        instrument = self.get_instrument(analysis_brain)
1203
1204
        if is_editable:
1205
            # Edition allowed
1206
            voc = self.get_instruments_vocabulary(analysis_brain)
1207
            item["Instrument"] = instrument.UID() if instrument else ""
1208
            item["choices"]["Instrument"] = voc
1209
            item["allow_edit"].append("Instrument")
1210
1211
        elif instrument:
1212
            # Edition not allowed
1213
            item["Instrument"] = api.get_title(instrument)
1214
            instrument_link = get_link_for(instrument, tabindex="-1")
1215
            item["replace"]["Instrument"] = instrument_link
1216
1217
        else:
1218
            item["Instrument"] = _("Manual")
1219
1220
    def _on_unit_change(self, uid=None, value=None, item=None, **kw):
1221
        """ updates the rendered unit on selection of unit.
1222
        """
1223
        if not all([value, item]):
1224
            return None
1225
        item["after"]["Result"] = self.render_unit(value)
1226
        uncertainty = item.get("Uncertainty")
1227
        if uncertainty:
1228
            item["after"]["Uncertainty"] = self.render_unit(value)
1229
        elif "Uncertainty" in item["allow_edit"]:
1230
            item["after"]["Uncertainty"] = self.render_unit(value)
1231
        return item
1232
1233
    def _folder_item_analyst(self, obj, item):
1234
        obj = self.get_object(obj)
1235
        analyst = obj.getAnalyst()
1236
        item["Analyst"] = self.get_user_name(analyst)
1237
1238
    def _folder_item_submitted_by(self, obj, item):
1239
        obj = self.get_object(obj)
1240
        submitted_by = obj.getSubmittedBy()
1241
        item["SubmittedBy"] = self.get_user_name(submitted_by)
1242
1243
    @viewcache.memoize
1244
    def get_user_name(self, user_id):
1245
        if not user_id:
1246
            return ""
1247
        user = api.get_user_properties(user_id)
1248
        return user and user.get("fullname") or user_id
1249
1250
    def _folder_item_attachments(self, obj, item):
1251
        if not self.has_permission(ViewResults, obj):
1252
            return
1253
1254
        attachments_names = []
1255
        attachments_html = []
1256
        analysis = self.get_object(obj)
1257
        for attachment in analysis.getRawAttachment():
1258
            attachment = self.get_object(attachment)
1259
            link = self.get_attachment_link(attachment)
1260
            attachments_html.append(link)
1261
            filename = attachment.getFilename()
1262
            attachments_names.append(filename)
1263
1264
        if attachments_html:
1265
            item["replace"]["Attachments"] = "<br/>".join(attachments_html)
1266
            item["Attachments"] = ", ".join(attachments_names)
1267
1268
        elif analysis.getAttachmentRequired():
1269
            img = get_image("warning.png", title=_("Attachment required"))
1270
            item["replace"]["Attachments"] = img
1271
1272
    def get_attachment_link(self, attachment):
1273
        """Returns a well-formed link for the attachment passed in
1274
        """
1275
        filename = attachment.getFilename()
1276
        att_url = api.get_url(attachment)
1277
        url = "{}/at_download/AttachmentFile".format(att_url)
1278
        return get_link(url, filename, tabindex="-1")
1279
1280
    def _folder_item_uncertainty(self, analysis_brain, item):
1281
        """Fills the analysis' uncertainty to the item passed in.
1282
1283
        :param analysis_brain: Brain that represents an analysis
1284
        :param item: analysis' dictionary counterpart that represents a row
1285
        """
1286
        item["Uncertainty"] = ""
1287
1288
        if not self.has_permission(ViewResults, analysis_brain):
1289
            return
1290
1291
        # Wake up the Analysis object
1292
        obj = self.get_object(analysis_brain)
1293
1294
        # NOTE: When we allow to edit the uncertainty, we want to have the raw
1295
        #       uncertainty value and not the formatted (and possibly rounded)!
1296
        #       This ensures that not the rounded value get stored
1297
        allow_edit = self.is_uncertainty_edition_allowed(analysis_brain)
1298
        if allow_edit:
1299
            item["Uncertainty"] = obj.getUncertainty()
1300
            item["before"]["Uncertainty"] = "± "
1301
            item["allow_edit"].append("Uncertainty")
1302
            unit = item.get("Unit")
1303
            if unit:
1304
                item["after"]["Uncertainty"] = self.render_unit(unit)
1305
            return
1306
1307
        formatted = format_uncertainty(
1308
            obj, decimalmark=self.dmk, sciformat=int(self.scinot))
1309
        if formatted:
1310
            item["replace"]["Uncertainty"] = formatted
1311
            item["before"]["Uncertainty"] = "± "
1312
            unit = item.get("Unit")
1313
            if unit:
1314
                item["after"]["Uncertainty"] = self.render_unit(unit)
1315
1316
    def _folder_item_detection_limits(self, analysis_brain, item):
1317
        """Fills the analysis' detection limits to the item passed in.
1318
1319
        :param analysis_brain: Brain that represents an analysis
1320
        :param item: analysis' dictionary counterpart that represents a row
1321
        """
1322
        item["DetectionLimitOperand"] = ""
1323
1324
        if not self.is_analysis_edition_allowed(analysis_brain):
1325
            # Return immediately if the we are not in edit mode
1326
            return
1327
1328
        # TODO: Performance, we wake-up the full object here
1329
        obj = self.get_object(analysis_brain)
1330
1331
        # No Detection Limit Selection
1332
        if not obj.getDetectionLimitSelector():
1333
            return None
1334
1335
        # Show Detection Limit Operand Selector
1336
        item["DetectionLimitOperand"] = obj.getDetectionLimitOperand()
1337
        item["allow_edit"].append("DetectionLimitOperand")
1338
        self.columns["DetectionLimitOperand"]["toggle"] = True
1339
1340
        # Prepare selection list for LDL/UDL
1341
        choices = [
1342
            {"ResultValue": "", "ResultText": ""},
1343
            {"ResultValue": LDL, "ResultText": LDL},
1344
            {"ResultValue": UDL, "ResultText": UDL}
1345
        ]
1346
        # Set the choices to the item
1347
        item["choices"]["DetectionLimitOperand"] = choices
1348
1349
    def _folder_item_specifications(self, analysis_brain, item):
1350
        """Set the results range to the item passed in"""
1351
        analysis = self.get_object(analysis_brain)
1352
        results_range = analysis.getResultsRange()
1353
1354
        item["Specification"] = ""
1355
        if results_range:
1356
            item["Specification"] = get_formatted_interval(results_range, "")
1357
1358
    def _folder_item_out_of_range(self, analysis_brain, item):
1359
        """Displays an icon if result is out of range
1360
        """
1361
        if not self.has_permission(ViewResults, analysis_brain):
1362
            # Users without permissions to see the result should not be able
1363
            # to see if the result is out of range naither
1364
            return
1365
1366
        analysis = self.get_object(analysis_brain)
1367
        out_range, out_shoulders = is_out_of_range(analysis)
1368
        if out_range:
1369
            msg = _("Result out of range")
1370
            img = get_image("exclamation.png", title=msg)
1371
            if not out_shoulders:
1372
                msg = _("Result in shoulder range")
1373
                img = get_image("warning.png", title=msg)
1374
            self._append_html_element(item, "Result", img)
1375
1376
    def _folder_item_result_range_compliance(self, analysis_brain, item):
1377
        """Displays an icon if the range is different from the results ranges
1378
        defined in the Sample
1379
        """
1380
        if not IAnalysisRequest.providedBy(self.context):
1381
            return
1382
1383
        analysis = self.get_object(analysis_brain)
1384
        if is_result_range_compliant(analysis):
1385
            return
1386
1387
        # Non-compliant range, display an icon
1388
        service_uid = analysis_brain.getServiceUID
1389
        original = self.context.getResultsRange(search_by=service_uid)
1390
        original = get_formatted_interval(original, "")
1391
        msg = _("Result range is different from Specification: {}"
1392
                .format(original))
1393
        img = get_image("warning.png", title=msg)
1394
        self._append_html_element(item, "Specification", img)
1395
1396
    def _folder_item_verify_icons(self, analysis_brain, item):
1397
        """Set the analysis' verification icons to the item passed in.
1398
1399
        :param analysis_brain: Brain that represents an analysis
1400
        :param item: analysis' dictionary counterpart that represents a row
1401
        """
1402
        submitter = analysis_brain.getSubmittedBy
1403
        if not submitter:
1404
            # This analysis hasn't yet been submitted, no verification yet
1405
            return
1406
1407
        if analysis_brain.review_state == 'retracted':
1408
            # Don't display icons and additional info about verification
1409
            return
1410
1411
        verifiers = analysis_brain.getVerificators
1412
        in_verifiers = submitter in verifiers
1413
        if in_verifiers:
1414
            # If analysis has been submitted and verified by the same person,
1415
            # display a warning icon
1416
            msg = t(_("Submitted and verified by the same user: {}"))
1417
            icon = get_image('warning.png', title=msg.format(submitter))
1418
            self._append_html_element(item, 'state_title', icon)
1419
1420
        num_verifications = analysis_brain.getNumberOfRequiredVerifications
1421
        if num_verifications > 1:
1422
            # More than one verification required, place an icon and display
1423
            # the number of verifications done vs. total required
1424
            done = analysis_brain.getNumberOfVerifications
1425
            pending = num_verifications - done
1426
            ratio = float(done) / float(num_verifications) if done > 0 else 0
1427
            ratio = int(ratio * 100)
1428
            scale = ratio == 0 and 0 or (ratio / 25) * 25
1429
            anchor = "<a href='#' tabindex='-1' title='{} &#13;{} {}' " \
1430
                     "class='multi-verification scale-{}'>{}/{}</a>"
1431
            anchor = anchor.format(t(_("Multi-verification required")),
1432
                                   str(pending),
1433
                                   t(_("verification(s) pending")),
1434
                                   str(scale),
1435
                                   str(done),
1436
                                   str(num_verifications))
1437
            self._append_html_element(item, 'state_title', anchor)
1438
1439
        if analysis_brain.review_state != 'to_be_verified':
1440
            # The verification of analysis has already been done or first
1441
            # verification has not been done yet. Nothing to do
1442
            return
1443
1444
        # Check if the user has "Bika: Verify" privileges
1445
        if not self.has_permission(TransitionVerify):
1446
            # User cannot verify, do nothing
1447
            return
1448
1449
        username = api.get_current_user().id
1450
        if username not in verifiers:
1451
            # Current user has not verified this analysis
1452
            if submitter != username:
1453
                # Current user is neither a submitter nor a verifier
1454
                return
1455
1456
            # Current user is the same who submitted the result
1457
            if analysis_brain.isSelfVerificationEnabled:
1458
                # Same user who submitted can verify
1459
                title = t(_("Can verify, but submitted by current user"))
1460
                html = get_image('warning.png', title=title)
1461
                self._append_html_element(item, 'state_title', html)
1462
                return
1463
1464
            # User who submitted cannot verify
1465
            title = t(_("Cannot verify, submitted by current user"))
1466
            html = get_image('submitted-by-current-user.png', title=title)
1467
            self._append_html_element(item, 'state_title', html)
1468
            return
1469
1470
        # This user verified this analysis before
1471
        multi_verif = self.context.bika_setup.getTypeOfmultiVerification()
1472
        if multi_verif != 'self_multi_not_cons':
1473
            # Multi verification by same user is not allowed
1474
            title = t(_("Cannot verify, was verified by current user"))
1475
            html = get_image('submitted-by-current-user.png', title=title)
1476
            self._append_html_element(item, 'state_title', html)
1477
            return
1478
1479
        # Multi-verification by same user, but non-consecutively, is allowed
1480
        if analysis_brain.getLastVerificator != username:
1481
            # Current user was not the last user to verify
1482
            title = t(
1483
                _("Can verify, but was already verified by current user"))
1484
            html = get_image('warning.png', title=title)
1485
            self._append_html_element(item, 'state_title', html)
1486
            return
1487
1488
        # Last user who verified is the same as current user
1489
        title = t(_("Cannot verify, last verified by current user"))
1490
        html = get_image('submitted-by-current-user.png', title=title)
1491
        self._append_html_element(item, 'state_title', html)
1492
        return
1493
1494
    def _folder_item_assigned_worksheet(self, analysis_brain, item):
1495
        """Adds an icon to the item dict if the analysis is assigned to a
1496
        worksheet and if the icon is suitable for the current context
1497
1498
        :param analysis_brain: Brain that represents an analysis
1499
        :param item: analysis' dictionary counterpart that represents a row
1500
        """
1501
        if not IAnalysisRequest.providedBy(self.context):
1502
            # We want this icon to only appear if the context is an AR
1503
            return
1504
1505
        analysis_obj = self.get_object(analysis_brain)
1506
        worksheet = analysis_obj.getWorksheet()
1507
        if not worksheet:
1508
            # No worksheet assigned. Do nothing
1509
            return
1510
1511
        title = t(_("Assigned to: ${worksheet_id}",
1512
                    mapping={'worksheet_id': safe_unicode(worksheet.id)}))
1513
        img = get_image('worksheet.png', title=title)
1514
        anchor = get_link(worksheet.absolute_url(), img, tabindex="-1")
1515
        self._append_html_element(item, 'state_title', anchor)
1516
1517
    def _folder_item_accredited_icon(self, analysis_brain, item):
1518
        """Adds an icon to the item dictionary if it is an accredited analysis
1519
        """
1520
        full_obj = self.get_object(analysis_brain)
1521
        if full_obj.getAccredited():
1522
            img = get_image("accredited.png", title=t(_("Accredited")))
1523
            self._append_html_element(item, "Service", img)
1524
1525
    def _folder_item_partition(self, analysis_brain, item):
1526
        """Adds an anchor to the partition if the current analysis is from a
1527
        partition that does not match with the current context
1528
        """
1529
        if not IAnalysisRequest.providedBy(self.context):
1530
            return
1531
1532
        sample_id = analysis_brain.getRequestID
1533
        if sample_id != api.get_id(self.context):
1534
            if not self.show_partitions:
1535
                # Do not display the link
1536
                return
1537
1538
            part_url = analysis_brain.getRequestURL
1539
            kwargs = {"class": "small", "tabindex": "-1"}
1540
            url = get_link(part_url, value=sample_id, **kwargs)
1541
            title = item["replace"].get("Service") or item["Service"]
1542
            item["replace"]["Service"] = "{}<br/>{}".format(title, url)
1543
1544
    def _folder_item_report_visibility(self, analysis_brain, item):
1545
        """Set if the hidden field can be edited (enabled/disabled)
1546
1547
        :analysis_brain: Brain that represents an analysis
1548
        :item: analysis' dictionary counterpart to be represented as a row"""
1549
        # Users that can Add Analyses to an Analysis Request must be able to
1550
        # set the visibility of the analysis in results report, also if the
1551
        # current state of the Analysis Request (e.g. verified) does not allow
1552
        # the edition of other fields. Note that an analyst has no privileges
1553
        # by default to edit this value, cause this "visibility" field is
1554
        # related with results reporting and/or visibility from the client
1555
        # side. This behavior only applies to routine analyses, the visibility
1556
        # of QC analyses is managed in publish and are not visible to clients.
1557
        if 'Hidden' not in self.columns:
1558
            return
1559
1560
        full_obj = self.get_object(analysis_brain)
1561
        item['Hidden'] = full_obj.getHidden()
1562
1563
        # Hidden checkbox is not reachable by tabbing
1564
        item["tabindex"]["Hidden"] = "disabled"
1565
        if self.has_permission(FieldEditAnalysisHidden, obj=full_obj):
1566
            item['allow_edit'].append('Hidden')
1567
1568
    def _folder_item_fieldicons(self, analysis_brain):
1569
        """Resolves if field-specific icons must be displayed for the object
1570
        passed in.
1571
1572
        :param analysis_brain: Brain that represents an analysis
1573
        """
1574
        full_obj = self.get_object(analysis_brain)
1575
        uid = api.get_uid(full_obj)
1576
        for name, adapter in getAdapters((full_obj,), IFieldIcons):
1577
            alerts = adapter()
1578
            if not alerts or uid not in alerts:
1579
                continue
1580
            alerts = alerts[uid]
1581
            if uid not in self.field_icons:
1582
                self.field_icons[uid] = alerts
1583
                continue
1584
            self.field_icons[uid].extend(alerts)
1585
1586
    def _folder_item_remarks(self, analysis_brain, item):
1587
        """Renders the Remarks field for the passed in analysis
1588
1589
        If the edition of the analysis is permitted, adds the field into the
1590
        list of editable fields.
1591
1592
        :param analysis_brain: Brain that represents an analysis
1593
        :param item: analysis' dictionary counterpart that represents a row
1594
        """
1595
1596
        if self.analysis_remarks_enabled():
1597
            item["Remarks"] = analysis_brain.getRemarks
1598
1599
        if self.is_analysis_edition_allowed(analysis_brain):
1600
            item["allow_edit"].extend(["Remarks"])
1601
        else:
1602
            # render HTMLified text in readonly mode
1603
            item["Remarks"] = api.text_to_html(
1604
                analysis_brain.getRemarks, wrap=None)
1605
1606
    def _append_html_element(self, item, element, html, glue="&nbsp;",
1607
                             after=True):
1608
        """Appends an html value after or before the element in the item dict
1609
1610
        :param item: dictionary that represents an analysis row
1611
        :param element: id of the element the html must be added thereafter
1612
        :param html: element to append
1613
        :param glue: glue to use for appending
1614
        :param after: if the html content must be added after or before"""
1615
        position = after and 'after' or 'before'
1616
        item[position] = item.get(position, {})
1617
        original = item[position].get(element, '')
1618
        if not original:
1619
            item[position][element] = html
1620
            return
1621
        item[position][element] = glue.join([original, html])
1622
1623
    def _folder_item_conditions(self, analysis_brain, item):
1624
        """Renders the analysis conditions
1625
        """
1626
        analysis = self.get_object(analysis_brain)
1627
1628
        if not IRoutineAnalysis.providedBy(analysis):
1629
            return
1630
1631
        conditions = analysis.getConditions()
1632
        if not conditions:
1633
            return
1634
1635
        def to_str(condition):
1636
            title = condition.get("title")
1637
            value = condition.get("value", "")
1638
            if condition.get("type") == "file" and api.is_uid(value):
1639
                att = self.get_object(value)
1640
                value = self.get_attachment_link(att)
1641
            return ": ".join([title, str(value)])
1642
1643
        # Display the conditions properly formatted
1644
        conditions = "<br/>".join([to_str(cond) for cond in conditions])
1645
        service = item["replace"].get("Service") or item["Service"]
1646
        item["replace"]["Service"] = "<br/>".join([service, conditions])
1647
1648
    def is_method_required(self, analysis):
1649
        """Returns whether the render of the selection list with methods is
1650
        required for the method passed-in, even if only option "None" is
1651
        displayed for selection
1652
        """
1653
        # Always return true if the analysis has a method assigned
1654
        obj = self.get_object(analysis)
1655
        method = obj.getRawMethod()
1656
        if method:
1657
            return True
1658
1659
        methods = obj.getRawAllowedMethods()
1660
        return len(methods) > 0
1661
1662
    def is_instrument_required(self, analysis):
1663
        """Returns whether the render of the selection list with instruments is
1664
        required for the analysis passed-in, even if only option "None" is
1665
        displayed for selection.
1666
        :param analysis: Brain or object that represents an analysis
1667
        """
1668
        # If method selection list is required, the instrument selection too
1669
        if self.is_method_required(analysis):
1670
            return True
1671
1672
        # Always return true if the analysis has an instrument assigned
1673
        analysis = self.get_object(analysis)
1674
        if analysis.getRawInstrument():
1675
            return True
1676
1677
        instruments = analysis.getRawAllowedInstruments()
1678
        # There is no need to check for the instruments of the method assigned
1679
        # to # the analysis (if any), because the instruments rendered in the
1680
        # selection list are always a subset of the allowed instruments when
1681
        # a method is selected
1682
        return len(instruments) > 0
1683
1684
    def is_unit_choices_required(self, analysis):
1685
        """Returns whether the render of the unit choice selection list is
1686
        required for the analysis passed-in.
1687
        :param analysis: Brain or object that represents an analysis
1688
        """
1689
        # Always return true if the analysis has unitchoices
1690
        analysis = self.get_object(analysis)
1691
        if analysis.getUnitChoices():
1692
            return True
1693
        return False
1694
1695
    def is_method_column_required(self, items):
1696
        """Returns whether the method column has to be rendered or not.
1697
        Returns True if at least one of the analyses from the listing requires
1698
        the list for method selection to be rendered
1699
        """
1700
        for item in items:
1701
            obj = item.get("obj")
1702
            if self.is_method_required(obj):
1703
                return True
1704
        return False
1705
1706
    def is_instrument_column_required(self, items):
1707
        """Returns whether the instrument column has to be rendered or not.
1708
        Returns True if at least one of the analyses from the listing requires
1709
        the list for instrument selection to be rendered
1710
        """
1711
        for item in items:
1712
            obj = item.get("obj")
1713
            if self.is_instrument_required(obj):
1714
                return True
1715
        return False
1716
1717
    def is_unit_selection_column_required(self, items):
1718
        """Returns whether the unit column has to be rendered or not.
1719
        Returns True if at least one of the analyses from the listing requires
1720
        the list for unit selection to be rendered
1721
        """
1722
        for item in items:
1723
            obj = item.get("obj")
1724
            if self.is_unit_choices_required(obj):
1725
                return True
1726
        return False
1727