Passed
Push — master ( 1b1a5c...7b3c17 )
by Jordi
05:45
created

bika.lims.browser.listing.ajax   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 434
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 47
eloc 241
dl 0
loc 434
rs 8.64
c 0
b 0
f 0

20 Methods

Rating   Name   Duplication   Size   Complexity  
A AjaxListingView.__init__() 0 3 1
A AjaxListingView.get_json() 0 23 4
A AjaxListingView.publishTraverse() 0 6 1
A AjaxListingView.get_folderitems() 0 13 1
A AjaxListingView.ajax_transitions() 0 21 1
A AjaxListingView.fail() 0 8 1
A AjaxListingView.ajax_columns() 0 6 1
A AjaxListingView.ajax_contents_table() 0 6 1
A AjaxListingView.ajax_review_states() 0 6 1
A AjaxListingView.handle_subpath() 0 29 4
A AjaxListingView.get_listing_config() 0 33 1
A AjaxListingView.ajax_query_folderitems() 0 38 3
A AjaxListingView.get_category_uid() 0 11 3
C AjaxListingView.get_allowed_transitions_for() 0 76 10
A AjaxListingView.get_selected_uids() 0 14 5
A AjaxListingView.get_api_url() 0 6 1
A AjaxListingView.review_states_by_id() 0 5 2
A AjaxListingView.to_form_data() 0 12 3
A AjaxListingView.base_info() 0 11 1
A AjaxListingView.ajax_folderitems() 0 59 2

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 api
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 Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
15
from zope.interface import implements
16
from zope.publisher.interfaces import IPublishTraverse
17
18
19
class AjaxListingView(BrowserView):
20
    """Mixin Class for the ajax enabled listing table
21
22
    The main purpose of this class is to provide a JSON API endpoint for the
23
    ReactJS based listing table.
24
    """
25
    implements(IPublishTraverse)
26
    contents_table_template = ViewPageTemplateFile(
27
        "templates/contents_table.pt")
28
29
    def __init__(self, context, request):
30
        super(AjaxListingView, self).__init__(context, request)
31
        self.traverse_subpath = []
32
33
    @property
34
    def review_states_by_id(self):
35
        """Returns a mapping of the review_states by id
36
        """
37
        return dict(map(lambda rs: (rs.get("id"), rs), self.review_states))
38
39
    def ajax_contents_table(self, *args, **kwargs):
40
        """Render the ReactJS enabled contents table template
41
42
        It is called from the `BikaListingView.contents_table` method
43
        """
44
        return self.contents_table_template()
45
46
    def publishTraverse(self, request, name):
47
        """Called before __call__ for each path name and allows to dispatch
48
        subpaths to methods
49
        """
50
        self.traverse_subpath.append(name)
51
        return self
52
53
    def handle_subpath(self, prefix="ajax_"):
54
        """Dispatch the subpath to a method prefixed with the given prefix
55
56
        N.B. Only the first subpath element will be dispatched to a method and
57
             the rest will be passed as arguments to this method
58
        """
59
        if len(self.traverse_subpath) < 1:
60
            return {}
61
62
        # check if the method exists
63
        func_arg = self.traverse_subpath[0]
64
        func_name = "{}{}".format(prefix, func_arg)
65
        func = getattr(self, func_name, None)
66
        if func is None:
67
            return self.fail("Invalid function", status=400)
68
69
        # Additional provided path segments after the function name are handled
70
        # as positional arguments
71
        args = self.traverse_subpath[1:]
72
73
        # check mandatory arguments
74
        func_sig = inspect.getargspec(func)
75
        # positional arguments after `self` argument
76
        required_args = func_sig.args[1:]
77
78
        if len(args) < len(required_args):
79
            return self.fail("Wrong signature, please use '{}/{}'"
80
                             .format(func_arg, "/".join(required_args)), 400)
81
        return func(*args)
82
83
    def get_json(self, encoding="utf8"):
84
        """Extracts the JSON from the request
85
        """
86
        body = self.request.get("BODY", "{}")
87
88
        def encode_hook(pairs):
89
            """This hook is called for dicitionaries on JSON deserialization
90
91
            It is used to encode unicode strings with the given encoding,
92
            because ZCatalogs have sometimes issues with unicode queries.
93
            """
94
            new_pairs = []
95
            for key, value in pairs.iteritems():
96
                # Encode the key
97
                if isinstance(key, unicode):
98
                    key = key.encode(encoding)
99
                # Encode the value
100
                if isinstance(value, unicode):
101
                    value = value.encode(encoding)
102
                new_pairs.append((key, value))
103
            return dict(new_pairs)
104
105
        return json.loads(body, object_hook=encode_hook)
106
107
    @returns_safe_json
108
    def fail(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_allowed_transitions_for(self, objects):
151
        """Retrieves all transitions from the given objects and calculate the
152
        ones which have all in common (intersection).
153
        """
154
155
        # Handle empty list of objects
156
        if not objects:
157
            return []
158
159
        # allowed transitions
160
        transitions = []
161
162
        # allowed transitions of the current workflow
163
        allowed_transitions = self.review_state.get("transitions", [])
164
        allowed_transition_ids = map(
165
            lambda t: t.get("id"), allowed_transitions)
166
167
        # internal mapping of transition id -> transition
168
        transitions_by_tid = {}
169
170
        # get the custom transitions of the current review_state
171
        custom_transitions = self.review_state.get("custom_transitions", [])
172
173
        # N.B. we use set() here to handle dupes in the views gracefully
174
        custom_tids = set()
175
        for transition in custom_transitions:
176
            tid = transition.get("id")
177
            custom_tids.add(tid)
178
            transitions_by_tid[tid] = transition
179
180
        # transition ids all objects have in common
181
        common_tids = None
182
183
        for obj in objects:
184
            # get the allowed transitions for this object
185
            obj_transitions = api.get_transitions_for(obj)
186
            tids = []
187
            for transition in obj_transitions:
188
                tid = transition.get("id")
189
                if allowed_transition_ids:
190
                    if tid not in allowed_transition_ids:
191
                        continue
192
                    tids.append(tid)
193
                transitions_by_tid[tid] = transition
194
195
            if common_tids is None:
196
                common_tids = set(tids)
197
198
            common_tids = common_tids.intersection(tids)
199
200
        # union the common and custom transitions
201
        all_transition_ids = common_tids.union(custom_tids)
202
203
        def sort_transitions(a, b):
204
            transition_weights = {
205
                "invalidate": 100,
206
                "retract": 90,
207
                "cancel": 80,
208
                "deactivate": 70,
209
                "publish": 60,
210
                "republish": 50,
211
                "prepublish": 50,
212
                "partition": 40,
213
                "assign": 30,
214
                "receive": 20,
215
                "submit": 10,
216
            }
217
            w1 = transition_weights.get(a, 0)
218
            w2 = transition_weights.get(b, 0)
219
            return cmp(w1, w2)
220
221
        for tid in sorted(all_transition_ids, cmp=sort_transitions):
222
            transition = transitions_by_tid.get(tid)
223
            transitions.append(transition)
224
225
        return transitions
226
227
    def base_info(self, brain_or_object):
228
        """Object/Brain Base info
229
        """
230
        info = {
231
            "id": api.get_id(brain_or_object),
232
            "uid": api.get_uid(brain_or_object),
233
            "url": api.get_url(brain_or_object),
234
            "title": api.get_title(brain_or_object),
235
            "portal_type": api.get_portal_type(brain_or_object),
236
        }
237
        return info
238
239
    def get_category_uid(self, brain_or_object, accessor="getCategoryUID"):
240
        """Get the category UID from the brain or object
241
242
        This will be used to speed up the listing by categories
243
        """
244
        attr = getattr(brain_or_object, accessor, None)
245
        if attr is None:
246
            return ""
247
        if callable(attr):
248
            return attr()
249
        return attr
250
251
    @translate
252
    def get_folderitems(self):
253
        """This method calls the folderitems method
254
        """
255
        # workaround for `pagesize` handling in BikaListing
256
        pagesize = self.get_pagesize()
257
        self.pagesize = pagesize
258
259
        # get the folderitems
260
        self.update()
261
        self.before_render()
262
263
        return self.folderitems()
264
265
    def get_selected_uids(self, folderitems, uids_to_keep=None):
266
        """Lookup selected UIDs from the folderitems
267
        """
268
        selected_uids = []
269
        if uids_to_keep:
270
            selected_uids = uids_to_keep
271
272
        for folderitem in folderitems:
273
            uid = folderitem.get("uid")
274
            if uid in selected_uids:
275
                continue
276
            if folderitem.get("selected", False):
277
                selected_uids.append(folderitem.get("uid"))
278
        return selected_uids
279
280
    def get_listing_config(self):
281
        """Get the configuration settings of the current listing view
282
        """
283
284
        config = {
285
            # N.B.: form_id, review_states and columns are passed in as data
286
            #       attributes and therefore not needed here.
287
            # "form_id": self.form_id,
288
            # "review_states": self.review_states,
289
            # "columns": self.columns,
290
            "allow_edit": self.allow_edit,
291
            "api_url": self.get_api_url(),
292
            "catalog": self.catalog,
293
            "catalog_indexes": self.get_catalog_indexes(),
294
            "categories": self.categories,
295
            "expand_all_categories": self.expand_all_categories,
296
            "limit_from": self.limit_from,
297
            "pagesize": self.pagesize,
298
            "post_action": self.getPOSTAction(),
299
            "review_state": self.review_state.get("id", "default"),
300
            "select_checkbox_name": self.select_checkbox_name,
301
            "show_categories": self.show_categories,
302
            "show_column_toggles": self.show_column_toggles,
303
            "show_more": self.show_more,
304
            "show_select_all_checkbox": self.show_select_all_checkbox,
305
            "show_select_column": self.show_select_column,
306
            "show_table_footer": self.show_table_footer,
307
            "show_workflow_action_buttons": self.show_workflow_action_buttons,
308
            "sort_on": self.get_sort_on(),
309
            "sort_order": self.get_sort_order(),
310
        }
311
312
        return config
313
314
    @set_application_json_header
315
    @returns_safe_json
316
    @inject_runtime
317
    def ajax_folderitems(self):
318
        """Calls the `folderitems` method of the view and returns it as JSON
319
320
        1. Extract the HTTP POST payload from the request
321
        2. Convert the payload to HTTP form data and inject it to the request
322
        3. Call the `folderitems` method
323
        4. Prepare a data structure for the ReactJS listing app
324
        """
325
326
        # Get the HTTP POST JSON Payload
327
        payload = self.get_json()
328
329
        # Fake a HTTP GET request with parameters, so that the `bika_listing`
330
        # view handles them correctly.
331
        form_data = self.to_form_data(payload)
332
333
        # this serves `request.form.get` calls
334
        self.request.form.update(form_data)
335
336
        # this serves `request.get` calls
337
        self.request.other.update(form_data)
338
339
        # generate a query string from the form data
340
        query_string = urllib.urlencode(form_data)
341
342
        # get the folder items
343
        folderitems = self.get_folderitems()
344
345
        # Process selected UIDs and their allowed transitions
346
        uids_to_keep = payload.get("selected_uids")
347
        selected_uids = self.get_selected_uids(folderitems, uids_to_keep)
348
        selected_objs = map(api.get_object_by_uid, selected_uids)
349
        transitions = self.get_allowed_transitions_for(selected_objs)
350
351
        # get the view config
352
        config = self.get_listing_config()
353
354
        # prepare the response object
355
        data = {
356
            "count": len(folderitems),
357
            "folderitems": folderitems,
358
            "query_string": query_string,
359
            "selected_uids": selected_uids,
360
            "total": self.total,
361
            "transitions": transitions,
362
        }
363
364
        # update the config
365
        data.update(config)
366
367
        # XXX fix broken `sort_on` lookup in BikaListing
368
        sort_on = payload.get("sort_on")
369
        if sort_on in self.get_catalog_indexes():
370
            data["sort_on"] = sort_on
371
372
        return data
373
374
    @set_application_json_header
375
    @returns_safe_json
376
    @inject_runtime
377
    def ajax_transitions(self):
378
        """Returns a list of possible transitions
379
        """
380
        # Get the HTTP POST JSON Payload
381
        payload = self.get_json()
382
        # Get the selected UIDs
383
        uids = payload.get("uids", [])
384
        objs = map(api.get_object_by_uid, uids)
385
386
        # get the allowed transitions
387
        transitions = self.get_allowed_transitions_for(objs)
388
389
        # prepare the response object
390
        data = {
391
            "transitions": transitions,
392
        }
393
394
        return data
395
396
    @set_application_json_header
397
    @returns_safe_json
398
    @inject_runtime
399
    def ajax_query_folderitems(self):
400
        """Get folderitems with a catalog query
401
402
        Required POST JSON Payload:
403
404
        :query: Catalog query to use
405
        :type query: dictionary
406
        """
407
408
        # Get the HTTP POST JSON Payload
409
        payload = self.get_json()
410
411
        # extract the catalog query
412
        query = payload.get("query", {})
413
414
        valid_catalog_indexes = self.get_catalog_indexes()
415
416
        # sanity check
417
        for key, value in query.iteritems():
418
            if key not in valid_catalog_indexes:
419
                return self.fail("{} is not a valid catalog index".format(key))
420
421
        # set the content filter
422
        self.contentFilter = query
423
424
        # get the folderitems
425
        folderitems = self.get_folderitems()
426
427
        # prepare the response object
428
        data = {
429
            "count": len(folderitems),
430
            "folderitems": folderitems,
431
        }
432
433
        return data
434