Passed
Push — 2.x ( 56cf07...33260b )
by Jordi
05:47
created

AnalysesView._folder_item_unit()   A

Complexity

Conditions 3

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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