Passed
Push — 2.x ( 15b24b...cf7444 )
by Jordi
05:33
created

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

Complexity

Total Complexity 46

Size/Duplication

Total Lines 597
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 46
eloc 357
dl 0
loc 597
rs 8.72
c 0
b 0
f 0

19 Methods

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