Passed
Push — master ( ef1ceb...20aa4c )
by Jordi
06:12
created

AnalysesView.update()   A

Complexity

Conditions 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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