Passed
Push — 2.x ( 488b85...c0712a )
by Jordi
06:35
created

AnalysesView.enable_remarks()   A

Complexity

Conditions 3

Size

Total Lines 22
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

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