Passed
Push — 2.x ( c6b8aa...18af2e )
by Ramon
05:36
created

AnalysesView._folder_item_fieldicons()   A

Complexity

Conditions 5

Size

Total Lines 17
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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