Passed
Push — master ( 28e0c7...eb911b )
by Jordi
08:07 queued 03:46
created

AnalysesView._folder_item_remarks()   A

Complexity

Conditions 3

Size

Total Lines 17
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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