Passed
Push — master ( 2b9191...4a8725 )
by Jordi
04:32
created

bika.lims.browser.listing.ajax.cache_transitions()   A

Complexity

Conditions 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 5
dl 0
loc 8
rs 10
c 0
b 0
f 0
cc 1
nop 3
1
# -*- coding: utf-8 -*-
2
3
import inspect
4
import json
5
import urllib
6
7
from bika.lims import api
8
from bika.lims import logger
9
from bika.lims.browser import BrowserView
10
from bika.lims.browser.listing.decorators import inject_runtime
11
from bika.lims.browser.listing.decorators import returns_safe_json
12
from bika.lims.browser.listing.decorators import set_application_json_header
13
from bika.lims.browser.listing.decorators import translate
14
from bika.lims.interfaces import IReferenceAnalysis
15
from bika.lims.interfaces import IRoutineAnalysis
16
from plone.memoize.volatile import cache
17
from plone.memoize.volatile import store_on_context
18
from Products.Archetypes.utils import mapply
19
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
20
from zope.interface import implements
21
from zope.lifecycleevent import modified
22
from zope.publisher.interfaces import IPublishTraverse
23
24
25
def cache_transitions(method, view, obj):
26
    """Cache key for the possible transitions
27
    """
28
    keys = [
29
        api.get_uid(obj),
30
        api.get_workflow_status_of(obj),
31
    ]
32
    return "-".join(keys)
33
34
35
class AjaxListingView(BrowserView):
36
    """Mixin Class for the ajax enabled listing table
37
38
    The main purpose of this class is to provide a JSON API endpoint for the
39
    ReactJS based listing table.
40
    """
41
    implements(IPublishTraverse)
42
    contents_table_template = ViewPageTemplateFile(
43
        "templates/contents_table.pt")
44
45
    def __init__(self, context, request):
46
        super(AjaxListingView, self).__init__(context, request)
47
        self.traverse_subpath = []
48
49
    def ajax_contents_table(self, *args, **kwargs):
50
        """Render the ReactJS enabled contents table template
51
52
        It is called from the `BikaListingView.contents_table` method
53
        """
54
        return self.contents_table_template()
55
56
    def publishTraverse(self, request, name):
57
        """Called before __call__ for each path name and allows to dispatch
58
        subpaths to methods
59
        """
60
        self.traverse_subpath.append(name)
61
        return self
62
63
    def handle_subpath(self, prefix="ajax_"):
64
        """Dispatch the subpath to a method prefixed with the given prefix
65
66
        N.B. Only the first subpath element will be dispatched to a method and
67
             the rest will be passed as arguments to this method
68
        """
69
        if len(self.traverse_subpath) < 1:
70
            return {}
71
72
        # check if the method exists
73
        func_arg = self.traverse_subpath[0]
74
        func_name = "{}{}".format(prefix, func_arg)
75
        func = getattr(self, func_name, None)
76
        if func is None:
77
            raise NameError("Invalid function name")
78
79
        # Additional provided path segments after the function name are handled
80
        # as positional arguments
81
        args = self.traverse_subpath[1:]
82
83
        # check mandatory arguments
84
        func_sig = inspect.getargspec(func)
85
        # positional arguments after `self` argument
86
        required_args = func_sig.args[1:]
87
88
        if len(args) < len(required_args):
89
            raise ValueError("Wrong signature, please use '{}/{}'"
90
                             .format(func_arg, "/".join(required_args)))
91
        return func(*args)
92
93
    def get_json(self, encoding="utf8"):
94
        """Extracts the JSON from the request
95
        """
96
        body = self.request.get("BODY", "{}")
97
98
        def encode_hook(pairs):
99
            """This hook is called for dicitionaries on JSON deserialization
100
101
            It is used to encode unicode strings with the given encoding,
102
            because ZCatalogs have sometimes issues with unicode queries.
103
            """
104
            new_pairs = []
105
            for key, value in pairs.iteritems():
106
                # Encode the key
107
                if isinstance(key, unicode):
108
                    key = key.encode(encoding)
109
                # Encode the value
110
                if isinstance(value, unicode):
111
                    value = value.encode(encoding)
112
                new_pairs.append((key, value))
113
            return dict(new_pairs)
114
115
        return json.loads(body, object_hook=encode_hook)
116
117
    def error(self, message, status=500, **kw):
118
        """Set a JSON error object and a status to the response
119
        """
120
        self.request.response.setStatus(status)
121
        result = {"success": False, "errors": message, "status": status}
122
        result.update(kw)
123
        return result
124
125
    @translate
126
    def get_columns(self):
127
        """Returns the `columns` dictionary of the view
128
        """
129
        return self.columns
130
131
    @translate
132
    def get_review_states(self):
133
        """Returns the `review_states` list of the view
134
        """
135
        return self.review_states
136
137
    def to_form_data(self, data):
138
        """Prefix all data keys with `self.form_id`
139
140
        This is needed to inject the POST Body to the request form data, so
141
        that the `BikaListingView.folderitems` finds them.
142
        """
143
        out = {}
144
        for key, value in data.iteritems():
145
            if not key.startswith(self.form_id):
146
                k = "{}_{}".format(self.form_id, key)
147
            out[k] = value
148
        return out
149
150
    def get_api_url(self):
151
        """Calculate the API URL of this view
152
        """
153
        url = self.context.absolute_url()
154
        view_name = self.__name__
155
        return "{}/{}".format(url, view_name)
156
157
    @cache(cache_transitions, store_on_context)
158
    def get_transitions_for(self, obj):
159
        """Get the allowed transitions for the given object
160
        """
161
        return api.get_transitions_for(obj)
162
163
    def get_allowed_transitions_for(self, uids):
164
        """Retrieves all transitions from the given UIDs and calculate the
165
        ones which have all in common (intersection).
166
        """
167
168
        # Handle empty list of objects
169
        if not uids:
170
            return []
171
172
        # allowed transitions
173
        transitions = []
174
175
        # allowed transitions of the current workflow
176
        allowed_transitions = self.review_state.get("transitions", [])
177
        allowed_transition_ids = map(
178
            lambda t: t.get("id"), allowed_transitions)
179
180
        # internal mapping of transition id -> transition
181
        transitions_by_tid = {}
182
183
        # get the custom transitions of the current review_state
184
        custom_transitions = self.review_state.get("custom_transitions", [])
185
186
        # N.B. we use set() here to handle dupes in the views gracefully
187
        custom_tids = set()
188
        for transition in custom_transitions:
189
            tid = transition.get("id")
190
            custom_tids.add(tid)
191
            transitions_by_tid[tid] = transition
192
193
        # transition ids all objects have in common
194
        common_tids = set()
195
196
        for uid in uids:
197
            # TODO: Research how to avoid the object wakeup here
198
            obj = api.get_object_by_uid(uid)
199
            obj_transitions = self.get_transitions_for(obj)
200
            # skip objects w/o transitions, e.g. retracted/rejected analyses
201
            if not obj_transitions:
202
                continue
203
            tids = []
204
            for transition in obj_transitions:
205
                tid = transition.get("id")
206
                if allowed_transition_ids:
207
                    if tid not in allowed_transition_ids:
208
                        continue
209
                    tids.append(tid)
210
                else:
211
                    # no restrictions by the selected review_state
212
                    tids.append(tid)
213
                transitions_by_tid[tid] = transition
214
215
            if not common_tids:
216
                common_tids = set(tids)
217
218
            common_tids = common_tids.intersection(tids)
219
220
        # union the common and custom transitions
221
        all_transition_ids = common_tids.union(custom_tids)
222
223
        def sort_transitions(a, b):
224
            transition_weights = {
225
                "invalidate": 100,
226
                "retract": 90,
227
                "reject": 90,
228
                "cancel": 80,
229
                "deactivate": 70,
230
                "unassign": 70,
231
                "publish": 60,
232
                "republish": 50,
233
                "prepublish": 50,
234
                "partition": 40,
235
                "assign": 30,
236
                "receive": 20,
237
                "submit": 10,
238
            }
239
            w1 = transition_weights.get(a, 0)
240
            w2 = transition_weights.get(b, 0)
241
            return cmp(w1, w2)
242
243
        for tid in sorted(all_transition_ids, cmp=sort_transitions):
244
            transition = transitions_by_tid.get(tid)
245
            transitions.append(transition)
246
247
        return transitions
248
249
    def get_category_uid(self, brain_or_object, accessor="getCategoryUID"):
250
        """Get the category UID from the brain or object
251
252
        This will be used to speed up the listing by categories
253
        """
254
        attr = getattr(brain_or_object, accessor, None)
255
        if attr is None:
256
            return ""
257
        if callable(attr):
258
            return attr()
259
        return attr
260
261
    @returns_safe_json
262
    def ajax_review_states(self):
263
        """Returns the `review_states` list of the view as JSON
264
        """
265
        return self.get_review_states()
266
267
    @returns_safe_json
268
    def ajax_columns(self):
269
        """Returns the `columns` dictionary of the view as JSON
270
        """
271
        return self.get_columns()
272
273
    @translate
274
    def get_folderitems(self):
275
        """This method calls the folderitems method
276
        """
277
        # workaround for `pagesize` handling in BikaListing
278
        pagesize = self.get_pagesize()
279
        self.pagesize = pagesize
280
281
        # get the folderitems
282
        self.update()
283
        self.before_render()
284
285
        return self.folderitems()
286
287
    def get_selected_uids(self, folderitems, uids_to_keep=None):
288
        """Lookup selected UIDs from the folderitems
289
        """
290
        selected_uids = []
291
        if uids_to_keep:
292
            selected_uids = uids_to_keep
293
294
        for folderitem in folderitems:
295
            uid = folderitem.get("uid")
296
            if uid in selected_uids:
297
                continue
298
            if folderitem.get("selected", False):
299
                selected_uids.append(folderitem.get("uid"))
300
        return selected_uids
301
302
    def get_listing_config(self):
303
        """Get the configuration settings of the current listing view
304
        """
305
306
        config = {
307
            "form_id": self.form_id,
308
            "review_states": self.get_review_states(),
309
            "columns": self.get_columns(),
310
            "content_filter": self.contentFilter,
311
            "allow_edit": self.allow_edit,
312
            "api_url": self.get_api_url(),
313
            "catalog": self.catalog,
314
            "catalog_indexes": self.get_catalog_indexes(),
315
            "categories": self.categories,
316
            "expand_all_categories": self.expand_all_categories,
317
            "limit_from": self.limit_from,
318
            "pagesize": self.pagesize,
319
            "post_action": self.getPOSTAction(),
320
            "review_state": self.review_state.get("id", "default"),
321
            "select_checkbox_name": self.select_checkbox_name,
322
            "show_categories": self.show_categories,
323
            "show_column_toggles": self.show_column_toggles,
324
            "show_more": self.show_more,
325
            "show_select_all_checkbox": self.show_select_all_checkbox,
326
            "show_select_column": self.show_select_column,
327
            "show_table_footer": self.show_table_footer,
328
            "show_workflow_action_buttons": self.show_workflow_action_buttons,
329
            "sort_on": self.get_sort_on(),
330
            "sort_order": self.get_sort_order(),
331
            "show_search": self.show_search,
332
            "fetch_transitions_on_select": self.fetch_transitions_on_select,
333
        }
334
335
        return config
336
337
    def recalculate_results(self, obj, recalculated=None):
338
        """Recalculate the result of the object and its dependents
339
340
        :returns: List of recalculated objects
341
        """
342
343
        if recalculated is None:
344
            recalculated = set()
345
346
        # avoid double recalculation in recursion
347
        if obj in recalculated:
348
            return set()
349
350
        # recalculate own result
351
        if obj.calculateResult(override=True):
352
            # append object to the list of recalculated results
353
            recalculated.add(obj)
354
        # recalculate dependent analyses
355
        for dep in obj.getDependents():
356
            if dep.calculateResult(override=True):
357
                # TODO the `calculateResult` method should return False here!
358
                if dep.getResult() in ["NA", "0/0"]:
359
                    continue
360
                recalculated.add(dep)
361
            # recalculate dependents of dependents
362
            for ddep in dep.getDependents():
363
                recalculated.update(
364
                    self.recalculate_results(
365
                        ddep, recalculated=recalculated))
366
        return recalculated
367
368
    def is_analysis(self, obj):
369
        """Check if the object is an analysis
370
        """
371
        if IRoutineAnalysis.providedBy(obj):
372
            return True
373
        if IReferenceAnalysis.providedBy(obj):
374
            return True
375
        return False
376
377
    def lookup_schema_field(self, obj, fieldname):
378
        """Lookup  a schema field by name
379
380
        :returns: Schema field or None
381
        """
382
        # Lookup the field by the given name
383
        field = obj.getField(fieldname)
384
        if field is None:
385
            # strip "get" from the fieldname
386
            if fieldname.startswith("get"):
387
                fieldname = fieldname.lstrip("get")
388
                field = obj.get(fieldname)
389
        return field
390
391
    def is_field_writeable(self, obj, field):
392
        """Checks if the field is writeable
393
        """
394
        if isinstance(field, basestring):
395
            field = obj.getField(field)
396
        return field.writeable(obj)
397
398
    def set_field(self, obj, name, value):
399
        """Set the value
400
401
        :returns: List of updated/changed objects
402
        """
403
404
        # set of updated objects
405
        updated_objects = set()
406
407
        # lookup the schema field
408
        field = self.lookup_schema_field(obj, name)
409
410
        # field exists, set it with the value
411
        if field:
412
413
            # Check the permission of the field
414
            if not self.is_field_writeable(obj, field):
415
                logger.error("Field '{}' not writeable!".format(name))
416
                return []
417
418
            # get the field mutator (works only for AT content types)
419
            if hasattr(field, "getMutator"):
420
                mutator = field.getMutator(obj)
421
                mapply(mutator, value)
422
            else:
423
                # Set the value on the field directly
424
                field.set(obj, value)
425
426
            updated_objects.add(obj)
427
428
        # check if the object is an analysis and has an interim
429
        if self.is_analysis(obj):
430
431
            # check the permission of the interims field
432
            if not self.is_field_writeable(obj, "InterimFields"):
433
                logger.error("Field 'InterimFields' not writeable!")
434
                return []
435
436
            interims = obj.getInterimFields()
437
            interim_keys = map(lambda i: i.get("keyword"), interims)
438
            if name in interim_keys:
439
                for interim in interims:
440
                    if interim.get("keyword") == name:
441
                        interim["value"] = value
442
                # set the new interim fields
443
                obj.setInterimFields(interims)
444
445
            # recalculate dependent results for result and interim fields
446
            if name == "Result" or name in interim_keys:
447
                updated_objects.add(obj)
448
                updated_objects.update(self.recalculate_results(obj))
449
450
        # unify the list of updated objects
451
        updated_objects = list(updated_objects)
452
453
        # reindex the objects
454
        map(lambda obj: obj.reindexObject(), updated_objects)
455
456
        # notify that the objects were modified
457
        map(modified, updated_objects)
458
459
        return updated_objects
460
461
    def get_children_hook(self, parent_uid, child_uids=None):
462
        """Custom hook to be implemented by subclass
463
        """
464
        raise NotImplementedError("Must be implemented by subclass")
465
466
    @set_application_json_header
467
    @returns_safe_json
468
    @inject_runtime
469
    def ajax_folderitems(self):
470
        """Calls the `folderitems` method of the view and returns it as JSON
471
472
        1. Extract the HTTP POST payload from the request
473
        2. Convert the payload to HTTP form data and inject it to the request
474
        3. Call the `folderitems` method
475
        4. Prepare a data structure for the ReactJS listing app
476
        """
477
478
        # Get the HTTP POST JSON Payload
479
        payload = self.get_json()
480
481
        # Fake a HTTP GET request with parameters, so that the `bika_listing`
482
        # view handles them correctly.
483
        form_data = self.to_form_data(payload)
484
485
        # this serves `request.form.get` calls
486
        self.request.form.update(form_data)
487
488
        # this serves `request.get` calls
489
        self.request.other.update(form_data)
490
491
        # generate a query string from the form data
492
        query_string = urllib.urlencode(form_data)
493
494
        # get the folder items
495
        folderitems = self.get_folderitems()
496
497
        # Process selected UIDs and their allowed transitions
498
        uids_to_keep = payload.get("selected_uids")
499
        selected_uids = self.get_selected_uids(folderitems, uids_to_keep)
500
        transitions = self.get_allowed_transitions_for(selected_uids)
501
502
        # get the view config
503
        config = self.get_listing_config()
504
505
        # prepare the response object
506
        data = {
507
            "count": len(folderitems),
508
            "folderitems": folderitems,
509
            "query_string": query_string,
510
            "selected_uids": selected_uids,
511
            "total": self.total,
512
            "transitions": transitions,
513
        }
514
515
        # update the config
516
        data.update(config)
517
518
        # XXX fix broken `sort_on` lookup in BikaListing
519
        sort_on = payload.get("sort_on")
520
        if sort_on in self.get_catalog_indexes():
521
            data["sort_on"] = sort_on
522
523
        return data
524
525
    @set_application_json_header
526
    @returns_safe_json
527
    @inject_runtime
528
    def ajax_transitions(self):
529
        """Returns a list of possible transitions
530
        """
531
        # Get the HTTP POST JSON Payload
532
        payload = self.get_json()
533
534
        # Get the selected UIDs
535
        uids = payload.get("selected_uids", [])
536
537
        # ----------------------------------8<---------------------------------
538
        # XXX Temporary (cut out as soon as possible)
539
        #
540
        # Some listings inject custom transitions before rendering the
541
        # folderitems, e.g. the worksheets folder listing view.
542
        # This can be removed as soon as all the relevant permission checks are
543
        # done on the object only and not by manual role checking in the view.
544
545
        # Fake a HTTP GET request with parameters, so that the `bika_listing`
546
        # view handles them correctly.
547
        form_data = self.to_form_data(payload)
548
549
        # this serves `request.form.get` calls
550
        self.request.form.update(form_data)
551
552
        # this serves `request.get` calls
553
        self.request.other.update(form_data)
554
555
        # Call the update and before_render hook, because these might modify
556
        # the allowed and custom transitions (and columns probably as well)
557
        self.update()
558
        self.before_render()
559
        # ----------------------------------8<---------------------------------
560
561
        # get the allowed transitions
562
        transitions = self.get_allowed_transitions_for(uids)
563
564
        # prepare the response object
565
        data = {
566
            "transitions": transitions,
567
        }
568
569
        return data
570
571
    @set_application_json_header
572
    @returns_safe_json
573
    @inject_runtime
574
    def ajax_get_children(self):
575
        """Get the children of a folderitem
576
577
        Required POST JSON Payload:
578
579
        :uid: UID of the parent folderitem
580
        """
581
582
        # Get the HTTP POST JSON Payload
583
        payload = self.get_json()
584
585
        # extract the parent UID
586
        parent_uid = payload.get("parent_uid")
587
        child_uids = payload.get("child_uids", [])
588
589
        try:
590
            children = self.get_children_hook(
591
                parent_uid, child_uids=child_uids)
592
        except NotImplementedError:
593
            # lookup by catalog search
594
            self.contentFilter = {"UID": child_uids}
595
            children = self.get_folderitems()
596
            # ensure the children have a reference to the parent
597
            for child in children:
598
                child["parent"] = parent_uid
599
600
        # prepare the response object
601
        data = {
602
            "children": children
603
        }
604
605
        return data
606
607
    @set_application_json_header
608
    @returns_safe_json
609
    @inject_runtime
610
    def ajax_query_folderitems(self):
611
        """Get folderitems with a catalog query
612
613
        Required POST JSON Payload:
614
615
        :query: Catalog query to use
616
        :type query: dictionary
617
        """
618
619
        # Get the HTTP POST JSON Payload
620
        payload = self.get_json()
621
622
        # extract the catalog query
623
        query = payload.get("query", {})
624
625
        valid_catalog_indexes = self.get_catalog_indexes()
626
627
        # sanity check
628
        for key, value in query.iteritems():
629
            if key not in valid_catalog_indexes:
630
                return self.error(
631
                    "{} is not a valid catalog index".format(key))
632
633
        # set the content filter
634
        self.contentFilter = query
635
636
        # get the folderitems
637
        folderitems = self.get_folderitems()
638
639
        # prepare the response object
640
        data = {
641
            "count": len(folderitems),
642
            "folderitems": folderitems,
643
        }
644
645
        return data
646
647
    @set_application_json_header
648
    @returns_safe_json
649
    @inject_runtime
650
    def ajax_set(self):
651
        """Set a value of an editable field
652
653
        The POST Payload needs to provide the following data:
654
655
        :uid: UID of the object changed
656
        :name: Column name as provided by the self.columns key
657
        :value: The value to save
658
        :item: The folderitem containing the data
659
        """
660
661
        # Get the HTTP POST JSON Payload
662
        payload = self.get_json()
663
664
        required = ["uid", "name", "value"]
665
        if not all(map(lambda k: k in payload, required)):
666
            return self.error("Payload needs to provide the keys {}"
667
                              .format(", ".join(required)), status=400)
668
669
        uid = payload.get("uid")
670
        name = payload.get("name")
671
        value = payload.get("value")
672
673
        # get the object
674
        obj = api.get_object_by_uid(uid)
675
676
        # set the field
677
        updated_objects = self.set_field(obj, name, value)
678
679
        if not updated_objects:
680
            return self.error("Failed to set field '{}'".format(name), 500)
681
682
        # get the folderitems
683
        self.contentFilter["UID"] = map(api.get_uid, updated_objects)
684
        folderitems = self.get_folderitems()
685
686
        # prepare the response object
687
        data = {
688
            "count": len(folderitems),
689
            "folderitems": folderitems,
690
        }
691
692
        return data
693
694
    @set_application_json_header
695
    @returns_safe_json
696
    @inject_runtime
697
    def ajax_set_fields(self):
698
        """Set multiple fields
699
700
        The POST Payload needs to provide the following data:
701
702
        :save_queue: A mapping of {UID: {fieldname: fieldvalue, ...}}
703
        """
704
705
        # Get the HTTP POST JSON Payload
706
        payload = self.get_json()
707
708
        required = ["save_queue"]
709
        if not all(map(lambda k: k in payload, required)):
710
            return self.error("Payload needs to provide the keys {}"
711
                              .format(", ".join(required)), status=400)
712
713
        save_queue = payload.get("save_queue")
714
715
        updated_objects = set()
716
        for uid, data in save_queue.iteritems():
717
            # get the object
718
            obj = api.get_object_by_uid(uid)
719
720
            # update the fields
721
            for name, value in data.iteritems():
722
                updated_objects.update(self.set_field(obj, name, value))
723
724
        if not updated_objects:
725
            return self.error("Failed to set field of save queue '{}'"
726
                              .format(save_queue), 500)
727
728
        # UIDs of updated objects
729
        updated_uids = map(api.get_uid, updated_objects)
730
731
        # prepare the response object
732
        data = {
733
            "uids": updated_uids,
734
        }
735
736
        return data
737