Passed
Push — 2.x ( 62b538...9829b2 )
by Jordi
06:35
created

AnalysesView.render_unit()   A

Complexity

Conditions 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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