Passed
Push — master ( c0868d...0b39e4 )
by Ramon
04:34
created

AnalysesView._folder_item_partition()   A

Complexity

Conditions 4

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 11
dl 0
loc 17
rs 9.85
c 0
b 0
f 0
cc 4
nop 3
1
# -*- coding: utf-8 -*-
2
#
3
# This file is part of SENAITE.CORE.
4
#
5
# SENAITE.CORE is free software: you can redistribute it and/or modify it under
6
# the terms of the GNU General Public License as published by the Free Software
7
# Foundation, version 2.
8
#
9
# This program is distributed in the hope that it will be useful, but WITHOUT
10
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12
# details.
13
#
14
# You should have received a copy of the GNU General Public License along with
15
# this program; if not, write to the Free Software Foundation, Inc., 51
16
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
#
18
# Copyright 2018-2019 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
25
from DateTime import DateTime
26
from Products.Archetypes.config import REFERENCE_CATALOG
27
from Products.CMFPlone.utils import safe_unicode
28
from plone.memoize import view as viewcache
29
from zope.component import getAdapters
30
31
from bika.lims import api
32
from bika.lims import bikaMessageFactory as _
33
from bika.lims import logger
34
from bika.lims.api.analysis import get_formatted_interval
35
from bika.lims.api.analysis import is_out_of_range
36
from bika.lims.api.analysis import is_result_range_compliant
37
from bika.lims.browser.bika_listing import BikaListingView
38
from bika.lims.catalog import CATALOG_ANALYSIS_LISTING
39
from bika.lims.config import LDL
40
from bika.lims.config import UDL
41
from bika.lims.interfaces import IAnalysisRequest
42
from bika.lims.interfaces import IFieldIcons
43
from bika.lims.permissions import EditFieldResults
44
from bika.lims.permissions import EditResults
45
from bika.lims.permissions import FieldEditAnalysisHidden
46
from bika.lims.permissions import FieldEditAnalysisResult
47
from bika.lims.permissions import TransitionVerify
48
from bika.lims.permissions import ViewResults
49
from bika.lims.permissions import ViewRetractedAnalyses
50
from bika.lims.utils import check_permission
51
from bika.lims.utils import formatDecimalMark
52
from bika.lims.utils import format_supsub
53
from bika.lims.utils import getUsers
54
from bika.lims.utils import get_image
55
from bika.lims.utils import get_link
56
from bika.lims.utils import t
57
from bika.lims.utils.analysis import format_uncertainty
58
59
60
class AnalysesView(BikaListingView):
61
    """Displays a list of Analyses in a table.
62
63
    Visible InterimFields from all analyses are added to self.columns[].
64
    Keyword arguments are passed directly to bika_analysis_catalog.
65
    """
66
67
    def __init__(self, context, request, **kwargs):
68
        super(AnalysesView, self).__init__(context, request, **kwargs)
69
70
        # prepare the content filter of this listing
71
        self.contentFilter = dict(kwargs)
72
        self.contentFilter.update({
73
            "portal_type": "Analysis",
74
            "sort_on": "sortable_title",
75
            "sort_order": "ascending",
76
        })
77
78
        # set the listing view config
79
        self.catalog = CATALOG_ANALYSIS_LISTING
80
        self.sort_order = "ascending"
81
        self.context_actions = {}
82
83
        self.show_select_row = False
84
        self.show_select_column = False
85
        self.show_column_toggles = False
86
        self.pagesize = 9999999
87
        self.form_id = "analyses_form"
88
        self.context_active = api.is_active(context)
89
        self.interim_fields = {}
90
        self.interim_columns = OrderedDict()
91
        self.specs = {}
92
        self.bsc = api.get_tool("bika_setup_catalog")
93
        self.portal = api.get_portal()
94
        self.portal_url = api.get_url(self.portal)
95
        self.rc = api.get_tool(REFERENCE_CATALOG)
96
        self.dmk = context.bika_setup.getResultsDecimalMark()
97
        self.scinot = context.bika_setup.getScientificNotationResults()
98
        self.categories = []
99
100
        # each editable item needs it's own allow_edit
101
        # which is a list of field names.
102
        self.allow_edit = False
103
104
        self.columns = OrderedDict((
105
            # Although 'created' column is not displayed in the list (see
106
            # review_states to check the columns that will be rendered), this
107
            # column is needed to sort the list by create date
108
            ("created", {
109
                "title": _("Date Created"),
110
                "toggle": False}),
111
            ("Service", {
112
                "title": _("Analysis"),
113
                "attr": "Title",
114
                "index": "sortable_title",
115
                "sortable": False}),
116
            ("Method", {
117
                "title": _("Method"),
118
                "sortable": False,
119
                "ajax": True,
120
                "toggle": True}),
121
            ("Instrument", {
122
                "title": _("Instrument"),
123
                "ajax": True,
124
                "sortable": False,
125
                "toggle": True}),
126
            ("Analyst", {
127
                "title": _("Analyst"),
128
                "sortable": False,
129
                "ajax": True,
130
                "toggle": True}),
131
            ("state_title", {
132
                "title": _("Status"),
133
                "sortable": False}),
134
            ("DetectionLimitOperand", {
135
                "title": _("DL"),
136
                "sortable": False,
137
                "ajax": True,
138
                "autosave": True,
139
                "toggle": False}),
140
            ("Result", {
141
                "title": _("Result"),
142
                "input_width": "6",
143
                "input_class": "ajax_calculate numeric",
144
                "ajax": True,
145
                "sortable": False}),
146
            ("Specification", {
147
                "title": _("Specification"),
148
                "sortable": False}),
149
            ("Uncertainty", {
150
                "title": _("+-"),
151
                "ajax": True,
152
                "sortable": False}),
153
            ("retested", {
154
                "title": _("Retested"),
155
                "type": "boolean",
156
                "sortable": False}),
157
            ("Attachments", {
158
                "title": _("Attachments"),
159
                "sortable": False}),
160
            ("CaptureDate", {
161
                "title": _("Captured"),
162
                "index": "getResultCaptureDate",
163
                "sortable": False}),
164
            ("SubmittedBy", {
165
                "title": _("Submitter"),
166
                "sortable": False}),
167
            ("DueDate", {
168
                "title": _("Due Date"),
169
                "index": "getDueDate",
170
                "sortable": False}),
171
            ("Hidden", {
172
                "title": _("Hidden"),
173
                "toggle": True,
174
                "sortable": False,
175
                "ajax": True,
176
                "type": "boolean"}),
177
        ))
178
179
        # Inject Remarks column for listing
180
        if self.analysis_remarks_enabled():
181
            self.columns["Remarks"] = {
182
                "title": "Remarks",
183
                "toggle": False,
184
                "sortable": False,
185
                "type": "remarks",
186
                "ajax": True,
187
            }
188
189
        self.review_states = [
190
            {
191
                "id": "default",
192
                "title": _("Valid"),
193
                "contentFilter": {
194
                    "review_state": [
195
                        "registered",
196
                        "unassigned",
197
                        "assigned",
198
                        "to_be_verified",
199
                        "verified",
200
                        "published",
201
                    ]
202
                },
203
                "columns": self.columns.keys()
204
            },
205
            {
206
                "id": "invalid",
207
                "contentFilter": {
208
                    "review_state": [
209
                        "cancelled",
210
                        "retracted",
211
                        "rejected",
212
                    ]
213
                },
214
                "title": _("Invalid"),
215
                "columns": self.columns.keys(),
216
            },
217
            {
218
                "id": "all",
219
                "title": _("All"),
220
                "contentFilter": {},
221
                "columns": self.columns.keys()
222
             },
223
        ]
224
225
        # This is used to display method and instrument columns if there is at
226
        # least one analysis to be rendered that allows the assignment of
227
        # method and/or instrument
228
        self.show_methodinstr_columns = False
229
230
    def update(self):
231
        """Update hook
232
        """
233
        super(AnalysesView, self).update()
234
        self.load_analysis_categories()
235
236
    def before_render(self):
237
        """Before render hook
238
        """
239
        super(AnalysesView, self).before_render()
240
        self.request.set("disable_plone.rightcolumn", 1)
241
242
    @property
243
    @viewcache.memoize
244
    def show_partitions(self):
245
        """Returns whether the partitions must be displayed or not
246
        """
247
        if api.get_current_client():
248
            # Current user is a client contact
249
            return api.get_setup().getShowPartitions()
250
        return True
251
252
    @viewcache.memoize
253
    def analysis_remarks_enabled(self):
254
        """Check if analysis remarks are enabled
255
        """
256
        return self.context.bika_setup.getEnableAnalysisRemarks()
257
258
    @viewcache.memoize
259
    def has_permission(self, permission, obj=None):
260
        """Returns if the current user has rights for the permission passed in
261
262
        :param permission: permission identifier
263
        :param obj: object to check the permission against
264
        :return: True if the user has rights for the permission passed in
265
        """
266
        if not permission:
267
            logger.warn("None permission is not allowed")
268
            return False
269
        if obj is None:
270
            return check_permission(permission, self.context)
271
        return check_permission(permission, api.get_object(obj))
272
273
    @viewcache.memoize
274
    def is_analysis_edition_allowed(self, analysis_brain):
275
        """Returns if the analysis passed in can be edited by the current user
276
277
        :param analysis_brain: Brain that represents an analysis
278
        :return: True if the user can edit the analysis, otherwise False
279
        """
280
        if not self.context_active:
281
            # The current context must be active. We cannot edit analyses from
282
            # inside a deactivated Analysis Request, for instance
283
            return False
284
285
        analysis_obj = api.get_object(analysis_brain)
286
        if analysis_obj.getPointOfCapture() == 'field':
287
            # This analysis must be captured on field, during sampling.
288
            if not self.has_permission(EditFieldResults, analysis_obj):
289
                # Current user cannot edit field analyses.
290
                return False
291
292
        elif not self.has_permission(EditResults, analysis_obj):
293
            # The Point of Capture is 'lab' and the current user cannot edit
294
            # lab analyses.
295
            return False
296
297
        # Check if the user is allowed to enter a value to to Result field
298
        if not self.has_permission(FieldEditAnalysisResult, analysis_obj):
299
            return False
300
301
        # Is the instrument out of date?
302
        # The user can assign a result to the analysis if it does not have any
303
        # instrument assigned or the instrument assigned is valid.
304
        if not self.is_analysis_instrument_valid(analysis_brain):
305
            # return if it is allowed to enter a manual result
306
            return analysis_obj.getManualEntryOfResults()
307
308
        return True
309
310
    @viewcache.memoize
311
    def is_result_edition_allowed(self, analysis_brain):
312
        """Checks if the edition of the result field is allowed
313
314
        :param analysis_brain: Brain that represents an analysis
315
        :return: True if the user can edit the result field, otherwise False
316
        """
317
318
        # Always check general edition first
319
        if not self.is_analysis_edition_allowed(analysis_brain):
320
            return False
321
322
        # Get the ananylsis object
323
        obj = api.get_object(analysis_brain)
324
325
        if not obj.getDetectionLimitOperand():
326
            # This is a regular result (not a detection limit)
327
            return True
328
329
        # Detection limit selector is enabled in the Analysis Service
330
        if obj.getDetectionLimitSelector():
331
            # Manual detection limit entry is *not* allowed
332
            if not obj.getAllowManualDetectionLimit():
333
                return False
334
335
        return True
336
337
    @viewcache.memoize
338
    def is_uncertainty_edition_allowed(self, analysis_brain):
339
        """Checks if the edition of the uncertainty field is allowed
340
341
        :param analysis_brain: Brain that represents an analysis
342
        :return: True if the user can edit the result field, otherwise False
343
        """
344
345
        # Only allow to edit the uncertainty if result edition is allowed
346
        if not self.is_result_edition_allowed(analysis_brain):
347
            return False
348
349
        # Get the ananylsis object
350
        obj = api.get_object(analysis_brain)
351
352
        # Manual setting of uncertainty is not allowed
353
        if not obj.getAllowManualUncertainty():
354
            return False
355
356
        # Result is a detection limit -> uncertainty setting makes no sense!
357
        if obj.getDetectionLimitOperand() in [LDL, UDL]:
358
            return False
359
360
        return True
361
362
    @viewcache.memoize
363
    def is_analysis_instrument_valid(self, analysis_brain):
364
        """Return if the analysis has a valid instrument.
365
366
        If the analysis passed in is from ReferenceAnalysis type or does not
367
        have an instrument assigned, returns True
368
369
        :param analysis_brain: Brain that represents an analysis
370
        :return: True if the instrument assigned is valid or is None"""
371
        if analysis_brain.meta_type == 'ReferenceAnalysis':
372
            # If this is a ReferenceAnalysis, there is no need to check the
373
            # validity of the instrument, because this is a QC analysis and by
374
            # definition, it has the ability to promote an instrument to a
375
            # valid state if the result is correct.
376
            return True
377
        instrument = self.get_instrument(analysis_brain)
378
        return not instrument or instrument.isValid()
379
380
    def get_instrument(self, analysis_brain):
381
        """Returns the instrument assigned to the analysis passed in, if any
382
383
        :param analysis_brain: Brain that represents an analysis
384
        :return: Instrument object or None"""
385
        instrument_uid = analysis_brain.getInstrumentUID
386
        # Note we look for the instrument by using its UID, case we want the
387
        # instrument to be cached by UID so if same instrument is assigned to
388
        # several analyses, a single search for instrument will be required
389
        return self.get_object(instrument_uid)
390
391
    @viewcache.memoize
392
    def get_object(self, brain_or_object_or_uid):
393
        """Get the full content object. Returns None if the param passed in is
394
        not a valid, not a valid object or not found
395
396
        :param brain_or_object_or_uid: UID/Catalog brain/content object
397
        :returns: content object
398
        """
399
        if api.is_uid(brain_or_object_or_uid):
400
            return api.get_object_by_uid(brain_or_object_or_uid, default=None)
401
        if api.is_object(brain_or_object_or_uid):
402
            return api.get_object(brain_or_object_or_uid)
403
        return None
404
405
    @viewcache.memoize
406
    def get_methods_vocabulary(self, analysis_brain):
407
        """Returns a vocabulary with all the methods available for the passed in
408
        analysis, either those assigned to an instrument that are capable to
409
        perform the test (option "Allow Entry of Results") and those assigned
410
        manually in the associated Analysis Service.
411
412
        The vocabulary is a list of dictionaries. Each dictionary has the
413
        following structure:
414
415
            {'ResultValue': <method_UID>,
416
             'ResultText': <method_Title>}
417
418
        :param analysis_brain: A single Analysis brain
419
        :type analysis_brain: CatalogBrain
420
        :returns: A list of dicts
421
        """
422
        uids = analysis_brain.getAllowedMethodUIDs
423
        query = {'portal_type': 'Method',
424
                 'is_active': True,
425
                 'UID': uids}
426
        brains = api.search(query, 'bika_setup_catalog')
427
        if not brains:
428
            return [{'ResultValue': '', 'ResultText': _('None')}]
429
        return map(lambda brain: {'ResultValue': brain.UID,
430
                                  'ResultText': brain.Title}, brains)
431
432
    @viewcache.memoize
433
    def get_instruments_vocabulary(self, analysis_brain):
434
        """Returns a vocabulary with the valid and active instruments available
435
        for the analysis passed in.
436
437
        If the option "Allow instrument entry of results" for the Analysis
438
        is disabled, the function returns an empty vocabulary.
439
440
        If the analysis passed in is a Reference Analysis (Blank or Control),
441
        the vocabulary, the invalid instruments will be included in the
442
        vocabulary too.
443
444
        The vocabulary is a list of dictionaries. Each dictionary has the
445
        following structure:
446
447
            {'ResultValue': <instrument_UID>,
448
             'ResultText': <instrument_Title>}
449
450
        :param analysis_brain: A single Analysis or ReferenceAnalysis
451
        :type analysis_brain: Analysis or.ReferenceAnalysis
452
        :return: A vocabulary with the instruments for the analysis
453
        :rtype: A list of dicts: [{'ResultValue':UID, 'ResultText':Title}]
454
        """
455
        if not analysis_brain.getInstrumentEntryOfResults:
456
            # Instrument entry of results for this analysis is not allowed
457
            return list()
458
459
        # If the analysis is a QC analysis, display all instruments, including
460
        # those uncalibrated or for which the last QC test failed.
461
        meta_type = analysis_brain.meta_type
462
        uncalibrated = meta_type == 'ReferenceAnalysis'
463
        if meta_type == 'DuplicateAnalysis':
464
            base_analysis_type = analysis_brain.getAnalysisPortalType
465
            uncalibrated = base_analysis_type == 'ReferenceAnalysis'
466
467
        uids = analysis_brain.getAllowedInstrumentUIDs
468
        query = {'portal_type': 'Instrument',
469
                 'is_active': True,
470
                 'UID': uids}
471
        brains = api.search(query, 'bika_setup_catalog')
472
        vocab = [{'ResultValue': '', 'ResultText': _('None')}]
473
        for brain in brains:
474
            instrument = self.get_object(brain)
475
            if uncalibrated and not instrument.isOutOfDate():
476
                # Is a QC analysis, include instrument also if is not valid
477
                vocab.append({'ResultValue': instrument.UID(),
478
                              'ResultText': instrument.Title()})
479
            if instrument.isValid():
480
                # Only add the 'valid' instruments: certificate
481
                # on-date and valid internal calibration tests
482
                vocab.append({'ResultValue': instrument.UID(),
483
                              'ResultText': instrument.Title()})
484
        return vocab
485
486
    @viewcache.memoize
487
    def get_analysts(self):
488
        analysts = getUsers(self.context, ['Manager', 'LabManager', 'Analyst'])
489
        analysts = analysts.sortedByKey()
490
        results = list()
491
        for analyst_id, analyst_name in analysts.items():
492
            results.append({'ResultValue': analyst_id,
493
                            'ResultText': analyst_name})
494
        return results
495
496
    def load_analysis_categories(self):
497
        # Getting analysis categories
498
        bsc = api.get_tool('bika_setup_catalog')
499
        analysis_categories = bsc(portal_type="AnalysisCategory",
500
                                  sort_on="sortable_title")
501
        # Sorting analysis categories
502
        self.analysis_categories_order = dict([
503
            (b.Title, "{:04}".format(a)) for a, b in
504
            enumerate(analysis_categories)])
505
506
    def isItemAllowed(self, obj):
507
        """Checks if the passed in Analysis must be displayed in the list.
508
        :param obj: A single Analysis brain or content object
509
        :type obj: ATContentType/CatalogBrain
510
        :returns: True if the item can be added to the list.
511
        :rtype: bool
512
        """
513
        if not obj:
514
            return False
515
516
        # Does the user has enough privileges to see retracted analyses?
517
        if obj.review_state == 'retracted' and \
518
                not self.has_permission(ViewRetractedAnalyses):
519
            return False
520
        return True
521
522
    def folderitem(self, obj, item, index):
523
        """Prepare a data item for the listing.
524
525
        :param obj: The catalog brain or content object
526
        :param item: Listing item (dictionary)
527
        :param index: Index of the listing item
528
        :returns: Augmented listing data item
529
        """
530
531
        item['Service'] = obj.Title
532
        item['class']['service'] = 'service_title'
533
        item['service_uid'] = obj.getServiceUID
534
        item['Keyword'] = obj.getKeyword
535
        item['Unit'] = format_supsub(obj.getUnit) if obj.getUnit else ''
536
        item['retested'] = obj.getRetestOfUID and True or False
537
        item['class']['retested'] = 'center'
538
539
        # Append info link before the service
540
        # see: bika.lims.site.coffee for the attached event handler
541
        item["before"]["Service"] = get_link(
542
            "analysisservice_info?service_uid={}&analysis_uid={}"
543
            .format(obj.getServiceUID, obj.UID),
544
            value="<span class='glyphicon glyphicon-info-sign'></span>",
545
            css_class="service_info")
546
547
        # Note that getSampleTypeUID returns the type of the Sample, no matter
548
        # if the sample associated to the analysis is a regular Sample (routine
549
        # analysis) or if is a Reference Sample (Reference Analysis). If the
550
        # analysis is a duplicate, it returns the Sample Type of the sample
551
        # associated to the source analysis.
552
        item['st_uid'] = obj.getSampleTypeUID
553
554
        # Fill item's category
555
        self._folder_item_category(obj, item)
556
        # Fill item's row class
557
        self._folder_item_css_class(obj, item)
558
        # Fill result and/or result options
559
        self._folder_item_result(obj, item)
560
        # Fill calculation and interim fields
561
        self._folder_item_calculation(obj, item)
562
        # Fill method
563
        self._folder_item_method(obj, item)
564
        # Fill instrument
565
        self._folder_item_instrument(obj, item)
566
        # Fill analyst
567
        self._folder_item_analyst(obj, item)
568
        # Fill submitted by
569
        self._folder_item_submitted_by(obj, item)
570
        # Fill attachments
571
        self._folder_item_attachments(obj, item)
572
        # Fill uncertainty
573
        self._folder_item_uncertainty(obj, item)
574
        # Fill Detection Limits
575
        self._folder_item_detection_limits(obj, item)
576
        # Fill Specifications
577
        self._folder_item_specifications(obj, item)
578
        self._folder_item_out_of_range(obj, item)
579
        self._folder_item_result_range_compliance(obj, item)
580
        # Fill Partition
581
        self._folder_item_partition(obj, item)
582
        # Fill Due Date and icon if late/overdue
583
        self._folder_item_duedate(obj, item)
584
        # Fill verification criteria
585
        self._folder_item_verify_icons(obj, item)
586
        # Fill worksheet anchor/icon
587
        self._folder_item_assigned_worksheet(obj, item)
588
        # Fill accredited icon
589
        self._folder_item_accredited_icon(obj, item)
590
        # Fill reflex analysis icons
591
        self._folder_item_reflex_icons(obj, item)
592
        # Fill hidden field (report visibility)
593
        self._folder_item_report_visibility(obj, item)
594
        # Renders additional icons to be displayed
595
        self._folder_item_fieldicons(obj)
596
        # Renders remarks toggle button
597
        self._folder_item_remarks(obj, item)
598
599
        return item
600
601
    def folderitems(self):
602
        # This shouldn't be required here, but there are some views that calls
603
        # directly contents_table() instead of __call__, so before_render is
604
        # never called. :(
605
        self.before_render()
606
607
        # Gettin all the items
608
        items = super(AnalysesView, self).folderitems(classic=False)
609
610
        # the TAL requires values for all interim fields on all
611
        # items, so we set blank values in unused cells
612
        for item in items:
613
            for field in self.interim_columns:
614
                if field not in item:
615
                    item[field] = ""
616
617
        # XXX order the list of interim columns
618
        interim_keys = self.interim_columns.keys()
619
        interim_keys.reverse()
620
621
        # add InterimFields keys to columns
622
        for col_id in interim_keys:
623
            if col_id not in self.columns:
624
                self.columns[col_id] = {
625
                    "title": self.interim_columns[col_id],
626
                    "input_width": "6",
627
                    "input_class": "ajax_calculate numeric",
628
                    "sortable": False,
629
                    "toggle": True,
630
                    "ajax": True,
631
                }
632
633
        if self.allow_edit:
634
            new_states = []
635
            for state in self.review_states:
636
                # InterimFields are displayed in review_state
637
                # They are anyway available through View.columns though.
638
                # In case of hidden fields, the calcs.py should check
639
                # calcs/services
640
                # for additional InterimFields!!
641
                pos = "Result" in state["columns"] and \
642
                      state["columns"].index("Result") or len(state["columns"])
643
                for col_id in interim_keys:
644
                    if col_id not in state["columns"]:
645
                        state["columns"].insert(pos, col_id)
646
                # retested column is added after Result.
647
                pos = "Result" in state["columns"] and \
648
                      state["columns"].index("Uncertainty") + 1 or len(
649
                    state["columns"])
650
                if "retested" in state["columns"]:
651
                    state["columns"].remove("retested")
652
                state["columns"].insert(pos, "retested")
653
                new_states.append(state)
654
            self.review_states = new_states
655
            # Allow selecting individual analyses
656
            self.show_select_column = True
657
658
        if self.show_categories:
659
            self.categories = map(lambda x: x[0],
660
                                  sorted(self.categories, key=lambda x: x[1]))
661
        else:
662
            self.categories.sort()
663
664
        # self.json_specs = json.dumps(self.specs)
665
        self.json_interim_fields = json.dumps(self.interim_fields)
666
        self.items = items
667
668
        # Method and Instrument columns must be shown or hidden at the
669
        # same time, because the value assigned to one causes
670
        # a value reassignment to the other (one method can be performed
671
        # by different instruments)
672
        self.columns["Method"]["toggle"] = self.show_methodinstr_columns
673
        self.columns["Instrument"]["toggle"] = self.show_methodinstr_columns
674
675
        return items
676
677
    def _folder_item_category(self, analysis_brain, item):
678
        """Sets the category to the item passed in
679
680
        :param analysis_brain: Brain that represents an analysis
681
        :param item: analysis' dictionary counterpart that represents a row
682
        """
683
        if not self.show_categories:
684
            return
685
686
        cat = analysis_brain.getCategoryTitle
687
        item["category"] = cat
688
        cat_order = self.analysis_categories_order.get(cat)
689
        if (cat, cat_order) not in self.categories:
690
            self.categories.append((cat, cat_order))
691
692
    def _folder_item_css_class(self, analysis_brain, item):
693
        """Sets the suitable css class name(s) to `table_row_class` from the
694
        item passed in, depending on the properties of the analysis object
695
696
        :param analysis_brain: Brain that represents an analysis
697
        :param item: analysis' dictionary counterpart that represents a row
698
        """
699
        meta_type = analysis_brain.meta_type
700
701
        # Default css names for table_row_class
702
        css_names = item.get('table_row_class', '').split()
703
        css_names.extend(['state-{}'.format(analysis_brain.review_state),
704
                          'type-{}'.format(meta_type.lower())])
705
706
        if meta_type == 'ReferenceAnalysis':
707
            css_names.append('qc-analysis')
708
709
        elif meta_type == 'DuplicateAnalysis':
710
            if analysis_brain.getAnalysisPortalType == 'ReferenceAnalysis':
711
                css_names.append('qc-analysis')
712
713
        item['table_row_class'] = ' '.join(css_names)
714
715
    def _folder_item_duedate(self, analysis_brain, item):
716
        """Set the analysis' due date to the item passed in.
717
718
        :param analysis_brain: Brain that represents an analysis
719
        :param item: analysis' dictionary counterpart that represents a row
720
        """
721
722
        # Note that if the analysis is a Reference Analysis, `getDueDate`
723
        # returns the date when the ReferenceSample expires. If the analysis is
724
        # a duplicate, `getDueDate` returns the due date of the source analysis
725
        due_date = analysis_brain.getDueDate
726
        if not due_date:
727
            return None
728
        due_date_str = self.ulocalized_time(due_date, long_format=0)
729
        item['DueDate'] = due_date_str
730
731
        # If the Analysis is late/overdue, display an icon
732
        capture_date = analysis_brain.getResultCaptureDate
733
        capture_date = capture_date or DateTime()
734
        if capture_date > due_date:
735
            # The analysis is late or overdue
736
            img = get_image('late.png', title=t(_("Late Analysis")),
737
                            width='16px', height='16px')
738
            item['replace']['DueDate'] = '{} {}'.format(due_date_str, img)
739
740
    def _folder_item_result(self, analysis_brain, item):
741
        """Set the analysis' result to the item passed in.
742
743
        :param analysis_brain: Brain that represents an analysis
744
        :param item: analysis' dictionary counterpart that represents a row
745
        """
746
747
        item["Result"] = ""
748
749
        if not self.has_permission(ViewResults, analysis_brain):
750
            # If user has no permissions, don"t display the result but an icon
751
            img = get_image("to_follow.png", width="16px", height="16px")
752
            item["before"]["Result"] = img
753
            return
754
755
        result = analysis_brain.getResult
756
        capture_date = analysis_brain.getResultCaptureDate
757
        capture_date_str = self.ulocalized_time(capture_date, long_format=0)
758
759
        item["Result"] = result
760
        item["CaptureDate"] = capture_date_str
761
        item["result_captured"] = capture_date_str
762
        item["string_result"] = False
763
764
        # Edit mode enabled of this Analysis
765
        if self.is_analysis_edition_allowed(analysis_brain):
766
            # Allow to set Remarks
767
            item["allow_edit"].append("Remarks")
768
769
            # Set the results field editable
770
            if self.is_result_edition_allowed(analysis_brain):
771
                item["allow_edit"].append("Result")
772
773
            # Prepare result options
774
            choices = analysis_brain.getResultOptions
775
            if choices:
776
                # N.B.we copy here the list to avoid persistent changes
777
                choices = copy(choices)
778
                # By default set empty as the default selected choice
779
                choices.insert(0, dict(ResultValue="", ResultText=""))
780
                item["choices"]["Result"] = choices
781
            else:
782
                # If not choices, set whether the result must be floatable
783
                obj = self.get_object(analysis_brain)
784
                item["string_result"] = obj.getStringResult()
785
786
        if not result:
787
            return
788
789
        obj = self.get_object(analysis_brain)
790
        formatted_result = obj.getFormattedResult(
791
            sciformat=int(self.scinot), decimalmark=self.dmk)
792
        item["formatted_result"] = formatted_result
793
794
    def _folder_item_calculation(self, analysis_brain, item):
795
        """Set the analysis' calculation and interims to the item passed in.
796
797
        :param analysis_brain: Brain that represents an analysis
798
        :param item: analysis' dictionary counterpart that represents a row
799
        """
800
801
        if not self.has_permission(ViewResults, analysis_brain):
802
            # Hide interims and calculation if user cannot view results
803
            return
804
805
        is_editable = self.is_analysis_edition_allowed(analysis_brain)
806
807
        # Set interim fields. Note we add the key 'formatted_value' to the list
808
        # of interims the analysis has already assigned.
809
        interim_fields = analysis_brain.getInterimFields or list()
810
811
        for interim_field in interim_fields:
812
            interim_keyword = interim_field.get('keyword', '')
813
            if not interim_keyword:
814
                continue
815
816
            interim_value = interim_field.get("value", "")
817
            interim_formatted = formatDecimalMark(interim_value, self.dmk)
818
            interim_field['formatted_value'] = interim_formatted
819
            item[interim_keyword] = interim_field
820
            item['class'][interim_keyword] = 'interim'
821
822
            # Note: As soon as we have a separate content type for field
823
            #       analysis, we can solely rely on the field permission
824
            #       "senaite.core: Field: Edit Analysis Result"
825
            if is_editable:
826
                if self.has_permission(FieldEditAnalysisResult, analysis_brain):
827
                    item['allow_edit'].append(interim_keyword)
828
829
            # Add this analysis' interim fields to the interim_columns list
830
            interim_hidden = interim_field.get('hidden', False)
831
            if not interim_hidden:
832
                interim_title = interim_field.get('title')
833
                self.interim_columns[interim_keyword] = interim_title
834
835
        item['interimfields'] = interim_fields
836
        self.interim_fields[analysis_brain.UID] = interim_fields
837
838
        # Set calculation
839
        calculation_uid = analysis_brain.getCalculationUID
840
        has_calculation = calculation_uid and True or False
841
        item['calculation'] = has_calculation
842
843
    def _folder_item_method(self, analysis_brain, item):
844
        """Fills the analysis' method to the item passed in.
845
846
        :param analysis_brain: Brain that represents an analysis
847
        :param item: analysis' dictionary counterpart that represents a row
848
        """
849
850
        is_editable = self.is_analysis_edition_allowed(analysis_brain)
851
        method_title = analysis_brain.getMethodTitle
852
        item['Method'] = method_title or ''
853
        if is_editable:
854
            method_vocabulary = self.get_methods_vocabulary(analysis_brain)
855
            if method_vocabulary:
856
                item['Method'] = analysis_brain.getMethodUID
857
                item['choices']['Method'] = method_vocabulary
858
                item['allow_edit'].append('Method')
859
                self.show_methodinstr_columns = True
860
        elif method_title:
861
            item['replace']['Method'] = get_link(analysis_brain.getMethodURL,
862
                                                 method_title)
863
            self.show_methodinstr_columns = True
864
865
    def _folder_item_instrument(self, analysis_brain, item):
866
        """Fills the analysis' instrument to the item passed in.
867
868
        :param analysis_brain: Brain that represents an analysis
869
        :param item: analysis' dictionary counterpart that represents a row
870
        """
871
        item['Instrument'] = ''
872
        if not analysis_brain.getInstrumentEntryOfResults:
873
            # Manual entry of results, instrument is not allowed
874
            item['Instrument'] = _('Manual')
875
            item['replace']['Instrument'] = \
876
                '<a href="#">{}</a>'.format(t(_('Manual')))
877
            return
878
879
        # Instrument can be assigned to this analysis
880
        is_editable = self.is_analysis_edition_allowed(analysis_brain)
881
        self.show_methodinstr_columns = True
882
        instrument = self.get_instrument(analysis_brain)
883
        if is_editable:
884
            # Edition allowed
885
            voc = self.get_instruments_vocabulary(analysis_brain)
886
            if voc:
887
                # The service has at least one instrument available
888
                item['Instrument'] = instrument.UID() if instrument else ''
889
                item['choices']['Instrument'] = voc
890
                item['allow_edit'].append('Instrument')
891
                return
892
893
        if instrument:
894
            # Edition not allowed
895
            instrument_title = instrument and instrument.Title() or ''
896
            instrument_link = get_link(instrument.absolute_url(),
897
                                       instrument_title)
898
            item['Instrument'] = instrument_title
899
            item['replace']['Instrument'] = instrument_link
900
            return
901
902
    def _folder_item_analyst(self, obj, item):
903
        is_editable = self.is_analysis_edition_allowed(obj)
904
        if not is_editable:
905
            item['Analyst'] = obj.getAnalystName
906
            return
907
908
        # Analyst is editable
909
        item['Analyst'] = obj.getAnalyst or api.get_current_user().id
910
        item['choices']['Analyst'] = self.get_analysts()
911
912
    def _folder_item_submitted_by(self, obj, item):
913
        submitted_by = obj.getSubmittedBy
914
        if submitted_by:
915
            user = self.get_user_by_id(submitted_by)
916
            user_name = user and user.getProperty("fullname") or submitted_by
917
            item['SubmittedBy'] = user_name
918
919
    @viewcache.memoize
920
    def get_user_by_id(self, user_id):
921
        return api.get_user(user_id)
922
923
    def _folder_item_attachments(self, obj, item):
924
        item['Attachments'] = ''
925
        attachment_uids = obj.getAttachmentUIDs
926
        if not attachment_uids:
927
            return
928
929
        if not self.has_permission(ViewResults, obj):
930
            return
931
932
        attachments_html = []
933
        attachments = api.search({'UID': attachment_uids}, 'uid_catalog')
934
        for attachment in attachments:
935
            attachment = api.get_object(attachment)
936
            uid = api.get_uid(attachment)
937
            html = '<span class="attachment" attachment_uid="{}">'.format(uid)
938
            attachments_html.append(html)
939
940
            at_file = attachment.getAttachmentFile()
941
            icon = at_file.icon
942
            if callable(icon):
943
                icon = icon()
944
            if icon:
945
                html = '<img src="{}/{}">'.format(self.portal_url, icon)
946
                attachments_html.append(html)
947
948
            url = '{}/at_download/AttachmentFile'
949
            url = url.format(attachment.absolute_url())
950
            link = get_link(url, at_file.filename)
951
            attachments_html.append(link)
952
953
            if not self.is_analysis_edition_allowed(obj):
954
                attachments_html.append('<br/></span>')
955
                continue
956
957
            img = '<img class="deleteAttachmentButton"' \
958
                  ' attachment_uid="{}" src="{}"/>'
959
            img = img.format(uid, '++resource++bika.lims.images/delete.png')
960
            attachments_html.append(img)
961
            attachments_html.append('<br/></span>')
962
963
        if attachments_html:
964
            # Remove the last <br/></span> and add only </span>
965
            attachments_html = attachments_html[:-1]
966
            attachments_html.append('</span>')
967
            item['replace']['Attachments'] = ''.join(attachments_html)
968
969
    def _folder_item_uncertainty(self, analysis_brain, item):
970
        """Fills the analysis' uncertainty to the item passed in.
971
972
        :param analysis_brain: Brain that represents an analysis
973
        :param item: analysis' dictionary counterpart that represents a row
974
        """
975
976
        item["Uncertainty"] = ""
977
978
        if not self.has_permission(ViewResults, analysis_brain):
979
            return
980
981
        result = analysis_brain.getResult
982
983
        obj = self.get_object(analysis_brain)
984
        formatted = format_uncertainty(obj, result, decimalmark=self.dmk,
985
                                       sciformat=int(self.scinot))
986
        if formatted:
987
            item["Uncertainty"] = formatted
988
        else:
989
            item["Uncertainty"] = obj.getUncertainty(result)
990
991
        if self.is_uncertainty_edition_allowed(analysis_brain):
992
            item["allow_edit"].append("Uncertainty")
993
994
    def _folder_item_detection_limits(self, analysis_brain, item):
995
        """Fills the analysis' detection limits to the item passed in.
996
997
        :param analysis_brain: Brain that represents an analysis
998
        :param item: analysis' dictionary counterpart that represents a row
999
        """
1000
        item["DetectionLimitOperand"] = ""
1001
1002
        if not self.is_analysis_edition_allowed(analysis_brain):
1003
            # Return immediately if the we are not in edit mode
1004
            return
1005
1006
        # TODO: Performance, we wake-up the full object here
1007
        obj = self.get_object(analysis_brain)
1008
1009
        # No Detection Limit Selection
1010
        if not obj.getDetectionLimitSelector():
1011
            return None
1012
1013
        # Show Detection Limit Operand Selector
1014
        item["DetectionLimitOperand"] = obj.getDetectionLimitOperand()
1015
        item["allow_edit"].append("DetectionLimitOperand")
1016
        self.columns["DetectionLimitOperand"]["toggle"] = True
1017
1018
        # Prepare selection list for LDL/UDL
1019
        choices = [
1020
            {"ResultValue": "", "ResultText": ""},
1021
            {"ResultValue": LDL, "ResultText": LDL},
1022
            {"ResultValue": UDL, "ResultText": UDL}
1023
        ]
1024
        # Set the choices to the item
1025
        item["choices"]["DetectionLimitOperand"] = choices
1026
1027
    def _folder_item_specifications(self, analysis_brain, item):
1028
        """Set the results range to the item passed in"""
1029
        analysis = self.get_object(analysis_brain)
1030
        results_range = analysis.getResultsRange()
1031
1032
        item["Specification"] = ""
1033
        if results_range:
1034
            item["Specification"] = get_formatted_interval(results_range, "")
1035
1036
    def _folder_item_out_of_range(self, analysis_brain, item):
1037
        """Displays an icon if result is out of range
1038
        """
1039
        analysis = self.get_object(analysis_brain)
1040
        out_range, out_shoulders = is_out_of_range(analysis)
1041
        if out_range:
1042
            msg = _("Result out of range")
1043
            img = get_image("exclamation.png", title=msg)
1044
            if not out_shoulders:
1045
                msg = _("Result in shoulder range")
1046
                img = get_image("warning.png", title=msg)
1047
            self._append_html_element(item, "Result", img)
1048
1049
    def _folder_item_result_range_compliance(self, analysis_brain, item):
1050
        """Displays an icon if the range is different from the results ranges
1051
        defined in the Sample
1052
        """
1053
        if not IAnalysisRequest.providedBy(self.context):
1054
            return
1055
1056
        analysis = self.get_object(analysis_brain)
1057
        if is_result_range_compliant(analysis):
1058
            return
1059
1060
        # Non-compliant range, display an icon
1061
        service_uid = analysis_brain.getServiceUID
1062
        original = self.context.getResultsRange(search_by=service_uid)
1063
        original = get_formatted_interval(original, "")
1064
        msg = _("Result range is different from Specification: {}"
1065
                .format(original))
1066
        img = get_image("warning.png", title=msg)
1067
        self._append_html_element(item, "Specification", img)
1068
1069
    def _folder_item_verify_icons(self, analysis_brain, item):
1070
        """Set the analysis' verification icons to the item passed in.
1071
1072
        :param analysis_brain: Brain that represents an analysis
1073
        :param item: analysis' dictionary counterpart that represents a row
1074
        """
1075
        submitter = analysis_brain.getSubmittedBy
1076
        if not submitter:
1077
            # This analysis hasn't yet been submitted, no verification yet
1078
            return
1079
1080
        if analysis_brain.review_state == 'retracted':
1081
            # Don't display icons and additional info about verification
1082
            return
1083
1084
        verifiers = analysis_brain.getVerificators
1085
        in_verifiers = submitter in verifiers
1086
        if in_verifiers:
1087
            # If analysis has been submitted and verified by the same person,
1088
            # display a warning icon
1089
            msg = t(_("Submitted and verified by the same user: {}"))
1090
            icon = get_image('warning.png', title=msg.format(submitter))
1091
            self._append_html_element(item, 'state_title', icon)
1092
1093
        num_verifications = analysis_brain.getNumberOfRequiredVerifications
1094
        if num_verifications > 1:
1095
            # More than one verification required, place an icon and display
1096
            # the number of verifications done vs. total required
1097
            done = analysis_brain.getNumberOfVerifications
1098
            pending = num_verifications - done
1099
            ratio = float(done) / float(num_verifications) if done > 0 else 0
1100
            ratio = int(ratio * 100)
1101
            scale = ratio == 0 and 0 or (ratio / 25) * 25
1102
            anchor = "<a href='#' title='{} &#13;{} {}' " \
1103
                     "class='multi-verification scale-{}'>{}/{}</a>"
1104
            anchor = anchor.format(t(_("Multi-verification required")),
1105
                                   str(pending),
1106
                                   t(_("verification(s) pending")),
1107
                                   str(scale),
1108
                                   str(done),
1109
                                   str(num_verifications))
1110
            self._append_html_element(item, 'state_title', anchor)
1111
1112
        if analysis_brain.review_state != 'to_be_verified':
1113
            # The verification of analysis has already been done or first
1114
            # verification has not been done yet. Nothing to do
1115
            return
1116
1117
        # Check if the user has "Bika: Verify" privileges
1118
        if not self.has_permission(TransitionVerify):
1119
            # User cannot verify, do nothing
1120
            return
1121
1122
        username = api.get_current_user().id
1123
        if username not in verifiers:
1124
            # Current user has not verified this analysis
1125
            if submitter != username:
1126
                # Current user is neither a submitter nor a verifier
1127
                return
1128
1129
            # Current user is the same who submitted the result
1130
            if analysis_brain.isSelfVerificationEnabled:
1131
                # Same user who submitted can verify
1132
                title = t(_("Can verify, but submitted by current user"))
1133
                html = get_image('warning.png', title=title)
1134
                self._append_html_element(item, 'state_title', html)
1135
                return
1136
1137
            # User who submitted cannot verify
1138
            title = t(_("Cannot verify, submitted by current user"))
1139
            html = get_image('submitted-by-current-user.png', title=title)
1140
            self._append_html_element(item, 'state_title', html)
1141
            return
1142
1143
        # This user verified this analysis before
1144
        multi_verif = self.context.bika_setup.getTypeOfmultiVerification()
1145
        if multi_verif != 'self_multi_not_cons':
1146
            # Multi verification by same user is not allowed
1147
            title = t(_("Cannot verify, was verified by current user"))
1148
            html = get_image('submitted-by-current-user.png', title=title)
1149
            self._append_html_element(item, 'state_title', html)
1150
            return
1151
1152
        # Multi-verification by same user, but non-consecutively, is allowed
1153
        if analysis_brain.getLastVerificator != username:
1154
            # Current user was not the last user to verify
1155
            title = t(
1156
                _("Can verify, but was already verified by current user"))
1157
            html = get_image('warning.png', title=title)
1158
            self._append_html_element(item, 'state_title', html)
1159
            return
1160
1161
        # Last user who verified is the same as current user
1162
        title = t(_("Cannot verify, last verified by current user"))
1163
        html = get_image('submitted-by-current-user.png', title=title)
1164
        self._append_html_element(item, 'state_title', html)
1165
        return
1166
1167
    def _folder_item_assigned_worksheet(self, analysis_brain, item):
1168
        """Adds an icon to the item dict if the analysis is assigned to a
1169
        worksheet and if the icon is suitable for the current context
1170
1171
        :param analysis_brain: Brain that represents an analysis
1172
        :param item: analysis' dictionary counterpart that represents a row
1173
        """
1174
        if not IAnalysisRequest.providedBy(self.context):
1175
            # We want this icon to only appear if the context is an AR
1176
            return
1177
1178
        analysis_obj = self.get_object(analysis_brain)
1179
        worksheet = analysis_obj.getWorksheet()
1180
        if not worksheet:
1181
            # No worksheet assigned. Do nothing
1182
            return
1183
1184
        title = t(_("Assigned to: ${worksheet_id}",
1185
                    mapping={'worksheet_id': safe_unicode(worksheet.id)}))
1186
        img = get_image('worksheet.png', title=title)
1187
        anchor = get_link(worksheet.absolute_url(), img)
1188
        self._append_html_element(item, 'state_title', anchor)
1189
1190
    def _folder_item_reflex_icons(self, analysis_brain, item):
1191
        """Adds an icon to the item dictionary if the analysis has been
1192
        automatically generated due to a reflex rule
1193
1194
        :param analysis_brain: Brain that represents an analysis
1195
        :param item: analysis' dictionary counterpart that represents a row
1196
        """
1197
        if not analysis_brain.getIsReflexAnalysis:
1198
            # Do nothing
1199
            return
1200
        img = get_image('reflexrule.png',
1201
                        title=t(_('It comes form a reflex rule')))
1202
        self._append_html_element(item, 'Service', img)
1203
1204
    def _folder_item_accredited_icon(self, analysis_brain, item):
1205
        """Adds an icon to the item dictionary if it is an accredited analysis
1206
        """
1207
        full_obj = self.get_object(analysis_brain)
1208
        if full_obj.getAccredited():
1209
            img = get_image("accredited.png", title=t(_("Accredited")))
1210
            self._append_html_element(item, "Service", img)
1211
1212
    def _folder_item_partition(self, analysis_brain, item):
1213
        """Adds an anchor to the partition if the current analysis is from a
1214
        partition that does not match with the current context
1215
        """
1216
        if not IAnalysisRequest.providedBy(self.context):
1217
            return
1218
1219
        sample_id = analysis_brain.getRequestID
1220
        if sample_id != api.get_id(self.context):
1221
            if not self.show_partitions:
1222
                # Do not display the link
1223
                return
1224
1225
            part_url = analysis_brain.getRequestURL
1226
            url = get_link(part_url, value=sample_id, **{"class": "small"})
1227
            title = item["replace"].get("Service") or item["Service"]
1228
            item["replace"]["Service"] = "{}<br/>{}".format(title, url)
1229
1230
    def _folder_item_report_visibility(self, analysis_brain, item):
1231
        """Set if the hidden field can be edited (enabled/disabled)
1232
1233
        :analysis_brain: Brain that represents an analysis
1234
        :item: analysis' dictionary counterpart to be represented as a row"""
1235
        # Users that can Add Analyses to an Analysis Request must be able to
1236
        # set the visibility of the analysis in results report, also if the
1237
        # current state of the Analysis Request (e.g. verified) does not allow
1238
        # the edition of other fields. Note that an analyst has no privileges
1239
        # by default to edit this value, cause this "visibility" field is
1240
        # related with results reporting and/or visibility from the client
1241
        # side. This behavior only applies to routine analyses, the visibility
1242
        # of QC analyses is managed in publish and are not visible to clients.
1243
        if 'Hidden' not in self.columns:
1244
            return
1245
1246
        full_obj = self.get_object(analysis_brain)
1247
        item['Hidden'] = full_obj.getHidden()
1248
        if self.has_permission(FieldEditAnalysisHidden, obj=full_obj):
1249
            item['allow_edit'].append('Hidden')
1250
1251
    def _folder_item_fieldicons(self, analysis_brain):
1252
        """Resolves if field-specific icons must be displayed for the object
1253
        passed in.
1254
1255
        :param analysis_brain: Brain that represents an analysis
1256
        """
1257
        full_obj = self.get_object(analysis_brain)
1258
        uid = api.get_uid(full_obj)
1259
        for name, adapter in getAdapters((full_obj,), IFieldIcons):
1260
            alerts = adapter()
1261
            if not alerts or uid not in alerts:
1262
                continue
1263
            alerts = alerts[uid]
1264
            if uid not in self.field_icons:
1265
                self.field_icons[uid] = alerts
1266
                continue
1267
            self.field_icons[uid].extend(alerts)
1268
1269
    def _folder_item_remarks(self, analysis_brain, item):
1270
        """Renders the Remarks field for the passed in analysis
1271
1272
        If the edition of the analysis is permitted, adds the field into the
1273
        list of editable fields.
1274
1275
        :param analysis_brain: Brain that represents an analysis
1276
        :param item: analysis' dictionary counterpart that represents a row
1277
        """
1278
1279
        if self.analysis_remarks_enabled():
1280
            item["Remarks"] = analysis_brain.getRemarks
1281
1282
        if self.is_analysis_edition_allowed(analysis_brain):
1283
            item["allow_edit"].extend(["Remarks"])
1284
        else:
1285
            # render HTMLified text in readonly mode
1286
            item["Remarks"] = api.text_to_html(
1287
                analysis_brain.getRemarks, wrap=None)
1288
1289
    def _append_html_element(self, item, element, html, glue="&nbsp;",
1290
                             after=True):
1291
        """Appends an html value after or before the element in the item dict
1292
1293
        :param item: dictionary that represents an analysis row
1294
        :param element: id of the element the html must be added thereafter
1295
        :param html: element to append
1296
        :param glue: glue to use for appending
1297
        :param after: if the html content must be added after or before"""
1298
        position = after and 'after' or 'before'
1299
        item[position] = item.get(position, {})
1300
        original = item[position].get(element, '')
1301
        if not original:
1302
            item[position][element] = html
1303
            return
1304
        item[position][element] = glue.join([original, html])
1305