Passed
Push — master ( 5f146e...28e0c7 )
by Jordi
04:40
created

AjaxListingView.set_field()   D

Complexity

Conditions 13

Size

Total Lines 62
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 30
dl 0
loc 62
rs 4.2
c 0
b 0
f 0
cc 13
nop 4

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

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