AnalysesView._folder_item_specifications()   A
last analyzed

Complexity

Conditions 3

Size

Total Lines 15
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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