Passed
Push — 2.x ( cfc718...bf0229 )
by Jordi
08:14
created

bika.lims.browser.worksheet.views.analyses   B

Complexity

Total Complexity 50

Size/Duplication

Total Lines 631
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 50
eloc 380
dl 0
loc 631
rs 8.4
c 0
b 0
f 0

21 Methods

Rating   Name   Duplication   Size   Complexity  
A AnalysesView.isItemAllowed() 0 14 2
A AnalysesView.is_analysis_remarks_enabled() 0 6 1
A AnalysesView.get_default_columns_order() 0 15 2
A AnalysesView.update() 0 3 1
A AnalysesView.get_uids_strpositions() 0 35 4
B AnalysesView._get_slots() 0 16 6
A AnalysesView.get_slot_header() 0 20 1
A AnalysesView.get_item_position() 0 15 2
A AnalysesView.folderitem() 0 52 2
B AnalysesView.__init__() 0 104 2
A AnalysesView.show_analysis_remarks_transition() 0 12 4
C AnalysesView.get_slot_header_data() 0 117 5
A AnalysesView.get_item_slot() 0 12 2
A AnalysesView.skip_item_key() 0 8 2
A AnalysesView.get_empty_slots() 0 9 1
A AnalysesView.before_render() 0 6 3
A AnalysesView.folderitems() 0 26 1
A AnalysesView.fill_slots_headers() 0 24 4
A AnalysesView.get_occupied_slots() 0 8 1
A AnalysesView.fill_empty_slots() 0 36 2
A AnalysesView.render_remarks_tag() 0 22 2

How to fix   Complexity   

Complexity

Complex classes like bika.lims.browser.worksheet.views.analyses 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 collections
22
from operator import itemgetter
23
24
from bika.lims import api
25
from bika.lims import bikaMessageFactory as _
26
from bika.lims import logger
27
from bika.lims.api.security import check_permission
28
from bika.lims.browser.analyses import AnalysesView as BaseView
29
from bika.lims.interfaces import IDuplicateAnalysis
30
from bika.lims.interfaces import IReferenceAnalysis
31
from bika.lims.interfaces import IRoutineAnalysis
32
from bika.lims.permissions import FieldEditAnalysisRemarks
33
from bika.lims.utils import get_image
34
from bika.lims.utils import t
35
from bika.lims.utils import to_int
36
from plone.memoize import view
37
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
38
from senaite.core.registry import get_registry_record
39
40
41
class AnalysesView(BaseView):
42
    """Manage Results View for Worksheet Analyses
43
    """
44
45
    def __init__(self, context, request):
46
        super(AnalysesView, self).__init__(context, request)
47
48
        self.context = context
49
        self.request = request
50
51
        self.analyst = None
52
        self.instrument = None
53
54
        self.contentFilter = {
55
            "getWorksheetUID": api.get_uid(context),
56
            "sort_on": "sortable_title",
57
        }
58
59
        self.icon = "{}/{}".format(
60
            self.portal_url,
61
            "senaite_theme/icon/worksheet"
62
        )
63
64
        self.allow_edit = True
65
        self.show_categories = False
66
        self.expand_all_categories = False
67
        self.show_search = False
68
69
        self.bika_setup = api.get_bika_setup()
70
        self.uids_strpositions = self.get_uids_strpositions()
71
        self.items_rowspans = dict()
72
73
        self.columns = collections.OrderedDict((
74
            ("Pos", {
75
                "sortable": False,
76
                "title": _("Position")}),
77
            ("Service", {
78
                "sortable": False,
79
                "title": _("Analysis")}),
80
            ("DetectionLimitOperand", {
81
                "title": _("DL"),
82
                "sortable": False,
83
                "ajax": True,
84
                "autosave": True,
85
                "toggle": False}),
86
            ("Result", {
87
                "title": _("Result"),
88
                "ajax": True,
89
                "sortable": False}),
90
            ("Uncertainty", {
91
                "sortable": False,
92
                "title": _("+-")}),
93
            ("Specification", {
94
                "title": _("Specification"),
95
                "sortable": False}),
96
            ("retested", {
97
                "title": get_image("retested.png", title=t(_("Retested"))),
98
                "toggle": False,
99
                "type": "boolean"}),
100
            ("Method", {
101
                "sortable": False,
102
                "ajax": True,
103
                "on_change": "_on_method_change",
104
                "title": _("Method")}),
105
            ("Instrument", {
106
                "sortable": False,
107
                "ajax": True,
108
                "title": _("Instrument")}),
109
            ("Attachments", {
110
                "sortable": False,
111
                "title": _("Attachments")}),
112
            ("DueDate", {
113
                "sortable": False,
114
                "title": _("Due Date")}),
115
            ("state_title", {
116
                "sortable": False,
117
                "title": _("State")}),
118
        ))
119
120
        # Inject Remarks column for listing
121
        if self.is_analysis_remarks_enabled():
122
            self.columns["Remarks"] = {
123
                "title": "Remarks",
124
                "ajax": True,
125
                "toggle": False,
126
                "sortable": False,
127
                "type": "remarks"
128
            }
129
130
        self.set_analysis_remarks_modal = {
131
            "id": "modal_set_analysis_remarks",
132
            "title": _("Set remarks"),
133
            "url": "{}/set_analysis_remarks_modal".format(
134
                api.get_url(self.context)),
135
            "css_class": "btn btn-outline-secondary",
136
            "help": _("Set remarks for selected analyses")
137
        }
138
139
        self.review_states = [
140
            {
141
                "id": "default",
142
                "title": _("All"),
143
                "contentFilter": {},
144
                "custom_transitions": [],
145
                "columns": self.columns.keys(),
146
                "confirm_messages": {
147
                    "reject": _(
148
                        "This operation can not be undone. Are you sure "
149
                        "you want to reject the selected analyses?"
150
                    )
151
                }
152
            },
153
        ]
154
155
    def update(self):
156
        super(AnalysesView, self).update()
157
        self.reorder_analysis_columns()
158
159
    def before_render(self):
160
        super(AnalysesView, self).before_render()
161
162
        if self.show_analysis_remarks_transition():
163
            for state in self.review_states:
164
                state["custom_transitions"] = [self.set_analysis_remarks_modal]
165
166
    @view.memoize
167
    def get_default_columns_order(self):
168
        """Return the default column order from the registry
169
170
        :returns: List of column keys
171
        """
172
        name = "worksheetview_analysis_columns_order"
173
        columns_order = get_registry_record(name, default=[]) or []
174
        # Always put `Pos` column first
175
        try:
176
            columns_order.remove("Pos")
177
        except ValueError:
178
            pass
179
        columns_order.insert(0, "Pos")
180
        return columns_order
181
182
    def show_analysis_remarks_transition(self):
183
        """Check if the analysis remarks transitions should be rendered
184
185
        XXX: Convert maybe better to a real WF transition with a guard
186
        """
187
        # Disable analysis remarks transition when global analysis remarks are disabled
188
        if not self.is_analysis_remarks_enabled():
189
            return False
190
        for analysis in self.context.getAnalyses():
191
            if check_permission(FieldEditAnalysisRemarks, analysis):
192
                return True
193
        return False
194
195
    @view.memoize
196
    def is_analysis_remarks_enabled(self):
197
        """Check if analysis remarks are enabled
198
        """
199
        setup = api.get_setup()
200
        return setup.getEnableAnalysisRemarks()
201
202
    def isItemAllowed(self, obj):
203
        """Returns true if the current analysis to be rendered has a slot
204
        assigned for the current layout.
205
206
        :param obj: analysis to be rendered as a row in the list
207
        :type obj: ATContentType/DexterityContentType
208
        :return: True if the obj has an slot assigned. Otherwise, False.
209
        :rtype: bool
210
        """
211
        uid = api.get_uid(obj)
212
        if not self.get_item_slot(uid):
213
            logger.warning("Slot not assigned to item %s" % uid)
214
            return False
215
        return BaseView.isItemAllowed(self, obj)
216
217
    def folderitem(self, obj, item, index):
218
        """Applies new properties to the item (analysis) that is currently
219
        being rendered as a row in the list.
220
221
        :param obj: analysis to be rendered as a row in the list
222
        :param item: dict representation of the analysis, suitable for the list
223
        :param index: current position of the item within the list
224
        :type obj: ATContentType/DexterityContentType
225
        :type item: dict
226
        :type index: int
227
        :return: the dict representation of the item
228
        :rtype: dict
229
        """
230
        item = super(AnalysesView, self).folderitem(obj, item, index)
231
        item_obj = api.get_object(obj)
232
        uid = item["uid"]
233
234
        # Slot is the row position where all analyses sharing the same parent
235
        # (eg. AnalysisRequest, SampleReference), will be displayed as a group
236
        slot = self.get_item_slot(uid)
237
        item["Pos"] = slot
238
239
        # The position string contains both the slot + the position of the
240
        # analysis within the slot: "position_sortkey" will be used to sort all
241
        # the analyses to be displayed in the list
242
        str_position = self.uids_strpositions[uid]
243
        item["pos_sortkey"] = str_position
244
245
        item["colspan"] = {"Pos": 1}
246
        item["Service"] = item_obj.Title()
247
        item["Category"] = item_obj.getCategoryTitle()
248
        item["DueDate"] = self.ulocalized_time(item_obj, long_format=0)
249
        item["class"]["Service"] = "service_title"
250
251
        # To prevent extra loops, we compute here the number of analyses to be
252
        # rendered within each slot. This information will be useful later for
253
        # applying rowspan to the first cell of each slot, that contains info
254
        # about the parent of all the analyses contained in that slot (e.g
255
        # Analysis Request ID, Sample Type, etc.)
256
        rowspans = self.items_rowspans.get(slot, 0) + 1
257
258
        remarks_enabled = self.is_analysis_remarks_enabled()
259
        if remarks_enabled:
260
            # Increase in one unit the rowspan, cause the comment field for
261
            # this analysis will be rendered in a new row, below the row that
262
            # displays the current item
263
            rowspans = rowspans + 1
264
        # We map this rowspan information in items_rowspan, that will be used
265
        # later during the rendereing of slot headers (first cell of each row)
266
        self.items_rowspans[slot] = rowspans
267
268
        return item
269
270
    def folderitems(self):
271
        """Returns an array of dictionaries, each dictionary represents an
272
        analysis row to be rendered in the list. The array returned is sorted
273
        in accordance with the layout positions set for the analyses this
274
        worksheet contains when the analyses were added in the worksheet.
275
276
        :returns: list of dicts with the items to be rendered in the list
277
        """
278
        items = BaseView.folderitems(self)
279
280
        # Fill empty positions from the layout with fake rows. The worksheet
281
        # can be generated by making use of a WorksheetTemplate, so there is
282
        # the chance that some slots of this worksheet being empty. We need to
283
        # render a row still, at lest to display the slot number (Pos)
284
        self.fill_empty_slots(items)
285
286
        # Sort the items in accordance with the layout
287
        items = sorted(items, key=itemgetter("pos_sortkey"))
288
289
        # Fill the slot header cells (first cell of each row). Each slot
290
        # contains the analyses that belong to the same parent
291
        # (AnalysisRequest, ReferenceSample), so the information about the
292
        # parent must be displayed in the first cell of each slot.
293
        self.fill_slots_headers(items)
294
295
        return items
296
297
    def get_uids_strpositions(self):
298
        """Returns a dict with the positions of each analysis within the
299
        current worksheet in accordance with the current layout. The key of the
300
        dict is the uid of the analysis and the value is an string
301
        representation of the full position of the analysis within the list:
302
303
            {<analysis_uid>: '<slot_number>:<position_within_slot>',}
304
305
        :returns: a dictionary with the full position within the worksheet of
306
                  all analyses defined in the current layout.
307
        """
308
        uids_positions = dict()
309
        layout = self.context.getLayout()
310
        layout = layout and layout or []
311
        # Map the analysis uids with their positions.
312
        occupied = []
313
        next_positions = {}
314
        for item in layout:
315
            uid = item.get("analysis_uid", "")
316
            slot = int(item["position"])
317
            occupied.append(slot)
318
            position = next_positions.get(slot, 1)
319
            str_position = "{:010}:{:010}".format(slot, position)
320
            next_positions[slot] = position + 1
321
            uids_positions[uid] = str_position
322
323
        # Fill empties
324
        last_slot = max(occupied) if occupied else 1
325
        empties = [num for num in range(1, last_slot) if num not in occupied]
326
        for empty_slot in empties:
327
            str_position = "{:010}:{:010}".format(empty_slot, 1)
328
            uid = "empty-{}".format(empty_slot)
329
            uids_positions[uid] = str_position
330
331
        return uids_positions
332
333
    def get_item_position(self, analysis_uid):
334
        """Returns a list with the position for the analysis_uid passed in
335
        within the current worksheet in accordance with the current layout,
336
        where the first item from the list returned is the slot and the second
337
        is the position of the analysis within the slot.
338
339
        :param analysis_uid: uid of the analysis the position is requested
340
        :return: the position (slot + position within slot) of the analysis
341
        :rtype: list
342
        """
343
        str_position = self.uids_strpositions.get(analysis_uid, "")
344
        tokens = str_position.split(":")
345
        if len(tokens) != 2:
346
            return None
347
        return [to_int(tokens[0]), to_int(tokens[1])]
348
349
    def get_item_slot(self, analysis_uid):
350
        """Returns the slot number where the analysis must be rendered. An slot
351
        contains all analyses that belong to the same parent (AnalysisRequest,
352
        ReferenceSample).
353
354
        :param analysis_uid: the uid of the analysis the slot is requested
355
        :return: the slot number where the analysis must be rendered
356
        """
357
        position = self.get_item_position(analysis_uid)
358
        if not position:
359
            return None
360
        return position[0]
361
362
    def _get_slots(self, empty_uid=False):
363
        """Returns a list with the position number of the slots that are
364
        occupied (if empty_uid=False) or are empty (if empty_uid=True)
365
366
        :param empty_uid: True exclude occupied slots. False excludes empties
367
        :return: sorted list with slot numbers
368
        """
369
        slots = list()
370
        for uid, position in self.uids_strpositions.items():
371
            if empty_uid and not uid.startswith("empty-"):
372
                continue
373
            elif not empty_uid and uid.startswith("empty-"):
374
                continue
375
            tokens = position.split(":")
376
            slots.append(to_int(tokens[0]))
377
        return sorted(list(set(slots)))
378
379
    def get_occupied_slots(self):
380
        """Returns the list of occupied slots, those that have at least one
381
        analysis assigned according to the current layout. Delegates to
382
        self._get_slots(empty_uid=False)
383
384
        :return: list of slot numbers that at least have one analysis assigned
385
        """
386
        return self._get_slots(empty_uid=False)
387
388
    def get_empty_slots(self):
389
        """Returns the list of empty slots, those that don't have any analysis
390
        assigned according to the current layout.
391
392
        Delegates to self._get_slots(empty_uid=True)
393
394
        :return: list of slot numbers that don't have any analysis assigned
395
        """
396
        return self._get_slots(empty_uid=True)
397
398
    def fill_empty_slots(self, items):
399
        """Append dicts to the items passed in for those slots that don't have
400
        any analysis assigned but the row needs to be rendered still.
401
402
        :param items: dictionary with the items to be rendered in the list
403
        """
404
        for pos in self.get_empty_slots():
405
            item = {
406
                "obj": self.context,
407
                "id": self.context.id,
408
                "uid": self.context.UID(),
409
                "title": self.context.Title(),
410
                "type_class": "blank-worksheet-row",
411
                "url": self.context.absolute_url(),
412
                "relative_url": self.context.absolute_url(),
413
                "view_url": self.context.absolute_url(),
414
                "path": "/".join(self.context.getPhysicalPath()),
415
                "before": {},
416
                "after": {},
417
                "replace": {
418
                    "Pos": "<span class='badge'>{}</span> {}".format(
419
                        pos, _("Reassignable Slot"))
420
                },
421
                "choices": {},
422
                "class": {},
423
                "state_class": "state-empty",
424
                "allow_edit": [],
425
                "Pos": pos,
426
                "pos_sortkey": "{:010}:{:010}".format(pos, 1),
427
                "Service": "",
428
                "Attachments": "",
429
                "state_title": "",
430
                "disabled": True,
431
            }
432
433
            items.append(item)
434
435
    def fill_slots_headers(self, items):
436
        """Generates the header cell for each slot. For each slot, the first
437
        cell displays information about the parent all analyses within that
438
        given slot have in common, such as the AR Id, SampleType, etc.
439
440
        :param items: dictionary with items to be rendered in the list
441
        """
442
        prev_position = 0
443
        for item in items:
444
            item_position = item["Pos"]
445
            if item_position == prev_position:
446
                item = self.skip_item_key(item, "Pos")
447
                # head slot already filled
448
                continue
449
            if item.get("disabled", False):
450
                # empty slot
451
                continue
452
453
            # This is the first analysis found for the given position, add the
454
            # slot info in there and apply a rowspan accordingly.
455
            rowspan = self.items_rowspans.get(item_position, 1)
456
            prev_position = item_position
457
            item["rowspan"] = {"Pos": rowspan}
458
            item["replace"]["Pos"] = self.get_slot_header(item)
459
460
    def skip_item_key(self, item, key):
461
        """Add the key to the item's "skip" list
462
        """
463
        if "skip" in item:
464
            item["skip"].append(key)
465
        else:
466
            item["skip"] = [key]
467
        return item
468
469
    def get_slot_header(self, item):
470
        """Generates a slot header (the first cell of the row) for the item
471
472
        :param item: the item for which the slot header is requested
473
        :return: the html contents to be displayed in the first cell of a slot
474
        """
475
        obj = item["obj"]
476
        obj = api.get_object(obj)
477
478
        # Prepare the template data
479
        data = {
480
            "obj": obj,
481
            "item": item,
482
            "position": item["Pos"],
483
        }
484
        # update the data
485
        data.update(self.get_slot_header_data(obj))
486
487
        template = ViewPageTemplateFile("../templates/slot_header.pt")
488
        return template(self, data=data)
489
490
    def get_slot_header_data(self, obj):
491
        """Prepare the data for the slot header template
492
        """
493
494
        item_obj = None
495
        item_title = ""
496
        item_url = ""
497
        item_img = ""
498
        item_img_url = ""
499
        item_img_text = ""
500
        additional_item_icons = []
501
502
        parent_obj = None
503
        parent_title = ""
504
        parent_url = ""
505
        parent_img = ""
506
        parent_img_text = ""
507
        additional_parent_icons = []
508
509
        sample_type_obj = None
510
        sample_type_title = ""
511
        sample_type_url = ""
512
        sample_type_img = ""
513
        sample_type_img_text = ""
514
515
        if IDuplicateAnalysis.providedBy(obj):
516
            # item
517
            request = obj.getRequest()
518
            item_obj = request
519
            item_title = api.get_id(request)
520
            item_url = api.get_url(request)
521
            item_img = "duplicate.png"
522
            item_img_url = api.get_url(request)
523
            item_img_text = t(_("Duplicate"))
524
            # additional item icons
525
            additional_item_icons.append(
526
                self.render_remarks_tag(request))
527
            # parent
528
            client = request.getClient()
529
            parent_obj = client
530
            parent_title = api.get_title(client)
531
            parent_url = api.get_url(client)
532
            parent_img = "client.png"
533
            parent_img_text = t(_("Client"))
534
            # sample type
535
            sample_type = request.getSampleType()
536
            sample_type_title = request.getSampleTypeTitle()
537
            sample_type_url = api.get_url(sample_type)
538
            sample_type_img = "sampletype.png"
539
            sample_type_img_text = t(_("Sample Type"))
540
        elif IReferenceAnalysis.providedBy(obj):
541
            # item
542
            sample = obj.getSample()
543
            item_obj = sample
544
            obj_id = api.get_id(obj)
545
            sample_id = api.get_id(sample)
546
            item_title = "%s (%s)" % (obj_id, sample_id)
547
            item_url = api.get_url(sample)
548
            item_img_url = api.get_url(sample)
549
            item_img = "control.png"
550
            item_img_text = t(_("Control"))
551
            if obj.getReferenceType() == "b":
552
                item_img = "blank.png"
553
                item_img_text = t(_("Blank"))
554
            # parent
555
            supplier = obj.getSupplier()
556
            parent_obj = supplier
557
            parent_title = api.get_title(supplier)
558
            parent_url = api.get_url(supplier)
559
            parent_img = "supplier.png"
560
            parent_img_text = t(_("Supplier"))
561
        elif IRoutineAnalysis.providedBy(obj):
562
            # item
563
            request = obj.getRequest()
564
            item_obj = request
565
            item_title = api.get_id(request)
566
            item_url = api.get_url(request)
567
            item_img = "sample.png"
568
            item_img_url = api.get_url(request)
569
            item_img_text = t(_("Sample"))
570
            # additional item icons
571
            additional_item_icons.append(
572
                self.render_remarks_tag(request))
573
            # parent
574
            client = obj.getClient()
575
            parent_obj = client
576
            parent_title = api.get_title(client)
577
            parent_url = api.get_url(client)
578
            parent_img = "client.png"
579
            parent_img_text = t(_("Client"))
580
            # sample type
581
            sample_type = obj.getSampleType()
582
            sample_type_title = obj.getSampleTypeTitle()
583
            sample_type_url = api.get_url(sample_type)
584
            sample_type_img = "sampletype.png"
585
            sample_type_img_text = t(_("Sample Type"))
586
587
        return {
588
            # item
589
            "item_obj": item_obj,
590
            "item_title": item_title,
591
            "item_url": item_url,
592
            "item_img": get_image(item_img, title=item_img_text),
593
            "item_img_url": item_img_url,
594
            "additional_item_icons": additional_item_icons,
595
            # parent
596
            "parent_obj": parent_obj,
597
            "parent_title": parent_title,
598
            "parent_url": parent_url,
599
            "parent_img": get_image(parent_img, title=parent_img_text),
600
            "additional_parent_icons": additional_parent_icons,
601
            # sample type
602
            "sample_type_obj": sample_type_obj,
603
            "sample_type_title": sample_type_title,
604
            "sample_type_url": sample_type_url,
605
            "sample_type_img": get_image(
606
                sample_type_img, title=sample_type_img_text),
607
        }
608
609
    def render_remarks_tag(self, ar):
610
        """Renders a remarks image icon
611
        """
612
        if not ar.getRemarks():
613
            return ""
614
615
        uid = api.get_uid(ar)
616
        url = ar.absolute_url()
617
        title = ar.Title()
618
        tooltip = _("Remarks of {}").format(title)
619
620
        # Note: The 'href' is picked up by the overlay handler, see
621
        #       bika.lims.worksheet.coffee
622
        attrs = {
623
            "css_class": "slot-remarks",
624
            "style": "cursor: pointer;",
625
            "title": tooltip,
626
            "uid": uid,
627
            "href": "{}/base_view".format(url),
628
        }
629
630
        return get_image("remarks.png", **attrs)
631