1 # -*- coding: utf-8 -*-
2 import base64, glob, os, re
3 from xml.etree import ElementTree
4 from cStringIO import StringIO
10 import openerpweb.nonliterals
14 # Should move to openerpweb.Xml2Json
17 # Simple and straightforward XML-to-JSON converter in Python
20 # URL: http://code.google.com/p/xml2json-direct/
22 def convert_to_json(s):
23 return simplejson.dumps(
24 Xml2Json.convert_to_structure(s), sort_keys=True, indent=4)
27 def convert_to_structure(s):
28 root = ElementTree.fromstring(s)
29 return Xml2Json.convert_element(root)
32 def convert_element(el, skip_whitespaces=True):
35 ns, name = el.tag.rsplit("}", 1)
37 res["namespace"] = ns[1:]
41 for k, v in el.items():
44 if el.text and (not skip_whitespaces or el.text.strip() != ''):
47 kids.append(Xml2Json.convert_element(kid))
48 if kid.tail and (not skip_whitespaces or kid.tail.strip() != ''):
50 res["children"] = kids
53 #----------------------------------------------------------
54 # OpenERP Web base Controllers
55 #----------------------------------------------------------
57 class Database(openerpweb.Controller):
58 _cp_path = "/base/database"
60 @openerpweb.jsonrequest
61 def get_databases_list(self, req):
62 proxy = req.session.proxy("db")
64 h = req.httprequest.headers['Host'].split(':')[0]
66 r = cherrypy.config['openerp.dbfilter'].replace('%h',h).replace('%d',d)
68 dbs = [i for i in dbs if re.match(r,i)]
69 return {"db_list": dbs}
71 class Session(openerpweb.Controller):
72 _cp_path = "/base/session"
74 def manifest_glob(self, addons, key):
77 globlist = openerpweb.addons_manifest.get(addon, {}).get(key, [])
80 resource_path[len(openerpweb.path_addons):]
81 for pattern in globlist
82 for resource_path in glob.glob(os.path.join(
83 openerpweb.path_addons, addon, pattern))
87 def concat_files(self, file_list):
88 """ Concatenate file content
89 return (concat,timestamp)
90 concat: concatenation of file content
91 timestamp: max(os.path.getmtime of file_list)
93 root = openerpweb.path_root
97 fname = os.path.join(root, i)
98 ftime = os.path.getmtime(fname)
99 if ftime > files_timestamp:
100 files_timestamp = ftime
101 files_content = open(fname).read()
102 files_concat = "".join(files_content)
105 @openerpweb.jsonrequest
106 def login(self, req, db, login, password):
107 req.session.login(db, login, password)
110 "session_id": req.session_id,
111 "uid": req.session._uid,
114 @openerpweb.jsonrequest
115 def sc_list(self, req):
116 return req.session.model('ir.ui.view_sc').get_sc(req.session._uid, "ir.ui.menu",
117 req.session.eval_context(req.context))
119 @openerpweb.jsonrequest
120 def modules(self, req):
121 return {"modules": [name
122 for name, manifest in openerpweb.addons_manifest.iteritems()
123 if manifest.get('active', True)]}
125 @openerpweb.jsonrequest
126 def csslist(self, req, mods='base'):
127 return {'files': self.manifest_glob(mods.split(','), 'css')}
129 @openerpweb.jsonrequest
130 def jslist(self, req, mods='base'):
131 return {'files': self.manifest_glob(mods.split(','), 'js')}
133 def css(self, req, mods='base'):
134 files = self.manifest_glob(mods.split(','), 'css')
135 concat = self.concat_files(files)[0]
136 # TODO request set the Date of last modif and Etag
140 def js(self, req, mods='base'):
141 files = self.manifest_glob(mods.split(','), 'js')
142 concat = self.concat_files(files)[0]
143 # TODO request set the Date of last modif and Etag
147 @openerpweb.jsonrequest
148 def eval_domain_and_context(self, req, contexts, domains,
150 """ Evaluates sequences of domains and contexts, composing them into
151 a single context, domain or group_by sequence.
153 :param list contexts: list of contexts to merge together. Contexts are
154 evaluated in sequence, all previous contexts
155 are part of their own evaluation context
156 (starting at the session context).
157 :param list domains: list of domains to merge together. Domains are
158 evaluated in sequence and appended to one another
159 (implicit AND), their evaluation domain is the
160 result of merging all contexts.
161 :param list group_by_seq: list of domains (which may be in a different
162 order than the ``contexts`` parameter),
163 evaluated in sequence, their ``'group_by'``
164 key is extracted if they have one.
169 the global context created by merging all of
173 the concatenation of all domains
176 a list of fields to group by, potentially empty (in which case
177 no group by should be performed)
179 context, domain = eval_context_and_domain(req.session,
180 openerpweb.nonliterals.CompoundContext(*(contexts or [])),
181 openerpweb.nonliterals.CompoundDomain(*(domains or [])))
183 group_by_sequence = []
184 for candidate in (group_by_seq or []):
185 ctx = req.session.eval_context(candidate, context)
186 group_by = ctx.get('group_by')
189 elif isinstance(group_by, basestring):
190 group_by_sequence.append(group_by)
192 group_by_sequence.extend(group_by)
197 'group_by': group_by_sequence
200 @openerpweb.jsonrequest
201 def save_session_action(self, req, the_action):
203 This method store an action object in the session object and returns an integer
204 identifying that action. The method get_session_action() can be used to get
207 :param the_action: The action to save in the session.
208 :type the_action: anything
209 :return: A key identifying the saved action.
212 saved_actions = cherrypy.session.get('saved_actions')
213 if not saved_actions:
214 saved_actions = {"next":0, "actions":{}}
215 cherrypy.session['saved_actions'] = saved_actions
216 # we don't allow more than 10 stored actions
217 if len(saved_actions["actions"]) >= 10:
218 del saved_actions["actions"][min(saved_actions["actions"].keys())]
219 key = saved_actions["next"]
220 saved_actions["actions"][key] = the_action
221 saved_actions["next"] = key + 1
224 @openerpweb.jsonrequest
225 def get_session_action(self, req, key):
227 Gets back a previously saved action. This method can return None if the action
228 was saved since too much time (this case should be handled in a smart way).
230 :param key: The key given by save_session_action()
232 :return: The saved action or None.
235 saved_actions = cherrypy.session.get('saved_actions')
236 if not saved_actions:
238 return saved_actions["actions"].get(key)
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])]
310 def fix_view_modes(action):
311 """ For historical reasons, OpenERP has weird dealings in relation to
312 view_mode and the view_type attribute (on window actions):
314 * one of the view modes is ``tree``, which stands for both list views
316 * the choice is made by checking ``view_type``, which is either
317 ``form`` for a list view or ``tree`` for an actual tree view
319 This methods simply folds the view_type into view_mode by adding a
320 new view mode ``list`` which is the result of the ``tree`` view_mode
321 in conjunction with the ``form`` view_type.
323 TODO: this should go into the doc, some kind of "peculiarities" section
325 :param dict action: an action descriptor
326 :returns: nothing, the action is modified in place
328 if 'views' not in action:
329 generate_views(action)
331 if action.pop('view_type') != 'form':
335 [id, mode if mode != 'tree' else 'list']
336 for id, mode in action['views']
341 class Menu(openerpweb.Controller):
342 _cp_path = "/base/menu"
344 @openerpweb.jsonrequest
346 return {'data': self.do_load(req)}
348 def do_load(self, req):
349 """ Loads all menu items (all applications and their sub-menus).
351 :param req: A request object, with an OpenERP session attribute
352 :type req: < session -> OpenERPSession >
353 :return: the menu root
354 :rtype: dict('children': menu_nodes)
356 Menus = req.session.model('ir.ui.menu')
357 # menus are loaded fully unlike a regular tree view, cause there are
358 # less than 512 items
359 context = req.session.eval_context(req.context)
360 menu_ids = Menus.search([], 0, False, False, context)
361 menu_items = Menus.read(menu_ids, ['name', 'sequence', 'parent_id'], context)
362 menu_root = {'id': False, 'name': 'root', 'parent_id': [-1, '']}
363 menu_items.append(menu_root)
365 # make a tree using parent_id
366 menu_items_map = dict((menu_item["id"], menu_item) for menu_item in menu_items)
367 for menu_item in menu_items:
368 if menu_item['parent_id']:
369 parent = menu_item['parent_id'][0]
372 if parent in menu_items_map:
373 menu_items_map[parent].setdefault(
374 'children', []).append(menu_item)
376 # sort by sequence a tree using parent_id
377 for menu_item in menu_items:
378 menu_item.setdefault('children', []).sort(
379 key=lambda x:x["sequence"])
383 @openerpweb.jsonrequest
384 def action(self, req, menu_id):
385 actions = load_actions_from_ir_values(req,'action', 'tree_but_open',
386 [('ir.ui.menu', menu_id)], False,
387 req.session.eval_context(req.context))
388 return {"action": actions}
390 class DataSet(openerpweb.Controller):
391 _cp_path = "/base/dataset"
393 @openerpweb.jsonrequest
394 def fields(self, req, model):
395 return {'fields': req.session.model(model).fields_get(False,
396 req.session.eval_context(req.context))}
398 @openerpweb.jsonrequest
399 def search_read(self, request, model, fields=False, offset=0, limit=False, domain=None, sort=None):
400 return self.do_search_read(request, model, fields, offset, limit, domain, sort)
401 def do_search_read(self, request, model, fields=False, offset=0, limit=False, domain=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 structure (dict) with two keys: ids (all the ids matching
416 the (domain, context) pair) and records (paginated records
417 matching fields selection set)
420 Model = request.session.model(model)
421 context, domain = eval_context_and_domain(
422 request.session, request.context, domain)
424 ids = Model.search(domain, 0, False, sort or False, context)
425 # need to fill the dataset with all ids for the (domain, context) pair,
426 # so search un-paginated and paginate manually before reading
427 paginated_ids = ids[offset:(offset + limit if limit else None)]
428 if fields and fields == ['id']:
429 # shortcut read if we only want the ids
432 'records': map(lambda id: {'id': id}, paginated_ids)
435 records = Model.read(paginated_ids, fields or False, context)
436 records.sort(key=lambda obj: ids.index(obj['id']))
443 @openerpweb.jsonrequest
444 def get(self, request, model, ids, fields=False):
445 return self.do_get(request, model, ids, fields)
446 def do_get(self, request, model, ids, fields=False):
447 """ Fetches and returns the records of the model ``model`` whose ids
450 The results are in the same order as the inputs, but elements may be
451 missing (if there is no record left for the id)
453 :param request: the JSON-RPC2 request object
454 :type request: openerpweb.JsonRequest
455 :param model: the model to read from
457 :param ids: a list of identifiers
459 :param fields: a list of fields to fetch, ``False`` or empty to fetch
460 all fields in the model
461 :type fields: list | False
462 :returns: a list of records, in the same order as the list of ids
465 Model = request.session.model(model)
466 records = Model.read(ids, fields, request.session.eval_context(request.context))
468 record_map = dict((record['id'], record) for record in records)
470 return [record_map[id] for id in ids if record_map.get(id)]
472 @openerpweb.jsonrequest
473 def load(self, req, model, id, fields):
474 m = req.session.model(model)
476 r = m.read([id], False, req.session.eval_context(req.context))
479 return {'value': value}
481 @openerpweb.jsonrequest
482 def create(self, req, model, data):
483 m = req.session.model(model)
484 r = m.create(data, req.session.eval_context(req.context))
487 @openerpweb.jsonrequest
488 def save(self, req, model, id, data):
489 m = req.session.model(model)
490 r = m.write([id], data, req.session.eval_context(req.context))
493 @openerpweb.jsonrequest
494 def unlink(self, request, model, ids=()):
495 Model = request.session.model(model)
496 return Model.unlink(ids, request.session.eval_context(request.context))
498 def call_common(self, req, model, method, args, domain_id=None, context_id=None):
499 domain = args[domain_id] if domain_id and len(args) - 1 >= domain_id else []
500 context = args[context_id] if context_id and len(args) - 1 >= context_id else {}
501 c, d = eval_context_and_domain(req.session, context, domain)
502 if domain_id and len(args) - 1 >= domain_id:
504 if context_id and len(args) - 1 >= context_id:
507 return getattr(req.session.model(model), method)(*args)
509 @openerpweb.jsonrequest
510 def call(self, req, model, method, args, domain_id=None, context_id=None):
511 return self.call_common(req, model, method, args, domain_id, context_id)
513 @openerpweb.jsonrequest
514 def call_button(self, req, model, method, args, domain_id=None, context_id=None):
515 action = self.call_common(req, model, method, args, domain_id, context_id)
516 if isinstance(action, dict) and action.get('type') != '':
517 return {'result': clean_action(action, req.session)}
518 return {'result': False}
520 @openerpweb.jsonrequest
521 def exec_workflow(self, req, model, id, signal):
522 r = req.session.exec_workflow(model, id, signal)
525 @openerpweb.jsonrequest
526 def default_get(self, req, model, fields):
527 Model = req.session.model(model)
528 return Model.default_get(fields, req.session.eval_context(req.context))
530 class DataGroup(openerpweb.Controller):
531 _cp_path = "/base/group"
532 @openerpweb.jsonrequest
533 def read(self, request, model, fields, group_by_fields, domain=None, sort=None):
534 Model = request.session.model(model)
535 context, domain = eval_context_and_domain(request.session, request.context, domain)
537 return Model.read_group(
538 domain or [], fields, group_by_fields, 0, False,
539 dict(context, group_by=group_by_fields), sort or False)
541 class View(openerpweb.Controller):
542 _cp_path = "/base/view"
544 def fields_view_get(self, request, model, view_id, view_type,
545 transform=True, toolbar=False, submenu=False):
546 Model = request.session.model(model)
547 context = request.session.eval_context(request.context)
548 fvg = Model.fields_view_get(view_id, view_type, context, toolbar, submenu)
549 # todo fme?: check that we should pass the evaluated context here
550 self.process_view(request.session, fvg, context, transform)
553 def process_view(self, session, fvg, context, transform):
554 # depending on how it feels, xmlrpclib.ServerProxy can translate
555 # XML-RPC strings to ``str`` or ``unicode``. ElementTree does not
556 # enjoy unicode strings which can not be trivially converted to
557 # strings, and it blows up during parsing.
559 # So ensure we fix this retardation by converting view xml back to
561 if isinstance(fvg['arch'], unicode):
562 arch = fvg['arch'].encode('utf-8')
567 evaluation_context = session.evaluation_context(context or {})
568 xml = self.transform_view(arch, session, evaluation_context)
570 xml = ElementTree.fromstring(arch)
571 fvg['arch'] = Xml2Json.convert_element(xml)
573 for field in fvg['fields'].itervalues():
574 if field.get('views'):
575 for view in field["views"].itervalues():
576 self.process_view(session, view, None, transform)
577 if field.get('domain'):
578 field["domain"] = self.parse_domain(field["domain"], session)
579 if field.get('context'):
580 field["context"] = self.parse_context(field["context"], session)
582 @openerpweb.jsonrequest
583 def add_custom(self, request, view_id, arch):
584 CustomView = request.session.model('ir.ui.view.custom')
586 'user_id': request.session._uid,
589 }, request.session.eval_context(request.context))
590 return {'result': True}
592 @openerpweb.jsonrequest
593 def undo_custom(self, request, view_id, reset=False):
594 CustomView = request.session.model('ir.ui.view.custom')
595 context = request.session.eval_context(request.context)
596 vcustom = CustomView.search([('user_id', '=', request.session._uid), ('ref_id' ,'=', view_id)],
597 0, False, False, context)
600 CustomView.unlink(vcustom, context)
602 CustomView.unlink([vcustom[0]], context)
603 return {'result': True}
604 return {'result': False}
606 def transform_view(self, view_string, session, context=None):
607 # transform nodes on the fly via iterparse, instead of
608 # doing it statically on the parsing result
609 parser = ElementTree.iterparse(StringIO(view_string), events=("start",))
611 for event, elem in parser:
615 self.parse_domains_and_contexts(elem, session)
618 def parse_domain(self, domain, session):
619 """ Parses an arbitrary string containing a domain, transforms it
620 to either a literal domain or a :class:`openerpweb.nonliterals.Domain`
622 :param domain: the domain to parse, if the domain is not a string it is assumed to
623 be a literal domain and is returned as-is
624 :param session: Current OpenERP session
625 :type session: openerpweb.openerpweb.OpenERPSession
627 if not isinstance(domain, (str, unicode)):
630 return openerpweb.ast.literal_eval(domain)
633 return openerpweb.nonliterals.Domain(session, domain)
635 def parse_context(self, context, session):
636 """ Parses an arbitrary string containing a context, transforms it
637 to either a literal context or a :class:`openerpweb.nonliterals.Context`
639 :param context: the context to parse, if the context is not a string it is assumed to
640 be a literal domain and is returned as-is
641 :param session: Current OpenERP session
642 :type session: openerpweb.openerpweb.OpenERPSession
644 if not isinstance(context, (str, unicode)):
647 return openerpweb.ast.literal_eval(context)
649 return openerpweb.nonliterals.Context(session, context)
651 def parse_domains_and_contexts(self, elem, session):
652 """ Converts domains and contexts from the view into Python objects,
653 either literals if they can be parsed by literal_eval or a special
654 placeholder object if the domain or context refers to free variables.
656 :param elem: the current node being parsed
657 :type param: xml.etree.ElementTree.Element
658 :param session: OpenERP session object, used to store and retrieve
660 :type session: openerpweb.openerpweb.OpenERPSession
662 for el in ['domain', 'filter_domain']:
663 domain = elem.get(el, '').strip()
665 elem.set(el, self.parse_domain(domain, session))
666 for el in ['context', 'default_get']:
667 context_string = elem.get(el, '').strip()
669 elem.set(el, self.parse_context(context_string, session))
671 class FormView(View):
672 _cp_path = "/base/formview"
674 @openerpweb.jsonrequest
675 def load(self, req, model, view_id, toolbar=False):
676 fields_view = self.fields_view_get(req, model, view_id, 'form', toolbar=toolbar)
677 return {'fields_view': fields_view}
679 class ListView(View):
680 _cp_path = "/base/listview"
682 @openerpweb.jsonrequest
683 def load(self, req, model, view_id, toolbar=False):
684 fields_view = self.fields_view_get(req, model, view_id, 'tree', toolbar=toolbar)
685 return {'fields_view': fields_view}
687 def process_colors(self, view, row, context):
688 colors = view['arch']['attrs'].get('colors')
695 for pair in colors.split(';')
696 if eval(pair.split(':')[1], dict(context, **row))
701 elif len(color) == 1:
705 class SearchView(View):
706 _cp_path = "/base/searchview"
708 @openerpweb.jsonrequest
709 def load(self, req, model, view_id):
710 fields_view = self.fields_view_get(req, model, view_id, 'search')
711 return {'fields_view': fields_view}
713 @openerpweb.jsonrequest
714 def fields_get(self, req, model):
715 Model = req.session.model(model)
716 fields = Model.fields_get(False, req.session.eval_context(req.context))
717 return {'fields': fields}
719 class Binary(openerpweb.Controller):
720 _cp_path = "/base/binary"
722 @openerpweb.httprequest
723 def image(self, request, session_id, model, id, field, **kw):
724 cherrypy.response.headers['Content-Type'] = 'image/png'
725 Model = request.session.model(model)
726 context = request.session.eval_context(request.context)
729 res = Model.default_get([field], context).get(field, '')
731 res = Model.read([int(id)], [field], context)[0].get(field, '')
732 return base64.decodestring(res)
733 except: # TODO: what's the exception here?
734 return self.placeholder()
735 def placeholder(self):
736 return open(os.path.join(openerpweb.path_addons, 'base', 'static', 'src', 'img', 'placeholder.png'), 'rb').read()
738 @openerpweb.httprequest
739 def saveas(self, request, session_id, model, id, field, fieldname, **kw):
740 Model = request.session.model(model)
741 context = request.session.eval_context(request.context)
742 res = Model.read([int(id)], [field, fieldname], context)[0]
743 filecontent = res.get(field, '')
745 raise cherrypy.NotFound
747 cherrypy.response.headers['Content-Type'] = 'application/octet-stream'
748 filename = '%s_%s' % (model.replace('.', '_'), id)
750 filename = res.get(fieldname, '') or filename
751 cherrypy.response.headers['Content-Disposition'] = 'attachment; filename=' + filename
752 return base64.decodestring(filecontent)
754 @openerpweb.httprequest
755 def upload(self, request, session_id, callback, ufile=None):
756 cherrypy.response.timeout = 500
758 for key, val in cherrypy.request.headers.iteritems():
759 headers[key.lower()] = val
760 size = int(headers.get('content-length', 0))
761 # TODO: might be useful to have a configuration flag for max-length file uploads
763 out = """<script language="javascript" type="text/javascript">
764 var win = window.top.window,
766 if (typeof(callback) === 'function') {
767 callback.apply(this, %s);
769 win.jQuery('#oe_notification', win.document).notify('create', {
770 title: "Ajax File Upload",
771 text: "Could not find callback"
775 data = ufile.file.read()
776 args = [size, ufile.filename, ufile.headers.getheader('Content-Type'), base64.encodestring(data)]
778 args = [False, e.message]
779 return out % (simplejson.dumps(callback), simplejson.dumps(args))
781 @openerpweb.httprequest
782 def upload_attachment(self, request, session_id, callback, model, id, ufile=None):
783 cherrypy.response.timeout = 500
784 context = request.session.eval_context(request.context)
785 Model = request.session.model('ir.attachment')
787 out = """<script language="javascript" type="text/javascript">
788 var win = window.top.window,
790 if (typeof(callback) === 'function') {
791 callback.call(this, %s);
794 attachment_id = Model.create({
795 'name': ufile.filename,
796 'datas': base64.encodestring(ufile.file.read()),
801 'filename': ufile.filename,
805 args = { 'error': e.message }
806 return out % (simplejson.dumps(callback), simplejson.dumps(args))
808 class Action(openerpweb.Controller):
809 _cp_path = "/base/action"
811 @openerpweb.jsonrequest
812 def load(self, req, action_id):
813 Actions = req.session.model('ir.actions.actions')
815 context = req.session.eval_context(req.context)
816 action_type = Actions.read([action_id], ['type'], context)
818 action = req.session.model(action_type[0]['type']).read([action_id], False,
821 value = clean_action(action[0], req.session)
822 return {'result': value}
824 @openerpweb.jsonrequest
825 def run(self, req, action_id):
826 return clean_action(req.session.model('ir.actions.server').run(
827 [action_id], req.session.eval_context(req.context)), req.session)