Passed
Push — master ( b81f15...92201a )
by Jordi
04:59
created

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