Passed
Push — 2.x ( 8bc3ab...07088f )
by Ramon
06:25
created

AnalysesView.get_attachment_link()   A

Complexity

Conditions 1

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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