Passed
Push — 2.x ( 0ba30f...7ab1ba )
by Jordi
08:49
created

AnalysesView._folder_item_result()   D

Complexity

Conditions 12

Size

Total Lines 79
Code Lines 45

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 45
dl 0
loc 79
rs 4.8
c 0
b 0
f 0
cc 12
nop 3

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like bika.lims.browser.analyses.view.AnalysesView._folder_item_result() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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