1 # -*- coding: utf-8 -*-
5 from xml.etree import ElementTree
6 from cStringIO import StringIO
12 import openerpweb.nonliterals
16 # Should move to openerpweb.Xml2Json
19 # Simple and straightforward XML-to-JSON converter in Python
22 # URL: http://code.google.com/p/xml2json-direct/
24 def convert_to_json(s):
25 return simplejson.dumps(
26 Xml2Json.convert_to_structure(s), sort_keys=True, indent=4)
29 def convert_to_structure(s):
30 root = ElementTree.fromstring(s)
31 return Xml2Json.convert_element(root)
34 def convert_element(el, skip_whitespaces=True):
37 ns, name = el.tag.rsplit("}", 1)
39 res["namespace"] = ns[1:]
43 for k, v in el.items():
46 if el.text and (not skip_whitespaces or el.text.strip() != ''):
49 kids.append(Xml2Json.convert_element(kid))
50 if kid.tail and (not skip_whitespaces or kid.tail.strip() != ''):
52 res["children"] = kids
55 #----------------------------------------------------------
56 # OpenERP Web base Controllers
57 #----------------------------------------------------------
59 class Session(openerpweb.Controller):
60 _cp_path = "/base/session"
62 def manifest_glob(self, addons, key):
65 globlist = openerpweb.addons_manifest.get(addon, {}).get(key, [])
68 resource_path[len(openerpweb.path_addons):]
69 for pattern in globlist
70 for resource_path in glob.glob(os.path.join(
71 openerpweb.path_addons, addon, pattern))
75 def concat_files(self, file_list):
76 """ Concatenate file content
77 return (concat,timestamp)
78 concat: concatenation of file content
79 timestamp: max(os.path.getmtime of file_list)
81 root = openerpweb.path_root
85 fname = os.path.join(root, i)
86 ftime = os.path.getmtime(fname)
87 if ftime > files_timestamp:
88 files_timestamp = ftime
89 files_content = open(fname).read()
90 files_concat = "".join(files_content)
93 @openerpweb.jsonrequest
94 def login(self, req, db, login, password):
95 req.session.login(db, login, password)
98 "session_id": req.session_id,
99 "uid": req.session._uid,
102 @openerpweb.jsonrequest
103 def sc_list(self, req):
104 return req.session.model('ir.ui.view_sc').get_sc(req.session._uid, "ir.ui.menu",
105 req.session.eval_context(req.context))
107 @openerpweb.jsonrequest
108 def get_databases_list(self, req):
109 proxy = req.session.proxy("db")
112 return {"db_list": dbs}
114 @openerpweb.jsonrequest
115 def modules(self, req):
116 return {"modules": [name
117 for name, manifest in openerpweb.addons_manifest.iteritems()
118 if manifest.get('active', True)]}
120 @openerpweb.jsonrequest
121 def csslist(self, req, mods='base'):
122 return {'files': self.manifest_glob(mods.split(','), 'css')}
124 @openerpweb.jsonrequest
125 def jslist(self, req, mods='base'):
126 return {'files': self.manifest_glob(mods.split(','), 'js')}
128 def css(self, req, mods='base,base_hello'):
129 files = self.manifest_glob(mods.split(','), 'css')
130 concat = self.concat_files(files)[0]
131 # TODO request set the Date of last modif and Etag
135 def js(self, req, mods='base,base_hello'):
136 files = self.manifest_glob(mods.split(','), 'js')
137 concat = self.concat_files(files)[0]
138 # TODO request set the Date of last modif and Etag
142 @openerpweb.jsonrequest
143 def eval_domain_and_context(self, req, contexts, domains,
145 """ Evaluates sequences of domains and contexts, composing them into
146 a single context, domain or group_by sequence.
148 :param list contexts: list of contexts to merge together. Contexts are
149 evaluated in sequence, all previous contexts
150 are part of their own evaluation context
151 (starting at the session context).
152 :param list domains: list of domains to merge together. Domains are
153 evaluated in sequence and appended to one another
154 (implicit AND), their evaluation domain is the
155 result of merging all contexts.
156 :param list group_by_seq: list of domains (which may be in a different
157 order than the ``contexts`` parameter),
158 evaluated in sequence, their ``'group_by'``
159 key is extracted if they have one.
164 the global context created by merging all of
168 the concatenation of all domains
171 a list of fields to group by, potentially empty (in which case
172 no group by should be performed)
174 context, domain = eval_context_and_domain(req.session,
175 openerpweb.nonliterals.CompoundContext(*(contexts or [])),
176 openerpweb.nonliterals.CompoundDomain(*(domains or [])))
178 group_by_sequence = []
179 for candidate in (group_by_seq or []):
180 ctx = req.session.eval_context(candidate, context)
181 group_by = ctx.get('group_by')
184 elif isinstance(group_by, basestring):
185 group_by_sequence.append(group_by)
187 group_by_sequence.extend(group_by)
192 'group_by': group_by_sequence
195 @openerpweb.jsonrequest
196 def save_session_action(self, req, the_action):
198 This method store an action object in the session object and returns an integer
199 identifying that action. The method get_session_action() can be used to get
202 :param the_action: The action to save in the session.
203 :type the_action: anything
204 :return: A key identifying the saved action.
207 saved_actions = cherrypy.session.get('saved_actions')
208 if not saved_actions:
209 saved_actions = {"next":0, "actions":{}}
210 cherrypy.session['saved_actions'] = saved_actions
211 # we don't allow more than 10 stored actions
212 if len(saved_actions["actions"]) >= 10:
213 del saved_actions["actions"][min(saved_actions["actions"].keys())]
214 key = saved_actions["next"]
215 saved_actions["actions"][key] = the_action
216 saved_actions["next"] = key + 1
219 @openerpweb.jsonrequest
220 def get_session_action(self, req, key):
222 Gets back a previously saved action. This method can return None if the action
223 was saved since too much time (this case should be handled in a smart way).
225 :param key: The key given by save_session_action()
227 :return: The saved action or None.
230 saved_actions = cherrypy.session.get('saved_actions')
231 if not saved_actions:
233 return saved_actions["actions"].get(key)
235 @openerpweb.jsonrequest
236 def check(self, req):
237 req.session.assert_valid()
240 def eval_context_and_domain(session, context, domain=None):
241 e_context = session.eval_context(context)
242 # should we give the evaluated context as an evaluation context to the domain?
243 e_domain = session.eval_domain(domain or [])
245 return e_context, e_domain
247 def load_actions_from_ir_values(req, key, key2, models, meta, context):
248 Values = req.session.model('ir.values')
249 actions = Values.get(key, key2, models, meta, context)
251 return [(id, name, clean_action(action, req.session))
252 for id, name, action in actions]
254 def clean_action(action, session):
255 if action['type'] != 'ir.actions.act_window':
257 # values come from the server, we can just eval them
258 if isinstance(action.get('context', None), basestring):
259 action['context'] = eval(
261 session.evaluation_context()) or {}
263 if isinstance(action.get('domain', None), basestring):
264 action['domain'] = eval(
266 session.evaluation_context(
267 action['context'])) or []
268 if 'flags' not in action:
269 # Set empty flags dictionary for web client.
270 action['flags'] = dict()
271 return fix_view_modes(action)
273 def generate_views(action):
275 While the server generates a sequence called "views" computing dependencies
276 between a bunch of stuff for views coming directly from the database
277 (the ``ir.actions.act_window model``), it's also possible for e.g. buttons
278 to return custom view dictionaries generated on the fly.
280 In that case, there is no ``views`` key available on the action.
282 Since the web client relies on ``action['views']``, generate it here from
283 ``view_mode`` and ``view_id``.
285 Currently handles two different cases:
287 * no view_id, multiple view_mode
288 * single view_id, single view_mode
290 :param dict action: action descriptor dictionary to generate a views key for
292 view_id = action.get('view_id', False)
293 if isinstance(view_id, (list, tuple)):
296 # providing at least one view mode is a requirement, not an option
297 view_modes = action['view_mode'].split(',')
299 if len(view_modes) > 1:
301 raise ValueError('Non-db action dictionaries should provide '
302 'either multiple view modes or a single view '
303 'mode and an optional view id.\n\n Got view '
304 'modes %r and view id %r for action %r' % (
305 view_modes, view_id, action))
306 action['views'] = [(False, mode) for mode in view_modes]
308 action['views'] = [(view_id, view_modes[0])]
311 def fix_view_modes(action):
312 """ For historical reasons, OpenERP has weird dealings in relation to
313 view_mode and the view_type attribute (on window actions):
315 * one of the view modes is ``tree``, which stands for both list views
317 * the choice is made by checking ``view_type``, which is either
318 ``form`` for a list view or ``tree`` for an actual tree view
320 This methods simply folds the view_type into view_mode by adding a
321 new view mode ``list`` which is the result of the ``tree`` view_mode
322 in conjunction with the ``form`` view_type.
324 TODO: this should go into the doc, some kind of "peculiarities" section
326 :param dict action: an action descriptor
327 :returns: nothing, the action is modified in place
329 if 'views' not in action:
330 generate_views(action)
332 if action.pop('view_type') != 'form':
336 [id, mode if mode != 'tree' else 'list']
337 for id, mode in action['views']
342 class Menu(openerpweb.Controller):
343 _cp_path = "/base/menu"
345 @openerpweb.jsonrequest
347 return {'data': self.do_load(req)}
349 def do_load(self, req):
350 """ Loads all menu items (all applications and their sub-menus).
352 :param req: A request object, with an OpenERP session attribute
353 :type req: < session -> OpenERPSession >
354 :return: the menu root
355 :rtype: dict('children': menu_nodes)
357 Menus = req.session.model('ir.ui.menu')
358 # menus are loaded fully unlike a regular tree view, cause there are
359 # less than 512 items
360 context = req.session.eval_context(req.context)
361 menu_ids = Menus.search([], 0, False, False, context)
362 menu_items = Menus.read(menu_ids, ['name', 'sequence', 'parent_id'], context)
363 menu_root = {'id': False, 'name': 'root', 'parent_id': [-1, '']}
364 menu_items.append(menu_root)
366 # make a tree using parent_id
367 menu_items_map = dict((menu_item["id"], menu_item) for menu_item in menu_items)
368 for menu_item in menu_items:
369 if menu_item['parent_id']:
370 parent = menu_item['parent_id'][0]
373 if parent in menu_items_map:
374 menu_items_map[parent].setdefault(
375 'children', []).append(menu_item)
377 # sort by sequence a tree using parent_id
378 for menu_item in menu_items:
379 menu_item.setdefault('children', []).sort(
380 key=lambda x:x["sequence"])
384 @openerpweb.jsonrequest
385 def action(self, req, menu_id):
386 actions = load_actions_from_ir_values(req,'action', 'tree_but_open',
387 [('ir.ui.menu', menu_id)], False,
388 req.session.eval_context(req.context))
389 return {"action": actions}
391 class DataSet(openerpweb.Controller):
392 _cp_path = "/base/dataset"
394 @openerpweb.jsonrequest
395 def fields(self, req, model):
396 return {'fields': req.session.model(model).fields_get(False,
397 req.session.eval_context(req.context))}
399 @openerpweb.jsonrequest
400 def search_read(self, request, model, fields=False, offset=0, limit=False, domain=None, context=None, sort=None):
401 return self.do_search_read(request, model, fields, offset, limit, domain, context, sort)
402 def do_search_read(self, request, model, fields=False, offset=0, limit=False, domain=None, context=None, sort=None):
403 """ Performs a search() followed by a read() (if needed) using the
404 provided search criteria
406 :param request: a JSON-RPC request object
407 :type request: openerpweb.JsonRequest
408 :param str model: the name of the model to search on
409 :param fields: a list of the fields to return in the result records
411 :param int offset: from which index should the results start being returned
412 :param int limit: the maximum number of records to return
413 :param list domain: the search domain for the query
414 :param list sort: sorting directives
415 :returns: a list of result records
418 Model = request.session.model(model)
419 context, domain = eval_context_and_domain(request.session, request.context, domain)
421 ids = Model.search(domain, offset or 0, limit or False,
422 sort or False, context)
424 if fields and fields == ['id']:
425 # shortcut read if we only want the ids
426 return map(lambda id: {'id': id}, ids)
428 reads = Model.read(ids, fields or False, context)
429 reads.sort(key=lambda obj: ids.index(obj['id']))
432 @openerpweb.jsonrequest
433 def get(self, request, model, ids, fields=False):
434 return self.do_get(request, model, ids, fields)
435 def do_get(self, request, model, ids, fields=False):
436 """ Fetches and returns the records of the model ``model`` whose ids
439 The results are in the same order as the inputs, but elements may be
440 missing (if there is no record left for the id)
442 :param request: the JSON-RPC2 request object
443 :type request: openerpweb.JsonRequest
444 :param model: the model to read from
446 :param ids: a list of identifiers
448 :param fields: a list of fields to fetch, ``False`` or empty to fetch
449 all fields in the model
450 :type fields: list | False
451 :returns: a list of records, in the same order as the list of ids
454 Model = request.session.model(model)
455 records = Model.read(ids, fields, request.session.eval_context(request.context))
457 record_map = dict((record['id'], record) for record in records)
459 return [record_map[id] for id in ids if record_map.get(id)]
461 @openerpweb.jsonrequest
462 def load(self, req, model, id, fields):
463 m = req.session.model(model)
465 r = m.read([id], False, req.session.eval_context(req.context))
468 return {'value': value}
470 @openerpweb.jsonrequest
471 def create(self, req, model, data):
472 m = req.session.model(model)
473 r = m.create(data, req.session.eval_context(req.context))
476 @openerpweb.jsonrequest
477 def save(self, req, model, id, data):
478 m = req.session.model(model)
479 r = m.write([id], data, req.session.eval_context(req.context))
482 @openerpweb.jsonrequest
483 def unlink(self, request, model, ids=()):
484 Model = request.session.model(model)
485 return Model.unlink(ids, request.session.eval_context(request.context))
487 def call_common(self, req, model, method, args, domain_id=None, context_id=None):
488 domain = args[domain_id] if domain_id and len(args) - 1 >= domain_id else []
489 context = args[context_id] if context_id and len(args) - 1 >= context_id else {}
490 c, d = eval_context_and_domain(req.session, context, domain)
491 if domain_id and len(args) - 1 >= domain_id:
493 if context_id and len(args) - 1 >= context_id:
496 return getattr(req.session.model(model), method)(*args)
498 @openerpweb.jsonrequest
499 def call(self, req, model, method, args, domain_id=None, context_id=None):
500 return {'result': self.call_common(req, model, method, args, domain_id, context_id)}
502 @openerpweb.jsonrequest
503 def call_button(self, req, model, method, args, domain_id=None, context_id=None):
504 action = self.call_common(req, model, method, args, domain_id, context_id)
505 if isinstance(action, dict) and action.get('type') != '':
506 return {'result': clean_action(action, req.session)}
507 return {'result': False}
509 @openerpweb.jsonrequest
510 def exec_workflow(self, req, model, id, signal):
511 r = req.session.exec_workflow(model, id, signal)
514 @openerpweb.jsonrequest
515 def default_get(self, req, model, fields):
516 m = req.session.model(model)
517 r = m.default_get(fields, req.session.eval_context(req.context))
520 class DataGroup(openerpweb.Controller):
521 _cp_path = "/base/group"
522 @openerpweb.jsonrequest
523 def read(self, request, model, group_by_fields, domain=None):
524 Model = request.session.model(model)
525 context, domain = eval_context_and_domain(request.session, request.context, domain)
527 return Model.read_group(
528 domain or [], False, group_by_fields, 0, False,
529 dict(context, group_by=group_by_fields))
531 class View(openerpweb.Controller):
532 _cp_path = "/base/view"
534 def fields_view_get(self, request, model, view_id, view_type,
535 transform=True, toolbar=False, submenu=False):
536 Model = request.session.model(model)
537 context = request.session.eval_context(request.context)
538 fvg = Model.fields_view_get(view_id, view_type, context, toolbar, submenu)
539 # todo fme?: check that we should pass the evaluated context here
540 self.process_view(request.session, fvg, context, transform)
543 def process_view(self, session, fvg, context, transform):
544 # depending on how it feels, xmlrpclib.ServerProxy can translate
545 # XML-RPC strings to ``str`` or ``unicode``. ElementTree does not
546 # enjoy unicode strings which can not be trivially converted to
547 # strings, and it blows up during parsing.
549 # So ensure we fix this retardation by converting view xml back to
551 if isinstance(fvg['arch'], unicode):
552 arch = fvg['arch'].encode('utf-8')
557 evaluation_context = session.evaluation_context(context or {})
558 xml = self.transform_view(arch, session, evaluation_context)
560 xml = ElementTree.fromstring(arch)
561 fvg['arch'] = Xml2Json.convert_element(xml)
562 for field in fvg['fields'].values():
563 if field.has_key('views') and field['views']:
564 for view in field["views"].values():
565 self.process_view(session, view, None, transform)
567 @openerpweb.jsonrequest
568 def add_custom(self, request, view_id, arch):
569 CustomView = request.session.model('ir.ui.view.custom')
571 'user_id': request.session._uid,
574 }, request.session.eval_context(request.context))
575 return {'result': True}
577 @openerpweb.jsonrequest
578 def undo_custom(self, request, view_id, reset=False):
579 CustomView = request.session.model('ir.ui.view.custom')
580 context = request.session.eval_context(request.context)
581 vcustom = CustomView.search([('user_id', '=', request.session._uid), ('ref_id' ,'=', view_id)],
582 0, False, False, context)
585 CustomView.unlink(vcustom, context)
587 CustomView.unlink([vcustom[0]], context)
588 return {'result': True}
589 return {'result': False}
591 def normalize_attrs(self, elem, context):
592 """ Normalize @attrs, @invisible, @required, @readonly and @states, so
593 the client only has to deal with @attrs.
595 See `the discoveries pad <http://pad.openerp.com/discoveries>`_ for
598 :param elem: the current view node (Python object)
599 :type elem: xml.etree.ElementTree.Element
600 :param dict context: evaluation context
602 # If @attrs is normalized in json by server, the eval should be replaced by simplejson.loads
603 attrs = openerpweb.ast.literal_eval(elem.get('attrs', '{}'))
604 if 'states' in elem.attrib:
605 attrs.setdefault('invisible', [])\
606 .append(('state', 'not in', elem.attrib.pop('states').split(',')))
608 elem.set('attrs', simplejson.dumps(attrs))
609 for a in ['invisible', 'readonly', 'required']:
611 # In the XML we trust
612 avalue = bool(eval(elem.get(a, 'False'),
613 {'context': context or {}}))
618 if a == 'invisible' and 'attrs' in elem.attrib:
619 del elem.attrib['attrs']
621 def transform_view(self, view_string, session, context=None):
622 # transform nodes on the fly via iterparse, instead of
623 # doing it statically on the parsing result
624 parser = ElementTree.iterparse(StringIO(view_string), events=("start",))
626 for event, elem in parser:
630 self.normalize_attrs(elem, context)
631 self.parse_domains_and_contexts(elem, session)
634 def parse_domain(self, elem, attr_name, session):
635 """ Parses an attribute of the provided name as a domain, transforms it
636 to either a literal domain or a :class:`openerpweb.nonliterals.Domain`
638 :param elem: the node being parsed
639 :type param: xml.etree.ElementTree.Element
640 :param str attr_name: the name of the attribute which should be parsed
641 :param session: Current OpenERP session
642 :type session: openerpweb.openerpweb.OpenERPSession
644 domain = elem.get(attr_name, '').strip()
649 openerpweb.ast.literal_eval(
654 openerpweb.nonliterals.Domain(session, domain))
656 def parse_domains_and_contexts(self, elem, session):
657 """ Converts domains and contexts from the view into Python objects,
658 either literals if they can be parsed by literal_eval or a special
659 placeholder object if the domain or context refers to free variables.
661 :param elem: the current node being parsed
662 :type param: xml.etree.ElementTree.Element
663 :param session: OpenERP session object, used to store and retrieve
665 :type session: openerpweb.openerpweb.OpenERPSession
667 self.parse_domain(elem, 'domain', session)
668 self.parse_domain(elem, 'filter_domain', session)
669 for el in ['context', 'default_get']:
670 context_string = elem.get(el, '').strip()
674 openerpweb.ast.literal_eval(context_string))
677 openerpweb.nonliterals.Context(
678 session, context_string))
680 class FormView(View):
681 _cp_path = "/base/formview"
683 @openerpweb.jsonrequest
684 def load(self, req, model, view_id, toolbar=False):
685 fields_view = self.fields_view_get(req, model, view_id, 'form', toolbar=toolbar)
686 return {'fields_view': fields_view}
688 class ListView(View):
689 _cp_path = "/base/listview"
691 @openerpweb.jsonrequest
692 def load(self, req, model, view_id, toolbar=False):
693 fields_view = self.fields_view_get(req, model, view_id, 'tree', toolbar=toolbar)
694 return {'fields_view': fields_view}
696 def process_colors(self, view, row, context):
697 colors = view['arch']['attrs'].get('colors')
704 for pair in colors.split(';')
705 if eval(pair.split(':')[1], dict(context, **row))
710 elif len(color) == 1:
714 class SearchView(View):
715 _cp_path = "/base/searchview"
717 @openerpweb.jsonrequest
718 def load(self, req, model, view_id):
719 fields_view = self.fields_view_get(req, model, view_id, 'search')
720 return {'fields_view': fields_view}
722 @openerpweb.jsonrequest
723 def fields_get(self, req, model):
724 Model = req.session.model(model)
725 fields = Model.fields_get(False, req.session.eval_context(req.context))
726 return {'fields': fields}
728 class Binary(openerpweb.Controller):
729 _cp_path = "/base/binary"
731 @openerpweb.httprequest
732 def image(self, request, session_id, model, id, field, **kw):
733 cherrypy.response.headers['Content-Type'] = 'image/png'
734 Model = request.session.model(model)
735 context = request.session.eval_context(request.context)
738 res = Model.default_get([field], context).get(field, '')
740 res = Model.read([int(id)], [field], context)[0].get(field, '')
741 return base64.decodestring(res)
742 except: # TODO: what's the exception here?
743 return self.placeholder()
744 def placeholder(self):
745 return open(os.path.join(openerpweb.path_addons, 'base', 'static', 'src', 'img', 'placeholder.png'), 'rb').read()
747 @openerpweb.httprequest
748 def saveas(self, request, session_id, model, id, field, fieldname, **kw):
749 Model = request.session.model(model)
750 context = request.session.eval_context(request.context)
751 res = Model.read([int(id)], [field, fieldname], context)[0]
752 filecontent = res.get(field, '')
754 raise cherrypy.NotFound
756 cherrypy.response.headers['Content-Type'] = 'application/octet-stream'
757 filename = '%s_%s' % (model.replace('.', '_'), id)
759 filename = res.get(fieldname, '') or filename
760 cherrypy.response.headers['Content-Disposition'] = 'attachment; filename=' + filename
761 return base64.decodestring(filecontent)
763 @openerpweb.httprequest
764 def upload(self, request, session_id, callback, ufile=None):
765 cherrypy.response.timeout = 500
767 for key, val in cherrypy.request.headers.iteritems():
768 headers[key.lower()] = val
769 size = int(headers.get('content-length', 0))
770 # TODO: might be useful to have a configuration flag for max-length file uploads
772 out = """<script language="javascript" type="text/javascript">
773 var win = window.top.window,
775 if (typeof(callback) === 'function') {
776 callback.apply(this, %s);
778 win.jQuery('#oe_notification', win.document).notify('create', {
779 title: "Ajax File Upload",
780 text: "Could not find callback"
784 data = ufile.file.read()
785 args = [size, ufile.filename, ufile.headers.getheader('Content-Type'), base64.encodestring(data)]
787 args = [False, e.message]
788 return out % (simplejson.dumps(callback), simplejson.dumps(args))
790 @openerpweb.httprequest
791 def upload_attachment(self, request, session_id, callback, model, id, ufile=None):
792 cherrypy.response.timeout = 500
793 context = request.session.eval_context(request.context)
794 Model = request.session.model('ir.attachment')
796 out = """<script language="javascript" type="text/javascript">
797 var win = window.top.window,
799 if (typeof(callback) === 'function') {
800 callback.call(this, %s);
803 attachment_id = Model.create({
804 'name': ufile.filename,
805 'datas': base64.encodestring(ufile.file.read()),
810 'filename': ufile.filename,
814 args = { 'error': e.message }
815 return out % (simplejson.dumps(callback), simplejson.dumps(args))
817 class Action(openerpweb.Controller):
818 _cp_path = "/base/action"
820 @openerpweb.jsonrequest
821 def load(self, req, action_id):
822 Actions = req.session.model('ir.actions.actions')
824 context = req.session.eval_context(req.context)
825 action_type = Actions.read([action_id], ['type'], context)
827 action = req.session.model(action_type[0]['type']).read([action_id], False,
830 value = clean_action(action[0], req.session)
831 return {'result': value}
833 @openerpweb.jsonrequest
834 def run(self, req, action_id):
835 return clean_action(req.session.model('ir.actions.server').run(
836 [action_id], req.session.eval_context(req.context)), req.session)