Passed
Push — 2.x ( c66db3...c8e751 )
by Jordi
06:01
created

AnalysesView._folder_item_duedate()   A

Complexity

Conditions 3

Size

Total Lines 24
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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