Passed
Push — 2.x ( 441526...cfc718 )
by Jordi
06:01
created

AnalysesView.reorder_analysis_columns()   A

Complexity

Conditions 4

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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