Passed
Push — 2.x ( 5fe529...b119db )
by Ramon
06:01
created

bika.lims.browser.analyses.view   F

Complexity

Total Complexity 237

Size/Duplication

Total Lines 1729
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 237
eloc 993
dl 0
loc 1729
rs 1.607
c 0
b 0
f 0

65 Methods

Rating   Name   Duplication   Size   Complexity  
A AnalysesView.before_render() 0 5 1
A AnalysesView.update() 0 8 2
B AnalysesView.__init__() 0 167 2
A AnalysesView._folder_item_duedate() 0 24 3
A AnalysesView.render_unit() 0 7 2
A AnalysesView._folder_item_category() 0 14 3
A AnalysesView.reorder_analysis_columns() 0 16 4
A AnalysesView.get_default_columns_order() 0 9 1
A AnalysesView._folder_item_css_class() 0 22 4
A AnalysesView.is_uncertainty_edition_allowed() 0 24 4
A AnalysesView.get_object() 0 9 1
F AnalysesView.folderitems() 0 73 16
A AnalysesView.load_analysis_categories() 0 9 1
A AnalysesView.get_methods_vocabulary() 0 27 3
A AnalysesView.is_analysis_conditions_edition_allowed() 0 18 4
B AnalysesView.is_analysis_edition_allowed() 0 29 6
A AnalysesView.is_result_edition_allowed() 0 26 5
A AnalysesView.isItemAllowed() 0 15 4
A AnalysesView.calculate_interim_columns_position() 0 9 3
A AnalysesView.get_calculation() 0 8 1
A AnalysesView.show_partitions() 0 9 2
A AnalysesView.analysis_categories_enabled() 0 9 2
A AnalysesView.analysis_remarks_enabled() 0 5 1
A AnalysesView.get_unit_vocabulary() 0 24 2
B AnalysesView.append_partition_filters() 0 56 8
B AnalysesView.folderitem() 0 91 3
A AnalysesView.get_instrument() 0 8 1
B AnalysesView.get_instruments_vocabulary() 0 74 8
A AnalysesView.senaite_theme() 0 6 1
A AnalysesView.has_permission() 0 14 3
A AnalysesView._folder_item_method() 0 19 3
A AnalysesView.get_attachment_link() 0 7 1
A AnalysesView._folder_item_accredited_icon() 0 7 2
A AnalysesView.get_result_options() 0 11 2
A AnalysesView.is_unit_choices_required() 0 10 2
A AnalysesView.is_instrument_required() 0 21 3
D AnalysesView._folder_item_verify_icons() 0 97 13
A AnalysesView._folder_item_out_of_range() 0 17 4
A AnalysesView._append_html_element() 0 16 2
A AnalysesView._folder_item_specifications() 0 8 2
F AnalysesView._folder_item_calculation() 0 110 18
A AnalysesView.is_unit_selection_column_required() 0 10 3
A AnalysesView._folder_item_report_visibility() 0 23 3
A AnalysesView._folder_item_unit() 0 14 3
C AnalysesView._folder_item_result() 0 73 11
A AnalysesView.is_instrument_column_required() 0 10 3
A AnalysesView._on_method_change() 0 20 2
A AnalysesView._folder_item_fieldicons() 0 17 5
A AnalysesView._folder_item_analyst() 0 4 1
A AnalysesView._folder_item_instrument() 0 27 4
A AnalysesView._folder_item_submitted_by() 0 4 1
A AnalysesView.get_user_name() 0 6 2
A AnalysesView.is_method_column_required() 0 10 3
A AnalysesView._on_unit_change() 0 12 4
A AnalysesView._folder_item_assigned_worksheet() 0 22 3
A AnalysesView._folder_item_partition() 0 18 4
A AnalysesView.to_list() 0 12 4
A AnalysesView._folder_item_conditions() 0 24 5
B AnalysesView._folder_item_uncertainty() 0 35 6
A AnalysesView._folder_item_remarks() 0 19 3
A AnalysesView.is_multi_interim() 0 6 1
A AnalysesView._folder_item_detection_limits() 0 32 3
A AnalysesView._folder_item_result_range_compliance() 0 19 3
A AnalysesView.is_method_required() 0 13 2
A AnalysesView._folder_item_attachments() 0 21 5

How to fix   Complexity   

Complexity

Complex classes like bika.lims.browser.analyses.view often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
# -*- coding: utf-8 -*-
2
#
3
# This file is part of SENAITE.CORE.
4
#
5
# SENAITE.CORE is free software: you can redistribute it and/or modify it under
6
# the terms of the GNU General Public License as published by the Free Software
7
# Foundation, version 2.
8
#
9
# This program is distributed in the hope that it will be useful, but WITHOUT
10
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12
# details.
13
#
14
# You should have received a copy of the GNU General Public License along with
15
# this program; if not, write to the Free Software Foundation, Inc., 51
16
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
#
18
# Copyright 2018-2021 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
import json
22
from collections import OrderedDict
23
from copy import copy
24
from copy import deepcopy
25
from operator import itemgetter
26
27
from bika.lims import api
28
from bika.lims import bikaMessageFactory as _
29
from bika.lims import logger
30
from bika.lims.api.analysis import get_formatted_interval
31
from bika.lims.api.analysis import is_out_of_range
32
from bika.lims.api.analysis import is_result_range_compliant
33
from bika.lims.config import LDL
34
from bika.lims.config import UDL
35
from bika.lims.interfaces import IAnalysisRequest
36
from bika.lims.interfaces import IFieldIcons
37
from bika.lims.interfaces import IReferenceAnalysis
38
from bika.lims.interfaces import IRoutineAnalysis
39
from senaite.core.permissions import EditFieldResults
40
from senaite.core.permissions import EditResults
41
from senaite.core.permissions import FieldEditAnalysisConditions
42
from senaite.core.permissions import FieldEditAnalysisHidden
43
from senaite.core.permissions import FieldEditAnalysisResult
44
from senaite.core.permissions import TransitionVerify
45
from senaite.core.permissions import ViewResults
46
from senaite.core.permissions import ViewRetractedAnalyses
47
from bika.lims.utils import check_permission
48
from bika.lims.utils import format_supsub
49
from bika.lims.utils import formatDecimalMark
50
from bika.lims.utils import get_image
51
from bika.lims.utils import get_link
52
from bika.lims.utils import get_link_for
53
from bika.lims.utils import t
54
from bika.lims.utils.analysis import format_uncertainty
55
from DateTime import DateTime
56
from plone.memoize import view as viewcache
57
from Products.Archetypes.config import REFERENCE_CATALOG
58
from Products.CMFPlone.utils import safe_unicode
59
from senaite.app.listing import ListingView
60
from senaite.core.catalog import ANALYSIS_CATALOG
61
from senaite.core.catalog import SETUP_CATALOG
62
from senaite.core.registry import get_registry_record
63
from zope.component import getAdapters
64
from zope.component import getMultiAdapter
65
66
67
class AnalysesView(ListingView):
68
    """Displays a list of Analyses in a table.
69
70
    Visible InterimFields from all analyses are added to self.columns[].
71
    Keyword arguments are passed directly to senaite_catalog_analysis.
72
    """
73
74
    def __init__(self, context, request, **kwargs):
75
        super(AnalysesView, self).__init__(context, request, **kwargs)
76
77
        # prepare the content filter of this listing
78
        self.contentFilter = dict(kwargs)
79
        self.contentFilter.update({
80
            "portal_type": "Analysis",
81
            "sort_on": "sortable_title",
82
            "sort_order": "ascending",
83
        })
84
85
        # set the listing view config
86
        self.catalog = ANALYSIS_CATALOG
87
        self.sort_order = "ascending"
88
        self.context_actions = {}
89
90
        self.show_select_row = False
91
        self.show_select_column = False
92
        self.show_column_toggles = False
93
        self.pagesize = 9999999
94
        self.form_id = "analyses_form"
95
        self.context_active = api.is_active(context)
96
        self.interim_fields = {}
97
        self.interim_columns = OrderedDict()
98
        self.specs = {}
99
        self.bsc = api.get_tool(SETUP_CATALOG)
100
        self.portal = api.get_portal()
101
        self.portal_url = api.get_url(self.portal)
102
        self.rc = api.get_tool(REFERENCE_CATALOG)
103
        self.dmk = context.bika_setup.getResultsDecimalMark()
104
        self.scinot = context.bika_setup.getScientificNotationResults()
105
        self.categories = []
106
        self.expand_all_categories = True
107
108
        # each editable item needs it's own allow_edit
109
        # which is a list of field names.
110
        self.allow_edit = False
111
112
        self.columns = OrderedDict((
113
            # Although 'created' column is not displayed in the list (see
114
            # review_states to check the columns that will be rendered), this
115
            # column is needed to sort the list by create date
116
            ("created", {
117
                "title": _("Date Created"),
118
                "toggle": False}),
119
            ("Service", {
120
                "title": _("Analysis"),
121
                "attr": "Title",
122
                "index": "sortable_title",
123
                "sortable": False}),
124
            ("DetectionLimitOperand", {
125
                "title": _("DL"),
126
                "sortable": False,
127
                "ajax": True,
128
                "autosave": True,
129
                "toggle": False}),
130
            ("Result", {
131
                "title": _("Result"),
132
                "input_width": "6",
133
                "input_class": "ajax_calculate numeric",
134
                "ajax": True,
135
                "sortable": False}),
136
            ("Uncertainty", {
137
                "title": _("+-"),
138
                "ajax": True,
139
                "sortable": False}),
140
            ("Unit", {
141
                "title": _("Unit"),
142
                "sortable": False,
143
                "ajax": True,
144
                "on_change": "_on_unit_change",
145
                "toggle": True}),
146
            ("Specification", {
147
                "title": _("Specification"),
148
                "sortable": False}),
149
            ("retested", {
150
                "title": _("Retested"),
151
                "type": "boolean",
152
                "sortable": False}),
153
            ("Method", {
154
                "title": _("Method"),
155
                "sortable": False,
156
                "ajax": True,
157
                "on_change": "_on_method_change",
158
                "toggle": True}),
159
            ("Instrument", {
160
                "title": _("Instrument"),
161
                "ajax": True,
162
                "sortable": False,
163
                "toggle": True}),
164
            ("Calculation", {
165
                "title": _("Calculation"),
166
                "sortable": False,
167
                "toggle": False}),
168
            ("Attachments", {
169
                "title": _("Attachments"),
170
                "sortable": False}),
171
            ("SubmittedBy", {
172
                "title": _("Submitter"),
173
                "sortable": False}),
174
            ("Analyst", {
175
                "title": _("Analyst"),
176
                "sortable": False,
177
                "ajax": True,
178
                "toggle": True}),
179
            ("CaptureDate", {
180
                "title": _("Captured"),
181
                "index": "getResultCaptureDate",
182
                "sortable": False}),
183
            ("DueDate", {
184
                "title": _("Due Date"),
185
                "index": "getDueDate",
186
                "sortable": False}),
187
            ("state_title", {
188
                "title": _("Status"),
189
                "sortable": False}),
190
            ("Hidden", {
191
                "title": _("Hidden"),
192
                "toggle": True,
193
                "sortable": False,
194
                "ajax": True,
195
                "type": "boolean"}),
196
        ))
197
198
        # Inject Remarks column for listing
199
        if self.analysis_remarks_enabled():
200
            self.columns["Remarks"] = {
201
                "title": "Remarks",
202
                "toggle": False,
203
                "sortable": False,
204
                "type": "remarks",
205
                "ajax": True,
206
            }
207
208
        self.review_states = [
209
            {
210
                "id": "default",
211
                "title": _("Valid"),
212
                "contentFilter": {
213
                    "review_state": [
214
                        "registered",
215
                        "unassigned",
216
                        "assigned",
217
                        "to_be_verified",
218
                        "verified",
219
                        "published",
220
                    ]
221
                },
222
                "columns": self.columns.keys()
223
            },
224
            {
225
                "id": "invalid",
226
                "contentFilter": {
227
                    "review_state": [
228
                        "cancelled",
229
                        "retracted",
230
                        "rejected",
231
                    ]
232
                },
233
                "title": _("Invalid"),
234
                "columns": self.columns.keys(),
235
            },
236
            {
237
                "id": "all",
238
                "title": _("All"),
239
                "contentFilter": {},
240
                "columns": self.columns.keys()
241
             },
242
        ]
243
244
    def update(self):
245
        """Update hook
246
        """
247
        super(AnalysesView, self).update()
248
        self.load_analysis_categories()
249
        self.append_partition_filters()
250
        if self.analysis_categories_enabled():
251
            self.show_categories = True
252
253
    def before_render(self):
254
        """Before render hook
255
        """
256
        super(AnalysesView, self).before_render()
257
        self.request.set("disable_plone.rightcolumn", 1)
258
259
    @viewcache.memoize
260
    def get_default_columns_order(self):
261
        """Return the default column order from the registry
262
263
        :returns: List of column keys
264
        """
265
        name = "sampleview_analysis_columns_order"
266
        columns_order = get_registry_record(name, default=[]) or []
267
        return columns_order
268
269
    def reorder_analysis_columns(self):
270
        """Reorder analysis columns based on registry configuration
271
        """
272
        columns_order = self.get_default_columns_order()
273
        if not columns_order:
274
            return
275
        # compute columns that are missing in the config
276
        missing_columns = filter(
277
            lambda col: col not in columns_order, self.columns.keys())
278
        # prepare the new sort order for the columns
279
        ordered_columns = columns_order + missing_columns
280
281
        # set the order in each review state
282
        for rs in self.review_states:
283
            # set a copy of the new ordered columns list
284
            rs["columns"] = ordered_columns[:]
285
286
    def calculate_interim_columns_position(self, review_state):
287
        """Calculate at which position the interim columns should be inserted
288
        """
289
        columns = review_state.get("columns", [])
290
        if "AdditionalValues" in columns:
291
            return columns.index("AdditionalValues")
292
        if "Result" in columns:
293
            return columns.index("Result")
294
        return len(columns)
295
296
    @property
297
    @viewcache.memoize
298
    def senaite_theme(self):
299
        return getMultiAdapter(
300
            (self.context, self.request),
301
            name="senaite_theme")
302
303
    @property
304
    @viewcache.memoize
305
    def show_partitions(self):
306
        """Returns whether the partitions must be displayed or not
307
        """
308
        if api.get_current_client():
309
            # Current user is a client contact
310
            return api.get_setup().getShowPartitions()
311
        return True
312
313
    @viewcache.memoize
314
    def analysis_remarks_enabled(self):
315
        """Check if analysis remarks are enabled
316
        """
317
        return self.context.bika_setup.getEnableAnalysisRemarks()
318
319
    @viewcache.memoize
320
    def analysis_categories_enabled(self):
321
        """Check if analyses should be grouped by category
322
        """
323
        # setting applies only for samples
324
        if not IAnalysisRequest.providedBy(self.context):
325
            return False
326
        setup = api.get_senaite_setup()
327
        return setup.getCategorizeSampleAnalyses()
328
329
    @viewcache.memoize
330
    def has_permission(self, permission, obj=None):
331
        """Returns if the current user has rights for the permission passed in
332
333
        :param permission: permission identifier
334
        :param obj: object to check the permission against
335
        :return: True if the user has rights for the permission passed in
336
        """
337
        if not permission:
338
            logger.warn("None permission is not allowed")
339
            return False
340
        if obj is None:
341
            return check_permission(permission, self.context)
342
        return check_permission(permission, self.get_object(obj))
343
344
    @viewcache.memoize
345
    def is_analysis_edition_allowed(self, analysis_brain):
346
        """Returns if the analysis passed in can be edited by the current user
347
348
        :param analysis_brain: Brain that represents an analysis
349
        :return: True if the user can edit the analysis, otherwise False
350
        """
351
        if not self.context_active:
352
            # The current context must be active. We cannot edit analyses from
353
            # inside a deactivated Analysis Request, for instance
354
            return False
355
356
        analysis_obj = self.get_object(analysis_brain)
357
        if analysis_obj.getPointOfCapture() == 'field':
358
            # This analysis must be captured on field, during sampling.
359
            if not self.has_permission(EditFieldResults, analysis_obj):
360
                # Current user cannot edit field analyses.
361
                return False
362
363
        elif not self.has_permission(EditResults, analysis_obj):
364
            # The Point of Capture is 'lab' and the current user cannot edit
365
            # lab analyses.
366
            return False
367
368
        # Check if the user is allowed to enter a value to to Result field
369
        if not self.has_permission(FieldEditAnalysisResult, analysis_obj):
370
            return False
371
372
        return True
373
374
    @viewcache.memoize
375
    def is_result_edition_allowed(self, analysis_brain):
376
        """Checks if the edition of the result field is allowed
377
378
        :param analysis_brain: Brain that represents an analysis
379
        :return: True if the user can edit the result field, otherwise False
380
        """
381
382
        # Always check general edition first
383
        if not self.is_analysis_edition_allowed(analysis_brain):
384
            return False
385
386
        # Get the ananylsis object
387
        obj = self.get_object(analysis_brain)
388
389
        if not obj.getDetectionLimitOperand():
390
            # This is a regular result (not a detection limit)
391
            return True
392
393
        # Detection limit selector is enabled in the Analysis Service
394
        if obj.getDetectionLimitSelector():
395
            # Manual detection limit entry is *not* allowed
396
            if not obj.getAllowManualDetectionLimit():
397
                return False
398
399
        return True
400
401
    @viewcache.memoize
402
    def is_uncertainty_edition_allowed(self, analysis_brain):
403
        """Checks if the edition of the uncertainty field is allowed
404
405
        :param analysis_brain: Brain that represents an analysis
406
        :return: True if the user can edit the result field, otherwise False
407
        """
408
409
        # Only allow to edit the uncertainty if result edition is allowed
410
        if not self.is_result_edition_allowed(analysis_brain):
411
            return False
412
413
        # Get the ananylsis object
414
        obj = self.get_object(analysis_brain)
415
416
        # Manual setting of uncertainty is not allowed
417
        if not obj.getAllowManualUncertainty():
418
            return False
419
420
        # Result is a detection limit -> uncertainty setting makes no sense!
421
        if obj.getDetectionLimitOperand() in [LDL, UDL]:
422
            return False
423
424
        return True
425
426
    @viewcache.memoize
427
    def is_analysis_conditions_edition_allowed(self, analysis_brain):
428
        """Returns whether the conditions of the analysis can be edited or not
429
        """
430
        # Check if permission is granted for the given analysis
431
        obj = self.get_object(analysis_brain)
432
433
        if IReferenceAnalysis.providedBy(obj):
434
            return False
435
436
        if not self.has_permission(FieldEditAnalysisConditions, obj):
437
            return False
438
439
        # Omit analysis does not have conditions set
440
        if not obj.getConditions(empties=True):
441
            return False
442
443
        return True
444
445
    def get_instrument(self, analysis_brain):
446
        """Returns the instrument assigned to the analysis passed in, if any
447
448
        :param analysis_brain: Brain that represents an analysis
449
        :return: Instrument object or None
450
        """
451
        obj = self.get_object(analysis_brain)
452
        return obj.getInstrument()
453
454
    def get_calculation(self, analysis_brain):
455
        """Returns the calculation assigned to the analysis passed in, if any
456
457
        :param analysis_brain: Brain that represents an analysis
458
        :return: Calculation object or None
459
        """
460
        obj = self.get_object(analysis_brain)
461
        return obj.getCalculation()
462
463
    @viewcache.memoize
464
    def get_object(self, brain_or_object_or_uid):
465
        """Get the full content object. Returns None if the param passed in is
466
        not a valid, not a valid object or not found
467
468
        :param brain_or_object_or_uid: UID/Catalog brain/content object
469
        :returns: content object
470
        """
471
        return api.get_object(brain_or_object_or_uid, default=None)
472
473
    def get_methods_vocabulary(self, analysis_brain):
474
        """Returns a vocabulary with all the methods available for the passed in
475
        analysis, either those assigned to an instrument that are capable to
476
        perform the test (option "Allow Entry of Results") and those assigned
477
        manually in the associated Analysis Service.
478
479
        The vocabulary is a list of dictionaries. Each dictionary has the
480
        following structure:
481
482
            {'ResultValue': <method_UID>,
483
             'ResultText': <method_Title>}
484
485
        :param analysis_brain: A single Analysis brain
486
        :type analysis_brain: CatalogBrain
487
        :returns: A list of dicts
488
        """
489
        obj = self.get_object(analysis_brain)
490
        methods = obj.getAllowedMethods()
491
        if not methods:
492
            return [{"ResultValue": "", "ResultText": _("None")}]
493
        vocab = []
494
        for method in methods:
495
            vocab.append({
496
                "ResultValue": api.get_uid(method),
497
                "ResultText": api.get_title(method),
498
            })
499
        return vocab
500
501
    def get_unit_vocabulary(self, analysis_brain):
502
        """Returns a vocabulary with all the units available for the passed in
503
        analysis.
504
505
        The vocabulary is a list of dictionaries. Each dictionary has the
506
        following structure:
507
508
            {'ResultValue': <unit>,
509
             'ResultText': <unit>}
510
511
        :param analysis_brain: A single Analysis brain
512
        :type analysis_brain: CatalogBrain
513
        :returns: A list of dicts
514
        """
515
        obj = self.get_object(analysis_brain)
516
        # Get unit choices
517
        unit_choices = obj.getUnitChoices()
518
        vocab = []
519
        for unit in unit_choices:
520
            vocab.append({
521
                "ResultValue": unit['value'],
522
                "ResultText": unit['value'],
523
            })
524
        return vocab
525
526
    def get_instruments_vocabulary(self, analysis, method=None):
527
        """Returns a vocabulary with the valid and active instruments available
528
        for the analysis passed in.
529
530
        If the option "Allow instrument entry of results" for the Analysis
531
        is disabled, the function returns an empty vocabulary.
532
533
        If the analysis passed in is a Reference Analysis (Blank or Control),
534
        the vocabulary, the invalid instruments will be included in the
535
        vocabulary too.
536
537
        The vocabulary is a list of dictionaries. Each dictionary has the
538
        following structure:
539
540
            {'ResultValue': <instrument_UID>,
541
             'ResultText': <instrument_Title>}
542
543
        :param analysis: A single Analysis or ReferenceAnalysis
544
        :type analysis_brain: Analysis or.ReferenceAnalysis
545
        :return: A vocabulary with the instruments for the analysis
546
        :rtype: A list of dicts: [{'ResultValue':UID, 'ResultText':Title}]
547
        """
548
        obj = self.get_object(analysis)
549
        # get the allowed interfaces from the analysis service
550
        instruments = obj.getAllowedInstruments()
551
        # if no method is passed, get the assigned method of the analyis
552
        if method is None:
553
            method = obj.getMethod()
554
555
        # check if the analysis has a method
556
        if method:
557
            # supported instrument from the method
558
            method_instruments = method.getInstruments()
559
            # allow only method instruments that are set in service
560
            instruments = list(
561
                set(instruments).intersection(method_instruments))
562
563
        # If the analysis is a QC analysis, display all instruments, including
564
        # those uncalibrated or for which the last QC test failed.
565
        is_qc = api.get_portal_type(obj) == "ReferenceAnalysis"
566
567
        vocab = []
568
        for instrument in instruments:
569
            uid = api.get_uid(instrument)
570
            title = api.safe_unicode(api.get_title(instrument))
571
            # append all valid instruments
572
            if instrument.isValid():
573
                vocab.append({
574
                    "ResultValue": uid,
575
                    "ResultText": title,
576
                })
577
            elif is_qc:
578
                # Is a QC analysis, include instrument also if is not valid
579
                if instrument.isOutOfDate():
580
                    title = _(u"{} (Out of date)".format(title))
581
                vocab.append({
582
                    "ResultValue": uid,
583
                    "ResultText": title,
584
                })
585
            elif instrument.isOutOfDate():
586
                # disable out of date instruments
587
                title = _(u"{} (Out of date)".format(title))
588
                vocab.append({
589
                    "disabled": True,
590
                    "ResultValue": None,
591
                    "ResultText": title,
592
                })
593
594
        # sort the vocabulary
595
        vocab = list(sorted(vocab, key=itemgetter("ResultText")))
596
        # prepend empty item
597
        vocab = [{"ResultValue": "", "ResultText": _("None")}] + vocab
598
599
        return vocab
600
601
    def load_analysis_categories(self):
602
        # Getting analysis categories
603
        bsc = api.get_tool('senaite_catalog_setup')
604
        analysis_categories = bsc(portal_type="AnalysisCategory",
605
                                  sort_on="sortable_title")
606
        # Sorting analysis categories
607
        self.analysis_categories_order = dict([
608
            (b.Title, "{:04}".format(a)) for a, b in
609
            enumerate(analysis_categories)])
610
611
    def append_partition_filters(self):
612
        """Append additional review state filters for partitions
613
        """
614
615
        # view is used for instrument QC view as well
616
        if not IAnalysisRequest.providedBy(self.context):
617
            return
618
619
        # check if the sample has partitions
620
        partitions = self.context.getDescendants()
621
622
        # root partition w/o partitions
623
        if not partitions:
624
            return
625
626
        new_states = []
627
        valid_states = [
628
            "registered",
629
            "unassigned",
630
            "assigned",
631
            "to_be_verified",
632
            "verified",
633
            "published",
634
        ]
635
636
        if self.context.isRootAncestor():
637
            root_id = api.get_id(self.context)
638
            new_states.append({
639
                "id": root_id,
640
                "title": root_id,
641
                "contentFilter": {
642
                    "getAncestorsUIDs": api.get_uid(self.context),
643
                    "review_state": valid_states,
644
                    "path": {
645
                        "query": api.get_path(self.context),
646
                        "level": 0,
647
                    },
648
                },
649
                "columns": self.columns.keys(),
650
            })
651
652
        for partition in partitions:
653
            part_id = api.get_id(partition)
654
            new_states.append({
655
                "id": part_id,
656
                "title": part_id,
657
                "contentFilter": {
658
                    "getAncestorsUIDs": api.get_uid(partition),
659
                    "review_state": valid_states,
660
                },
661
                "columns": self.columns.keys(),
662
            })
663
664
        for state in sorted(new_states, key=lambda s: s.get("id")):
665
            if state not in self.review_states:
666
                self.review_states.append(state)
667
668
    def isItemAllowed(self, obj):
669
        """Checks if the passed in Analysis must be displayed in the list.
670
        :param obj: A single Analysis brain or content object
671
        :type obj: ATContentType/CatalogBrain
672
        :returns: True if the item can be added to the list.
673
        :rtype: bool
674
        """
675
        if not obj:
676
            return False
677
678
        # Does the user has enough privileges to see retracted analyses?
679
        if obj.review_state == 'retracted' and \
680
                not self.has_permission(ViewRetractedAnalyses):
681
            return False
682
        return True
683
684
    def folderitem(self, obj, item, index):
685
        """Prepare a data item for the listing.
686
687
        :param obj: The catalog brain or content object
688
        :param item: Listing item (dictionary)
689
        :param index: Index of the listing item
690
        :returns: Augmented listing data item
691
        """
692
693
        item['Service'] = obj.Title
694
        item['class']['service'] = 'service_title'
695
        item['service_uid'] = obj.getServiceUID
696
        item['Keyword'] = obj.getKeyword
697
        item['Unit'] = format_supsub(obj.getUnit) if obj.getUnit else ''
698
        item['retested'] = obj.getRetestOfUID and True or False
699
        item['replace']['Service'] = '<strong>{}</strong>'.format(obj.Title)
700
701
        # Append info link before the service
702
        # see: bika.lims.site.coffee for the attached event handler
703
        item["before"]["Service"] = get_link(
704
            "analysisservice_info?service_uid={}&analysis_uid={}"
705
            .format(obj.getServiceUID, obj.UID),
706
            value="<i class='fas fa-info-circle'></i>",
707
            css_class="overlay_panel", tabindex="-1")
708
709
        # Append conditions link before the analysis
710
        # see: bika.lims.site.coffee for the attached event handler
711
        if self.is_analysis_conditions_edition_allowed(obj):
712
            url = api.get_url(self.context)
713
            url = "{}/set_analysis_conditions?uid={}".format(url, obj.UID)
714
            ico = "<i class='fas fa-list' style='padding-top: 5px;'/>"
715
            conditions = get_link(url, value=ico, css_class="overlay_panel",
716
                                  tabindex="-1")
717
            info = item["before"]["Service"]
718
            item["before"]["Service"] = "<br/>".join([info, conditions])
719
720
        # Note that getSampleTypeUID returns the type of the Sample, no matter
721
        # if the sample associated to the analysis is a regular Sample (routine
722
        # analysis) or if is a Reference Sample (Reference Analysis). If the
723
        # analysis is a duplicate, it returns the Sample Type of the sample
724
        # associated to the source analysis.
725
        item['st_uid'] = obj.getSampleTypeUID
726
727
        # Fill item's category
728
        self._folder_item_category(obj, item)
729
        # Fill item's row class
730
        self._folder_item_css_class(obj, item)
731
        # Fill result and/or result options
732
        self._folder_item_result(obj, item)
733
        # Fill calculation and interim fields
734
        self._folder_item_calculation(obj, item)
735
        # Fill unit field
736
        self._folder_item_unit(obj, item)
737
        # Fill method
738
        self._folder_item_method(obj, item)
739
        # Fill instrument
740
        self._folder_item_instrument(obj, item)
741
        # Fill analyst
742
        self._folder_item_analyst(obj, item)
743
        # Fill submitted by
744
        self._folder_item_submitted_by(obj, item)
745
        # Fill attachments
746
        self._folder_item_attachments(obj, item)
747
        # Fill uncertainty
748
        self._folder_item_uncertainty(obj, item)
749
        # Fill Detection Limits
750
        self._folder_item_detection_limits(obj, item)
751
        # Fill Specifications
752
        self._folder_item_specifications(obj, item)
753
        self._folder_item_out_of_range(obj, item)
754
        self._folder_item_result_range_compliance(obj, item)
755
        # Fill Partition
756
        self._folder_item_partition(obj, item)
757
        # Fill Due Date and icon if late/overdue
758
        self._folder_item_duedate(obj, item)
759
        # Fill verification criteria
760
        self._folder_item_verify_icons(obj, item)
761
        # Fill worksheet anchor/icon
762
        self._folder_item_assigned_worksheet(obj, item)
763
        # Fill accredited icon
764
        self._folder_item_accredited_icon(obj, item)
765
        # Fill hidden field (report visibility)
766
        self._folder_item_report_visibility(obj, item)
767
        # Renders additional icons to be displayed
768
        self._folder_item_fieldicons(obj)
769
        # Renders remarks toggle button
770
        self._folder_item_remarks(obj, item)
771
        # Renders the analysis conditions
772
        self._folder_item_conditions(obj, item)
773
774
        return item
775
776
    def folderitems(self):
777
        # This shouldn't be required here, but there are some views that calls
778
        # directly contents_table() instead of __call__, so before_render is
779
        # never called. :(
780
        self.before_render()
781
782
        # Gettin all the items
783
        items = super(AnalysesView, self).folderitems()
784
785
        # the TAL requires values for all interim fields on all
786
        # items, so we set blank values in unused cells
787
        for item in items:
788
            for field in self.interim_columns:
789
                if field not in item:
790
                    item[field] = ""
791
792
            # Graceful handling of new item key introduced in
793
            # https://github.com/senaite/senaite.app.listing/pull/81
794
            item["help"] = item.get("help", {})
795
796
        # XXX order the list of interim columns
797
        interim_keys = self.interim_columns.keys()
798
        interim_keys.reverse()
799
800
        # add InterimFields keys to columns
801
        for col_id in interim_keys:
802
            if col_id not in self.columns:
803
                self.columns[col_id] = {
804
                    "title": self.interim_columns[col_id],
805
                    "input_width": "6",
806
                    "input_class": "ajax_calculate numeric",
807
                    "sortable": False,
808
                    "toggle": True,
809
                    "ajax": True,
810
                }
811
812
        if self.allow_edit:
813
            new_states = []
814
            for state in self.review_states:
815
                pos = self.calculate_interim_columns_position(state)
816
                for col_id in interim_keys:
817
                    if col_id not in state["columns"]:
818
                        state["columns"].insert(pos, col_id)
819
                new_states.append(state)
820
            self.review_states = new_states
821
            # Allow selecting individual analyses
822
            self.show_select_column = True
823
824
        if self.show_categories:
825
            self.categories = map(lambda x: x[0],
826
                                  sorted(self.categories, key=lambda x: x[1]))
827
        else:
828
            self.categories.sort()
829
830
        # self.json_specs = json.dumps(self.specs)
831
        self.json_interim_fields = json.dumps(self.interim_fields)
832
833
        # Display method and instrument columns only if at least one of the
834
        # analyses requires them to be displayed for selection
835
        show_method_column = self.is_method_column_required(items)
836
        if "Method" in self.columns:
837
            self.columns["Method"]["toggle"] = show_method_column
838
839
        show_instrument_column = self.is_instrument_column_required(items)
840
        if "Instrument" in self.columns:
841
            self.columns["Instrument"]["toggle"] = show_instrument_column
842
843
        # show unit selection column only if required
844
        show_unit_column = self.is_unit_selection_column_required(items)
845
        if "Unit" in self.columns:
846
            self.columns["Unit"]["toggle"] = show_unit_column
847
848
        return items
849
850
    def render_unit(self, unit, css_class=None):
851
        """Render HTML element for unit
852
        """
853
        if css_class is None:
854
            css_class = "unit d-inline-block py-2 small text-secondary text-nowrap"
855
        return "<span class='{css_class}'>{unit}</span>".format(
856
            unit=unit, css_class=css_class)
857
858
    def _folder_item_category(self, analysis_brain, item):
859
        """Sets the category to the item passed in
860
861
        :param analysis_brain: Brain that represents an analysis
862
        :param item: analysis' dictionary counterpart that represents a row
863
        """
864
        if not self.show_categories:
865
            return
866
867
        cat = analysis_brain.getCategoryTitle
868
        item["category"] = cat
869
        cat_order = self.analysis_categories_order.get(cat)
870
        if (cat, cat_order) not in self.categories:
871
            self.categories.append((cat, cat_order))
872
873
    def _folder_item_css_class(self, analysis_brain, item):
874
        """Sets the suitable css class name(s) to `table_row_class` from the
875
        item passed in, depending on the properties of the analysis object
876
877
        :param analysis_brain: Brain that represents an analysis
878
        :param item: analysis' dictionary counterpart that represents a row
879
        """
880
        meta_type = analysis_brain.meta_type
881
882
        # Default css names for table_row_class
883
        css_names = item.get('table_row_class', '').split()
884
        css_names.extend(['state-{}'.format(analysis_brain.review_state),
885
                          'type-{}'.format(meta_type.lower())])
886
887
        if meta_type == 'ReferenceAnalysis':
888
            css_names.append('qc-analysis')
889
890
        elif meta_type == 'DuplicateAnalysis':
891
            if analysis_brain.getAnalysisPortalType == 'ReferenceAnalysis':
892
                css_names.append('qc-analysis')
893
894
        item['table_row_class'] = ' '.join(css_names)
895
896
    def _folder_item_duedate(self, analysis_brain, item):
897
        """Set the analysis' due date to the item passed in.
898
899
        :param analysis_brain: Brain that represents an analysis
900
        :param item: analysis' dictionary counterpart that represents a row
901
        """
902
903
        # Note that if the analysis is a Reference Analysis, `getDueDate`
904
        # returns the date when the ReferenceSample expires. If the analysis is
905
        # a duplicate, `getDueDate` returns the due date of the source analysis
906
        due_date = analysis_brain.getDueDate
907
        if not due_date:
908
            return None
909
        due_date_str = self.ulocalized_time(due_date, long_format=0)
910
        item['DueDate'] = due_date_str
911
912
        # If the Analysis is late/overdue, display an icon
913
        capture_date = analysis_brain.getResultCaptureDate
914
        capture_date = capture_date or DateTime()
915
        if capture_date > due_date:
916
            # The analysis is late or overdue
917
            img = get_image('late.png', title=t(_("Late Analysis")),
918
                            width='16px', height='16px')
919
            item['replace']['DueDate'] = '{} {}'.format(due_date_str, img)
920
921
    def _folder_item_result(self, analysis_brain, item):
922
        """Set the analysis' result to the item passed in.
923
924
        :param analysis_brain: Brain that represents an analysis
925
        :param item: analysis' dictionary counterpart that represents a row
926
        """
927
928
        item["Result"] = ""
929
930
        if not self.has_permission(ViewResults, analysis_brain):
931
            # If user has no permissions, don"t display the result but an icon
932
            img = get_image("to_follow.png", width="16px", height="16px")
933
            item["before"]["Result"] = img
934
            return
935
936
        result = analysis_brain.getResult
937
        capture_date = analysis_brain.getResultCaptureDate
938
        capture_date_str = self.ulocalized_time(capture_date, long_format=0)
939
940
        item["Result"] = result
941
        item["CaptureDate"] = capture_date_str
942
        item["result_captured"] = capture_date_str
943
944
        # Add the unit after the result
945
        unit = item.get("Unit")
946
        if unit:
947
            item["after"]["Result"] = self.render_unit(unit)
948
949
        # Get the analysis object
950
        obj = self.get_object(analysis_brain)
951
952
        # Edit mode enabled of this Analysis
953
        if self.is_analysis_edition_allowed(analysis_brain):
954
            # Allow to set Remarks
955
            item["allow_edit"].append("Remarks")
956
957
            # Set the results field editable
958
            if self.is_result_edition_allowed(analysis_brain):
959
                item["allow_edit"].append("Result")
960
961
            # Display the DL operand (< or >) in the results entry field if
962
            # the manual entry of DL is set, but DL selector is hidden
963
            allow_manual = obj.getAllowManualDetectionLimit()
964
            selector = obj.getDetectionLimitSelector()
965
            if allow_manual and not selector:
966
                operand = obj.getDetectionLimitOperand()
967
                item["Result"] = "{} {}".format(operand, result).strip()
968
969
            # Prepare result options
970
            choices = self.get_result_options(obj)
971
            if choices:
972
                choices_type = obj.getResultOptionsType()
973
                if choices_type == "select":
974
                    # By default set empty as the default selected choice
975
                    choices.insert(0, dict(ResultValue="", ResultText=""))
976
                item["choices"]["Result"] = choices
977
                item["result_type"] = choices_type
978
979
            elif obj.getStringResult():
980
                item["result_type"] = "string"
981
982
            else:
983
                item["result_type"] = "numeric"
984
                item["help"]["Result"] = _(
985
                    "Enter the result either in decimal or scientific "
986
                    "notation, e.g. 0.00005 or 1e-5, 10000 or 1e5")
987
988
        if not result:
989
            return
990
991
        formatted_result = obj.getFormattedResult(
992
            sciformat=int(self.scinot), decimalmark=self.dmk)
993
        item["formatted_result"] = formatted_result
994
995
    def get_result_options(self, analysis):
996
        """Returns the result options of the analysis to be rendered or empty
997
        """
998
        options = copy(analysis.getResultOptions())
999
        sort_by = analysis.getResultOptionsSorting()
1000
        if not sort_by:
1001
            return options
1002
1003
        sort_by, sort_order = sort_by.split("-")
1004
        reverse = sort_order == "desc"
1005
        return sorted(options, key=itemgetter(sort_by), reverse=reverse)
1006
1007
    def is_multi_interim(self, interim):
1008
        """Returns whether the interim stores a list of values instead of a
1009
        single value
1010
        """
1011
        result_type = interim.get("result_type", "")
1012
        return result_type.startswith("multi")
1013
1014
    def to_list(self, value):
1015
        """Converts the value to a list
1016
        """
1017
        try:
1018
            val = json.loads(value)
1019
            if isinstance(val, (list, tuple, set)):
1020
                value = val
1021
        except (ValueError, TypeError):
1022
            pass
1023
        if not isinstance(value, (list, tuple, set)):
1024
            value = [value]
1025
        return value
1026
1027
    def _folder_item_calculation(self, analysis_brain, item):
1028
        """Set the analysis' calculation and interims to the item passed in.
1029
1030
        :param analysis_brain: Brain that represents an analysis
1031
        :param item: analysis' dictionary counterpart that represents a row
1032
        """
1033
1034
        if not self.has_permission(ViewResults, analysis_brain):
1035
            # Hide interims and calculation if user cannot view results
1036
            return
1037
1038
        is_editable = self.is_analysis_edition_allowed(analysis_brain)
1039
1040
        # calculation
1041
        calculation = self.get_calculation(analysis_brain)
1042
        calculation_uid = api.get_uid(calculation) if calculation else ""
1043
        calculation_title = api.get_title(calculation) if calculation else ""
1044
        calculation_link = get_link_for(calculation) if calculation else ""
1045
        item["calculation"] = calculation_uid
1046
        item["Calculation"] = calculation_title
1047
        item["replace"]["Calculation"] = calculation_link or _("Manual")
1048
1049
        # Set interim fields. Note we add the key 'formatted_value' to the list
1050
        # of interims the analysis has already assigned.
1051
        analysis_obj = self.get_object(analysis_brain)
1052
        interim_fields = analysis_obj.getInterimFields() or list()
1053
1054
        # Copy to prevent to avoid persistent changes
1055
        interim_fields = deepcopy(interim_fields)
1056
        for interim_field in interim_fields:
1057
            interim_keyword = interim_field.get('keyword', '')
1058
            if not interim_keyword:
1059
                continue
1060
1061
            interim_value = interim_field.get("value", "")
1062
            interim_allow_empty = interim_field.get("allow_empty") == "on"
1063
            interim_unit = interim_field.get("unit", "")
1064
            interim_formatted = formatDecimalMark(interim_value, self.dmk)
1065
            interim_field["formatted_value"] = interim_formatted
1066
            item[interim_keyword] = interim_field
1067
            item["class"][interim_keyword] = "interim"
1068
1069
            # render the unit after the interim field
1070
            if interim_unit:
1071
                formatted_interim_unit = format_supsub(interim_unit)
1072
                item["after"][interim_keyword] = self.render_unit(
1073
                    formatted_interim_unit)
1074
1075
            # Note: As soon as we have a separate content type for field
1076
            #       analysis, we can solely rely on the field permission
1077
            #       "senaite.core: Field: Edit Analysis Result"
1078
            if is_editable:
1079
                if self.has_permission(
1080
                        FieldEditAnalysisResult, analysis_brain):
1081
                    item["allow_edit"].append(interim_keyword)
1082
1083
            # Add this analysis' interim fields to the interim_columns list
1084
            interim_hidden = interim_field.get("hidden", False)
1085
            if not interim_hidden:
1086
                interim_title = interim_field.get("title")
1087
                self.interim_columns[interim_keyword] = interim_title
1088
1089
            # Does interim's results list needs to be rendered?
1090
            choices = interim_field.get("choices")
1091
            if choices:
1092
                # Process the value as a list
1093
                interim_value = self.to_list(interim_value)
1094
1095
                # Get the {value:text} dict
1096
                choices = choices.split("|")
1097
                choices = dict(map(lambda ch: ch.strip().split(":"), choices))
1098
1099
                # Generate the display list
1100
                # [{"ResultValue": value, "ResultText": text},]
1101
                headers = ["ResultValue", "ResultText"]
1102
                dl = map(lambda it: dict(zip(headers, it)), choices.items())
0 ignored issues
show
introduced by
The variable headers does not seem to be defined for all execution paths.
Loading history...
1103
                # Allow empty selection by adding an empty record to the list
1104
                if interim_allow_empty:
1105
                    empty = {"ResultValue": "", "ResultText": ""}
1106
                    dl = [empty] + list(dl)
1107
1108
                item.setdefault("choices", {})[interim_keyword] = dl
1109
1110
                # Set the text as the formatted value
1111
                texts = [choices.get(v, "") for v in interim_value]
1112
                text = "<br/>".join(filter(None, texts))
1113
                interim_field["formatted_value"] = text
1114
1115
                if not is_editable:
1116
                    # Display the text instead of the value
1117
                    interim_field["value"] = text
1118
1119
                item[interim_keyword] = interim_field
1120
1121
            elif self.is_multi_interim(interim_field):
1122
                # Process the value as a list
1123
                interim_value = self.to_list(interim_value)
1124
1125
                # Set the text as the formatted value
1126
                text = "<br/>".join(filter(None, interim_value))
1127
                interim_field["formatted_value"] = text
1128
1129
                if not is_editable:
1130
                    # Display the text instead of the value
1131
                    interim_field["value"] = text
1132
1133
                item[interim_keyword] = interim_field
1134
1135
        item["interimfields"] = interim_fields
1136
        self.interim_fields[analysis_brain.UID] = interim_fields
1137
1138
    def _folder_item_unit(self, analysis_brain, item):
1139
        """Fills the analysis' unit to the item passed in.
1140
1141
        :param analysis_brain: Brain that represents an analysis
1142
        :param item: analysis' dictionary counterpart that represents a row
1143
        """
1144
        if not self.is_analysis_edition_allowed(analysis_brain):
1145
            return
1146
1147
        # Edition allowed
1148
        voc = self.get_unit_vocabulary(analysis_brain)
1149
        if voc:
1150
            item["choices"]["Unit"] = voc
1151
            item["allow_edit"].append("Unit")
1152
1153
    def _folder_item_method(self, analysis_brain, item):
1154
        """Fills the analysis' method to the item passed in.
1155
1156
        :param analysis_brain: Brain that represents an analysis
1157
        :param item: analysis' dictionary counterpart that represents a row
1158
        """
1159
        obj = self.get_object(analysis_brain)
1160
        is_editable = self.is_analysis_edition_allowed(analysis_brain)
1161
        if is_editable:
1162
            method_vocabulary = self.get_methods_vocabulary(analysis_brain)
1163
            item["Method"] = obj.getRawMethod()
1164
            item["choices"]["Method"] = method_vocabulary
1165
            item["allow_edit"].append("Method")
1166
        else:
1167
            item["Method"] = _("Manual")
1168
            method = obj.getMethod()
1169
            if method:
1170
                item["Method"] = api.get_title(method)
1171
                item["replace"]["Method"] = get_link_for(method, tabindex="-1")
1172
1173
    def _on_method_change(self, uid=None, value=None, item=None, **kw):
1174
        """Update instrument and calculation when the method changes
1175
1176
        :param uid: object UID
1177
        :value: UID of the new method
1178
        :item: old folderitem
1179
1180
        :returns: updated folderitem
1181
        """
1182
        obj = api.get_object_by_uid(uid, None)
1183
        method = api.get_object_by_uid(value, None)
1184
1185
        if not all([obj, method, item]):
1186
            return None
1187
1188
        # update the available instruments
1189
        inst_vocab = self.get_instruments_vocabulary(obj, method=method)
1190
        item["choices"]["Instrument"] = inst_vocab
1191
1192
        return item
1193
1194
    def _folder_item_instrument(self, analysis_brain, item):
1195
        """Fills the analysis' instrument to the item passed in.
1196
1197
        :param analysis_brain: Brain that represents an analysis
1198
        :param item: analysis' dictionary counterpart that represents a row
1199
        """
1200
        item["Instrument"] = ""
1201
1202
        # Instrument can be assigned to this analysis
1203
        is_editable = self.is_analysis_edition_allowed(analysis_brain)
1204
        instrument = self.get_instrument(analysis_brain)
1205
1206
        if is_editable:
1207
            # Edition allowed
1208
            voc = self.get_instruments_vocabulary(analysis_brain)
1209
            item["Instrument"] = instrument.UID() if instrument else ""
1210
            item["choices"]["Instrument"] = voc
1211
            item["allow_edit"].append("Instrument")
1212
1213
        elif instrument:
1214
            # Edition not allowed
1215
            item["Instrument"] = api.get_title(instrument)
1216
            instrument_link = get_link_for(instrument, tabindex="-1")
1217
            item["replace"]["Instrument"] = instrument_link
1218
1219
        else:
1220
            item["Instrument"] = _("Manual")
1221
1222
    def _on_unit_change(self, uid=None, value=None, item=None, **kw):
1223
        """ updates the rendered unit on selection of unit.
1224
        """
1225
        if not all([value, item]):
1226
            return None
1227
        item["after"]["Result"] = self.render_unit(value)
1228
        uncertainty = item.get("Uncertainty")
1229
        if uncertainty:
1230
            item["after"]["Uncertainty"] = self.render_unit(value)
1231
        elif "Uncertainty" in item["allow_edit"]:
1232
            item["after"]["Uncertainty"] = self.render_unit(value)
1233
        return item
1234
1235
    def _folder_item_analyst(self, obj, item):
1236
        obj = self.get_object(obj)
1237
        analyst = obj.getAnalyst()
1238
        item["Analyst"] = self.get_user_name(analyst)
1239
1240
    def _folder_item_submitted_by(self, obj, item):
1241
        obj = self.get_object(obj)
1242
        submitted_by = obj.getSubmittedBy()
1243
        item["SubmittedBy"] = self.get_user_name(submitted_by)
1244
1245
    @viewcache.memoize
1246
    def get_user_name(self, user_id):
1247
        if not user_id:
1248
            return ""
1249
        user = api.get_user_properties(user_id)
1250
        return user and user.get("fullname") or user_id
1251
1252
    def _folder_item_attachments(self, obj, item):
1253
        if not self.has_permission(ViewResults, obj):
1254
            return
1255
1256
        attachments_names = []
1257
        attachments_html = []
1258
        analysis = self.get_object(obj)
1259
        for attachment in analysis.getRawAttachment():
1260
            attachment = self.get_object(attachment)
1261
            link = self.get_attachment_link(attachment)
1262
            attachments_html.append(link)
1263
            filename = attachment.getFilename()
1264
            attachments_names.append(filename)
1265
1266
        if attachments_html:
1267
            item["replace"]["Attachments"] = "<br/>".join(attachments_html)
1268
            item["Attachments"] = ", ".join(attachments_names)
1269
1270
        elif analysis.getAttachmentRequired():
1271
            img = get_image("warning.png", title=_("Attachment required"))
1272
            item["replace"]["Attachments"] = img
1273
1274
    def get_attachment_link(self, attachment):
1275
        """Returns a well-formed link for the attachment passed in
1276
        """
1277
        filename = attachment.getFilename()
1278
        att_url = api.get_url(attachment)
1279
        url = "{}/at_download/AttachmentFile".format(att_url)
1280
        return get_link(url, filename, tabindex="-1")
1281
1282
    def _folder_item_uncertainty(self, analysis_brain, item):
1283
        """Fills the analysis' uncertainty to the item passed in.
1284
1285
        :param analysis_brain: Brain that represents an analysis
1286
        :param item: analysis' dictionary counterpart that represents a row
1287
        """
1288
        item["Uncertainty"] = ""
1289
1290
        if not self.has_permission(ViewResults, analysis_brain):
1291
            return
1292
1293
        # Wake up the Analysis object
1294
        obj = self.get_object(analysis_brain)
1295
1296
        # NOTE: When we allow to edit the uncertainty, we want to have the raw
1297
        #       uncertainty value and not the formatted (and possibly rounded)!
1298
        #       This ensures that not the rounded value get stored
1299
        allow_edit = self.is_uncertainty_edition_allowed(analysis_brain)
1300
        if allow_edit:
1301
            item["Uncertainty"] = obj.getUncertainty()
1302
            item["before"]["Uncertainty"] = "± "
1303
            item["allow_edit"].append("Uncertainty")
1304
            unit = item.get("Unit")
1305
            if unit:
1306
                item["after"]["Uncertainty"] = self.render_unit(unit)
1307
            return
1308
1309
        formatted = format_uncertainty(
1310
            obj, decimalmark=self.dmk, sciformat=int(self.scinot))
1311
        if formatted:
1312
            item["replace"]["Uncertainty"] = formatted
1313
            item["before"]["Uncertainty"] = "± "
1314
            unit = item.get("Unit")
1315
            if unit:
1316
                item["after"]["Uncertainty"] = self.render_unit(unit)
1317
1318
    def _folder_item_detection_limits(self, analysis_brain, item):
1319
        """Fills the analysis' detection limits to the item passed in.
1320
1321
        :param analysis_brain: Brain that represents an analysis
1322
        :param item: analysis' dictionary counterpart that represents a row
1323
        """
1324
        item["DetectionLimitOperand"] = ""
1325
1326
        if not self.is_analysis_edition_allowed(analysis_brain):
1327
            # Return immediately if the we are not in edit mode
1328
            return
1329
1330
        # TODO: Performance, we wake-up the full object here
1331
        obj = self.get_object(analysis_brain)
1332
1333
        # No Detection Limit Selection
1334
        if not obj.getDetectionLimitSelector():
1335
            return None
1336
1337
        # Show Detection Limit Operand Selector
1338
        item["DetectionLimitOperand"] = obj.getDetectionLimitOperand()
1339
        item["allow_edit"].append("DetectionLimitOperand")
1340
        self.columns["DetectionLimitOperand"]["toggle"] = True
1341
1342
        # Prepare selection list for LDL/UDL
1343
        choices = [
1344
            {"ResultValue": "", "ResultText": ""},
1345
            {"ResultValue": LDL, "ResultText": LDL},
1346
            {"ResultValue": UDL, "ResultText": UDL}
1347
        ]
1348
        # Set the choices to the item
1349
        item["choices"]["DetectionLimitOperand"] = choices
1350
1351
    def _folder_item_specifications(self, analysis_brain, item):
1352
        """Set the results range to the item passed in"""
1353
        analysis = self.get_object(analysis_brain)
1354
        results_range = analysis.getResultsRange()
1355
1356
        item["Specification"] = ""
1357
        if results_range:
1358
            item["Specification"] = get_formatted_interval(results_range, "")
1359
1360
    def _folder_item_out_of_range(self, analysis_brain, item):
1361
        """Displays an icon if result is out of range
1362
        """
1363
        if not self.has_permission(ViewResults, analysis_brain):
1364
            # Users without permissions to see the result should not be able
1365
            # to see if the result is out of range naither
1366
            return
1367
1368
        analysis = self.get_object(analysis_brain)
1369
        out_range, out_shoulders = is_out_of_range(analysis)
1370
        if out_range:
1371
            msg = _("Result out of range")
1372
            img = get_image("exclamation.png", title=msg)
1373
            if not out_shoulders:
1374
                msg = _("Result in shoulder range")
1375
                img = get_image("warning.png", title=msg)
1376
            self._append_html_element(item, "Result", img)
1377
1378
    def _folder_item_result_range_compliance(self, analysis_brain, item):
1379
        """Displays an icon if the range is different from the results ranges
1380
        defined in the Sample
1381
        """
1382
        if not IAnalysisRequest.providedBy(self.context):
1383
            return
1384
1385
        analysis = self.get_object(analysis_brain)
1386
        if is_result_range_compliant(analysis):
1387
            return
1388
1389
        # Non-compliant range, display an icon
1390
        service_uid = analysis_brain.getServiceUID
1391
        original = self.context.getResultsRange(search_by=service_uid)
1392
        original = get_formatted_interval(original, "")
1393
        msg = _("Result range is different from Specification: {}"
1394
                .format(original))
1395
        img = get_image("warning.png", title=msg)
1396
        self._append_html_element(item, "Specification", img)
1397
1398
    def _folder_item_verify_icons(self, analysis_brain, item):
1399
        """Set the analysis' verification icons to the item passed in.
1400
1401
        :param analysis_brain: Brain that represents an analysis
1402
        :param item: analysis' dictionary counterpart that represents a row
1403
        """
1404
        submitter = analysis_brain.getSubmittedBy
1405
        if not submitter:
1406
            # This analysis hasn't yet been submitted, no verification yet
1407
            return
1408
1409
        if analysis_brain.review_state == 'retracted':
1410
            # Don't display icons and additional info about verification
1411
            return
1412
1413
        verifiers = analysis_brain.getVerificators
1414
        in_verifiers = submitter in verifiers
1415
        if in_verifiers:
1416
            # If analysis has been submitted and verified by the same person,
1417
            # display a warning icon
1418
            msg = t(_("Submitted and verified by the same user: {}"))
1419
            icon = get_image('warning.png', title=msg.format(submitter))
1420
            self._append_html_element(item, 'state_title', icon)
1421
1422
        num_verifications = analysis_brain.getNumberOfRequiredVerifications
1423
        if num_verifications > 1:
1424
            # More than one verification required, place an icon and display
1425
            # the number of verifications done vs. total required
1426
            done = analysis_brain.getNumberOfVerifications
1427
            pending = num_verifications - done
1428
            ratio = float(done) / float(num_verifications) if done > 0 else 0
1429
            ratio = int(ratio * 100)
1430
            scale = ratio == 0 and 0 or (ratio / 25) * 25
1431
            anchor = "<a href='#' tabindex='-1' title='{} &#13;{} {}' " \
1432
                     "class='multi-verification scale-{}'>{}/{}</a>"
1433
            anchor = anchor.format(t(_("Multi-verification required")),
1434
                                   str(pending),
1435
                                   t(_("verification(s) pending")),
1436
                                   str(scale),
1437
                                   str(done),
1438
                                   str(num_verifications))
1439
            self._append_html_element(item, 'state_title', anchor)
1440
1441
        if analysis_brain.review_state != 'to_be_verified':
1442
            # The verification of analysis has already been done or first
1443
            # verification has not been done yet. Nothing to do
1444
            return
1445
1446
        # Check if the user has "Bika: Verify" privileges
1447
        if not self.has_permission(TransitionVerify):
1448
            # User cannot verify, do nothing
1449
            return
1450
1451
        username = api.get_current_user().id
1452
        if username not in verifiers:
1453
            # Current user has not verified this analysis
1454
            if submitter != username:
1455
                # Current user is neither a submitter nor a verifier
1456
                return
1457
1458
            # Current user is the same who submitted the result
1459
            if analysis_brain.isSelfVerificationEnabled:
1460
                # Same user who submitted can verify
1461
                title = t(_("Can verify, but submitted by current user"))
1462
                html = get_image('warning.png', title=title)
1463
                self._append_html_element(item, 'state_title', html)
1464
                return
1465
1466
            # User who submitted cannot verify
1467
            title = t(_("Cannot verify, submitted by current user"))
1468
            html = get_image('submitted-by-current-user.png', title=title)
1469
            self._append_html_element(item, 'state_title', html)
1470
            return
1471
1472
        # This user verified this analysis before
1473
        multi_verif = self.context.bika_setup.getTypeOfmultiVerification()
1474
        if multi_verif != 'self_multi_not_cons':
1475
            # Multi verification by same user is not allowed
1476
            title = t(_("Cannot verify, was verified by current user"))
1477
            html = get_image('submitted-by-current-user.png', title=title)
1478
            self._append_html_element(item, 'state_title', html)
1479
            return
1480
1481
        # Multi-verification by same user, but non-consecutively, is allowed
1482
        if analysis_brain.getLastVerificator != username:
1483
            # Current user was not the last user to verify
1484
            title = t(
1485
                _("Can verify, but was already verified by current user"))
1486
            html = get_image('warning.png', title=title)
1487
            self._append_html_element(item, 'state_title', html)
1488
            return
1489
1490
        # Last user who verified is the same as current user
1491
        title = t(_("Cannot verify, last verified by current user"))
1492
        html = get_image('submitted-by-current-user.png', title=title)
1493
        self._append_html_element(item, 'state_title', html)
1494
        return
1495
1496
    def _folder_item_assigned_worksheet(self, analysis_brain, item):
1497
        """Adds an icon to the item dict if the analysis is assigned to a
1498
        worksheet and if the icon is suitable for the current context
1499
1500
        :param analysis_brain: Brain that represents an analysis
1501
        :param item: analysis' dictionary counterpart that represents a row
1502
        """
1503
        if not IAnalysisRequest.providedBy(self.context):
1504
            # We want this icon to only appear if the context is an AR
1505
            return
1506
1507
        analysis_obj = self.get_object(analysis_brain)
1508
        worksheet = analysis_obj.getWorksheet()
1509
        if not worksheet:
1510
            # No worksheet assigned. Do nothing
1511
            return
1512
1513
        title = t(_("Assigned to: ${worksheet_id}",
1514
                    mapping={'worksheet_id': safe_unicode(worksheet.id)}))
1515
        img = get_image('worksheet.png', title=title)
1516
        anchor = get_link(worksheet.absolute_url(), img, tabindex="-1")
1517
        self._append_html_element(item, 'state_title', anchor)
1518
1519
    def _folder_item_accredited_icon(self, analysis_brain, item):
1520
        """Adds an icon to the item dictionary if it is an accredited analysis
1521
        """
1522
        full_obj = self.get_object(analysis_brain)
1523
        if full_obj.getAccredited():
1524
            img = get_image("accredited.png", title=t(_("Accredited")))
1525
            self._append_html_element(item, "Service", img)
1526
1527
    def _folder_item_partition(self, analysis_brain, item):
1528
        """Adds an anchor to the partition if the current analysis is from a
1529
        partition that does not match with the current context
1530
        """
1531
        if not IAnalysisRequest.providedBy(self.context):
1532
            return
1533
1534
        sample_id = analysis_brain.getRequestID
1535
        if sample_id != api.get_id(self.context):
1536
            if not self.show_partitions:
1537
                # Do not display the link
1538
                return
1539
1540
            part_url = analysis_brain.getRequestURL
1541
            kwargs = {"class": "small", "tabindex": "-1"}
1542
            url = get_link(part_url, value=sample_id, **kwargs)
1543
            title = item["replace"].get("Service") or item["Service"]
1544
            item["replace"]["Service"] = "{}<br/>{}".format(title, url)
1545
1546
    def _folder_item_report_visibility(self, analysis_brain, item):
1547
        """Set if the hidden field can be edited (enabled/disabled)
1548
1549
        :analysis_brain: Brain that represents an analysis
1550
        :item: analysis' dictionary counterpart to be represented as a row"""
1551
        # Users that can Add Analyses to an Analysis Request must be able to
1552
        # set the visibility of the analysis in results report, also if the
1553
        # current state of the Analysis Request (e.g. verified) does not allow
1554
        # the edition of other fields. Note that an analyst has no privileges
1555
        # by default to edit this value, cause this "visibility" field is
1556
        # related with results reporting and/or visibility from the client
1557
        # side. This behavior only applies to routine analyses, the visibility
1558
        # of QC analyses is managed in publish and are not visible to clients.
1559
        if 'Hidden' not in self.columns:
1560
            return
1561
1562
        full_obj = self.get_object(analysis_brain)
1563
        item['Hidden'] = full_obj.getHidden()
1564
1565
        # Hidden checkbox is not reachable by tabbing
1566
        item["tabindex"]["Hidden"] = "disabled"
1567
        if self.has_permission(FieldEditAnalysisHidden, obj=full_obj):
1568
            item['allow_edit'].append('Hidden')
1569
1570
    def _folder_item_fieldicons(self, analysis_brain):
1571
        """Resolves if field-specific icons must be displayed for the object
1572
        passed in.
1573
1574
        :param analysis_brain: Brain that represents an analysis
1575
        """
1576
        full_obj = self.get_object(analysis_brain)
1577
        uid = api.get_uid(full_obj)
1578
        for name, adapter in getAdapters((full_obj,), IFieldIcons):
1579
            alerts = adapter()
1580
            if not alerts or uid not in alerts:
1581
                continue
1582
            alerts = alerts[uid]
1583
            if uid not in self.field_icons:
1584
                self.field_icons[uid] = alerts
1585
                continue
1586
            self.field_icons[uid].extend(alerts)
1587
1588
    def _folder_item_remarks(self, analysis_brain, item):
1589
        """Renders the Remarks field for the passed in analysis
1590
1591
        If the edition of the analysis is permitted, adds the field into the
1592
        list of editable fields.
1593
1594
        :param analysis_brain: Brain that represents an analysis
1595
        :param item: analysis' dictionary counterpart that represents a row
1596
        """
1597
1598
        if self.analysis_remarks_enabled():
1599
            item["Remarks"] = analysis_brain.getRemarks
1600
1601
        if self.is_analysis_edition_allowed(analysis_brain):
1602
            item["allow_edit"].extend(["Remarks"])
1603
        else:
1604
            # render HTMLified text in readonly mode
1605
            item["Remarks"] = api.text_to_html(
1606
                analysis_brain.getRemarks, wrap=None)
1607
1608
    def _append_html_element(self, item, element, html, glue="&nbsp;",
1609
                             after=True):
1610
        """Appends an html value after or before the element in the item dict
1611
1612
        :param item: dictionary that represents an analysis row
1613
        :param element: id of the element the html must be added thereafter
1614
        :param html: element to append
1615
        :param glue: glue to use for appending
1616
        :param after: if the html content must be added after or before"""
1617
        position = after and 'after' or 'before'
1618
        item[position] = item.get(position, {})
1619
        original = item[position].get(element, '')
1620
        if not original:
1621
            item[position][element] = html
1622
            return
1623
        item[position][element] = glue.join([original, html])
1624
1625
    def _folder_item_conditions(self, analysis_brain, item):
1626
        """Renders the analysis conditions
1627
        """
1628
        analysis = self.get_object(analysis_brain)
1629
1630
        if not IRoutineAnalysis.providedBy(analysis):
1631
            return
1632
1633
        conditions = analysis.getConditions()
1634
        if not conditions:
1635
            return
1636
1637
        def to_str(condition):
1638
            title = condition.get("title")
1639
            value = condition.get("value", "")
1640
            if condition.get("type") == "file" and api.is_uid(value):
1641
                att = self.get_object(value)
1642
                value = self.get_attachment_link(att)
1643
            return ": ".join([title, str(value)])
1644
1645
        # Display the conditions properly formatted
1646
        conditions = "<br/>".join([to_str(cond) for cond in conditions])
1647
        service = item["replace"].get("Service") or item["Service"]
1648
        item["replace"]["Service"] = "<br/>".join([service, conditions])
1649
1650
    def is_method_required(self, analysis):
1651
        """Returns whether the render of the selection list with methods is
1652
        required for the method passed-in, even if only option "None" is
1653
        displayed for selection
1654
        """
1655
        # Always return true if the analysis has a method assigned
1656
        obj = self.get_object(analysis)
1657
        method = obj.getRawMethod()
1658
        if method:
1659
            return True
1660
1661
        methods = obj.getRawAllowedMethods()
1662
        return len(methods) > 0
1663
1664
    def is_instrument_required(self, analysis):
1665
        """Returns whether the render of the selection list with instruments is
1666
        required for the analysis passed-in, even if only option "None" is
1667
        displayed for selection.
1668
        :param analysis: Brain or object that represents an analysis
1669
        """
1670
        # If method selection list is required, the instrument selection too
1671
        if self.is_method_required(analysis):
1672
            return True
1673
1674
        # Always return true if the analysis has an instrument assigned
1675
        analysis = self.get_object(analysis)
1676
        if analysis.getRawInstrument():
1677
            return True
1678
1679
        instruments = analysis.getRawAllowedInstruments()
1680
        # There is no need to check for the instruments of the method assigned
1681
        # to # the analysis (if any), because the instruments rendered in the
1682
        # selection list are always a subset of the allowed instruments when
1683
        # a method is selected
1684
        return len(instruments) > 0
1685
1686
    def is_unit_choices_required(self, analysis):
1687
        """Returns whether the render of the unit choice selection list is
1688
        required for the analysis passed-in.
1689
        :param analysis: Brain or object that represents an analysis
1690
        """
1691
        # Always return true if the analysis has unitchoices
1692
        analysis = self.get_object(analysis)
1693
        if analysis.getUnitChoices():
1694
            return True
1695
        return False
1696
1697
    def is_method_column_required(self, items):
1698
        """Returns whether the method column has to be rendered or not.
1699
        Returns True if at least one of the analyses from the listing requires
1700
        the list for method selection to be rendered
1701
        """
1702
        for item in items:
1703
            obj = item.get("obj")
1704
            if self.is_method_required(obj):
1705
                return True
1706
        return False
1707
1708
    def is_instrument_column_required(self, items):
1709
        """Returns whether the instrument column has to be rendered or not.
1710
        Returns True if at least one of the analyses from the listing requires
1711
        the list for instrument selection to be rendered
1712
        """
1713
        for item in items:
1714
            obj = item.get("obj")
1715
            if self.is_instrument_required(obj):
1716
                return True
1717
        return False
1718
1719
    def is_unit_selection_column_required(self, items):
1720
        """Returns whether the unit column has to be rendered or not.
1721
        Returns True if at least one of the analyses from the listing requires
1722
        the list for unit selection to be rendered
1723
        """
1724
        for item in items:
1725
            obj = item.get("obj")
1726
            if self.is_unit_choices_required(obj):
1727
                return True
1728
        return False
1729