Passed
Push — master ( ef1ceb...20aa4c )
by Jordi
06:12
created

bika.lims.browser.listing.ajax   F

Complexity

Total Complexity 66

Size/Duplication

Total Lines 572
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 66
eloc 304
dl 0
loc 572
rs 3.12
c 0
b 0
f 0

25 Methods

Rating   Name   Duplication   Size   Complexity  
A AjaxListingView.ajax_columns() 0 6 1
A AjaxListingView.ajax_review_states() 0 6 1
A AjaxListingView.get_api_url() 0 6 1
A AjaxListingView.to_form_data() 0 12 3
A AjaxListingView.get_json() 0 23 4
A AjaxListingView.publishTraverse() 0 6 1
A AjaxListingView.error() 0 7 1
A AjaxListingView.ajax_contents_table() 0 6 1
A AjaxListingView.get_transitions_for() 0 4 1
A AjaxListingView.handle_subpath() 0 29 4
A AjaxListingView.review_states_by_id() 0 5 2
A AjaxListingView.__init__() 0 3 1
A AjaxListingView.get_folderitems() 0 13 1
A AjaxListingView.get_listing_config() 0 32 1
A AjaxListingView.recalculate_results() 0 13 3
A AjaxListingView.is_analysis() 0 4 1
A AjaxListingView.get_category_uid() 0 11 3
A AjaxListingView.get_selected_uids() 0 14 5
A AjaxListingView.base_info() 0 11 1
A AjaxListingView.ajax_transitions() 0 45 1
A AjaxListingView.ajax_query_folderitems() 0 38 3
A AjaxListingView.ajax_set() 0 46 4
A AjaxListingView.ajax_folderitems() 0 58 2
C AjaxListingView.get_allowed_transitions_for() 0 78 10
C AjaxListingView.set_field() 0 42 10

How to fix   Complexity   

Complexity

Complex classes like bika.lims.browser.listing.ajax 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
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 IRoutineAnalysis
15
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
16
from zope.interface import implements
17
from zope.publisher.interfaces import IPublishTraverse
18
19
20
class AjaxListingView(BrowserView):
21
    """Mixin Class for the ajax enabled listing table
22
23
    The main purpose of this class is to provide a JSON API endpoint for the
24
    ReactJS based listing table.
25
    """
26
    implements(IPublishTraverse)
27
    contents_table_template = ViewPageTemplateFile(
28
        "templates/contents_table.pt")
29
30
    def __init__(self, context, request):
31
        super(AjaxListingView, self).__init__(context, request)
32
        self.traverse_subpath = []
33
34
    @property
35
    def review_states_by_id(self):
36
        """Returns a mapping of the review_states by id
37
        """
38
        return dict(map(lambda rs: (rs.get("id"), rs), self.review_states))
39
40
    def ajax_contents_table(self, *args, **kwargs):
41
        """Render the ReactJS enabled contents table template
42
43
        It is called from the `BikaListingView.contents_table` method
44
        """
45
        return self.contents_table_template()
46
47
    def publishTraverse(self, request, name):
48
        """Called before __call__ for each path name and allows to dispatch
49
        subpaths to methods
50
        """
51
        self.traverse_subpath.append(name)
52
        return self
53
54
    def handle_subpath(self, prefix="ajax_"):
55
        """Dispatch the subpath to a method prefixed with the given prefix
56
57
        N.B. Only the first subpath element will be dispatched to a method and
58
             the rest will be passed as arguments to this method
59
        """
60
        if len(self.traverse_subpath) < 1:
61
            return {}
62
63
        # check if the method exists
64
        func_arg = self.traverse_subpath[0]
65
        func_name = "{}{}".format(prefix, func_arg)
66
        func = getattr(self, func_name, None)
67
        if func is None:
68
            raise NameError("Invalid function name")
69
70
        # Additional provided path segments after the function name are handled
71
        # as positional arguments
72
        args = self.traverse_subpath[1:]
73
74
        # check mandatory arguments
75
        func_sig = inspect.getargspec(func)
76
        # positional arguments after `self` argument
77
        required_args = func_sig.args[1:]
78
79
        if len(args) < len(required_args):
80
            raise ValueError("Wrong signature, please use '{}/{}'"
81
                             .format(func_arg, "/".join(required_args)))
82
        return func(*args)
83
84
    def get_json(self, encoding="utf8"):
85
        """Extracts the JSON from the request
86
        """
87
        body = self.request.get("BODY", "{}")
88
89
        def encode_hook(pairs):
90
            """This hook is called for dicitionaries on JSON deserialization
91
92
            It is used to encode unicode strings with the given encoding,
93
            because ZCatalogs have sometimes issues with unicode queries.
94
            """
95
            new_pairs = []
96
            for key, value in pairs.iteritems():
97
                # Encode the key
98
                if isinstance(key, unicode):
99
                    key = key.encode(encoding)
100
                # Encode the value
101
                if isinstance(value, unicode):
102
                    value = value.encode(encoding)
103
                new_pairs.append((key, value))
104
            return dict(new_pairs)
105
106
        return json.loads(body, object_hook=encode_hook)
107
108
    def error(self, message, status=500, **kw):
109
        """Set a JSON error object and a status to the response
110
        """
111
        self.request.response.setStatus(status)
112
        result = {"success": False, "errors": message, "status": status}
113
        result.update(kw)
114
        return result
115
116
    @returns_safe_json
117
    @translate
118
    def ajax_columns(self):
119
        """Returns the `column` dictionary of the view
120
        """
121
        return self.columns
122
123
    @returns_safe_json
124
    @translate
125
    def ajax_review_states(self):
126
        """Returns the `review_states` list of the view
127
        """
128
        return self.review_states
129
130
    def to_form_data(self, data):
131
        """Prefix all data keys with `self.form_id`
132
133
        This is needed to inject the POST Body to the request form data, so
134
        that the `BikaListingView.folderitems` finds them.
135
        """
136
        out = {}
137
        for key, value in data.iteritems():
138
            if not key.startswith(self.form_id):
139
                k = "{}_{}".format(self.form_id, key)
140
            out[k] = value
141
        return out
142
143
    def get_api_url(self):
144
        """Calculate the API URL of this view
145
        """
146
        url = self.context.absolute_url()
147
        view_name = self.__name__
148
        return "{}/{}".format(url, view_name)
149
150
    def get_transitions_for(self, obj):
151
        """Get the allowed transitions for the given object
152
        """
153
        return api.get_transitions_for(obj)
154
155
    def get_allowed_transitions_for(self, uids):
156
        """Retrieves all transitions from the given UIDs and calculate the
157
        ones which have all in common (intersection).
158
        """
159
160
        # Handle empty list of objects
161
        if not uids:
162
            return []
163
164
        # allowed transitions
165
        transitions = []
166
167
        # allowed transitions of the current workflow
168
        allowed_transitions = self.review_state.get("transitions", [])
169
        allowed_transition_ids = map(
170
            lambda t: t.get("id"), allowed_transitions)
171
172
        # internal mapping of transition id -> transition
173
        transitions_by_tid = {}
174
175
        # get the custom transitions of the current review_state
176
        custom_transitions = self.review_state.get("custom_transitions", [])
177
178
        # N.B. we use set() here to handle dupes in the views gracefully
179
        custom_tids = set()
180
        for transition in custom_transitions:
181
            tid = transition.get("id")
182
            custom_tids.add(tid)
183
            transitions_by_tid[tid] = transition
184
185
        # transition ids all objects have in common
186
        common_tids = None
187
188
        for uid in uids:
189
            # TODO: Research how to avoid the object wakeup here
190
            obj = api.get_object_by_uid(uid)
191
            obj_transitions = self.get_transitions_for(obj)
192
            tids = []
193
            for transition in obj_transitions:
194
                tid = transition.get("id")
195
                if allowed_transition_ids:
196
                    if tid not in allowed_transition_ids:
197
                        continue
198
                    tids.append(tid)
199
                transitions_by_tid[tid] = transition
200
201
            if common_tids is None:
202
                common_tids = set(tids)
203
204
            common_tids = common_tids.intersection(tids)
205
206
        # union the common and custom transitions
207
        all_transition_ids = common_tids.union(custom_tids)
208
209
        def sort_transitions(a, b):
210
            transition_weights = {
211
                "invalidate": 100,
212
                "retract": 90,
213
                "reject": 90,
214
                "cancel": 80,
215
                "deactivate": 70,
216
                "publish": 60,
217
                "republish": 50,
218
                "prepublish": 50,
219
                "partition": 40,
220
                "assign": 30,
221
                "receive": 20,
222
                "submit": 10,
223
            }
224
            w1 = transition_weights.get(a, 0)
225
            w2 = transition_weights.get(b, 0)
226
            return cmp(w1, w2)
227
228
        for tid in sorted(all_transition_ids, cmp=sort_transitions):
229
            transition = transitions_by_tid.get(tid)
230
            transitions.append(transition)
231
232
        return transitions
233
234
    def base_info(self, brain_or_object):
235
        """Object/Brain Base info
236
        """
237
        info = {
238
            "id": api.get_id(brain_or_object),
239
            "uid": api.get_uid(brain_or_object),
240
            "url": api.get_url(brain_or_object),
241
            "title": api.get_title(brain_or_object),
242
            "portal_type": api.get_portal_type(brain_or_object),
243
        }
244
        return info
245
246
    def get_category_uid(self, brain_or_object, accessor="getCategoryUID"):
247
        """Get the category UID from the brain or object
248
249
        This will be used to speed up the listing by categories
250
        """
251
        attr = getattr(brain_or_object, accessor, None)
252
        if attr is None:
253
            return ""
254
        if callable(attr):
255
            return attr()
256
        return attr
257
258
    @translate
259
    def get_folderitems(self):
260
        """This method calls the folderitems method
261
        """
262
        # workaround for `pagesize` handling in BikaListing
263
        pagesize = self.get_pagesize()
264
        self.pagesize = pagesize
265
266
        # get the folderitems
267
        self.update()
268
        self.before_render()
269
270
        return self.folderitems()
271
272
    def get_selected_uids(self, folderitems, uids_to_keep=None):
273
        """Lookup selected UIDs from the folderitems
274
        """
275
        selected_uids = []
276
        if uids_to_keep:
277
            selected_uids = uids_to_keep
278
279
        for folderitem in folderitems:
280
            uid = folderitem.get("uid")
281
            if uid in selected_uids:
282
                continue
283
            if folderitem.get("selected", False):
284
                selected_uids.append(folderitem.get("uid"))
285
        return selected_uids
286
287
    def get_listing_config(self):
288
        """Get the configuration settings of the current listing view
289
        """
290
291
        config = {
292
            "form_id": self.form_id,
293
            "review_states": self.review_states,
294
            "columns": self.columns,
295
            "allow_edit": self.allow_edit,
296
            "api_url": self.get_api_url(),
297
            "catalog": self.catalog,
298
            "catalog_indexes": self.get_catalog_indexes(),
299
            "categories": self.categories,
300
            "expand_all_categories": self.expand_all_categories,
301
            "limit_from": self.limit_from,
302
            "pagesize": self.pagesize,
303
            "post_action": self.getPOSTAction(),
304
            "review_state": self.review_state.get("id", "default"),
305
            "select_checkbox_name": self.select_checkbox_name,
306
            "show_categories": self.show_categories,
307
            "show_column_toggles": self.show_column_toggles,
308
            "show_more": self.show_more,
309
            "show_select_all_checkbox": self.show_select_all_checkbox,
310
            "show_select_column": self.show_select_column,
311
            "show_table_footer": self.show_table_footer,
312
            "show_workflow_action_buttons": self.show_workflow_action_buttons,
313
            "sort_on": self.get_sort_on(),
314
            "sort_order": self.get_sort_order(),
315
            "show_search": self.show_search,
316
        }
317
318
        return config
319
320
    def recalculate_results(self, obj):
321
        """Recalculate the result of the object and its dependents
322
323
        :returns: List of recalculated objects
324
        """
325
        recalculated_objects = []
326
        # recalculate own result
327
        obj.calculateResult(override=True, cascade=True)
328
        # recalculate dependent analyses
329
        for dep in obj.getDependents():
330
            if dep.calculateResult(override=True, cascade=True):
331
                recalculated_objects.append(dep)
332
        return recalculated_objects
333
334
    def is_analysis(self, obj):
335
        """Check if the object is an analysis
336
        """
337
        return IRoutineAnalysis.providedBy(obj)
338
339
    def set_field(self, obj, name, value):
340
        """Set the value
341
342
        :returns: List of updated/changed objects
343
        """
344
345
        # set of updated objects
346
        updated_objects = []
347
348
        # sanitize the name
349
        fieldname = name.lstrip("get")
350
351
        # fetch the schema field
352
        field = obj.getField(fieldname)
353
354
        # field exists, set it with the value
355
        if field:
356
            obj.edit(**{fieldname: value})
357
            updated_objects.append(obj)
358
359
        # check if the object is an analysis and has an interim
360
        if self.is_analysis(obj):
361
            interims = obj.getInterimFields()
362
            interim_keys = map(lambda i: i.get("keyword"), interims)
363
            if fieldname in interim_keys:
364
                for interim in interims:
365
                    if interim.get("keyword") == name:
366
                        interim["value"] = value
367
                # set the new interim fields
368
                obj.setInterimFields(interims)
369
            # recalculate dependent results for result and interim fields
370
            if fieldname == "Result" or fieldname in interim_keys:
371
                updated_objects.append(obj)
372
                updated_objects.extend(self.recalculate_results(obj))
373
374
        # unify the list of updated objects
375
        updated_objects = list(set(updated_objects))
376
377
        # reindex updated objects
378
        map(lambda o: o.reindexObject(), updated_objects)
379
380
        return updated_objects
381
382
    @set_application_json_header
383
    @returns_safe_json
384
    @inject_runtime
385
    def ajax_folderitems(self):
386
        """Calls the `folderitems` method of the view and returns it as JSON
387
388
        1. Extract the HTTP POST payload from the request
389
        2. Convert the payload to HTTP form data and inject it to the request
390
        3. Call the `folderitems` method
391
        4. Prepare a data structure for the ReactJS listing app
392
        """
393
394
        # Get the HTTP POST JSON Payload
395
        payload = self.get_json()
396
397
        # Fake a HTTP GET request with parameters, so that the `bika_listing`
398
        # view handles them correctly.
399
        form_data = self.to_form_data(payload)
400
401
        # this serves `request.form.get` calls
402
        self.request.form.update(form_data)
403
404
        # this serves `request.get` calls
405
        self.request.other.update(form_data)
406
407
        # generate a query string from the form data
408
        query_string = urllib.urlencode(form_data)
409
410
        # get the folder items
411
        folderitems = self.get_folderitems()
412
413
        # Process selected UIDs and their allowed transitions
414
        uids_to_keep = payload.get("selected_uids")
415
        selected_uids = self.get_selected_uids(folderitems, uids_to_keep)
416
        transitions = self.get_allowed_transitions_for(selected_uids)
417
418
        # get the view config
419
        config = self.get_listing_config()
420
421
        # prepare the response object
422
        data = {
423
            "count": len(folderitems),
424
            "folderitems": folderitems,
425
            "query_string": query_string,
426
            "selected_uids": selected_uids,
427
            "total": self.total,
428
            "transitions": transitions,
429
        }
430
431
        # update the config
432
        data.update(config)
433
434
        # XXX fix broken `sort_on` lookup in BikaListing
435
        sort_on = payload.get("sort_on")
436
        if sort_on in self.get_catalog_indexes():
437
            data["sort_on"] = sort_on
438
439
        return data
440
441
    @set_application_json_header
442
    @returns_safe_json
443
    @inject_runtime
444
    def ajax_transitions(self):
445
        """Returns a list of possible transitions
446
        """
447
        # Get the HTTP POST JSON Payload
448
        payload = self.get_json()
449
450
        # Get the selected UIDs
451
        uids = payload.get("selected_uids", [])
452
453
        # ----------------------------------8<---------------------------------
454
        # XXX Temporary (cut out as soon as possible)
455
        #
456
        # Some listings inject custom transitions before rendering the
457
        # folderitems, e.g. the worksheets folder listing view.
458
        # This can be removed as soon as all the relevant permission checks are
459
        # done on the object only and not by manual role checking in the view.
460
461
        # Fake a HTTP GET request with parameters, so that the `bika_listing`
462
        # view handles them correctly.
463
        form_data = self.to_form_data(payload)
464
465
        # this serves `request.form.get` calls
466
        self.request.form.update(form_data)
467
468
        # this serves `request.get` calls
469
        self.request.other.update(form_data)
470
471
        # Call the update and before_render hook, because these might modify
472
        # the allowed and custom transitions (and columns probably as well)
473
        self.update()
474
        self.before_render()
475
        # ----------------------------------8<---------------------------------
476
477
        # get the allowed transitions
478
        transitions = self.get_allowed_transitions_for(uids)
479
480
        # prepare the response object
481
        data = {
482
            "transitions": transitions,
483
        }
484
485
        return data
486
487
    @set_application_json_header
488
    @returns_safe_json
489
    @inject_runtime
490
    def ajax_query_folderitems(self):
491
        """Get folderitems with a catalog query
492
493
        Required POST JSON Payload:
494
495
        :query: Catalog query to use
496
        :type query: dictionary
497
        """
498
499
        # Get the HTTP POST JSON Payload
500
        payload = self.get_json()
501
502
        # extract the catalog query
503
        query = payload.get("query", {})
504
505
        valid_catalog_indexes = self.get_catalog_indexes()
506
507
        # sanity check
508
        for key, value in query.iteritems():
509
            if key not in valid_catalog_indexes:
510
                return self.error("{} is not a valid catalog index".format(key))
511
512
        # set the content filter
513
        self.contentFilter = query
514
515
        # get the folderitems
516
        folderitems = self.get_folderitems()
517
518
        # prepare the response object
519
        data = {
520
            "count": len(folderitems),
521
            "folderitems": folderitems,
522
        }
523
524
        return data
525
526
    @set_application_json_header
527
    @returns_safe_json
528
    @inject_runtime
529
    def ajax_set(self):
530
        """Set a value of an editable field
531
532
        The POST Payload needs to provide the following data:
533
534
        :uid: UID of the object changed
535
        :name: Column name as provided by the self.columns key
536
        :value: The value to save
537
        :item: The folderitem containing the data
538
        """
539
540
        # Get the HTTP POST JSON Payload
541
        payload = self.get_json()
542
543
        required = ["uid", "name", "value"]
544
        if not all(map(lambda k: k in payload, required)):
545
            return self.error("Payload needs to provide the keys {}"
546
                              .format(", ".join(required)), status=400)
547
548
        uid = payload.get("uid")
549
        name = payload.get("name")
550
        value = payload.get("value")
551
552
        # get the object
553
        obj = api.get_object_by_uid(uid)
554
555
        # set the field
556
        updated_objects = self.set_field(obj, name, value)
557
558
        if not updated_objects:
559
            return self.error("Failed to set field '{}'".format(name), 500)
560
561
        # get the folderitems
562
        self.contentFilter["UID"] = map(api.get_uid, updated_objects)
563
        folderitems = self.get_folderitems()
564
565
        # prepare the response object
566
        data = {
567
            "count": len(folderitems),
568
            "folderitems": folderitems,
569
        }
570
571
        return data
572