Passed
Push — 2.x ( b694ce...ca4f30 )
by Ramon
06:50
created

AnalysesView.get_formatted_interim()   A

Complexity

Conditions 3

Size

Total Lines 25
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 11
dl 0
loc 25
rs 9.85
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
1075
            # Get the interim's formatted value
1076
            interim_formatted = self.get_formatted_interim(interim_field)
1077
            interim_field["formatted_value"] = interim_formatted
1078
1079
            # Update the item with the interim
1080
            item[interim_keyword] = interim_field
1081
            item["class"][interim_keyword] = "interim"
1082
1083
            # render the unit after the interim field
1084
            if interim_unit:
1085
                formatted_interim_unit = format_supsub(interim_unit)
1086
                item["after"][interim_keyword] = self.render_unit(
1087
                    formatted_interim_unit)
1088
1089
            # Note: As soon as we have a separate content type for field
1090
            #       analysis, we can solely rely on the field permission
1091
            #       "senaite.core: Field: Edit Analysis Result"
1092
            if is_editable:
1093
                if self.has_permission(
1094
                        FieldEditAnalysisResult, analysis_brain):
1095
                    item["allow_edit"].append(interim_keyword)
1096
1097
            # Add this analysis' interim fields to the interim_columns list
1098
            interim_hidden = interim_field.get("hidden", False)
1099
            if not interim_hidden:
1100
                interim_title = interim_field.get("title")
1101
                self.interim_columns[interim_keyword] = interim_title
1102
1103
            # Does interim's results list needs to be rendered?
1104
            choices = self.get_interim_choices(interim_field)
1105
            if choices:
1106
                multi = self.is_multi_interim(interim_field)
1107
1108
                # Ensure empty option is available if no default value is set
1109
                if not interim_value and not multi:
1110
                    # allow empty selection and flush default value
1111
                    interim_allow_empty = True
1112
1113
                # Generate the display list
1114
                # [{"ResultValue": value, "ResultText": text},]
1115
                headers = ["ResultValue", "ResultText"]
1116
                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...
1117
1118
                # Allow empty selection if allowed
1119
                if interim_allow_empty:
1120
                    empty = {"ResultValue": "", "ResultText": ""}
1121
                    dl = [empty] + list(dl)
1122
1123
                item.setdefault("choices", {})[interim_keyword] = dl
1124
1125
            if not is_editable:
1126
                # Display the text instead of the value
1127
                interim_field["value"] = interim_formatted
1128
1129
            item[interim_keyword] = interim_field
1130
1131
        item["interimfields"] = interim_fields
1132
        self.interim_fields[analysis_brain.UID] = interim_fields
1133
1134
    def get_formatted_interim(self, interim):
1135
        """Returns the formatted value of the interim
1136
        """
1137
        # get the 'raw' value stored for this interim
1138
        raw_value = interim.get("value")
1139
1140
        if self.is_multi_interim(interim):
1141
            # value is a jsonified list of values
1142
            values = api.to_list(raw_value)
1143
        else:
1144
            values = [raw_value]
1145
1146
        # remove empties
1147
        values = filter(None, values)
1148
1149
        choices = self.get_interim_choices(interim)
1150
        if choices:
1151
            # values are predefined options for selection
1152
            values = [choices.get(v) for v in values]
1153
        else:
1154
            # values are captured directly by the user
1155
            values = [formatDecimalMark(value, self.dmk) for value in values]
1156
1157
        # return the values as a single string
1158
        return "<br/>".join(values)
1159
1160
    def _folder_item_unit(self, analysis_brain, item):
1161
        """Fills the analysis' unit to the item passed in.
1162
1163
        :param analysis_brain: Brain that represents an analysis
1164
        :param item: analysis' dictionary counterpart that represents a row
1165
        """
1166
        if not self.is_analysis_edition_allowed(analysis_brain):
1167
            return
1168
1169
        # Edition allowed
1170
        voc = self.get_unit_vocabulary(analysis_brain)
1171
        if voc:
1172
            item["choices"]["Unit"] = voc
1173
            item["allow_edit"].append("Unit")
1174
1175
    def _folder_item_method(self, analysis_brain, item):
1176
        """Fills the analysis' method to the item passed in.
1177
1178
        :param analysis_brain: Brain that represents an analysis
1179
        :param item: analysis' dictionary counterpart that represents a row
1180
        """
1181
        obj = self.get_object(analysis_brain)
1182
        is_editable = self.is_analysis_edition_allowed(analysis_brain)
1183
        if is_editable:
1184
            method_vocabulary = self.get_methods_vocabulary(analysis_brain)
1185
            item["Method"] = obj.getRawMethod()
1186
            item["choices"]["Method"] = method_vocabulary
1187
            item["allow_edit"].append("Method")
1188
        else:
1189
            item["Method"] = _("Manual")
1190
            method = obj.getMethod()
1191
            if method:
1192
                item["Method"] = api.get_title(method)
1193
                item["replace"]["Method"] = get_link_for(method, tabindex="-1")
1194
1195
    def _on_method_change(self, uid=None, value=None, item=None, **kw):
1196
        """Update instrument and calculation when the method changes
1197
1198
        :param uid: object UID
1199
        :value: UID of the new method
1200
        :item: old folderitem
1201
1202
        :returns: updated folderitem
1203
        """
1204
        obj = api.get_object_by_uid(uid, None)
1205
        method = api.get_object_by_uid(value, None)
1206
1207
        if not all([obj, method, item]):
1208
            return None
1209
1210
        # update the available instruments
1211
        inst_vocab = self.get_instruments_vocabulary(obj, method=method)
1212
        item["choices"]["Instrument"] = inst_vocab
1213
1214
        return item
1215
1216
    def _folder_item_instrument(self, analysis_brain, item):
1217
        """Fills the analysis' instrument to the item passed in.
1218
1219
        :param analysis_brain: Brain that represents an analysis
1220
        :param item: analysis' dictionary counterpart that represents a row
1221
        """
1222
        item["Instrument"] = ""
1223
1224
        # Instrument can be assigned to this analysis
1225
        is_editable = self.is_analysis_edition_allowed(analysis_brain)
1226
        instrument = self.get_instrument(analysis_brain)
1227
1228
        if is_editable:
1229
            # Edition allowed
1230
            voc = self.get_instruments_vocabulary(analysis_brain)
1231
            item["Instrument"] = instrument.UID() if instrument else ""
1232
            item["choices"]["Instrument"] = voc
1233
            item["allow_edit"].append("Instrument")
1234
1235
        elif instrument:
1236
            # Edition not allowed
1237
            item["Instrument"] = api.get_title(instrument)
1238
            instrument_link = get_link_for(instrument, tabindex="-1")
1239
            item["replace"]["Instrument"] = instrument_link
1240
1241
        else:
1242
            item["Instrument"] = _("Manual")
1243
1244
    def _on_unit_change(self, uid=None, value=None, item=None, **kw):
1245
        """ updates the rendered unit on selection of unit.
1246
        """
1247
        if not all([value, item]):
1248
            return None
1249
        item["after"]["Result"] = self.render_unit(value)
1250
        uncertainty = item.get("Uncertainty")
1251
        if uncertainty:
1252
            item["after"]["Uncertainty"] = self.render_unit(value)
1253
        elif "Uncertainty" in item["allow_edit"]:
1254
            item["after"]["Uncertainty"] = self.render_unit(value)
1255
        return item
1256
1257
    def _folder_item_analyst(self, obj, item):
1258
        obj = self.get_object(obj)
1259
        analyst = obj.getAnalyst()
1260
        item["Analyst"] = self.get_user_name(analyst)
1261
1262
    def _folder_item_submitted_by(self, obj, item):
1263
        obj = self.get_object(obj)
1264
        submitted_by = obj.getSubmittedBy()
1265
        item["SubmittedBy"] = self.get_user_name(submitted_by)
1266
1267
    @viewcache.memoize
1268
    def get_user_name(self, user_id):
1269
        if not user_id:
1270
            return ""
1271
        user = api.get_user_properties(user_id)
1272
        return user and user.get("fullname") or user_id
1273
1274
    def _folder_item_attachments(self, obj, item):
1275
        if not self.has_permission(ViewResults, obj):
1276
            return
1277
1278
        attachments_names = []
1279
        attachments_html = []
1280
        analysis = self.get_object(obj)
1281
        for attachment in analysis.getRawAttachment():
1282
            attachment = self.get_object(attachment)
1283
            link = self.get_attachment_link(attachment)
1284
            attachments_html.append(link)
1285
            filename = attachment.getFilename()
1286
            attachments_names.append(filename)
1287
1288
        if attachments_html:
1289
            item["replace"]["Attachments"] = "<br/>".join(attachments_html)
1290
            item["Attachments"] = ", ".join(attachments_names)
1291
1292
        elif analysis.getAttachmentRequired():
1293
            img = get_image("warning.png", title=_("Attachment required"))
1294
            item["replace"]["Attachments"] = img
1295
1296
    def get_attachment_link(self, attachment):
1297
        """Returns a well-formed link for the attachment passed in
1298
        """
1299
        filename = attachment.getFilename()
1300
        att_url = api.get_url(attachment)
1301
        url = "{}/at_download/AttachmentFile".format(att_url)
1302
        return get_link(url, filename, tabindex="-1")
1303
1304
    def _folder_item_uncertainty(self, analysis_brain, item):
1305
        """Fills the analysis' uncertainty to the item passed in.
1306
1307
        :param analysis_brain: Brain that represents an analysis
1308
        :param item: analysis' dictionary counterpart that represents a row
1309
        """
1310
        item["Uncertainty"] = ""
1311
1312
        if not self.has_permission(ViewResults, analysis_brain):
1313
            return
1314
1315
        # Wake up the Analysis object
1316
        obj = self.get_object(analysis_brain)
1317
1318
        # NOTE: When we allow to edit the uncertainty, we want to have the raw
1319
        #       uncertainty value and not the formatted (and possibly rounded)!
1320
        #       This ensures that not the rounded value get stored
1321
        allow_edit = self.is_uncertainty_edition_allowed(analysis_brain)
1322
        if allow_edit:
1323
            item["Uncertainty"] = obj.getUncertainty()
1324
            item["before"]["Uncertainty"] = "± "
1325
            item["allow_edit"].append("Uncertainty")
1326
            unit = item.get("Unit")
1327
            if unit:
1328
                item["after"]["Uncertainty"] = self.render_unit(unit)
1329
            return
1330
1331
        formatted = format_uncertainty(
1332
            obj, decimalmark=self.dmk, sciformat=int(self.scinot))
1333
        if formatted:
1334
            item["replace"]["Uncertainty"] = formatted
1335
            item["before"]["Uncertainty"] = "± "
1336
            unit = item.get("Unit")
1337
            if unit:
1338
                item["after"]["Uncertainty"] = self.render_unit(unit)
1339
1340
    def _folder_item_detection_limits(self, analysis_brain, item):
1341
        """Fills the analysis' detection limits to the item passed in.
1342
1343
        :param analysis_brain: Brain that represents an analysis
1344
        :param item: analysis' dictionary counterpart that represents a row
1345
        """
1346
        item["DetectionLimitOperand"] = ""
1347
1348
        if not self.is_analysis_edition_allowed(analysis_brain):
1349
            # Return immediately if the we are not in edit mode
1350
            return
1351
1352
        # TODO: Performance, we wake-up the full object here
1353
        obj = self.get_object(analysis_brain)
1354
1355
        # No Detection Limit Selection
1356
        if not obj.getDetectionLimitSelector():
1357
            return None
1358
1359
        # Show Detection Limit Operand Selector
1360
        item["DetectionLimitOperand"] = obj.getDetectionLimitOperand()
1361
        item["allow_edit"].append("DetectionLimitOperand")
1362
        self.columns["DetectionLimitOperand"]["toggle"] = True
1363
1364
        # Prepare selection list for LDL/UDL
1365
        choices = [
1366
            {"ResultValue": "", "ResultText": ""},
1367
            {"ResultValue": LDL, "ResultText": LDL},
1368
            {"ResultValue": UDL, "ResultText": UDL}
1369
        ]
1370
        # Set the choices to the item
1371
        item["choices"]["DetectionLimitOperand"] = choices
1372
1373
    def _folder_item_specifications(self, analysis_brain, item):
1374
        """Set the results range to the item passed in"""
1375
        analysis = self.get_object(analysis_brain)
1376
        results_range = analysis.getResultsRange()
1377
1378
        item["Specification"] = ""
1379
        if results_range:
1380
            item["Specification"] = get_formatted_interval(results_range, "")
1381
1382
    def _folder_item_out_of_range(self, analysis_brain, item):
1383
        """Displays an icon if result is out of range
1384
        """
1385
        if not self.has_permission(ViewResults, analysis_brain):
1386
            # Users without permissions to see the result should not be able
1387
            # to see if the result is out of range naither
1388
            return
1389
1390
        analysis = self.get_object(analysis_brain)
1391
        out_range, out_shoulders = is_out_of_range(analysis)
1392
        if out_range:
1393
            msg = _("Result out of range")
1394
            img = get_image("exclamation.png", title=msg)
1395
            if not out_shoulders:
1396
                msg = _("Result in shoulder range")
1397
                img = get_image("warning.png", title=msg)
1398
            self._append_html_element(item, "Result", img)
1399
1400
    def _folder_item_result_range_compliance(self, analysis_brain, item):
1401
        """Displays an icon if the range is different from the results ranges
1402
        defined in the Sample
1403
        """
1404
        if not IAnalysisRequest.providedBy(self.context):
1405
            return
1406
1407
        analysis = self.get_object(analysis_brain)
1408
        if is_result_range_compliant(analysis):
1409
            return
1410
1411
        # Non-compliant range, display an icon
1412
        service_uid = analysis_brain.getServiceUID
1413
        original = self.context.getResultsRange(search_by=service_uid)
1414
        original = get_formatted_interval(original, "")
1415
        msg = _("Result range is different from Specification: {}"
1416
                .format(original))
1417
        img = get_image("warning.png", title=msg)
1418
        self._append_html_element(item, "Specification", img)
1419
1420
    def _folder_item_verify_icons(self, analysis_brain, item):
1421
        """Set the analysis' verification icons to the item passed in.
1422
1423
        :param analysis_brain: Brain that represents an analysis
1424
        :param item: analysis' dictionary counterpart that represents a row
1425
        """
1426
        submitter = analysis_brain.getSubmittedBy
1427
        if not submitter:
1428
            # This analysis hasn't yet been submitted, no verification yet
1429
            return
1430
1431
        if analysis_brain.review_state == 'retracted':
1432
            # Don't display icons and additional info about verification
1433
            return
1434
1435
        verifiers = analysis_brain.getVerificators
1436
        in_verifiers = submitter in verifiers
1437
        if in_verifiers:
1438
            # If analysis has been submitted and verified by the same person,
1439
            # display a warning icon
1440
            msg = t(_("Submitted and verified by the same user: {}"))
1441
            icon = get_image('warning.png', title=msg.format(submitter))
1442
            self._append_html_element(item, 'state_title', icon)
1443
1444
        num_verifications = analysis_brain.getNumberOfRequiredVerifications
1445
        if num_verifications > 1:
1446
            # More than one verification required, place an icon and display
1447
            # the number of verifications done vs. total required
1448
            done = analysis_brain.getNumberOfVerifications
1449
            pending = num_verifications - done
1450
            ratio = float(done) / float(num_verifications) if done > 0 else 0
1451
            ratio = int(ratio * 100)
1452
            scale = ratio == 0 and 0 or (ratio / 25) * 25
1453
            anchor = "<a href='#' tabindex='-1' title='{} &#13;{} {}' " \
1454
                     "class='multi-verification scale-{}'>{}/{}</a>"
1455
            anchor = anchor.format(t(_("Multi-verification required")),
1456
                                   str(pending),
1457
                                   t(_("verification(s) pending")),
1458
                                   str(scale),
1459
                                   str(done),
1460
                                   str(num_verifications))
1461
            self._append_html_element(item, 'state_title', anchor)
1462
1463
        if analysis_brain.review_state != 'to_be_verified':
1464
            # The verification of analysis has already been done or first
1465
            # verification has not been done yet. Nothing to do
1466
            return
1467
1468
        # Check if the user has "Bika: Verify" privileges
1469
        if not self.has_permission(TransitionVerify):
1470
            # User cannot verify, do nothing
1471
            return
1472
1473
        username = api.get_current_user().id
1474
        if username not in verifiers:
1475
            # Current user has not verified this analysis
1476
            if submitter != username:
1477
                # Current user is neither a submitter nor a verifier
1478
                return
1479
1480
            # Current user is the same who submitted the result
1481
            if analysis_brain.isSelfVerificationEnabled:
1482
                # Same user who submitted can verify
1483
                title = t(_("Can verify, but submitted by current user"))
1484
                html = get_image('warning.png', title=title)
1485
                self._append_html_element(item, 'state_title', html)
1486
                return
1487
1488
            # User who submitted cannot verify
1489
            title = t(_("Cannot verify, submitted 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
        # This user verified this analysis before
1495
        multi_verif = self.context.bika_setup.getTypeOfmultiVerification()
1496
        if multi_verif != 'self_multi_not_cons':
1497
            # Multi verification by same user is not allowed
1498
            title = t(_("Cannot verify, was verified by current user"))
1499
            html = get_image('submitted-by-current-user.png', title=title)
1500
            self._append_html_element(item, 'state_title', html)
1501
            return
1502
1503
        # Multi-verification by same user, but non-consecutively, is allowed
1504
        if analysis_brain.getLastVerificator != username:
1505
            # Current user was not the last user to verify
1506
            title = t(
1507
                _("Can verify, but was already verified by current user"))
1508
            html = get_image('warning.png', title=title)
1509
            self._append_html_element(item, 'state_title', html)
1510
            return
1511
1512
        # Last user who verified is the same as current user
1513
        title = t(_("Cannot verify, last verified by current user"))
1514
        html = get_image('submitted-by-current-user.png', title=title)
1515
        self._append_html_element(item, 'state_title', html)
1516
        return
1517
1518
    def _folder_item_assigned_worksheet(self, analysis_brain, item):
1519
        """Adds an icon to the item dict if the analysis is assigned to a
1520
        worksheet and if the icon is suitable for the current context
1521
1522
        :param analysis_brain: Brain that represents an analysis
1523
        :param item: analysis' dictionary counterpart that represents a row
1524
        """
1525
        if not IAnalysisRequest.providedBy(self.context):
1526
            # We want this icon to only appear if the context is an AR
1527
            return
1528
1529
        analysis_obj = self.get_object(analysis_brain)
1530
        worksheet = analysis_obj.getWorksheet()
1531
        if not worksheet:
1532
            # No worksheet assigned. Do nothing
1533
            return
1534
1535
        title = t(_("Assigned to: ${worksheet_id}",
1536
                    mapping={'worksheet_id': safe_unicode(worksheet.id)}))
1537
        img = get_image('worksheet.png', title=title)
1538
        anchor = get_link(worksheet.absolute_url(), img, tabindex="-1")
1539
        self._append_html_element(item, 'state_title', anchor)
1540
1541
    def _folder_item_accredited_icon(self, analysis_brain, item):
1542
        """Adds an icon to the item dictionary if it is an accredited analysis
1543
        """
1544
        full_obj = self.get_object(analysis_brain)
1545
        if full_obj.getAccredited():
1546
            img = get_image("accredited.png", title=t(_("Accredited")))
1547
            self._append_html_element(item, "Service", img)
1548
1549
    def _folder_item_partition(self, analysis_brain, item):
1550
        """Adds an anchor to the partition if the current analysis is from a
1551
        partition that does not match with the current context
1552
        """
1553
        if not IAnalysisRequest.providedBy(self.context):
1554
            return
1555
1556
        sample_id = analysis_brain.getRequestID
1557
        if sample_id != api.get_id(self.context):
1558
            if not self.show_partitions:
1559
                # Do not display the link
1560
                return
1561
1562
            part_url = analysis_brain.getRequestURL
1563
            kwargs = {"class": "small", "tabindex": "-1"}
1564
            url = get_link(part_url, value=sample_id, **kwargs)
1565
            title = item["replace"].get("Service") or item["Service"]
1566
            item["replace"]["Service"] = "{}<br/>{}".format(title, url)
1567
1568
    def _folder_item_report_visibility(self, analysis_brain, item):
1569
        """Set if the hidden field can be edited (enabled/disabled)
1570
1571
        :analysis_brain: Brain that represents an analysis
1572
        :item: analysis' dictionary counterpart to be represented as a row"""
1573
        # Users that can Add Analyses to an Analysis Request must be able to
1574
        # set the visibility of the analysis in results report, also if the
1575
        # current state of the Analysis Request (e.g. verified) does not allow
1576
        # the edition of other fields. Note that an analyst has no privileges
1577
        # by default to edit this value, cause this "visibility" field is
1578
        # related with results reporting and/or visibility from the client
1579
        # side. This behavior only applies to routine analyses, the visibility
1580
        # of QC analyses is managed in publish and are not visible to clients.
1581
        if 'Hidden' not in self.columns:
1582
            return
1583
1584
        full_obj = self.get_object(analysis_brain)
1585
        item['Hidden'] = full_obj.getHidden()
1586
1587
        # Hidden checkbox is not reachable by tabbing
1588
        item["tabindex"]["Hidden"] = "disabled"
1589
        if self.has_permission(FieldEditAnalysisHidden, obj=full_obj):
1590
            item['allow_edit'].append('Hidden')
1591
1592
    def _folder_item_fieldicons(self, analysis_brain):
1593
        """Resolves if field-specific icons must be displayed for the object
1594
        passed in.
1595
1596
        :param analysis_brain: Brain that represents an analysis
1597
        """
1598
        full_obj = self.get_object(analysis_brain)
1599
        uid = api.get_uid(full_obj)
1600
        for name, adapter in getAdapters((full_obj,), IFieldIcons):
1601
            alerts = adapter()
1602
            if not alerts or uid not in alerts:
1603
                continue
1604
            alerts = alerts[uid]
1605
            if uid not in self.field_icons:
1606
                self.field_icons[uid] = alerts
1607
                continue
1608
            self.field_icons[uid].extend(alerts)
1609
1610
    def _folder_item_remarks(self, analysis_brain, item):
1611
        """Renders the Remarks field for the passed in analysis
1612
1613
        If the edition of the analysis is permitted, adds the field into the
1614
        list of editable fields.
1615
1616
        :param analysis_brain: Brain that represents an analysis
1617
        :param item: analysis' dictionary counterpart that represents a row
1618
        """
1619
1620
        if self.analysis_remarks_enabled():
1621
            item["Remarks"] = analysis_brain.getRemarks
1622
1623
        if self.is_analysis_edition_allowed(analysis_brain):
1624
            item["allow_edit"].extend(["Remarks"])
1625
        else:
1626
            # render HTMLified text in readonly mode
1627
            item["Remarks"] = api.text_to_html(
1628
                analysis_brain.getRemarks, wrap=None)
1629
1630
    def _append_html_element(self, item, element, html, glue="&nbsp;",
1631
                             after=True):
1632
        """Appends an html value after or before the element in the item dict
1633
1634
        :param item: dictionary that represents an analysis row
1635
        :param element: id of the element the html must be added thereafter
1636
        :param html: element to append
1637
        :param glue: glue to use for appending
1638
        :param after: if the html content must be added after or before"""
1639
        position = after and 'after' or 'before'
1640
        item[position] = item.get(position, {})
1641
        original = item[position].get(element, '')
1642
        if not original:
1643
            item[position][element] = html
1644
            return
1645
        item[position][element] = glue.join([original, html])
1646
1647
    def _folder_item_conditions(self, analysis_brain, item):
1648
        """Renders the analysis conditions
1649
        """
1650
        analysis = self.get_object(analysis_brain)
1651
1652
        if not IRoutineAnalysis.providedBy(analysis):
1653
            return
1654
1655
        conditions = analysis.getConditions()
1656
        if not conditions:
1657
            return
1658
1659
        def to_str(condition):
1660
            title = condition.get("title")
1661
            value = condition.get("value", "")
1662
            if condition.get("type") == "file" and api.is_uid(value):
1663
                att = self.get_object(value)
1664
                value = self.get_attachment_link(att)
1665
            return ": ".join([title, str(value)])
1666
1667
        # Display the conditions properly formatted
1668
        conditions = "<br/>".join([to_str(cond) for cond in conditions])
1669
        service = item["replace"].get("Service") or item["Service"]
1670
        item["replace"]["Service"] = "<br/>".join([service, conditions])
1671
1672
    def is_method_required(self, analysis):
1673
        """Returns whether the render of the selection list with methods is
1674
        required for the method passed-in, even if only option "None" is
1675
        displayed for selection
1676
        """
1677
        # Always return true if the analysis has a method assigned
1678
        obj = self.get_object(analysis)
1679
        method = obj.getRawMethod()
1680
        if method:
1681
            return True
1682
1683
        methods = obj.getRawAllowedMethods()
1684
        return len(methods) > 0
1685
1686
    def is_instrument_required(self, analysis):
1687
        """Returns whether the render of the selection list with instruments is
1688
        required for the analysis passed-in, even if only option "None" is
1689
        displayed for selection.
1690
        :param analysis: Brain or object that represents an analysis
1691
        """
1692
        # If method selection list is required, the instrument selection too
1693
        if self.is_method_required(analysis):
1694
            return True
1695
1696
        # Always return true if the analysis has an instrument assigned
1697
        analysis = self.get_object(analysis)
1698
        if analysis.getRawInstrument():
1699
            return True
1700
1701
        instruments = analysis.getRawAllowedInstruments()
1702
        # There is no need to check for the instruments of the method assigned
1703
        # to # the analysis (if any), because the instruments rendered in the
1704
        # selection list are always a subset of the allowed instruments when
1705
        # a method is selected
1706
        return len(instruments) > 0
1707
1708
    def is_unit_choices_required(self, analysis):
1709
        """Returns whether the render of the unit choice selection list is
1710
        required for the analysis passed-in.
1711
        :param analysis: Brain or object that represents an analysis
1712
        """
1713
        # Always return true if the analysis has unitchoices
1714
        analysis = self.get_object(analysis)
1715
        if analysis.getUnitChoices():
1716
            return True
1717
        return False
1718
1719
    def is_method_column_required(self, items):
1720
        """Returns whether the method column has to be rendered or not.
1721
        Returns True if at least one of the analyses from the listing requires
1722
        the list for method selection to be rendered
1723
        """
1724
        for item in items:
1725
            obj = item.get("obj")
1726
            if self.is_method_required(obj):
1727
                return True
1728
        return False
1729
1730
    def is_instrument_column_required(self, items):
1731
        """Returns whether the instrument column has to be rendered or not.
1732
        Returns True if at least one of the analyses from the listing requires
1733
        the list for instrument selection to be rendered
1734
        """
1735
        for item in items:
1736
            obj = item.get("obj")
1737
            if self.is_instrument_required(obj):
1738
                return True
1739
        return False
1740
1741
    def is_unit_selection_column_required(self, items):
1742
        """Returns whether the unit column has to be rendered or not.
1743
        Returns True if at least one of the analyses from the listing requires
1744
        the list for unit selection to be rendered
1745
        """
1746
        for item in items:
1747
            obj = item.get("obj")
1748
            if self.is_unit_choices_required(obj):
1749
                return True
1750
        return False
1751