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 def eval_context_and_domain(session, context, domain=None):
236 e_context = session.eval_context(context)
237 # should we give the evaluated context as an evaluation context to the domain?
238 e_domain = session.eval_domain(domain or [])
240 return e_context, e_domain
242 def load_actions_from_ir_values(req, key, key2, models, meta, context):
243 Values = req.session.model('ir.values')
244 actions = Values.get(key, key2, models, meta, context)
246 return [(id, name, clean_action(action, req.session))
247 for id, name, action in actions]
249 def clean_action(action, session):
250 if action['type'] != 'ir.actions.act_window':
252 # values come from the server, we can just eval them
253 if isinstance(action.get('context', None), basestring):
254 action['context'] = eval(
256 session.evaluation_context()) or {}
258 if isinstance(action.get('domain', None), basestring):
259 action['domain'] = eval(
261 session.evaluation_context(
262 action['context'])) or []
263 if 'flags' not in action:
264 # Set empty flags dictionary for web client.
265 action['flags'] = dict()
266 return fix_view_modes(action)
268 def generate_views(action):
270 While the server generates a sequence called "views" computing dependencies
271 between a bunch of stuff for views coming directly from the database
272 (the ``ir.actions.act_window model``), it's also possible for e.g. buttons
273 to return custom view dictionaries generated on the fly.
275 In that case, there is no ``views`` key available on the action.
277 Since the web client relies on ``action['views']``, generate it here from
278 ``view_mode`` and ``view_id``.
280 Currently handles two different cases:
282 * no view_id, multiple view_mode
283 * single view_id, single view_mode
285 :param dict action: action descriptor dictionary to generate a views key for
287 view_id = action.get('view_id', False)
288 if isinstance(view_id, (list, tuple)):
291 # providing at least one view mode is a requirement, not an option
292 view_modes = action['view_mode'].split(',')
294 if len(view_modes) > 1:
296 raise ValueError('Non-db action dictionaries should provide '
297 'either multiple view modes or a single view '
298 'mode and an optional view id.\n\n Got view '
299 'modes %r and view id %r for action %r' % (
300 view_modes, view_id, action))
301 action['views'] = [(False, mode) for mode in view_modes]
303 action['views'] = [(view_id, view_modes[0])]
306 def fix_view_modes(action):
307 """ For historical reasons, OpenERP has weird dealings in relation to
308 view_mode and the view_type attribute (on window actions):
310 * one of the view modes is ``tree``, which stands for both list views
312 * the choice is made by checking ``view_type``, which is either
313 ``form`` for a list view or ``tree`` for an actual tree view
315 This methods simply folds the view_type into view_mode by adding a
316 new view mode ``list`` which is the result of the ``tree`` view_mode
317 in conjunction with the ``form`` view_type.
319 TODO: this should go into the doc, some kind of "peculiarities" section
321 :param dict action: an action descriptor
322 :returns: nothing, the action is modified in place
324 if 'views' not in action:
325 generate_views(action)
327 if action.pop('view_type') != 'form':
331 [id, mode if mode != 'tree' else 'list']
332 for id, mode in action['views']
337 class Menu(openerpweb.Controller):
338 _cp_path = "/base/menu"
340 @openerpweb.jsonrequest
342 return {'data': self.do_load(req)}
344 def do_load(self, req):
345 """ Loads all menu items (all applications and their sub-menus).
347 :param req: A request object, with an OpenERP session attribute
348 :type req: < session -> OpenERPSession >
349 :return: the menu root
350 :rtype: dict('children': menu_nodes)
352 Menus = req.session.model('ir.ui.menu')
353 # menus are loaded fully unlike a regular tree view, cause there are
354 # less than 512 items
355 context = req.session.eval_context(req.context)
356 menu_ids = Menus.search([], 0, False, False, context)
357 menu_items = Menus.read(menu_ids, ['name', 'sequence', 'parent_id'], context)
358 menu_root = {'id': False, 'name': 'root', 'parent_id': [-1, '']}
359 menu_items.append(menu_root)
361 # make a tree using parent_id
362 menu_items_map = dict((menu_item["id"], menu_item) for menu_item in menu_items)
363 for menu_item in menu_items:
364 if menu_item['parent_id']:
365 parent = menu_item['parent_id'][0]
368 if parent in menu_items_map:
369 menu_items_map[parent].setdefault(
370 'children', []).append(menu_item)
372 # sort by sequence a tree using parent_id
373 for menu_item in menu_items:
374 menu_item.setdefault('children', []).sort(
375 key=lambda x:x["sequence"])
379 @openerpweb.jsonrequest
380 def action(self, req, menu_id):
381 actions = load_actions_from_ir_values(req,'action', 'tree_but_open',
382 [('ir.ui.menu', menu_id)], False,
383 req.session.eval_context(req.context))
384 return {"action": actions}
386 class DataSet(openerpweb.Controller):
387 _cp_path = "/base/dataset"
389 @openerpweb.jsonrequest
390 def fields(self, req, model):
391 return {'fields': req.session.model(model).fields_get(False,
392 req.session.eval_context(req.context))}
394 @openerpweb.jsonrequest
395 def search_read(self, request, model, fields=False, offset=0, limit=False, domain=None, context=None, sort=None):
396 return self.do_search_read(request, model, fields, offset, limit, domain, context, sort)
397 def do_search_read(self, request, model, fields=False, offset=0, limit=False, domain=None, context=None, sort=None):
398 """ Performs a search() followed by a read() (if needed) using the
399 provided search criteria
401 :param request: a JSON-RPC request object
402 :type request: openerpweb.JsonRequest
403 :param str model: the name of the model to search on
404 :param fields: a list of the fields to return in the result records
406 :param int offset: from which index should the results start being returned
407 :param int limit: the maximum number of records to return
408 :param list domain: the search domain for the query
409 :param list sort: sorting directives
410 :returns: a list of result records
413 Model = request.session.model(model)
414 context, domain = eval_context_and_domain(request.session, request.context, domain)
416 ids = Model.search(domain, offset or 0, limit or False,
417 sort or False, context)
419 if fields and fields == ['id']:
420 # shortcut read if we only want the ids
421 return map(lambda id: {'id': id}, ids)
423 reads = Model.read(ids, fields or False, context)
424 reads.sort(key=lambda obj: ids.index(obj['id']))
427 @openerpweb.jsonrequest
428 def get(self, request, model, ids, fields=False):
429 return self.do_get(request, model, ids, fields)
430 def do_get(self, request, model, ids, fields=False):
431 """ Fetches and returns the records of the model ``model`` whose ids
434 The results are in the same order as the inputs, but elements may be
435 missing (if there is no record left for the id)
437 :param request: the JSON-RPC2 request object
438 :type request: openerpweb.JsonRequest
439 :param model: the model to read from
441 :param ids: a list of identifiers
443 :param fields: a list of fields to fetch, ``False`` or empty to fetch
444 all fields in the model
445 :type fields: list | False
446 :returns: a list of records, in the same order as the list of ids
449 Model = request.session.model(model)
450 records = Model.read(ids, fields, request.session.eval_context(request.context))
452 record_map = dict((record['id'], record) for record in records)
454 return [record_map[id] for id in ids if record_map.get(id)]
456 @openerpweb.jsonrequest
457 def load(self, req, model, id, fields):
458 m = req.session.model(model)
460 r = m.read([id], False, req.session.eval_context(req.context))
463 return {'value': value}
465 @openerpweb.jsonrequest
466 def create(self, req, model, data):
467 m = req.session.model(model)
468 r = m.create(data, req.session.eval_context(req.context))
471 @openerpweb.jsonrequest
472 def save(self, req, model, id, data):
473 m = req.session.model(model)
474 r = m.write([id], data, req.session.eval_context(req.context))
477 @openerpweb.jsonrequest
478 def unlink(self, request, model, ids=()):
479 Model = request.session.model(model)
480 return Model.unlink(ids, request.session.eval_context(request.context))
482 def call_common(self, req, model, method, args, domain_id=None, context_id=None):
483 domain = args[domain_id] if domain_id and len(args) - 1 >= domain_id else []
484 context = args[context_id] if context_id and len(args) - 1 >= context_id else {}
485 c, d = eval_context_and_domain(req.session, context, domain)
486 if domain_id and len(args) - 1 >= domain_id:
488 if context_id and len(args) - 1 >= context_id:
491 return getattr(req.session.model(model), method)(*args)
493 @openerpweb.jsonrequest
494 def call(self, req, model, method, args, domain_id=None, context_id=None):
495 return {'result': self.call_common(req, model, method, args, domain_id, context_id)}
497 @openerpweb.jsonrequest
498 def call_button(self, req, model, method, args, domain_id=None, context_id=None):
499 action = self.call_common(req, model, method, args, domain_id, context_id)
500 if isinstance(action, dict) and action.get('type') != '':
501 return {'result': clean_action(action, req.session)}
502 return {'result': False}
504 @openerpweb.jsonrequest
505 def exec_workflow(self, req, model, id, signal):
506 r = req.session.exec_workflow(model, id, signal)
509 @openerpweb.jsonrequest
510 def default_get(self, req, model, fields):
511 m = req.session.model(model)
512 r = m.default_get(fields, req.session.eval_context(req.context))
515 class DataGroup(openerpweb.Controller):
516 _cp_path = "/base/group"
517 @openerpweb.jsonrequest
518 def read(self, request, model, group_by_fields, domain=None):
519 Model = request.session.model(model)
520 context, domain = eval_context_and_domain(request.session, request.context, domain)
522 return Model.read_group(
523 domain or [], False, group_by_fields, 0, False,
524 dict(context, group_by=group_by_fields))
526 class View(openerpweb.Controller):
527 _cp_path = "/base/view"
529 def fields_view_get(self, request, model, view_id, view_type,
530 transform=True, toolbar=False, submenu=False):
531 Model = request.session.model(model)
532 context = request.session.eval_context(request.context)
533 fvg = Model.fields_view_get(view_id, view_type, context, toolbar, submenu)
534 # todo fme?: check that we should pass the evaluated context here
535 self.process_view(request.session, fvg, context, transform)
538 def process_view(self, session, fvg, context, transform):
540 evaluation_context = session.evaluation_context(context or {})
541 xml = self.transform_view(fvg['arch'], session, evaluation_context)
543 xml = ElementTree.fromstring(fvg['arch'])
544 fvg['arch'] = Xml2Json.convert_element(xml)
545 for field in fvg['fields'].values():
546 if field.has_key('views') and field['views']:
547 for view in field["views"].values():
548 self.process_view(session, view, None, transform)
550 @openerpweb.jsonrequest
551 def add_custom(self, request, view_id, arch):
552 CustomView = request.session.model('ir.ui.view.custom')
554 'user_id': request.session._uid,
557 }, request.session.eval_context(request.context))
558 return {'result': True}
560 @openerpweb.jsonrequest
561 def undo_custom(self, request, view_id, reset=False):
562 CustomView = request.session.model('ir.ui.view.custom')
563 context = request.session.eval_context(request.context)
564 vcustom = CustomView.search([('user_id', '=', request.session._uid), ('ref_id' ,'=', view_id)],
565 0, False, False, context)
568 CustomView.unlink(vcustom, context)
570 CustomView.unlink([vcustom[0]], context)
571 return {'result': True}
572 return {'result': False}
574 def normalize_attrs(self, elem, context):
575 """ Normalize @attrs, @invisible, @required, @readonly and @states, so
576 the client only has to deal with @attrs.
578 See `the discoveries pad <http://pad.openerp.com/discoveries>`_ for
581 :param elem: the current view node (Python object)
582 :type elem: xml.etree.ElementTree.Element
583 :param dict context: evaluation context
585 # If @attrs is normalized in json by server, the eval should be replaced by simplejson.loads
586 attrs = openerpweb.ast.literal_eval(elem.get('attrs', '{}'))
587 if 'states' in elem.attrib:
588 attrs.setdefault('invisible', [])\
589 .append(('state', 'not in', elem.attrib.pop('states').split(',')))
591 elem.set('attrs', simplejson.dumps(attrs))
592 for a in ['invisible', 'readonly', 'required']:
594 # In the XML we trust
595 avalue = bool(eval(elem.get(a, 'False'),
596 {'context': context or {}}))
601 if a == 'invisible' and 'attrs' in elem.attrib:
602 del elem.attrib['attrs']
604 def transform_view(self, view_string, session, context=None):
605 # transform nodes on the fly via iterparse, instead of
606 # doing it statically on the parsing result
607 parser = ElementTree.iterparse(StringIO(view_string), events=("start",))
609 for event, elem in parser:
613 self.normalize_attrs(elem, context)
614 self.parse_domains_and_contexts(elem, session)
617 def parse_domain(self, elem, attr_name, session):
618 """ Parses an attribute of the provided name as a domain, transforms it
619 to either a literal domain or a :class:`openerpweb.nonliterals.Domain`
621 :param elem: the node being parsed
622 :type param: xml.etree.ElementTree.Element
623 :param str attr_name: the name of the attribute which should be parsed
624 :param session: Current OpenERP session
625 :type session: openerpweb.openerpweb.OpenERPSession
627 domain = elem.get(attr_name, '').strip()
632 openerpweb.ast.literal_eval(
637 openerpweb.nonliterals.Domain(session, domain))
639 def parse_domains_and_contexts(self, elem, session):
640 """ Converts domains and contexts from the view into Python objects,
641 either literals if they can be parsed by literal_eval or a special
642 placeholder object if the domain or context refers to free variables.
644 :param elem: the current node being parsed
645 :type param: xml.etree.ElementTree.Element
646 :param session: OpenERP session object, used to store and retrieve
648 :type session: openerpweb.openerpweb.OpenERPSession
650 self.parse_domain(elem, 'domain', session)
651 self.parse_domain(elem, 'filter_domain', session)
652 context_string = elem.get('context', '').strip()
656 openerpweb.ast.literal_eval(context_string))
659 openerpweb.nonliterals.Context(
660 session, context_string))
662 class FormView(View):
663 _cp_path = "/base/formview"
665 @openerpweb.jsonrequest
666 def load(self, req, model, view_id, toolbar=False):
667 fields_view = self.fields_view_get(req, model, view_id, 'form', toolbar=toolbar)
668 return {'fields_view': fields_view}
670 class ListView(View):
671 _cp_path = "/base/listview"
673 @openerpweb.jsonrequest
674 def load(self, req, model, view_id, toolbar=False):
675 fields_view = self.fields_view_get(req, model, view_id, 'tree', toolbar=toolbar)
676 return {'fields_view': fields_view}
678 def process_colors(self, view, row, context):
679 colors = view['arch']['attrs'].get('colors')
686 for pair in colors.split(';')
687 if eval(pair.split(':')[1], dict(context, **row))
692 elif len(color) == 1:
696 class SearchView(View):
697 _cp_path = "/base/searchview"
699 @openerpweb.jsonrequest
700 def load(self, req, model, view_id):
701 fields_view = self.fields_view_get(req, model, view_id, 'search')
702 return {'fields_view': fields_view}
704 @openerpweb.jsonrequest
705 def fields_get(self, req, model):
706 Model = req.session.model(model)
707 fields = Model.fields_get(False, req.session.eval_context(req.context))
708 return {'fields': fields}
710 class Binary(openerpweb.Controller):
711 _cp_path = "/base/binary"
713 @openerpweb.httprequest
714 def image(self, request, session_id, model, id, field, **kw):
715 cherrypy.response.headers['Content-Type'] = 'image/png'
716 Model = request.session.model(model)
717 context = request.session.eval_context(request.context)
720 res = Model.default_get([field], context).get(field, '')
722 res = Model.read([int(id)], [field], context)[0].get(field, '')
723 return base64.decodestring(res)
724 except: # TODO: what's the exception here?
725 return self.placeholder()
726 def placeholder(self):
727 return open(os.path.join(openerpweb.path_addons, 'base', 'static', 'src', 'img', 'placeholder.png'), 'rb').read()
729 @openerpweb.httprequest
730 def saveas(self, request, session_id, model, id, field, fieldname, **kw):
731 Model = request.session.model(model)
732 context = request.session.eval_context(request.context)
733 res = Model.read([int(id)], [field, fieldname], context)[0]
734 filecontent = res.get(field, '')
736 raise cherrypy.NotFound
738 cherrypy.response.headers['Content-Type'] = 'application/octet-stream'
739 filename = '%s_%s' % (model.replace('.', '_'), id)
741 filename = res.get(fieldname, '') or filename
742 cherrypy.response.headers['Content-Disposition'] = 'attachment; filename=' + filename
743 return base64.decodestring(filecontent)
745 @openerpweb.httprequest
746 def upload(self, request, session_id, callback, ufile=None):
747 cherrypy.response.timeout = 500
749 for key, val in cherrypy.request.headers.iteritems():
750 headers[key.lower()] = val
751 size = int(headers.get('content-length', 0))
752 # TODO: might be useful to have a configuration flag for max-length file uploads
754 out = """<script language="javascript" type="text/javascript">
755 var win = window.top.window,
757 if (typeof(callback) === 'function') {
758 callback.apply(this, %s);
760 win.jQuery('#oe_notification', win.document).notify('create', {
761 title: "Ajax File Upload",
762 text: "Could not find callback"
766 data = ufile.file.read()
767 args = [size, ufile.filename, ufile.headers.getheader('Content-Type'), base64.encodestring(data)]
769 args = [False, e.message]
770 return out % (simplejson.dumps(callback), simplejson.dumps(args))
772 @openerpweb.httprequest
773 def upload_attachment(self, request, session_id, callback, model, id, ufile=None):
774 cherrypy.response.timeout = 500
775 context = request.session.eval_context(request.context)
776 Model = request.session.model('ir.attachment')
778 out = """<script language="javascript" type="text/javascript">
779 var win = window.top.window,
781 if (typeof(callback) === 'function') {
782 callback.call(this, %s);
785 attachment_id = Model.create({
786 'name': ufile.filename,
787 'datas': base64.encodestring(ufile.file.read()),
792 'filename': ufile.filename,
796 args = { 'error': e.message }
797 return out % (simplejson.dumps(callback), simplejson.dumps(args))
799 class Action(openerpweb.Controller):
800 _cp_path = "/base/action"
802 @openerpweb.jsonrequest
803 def load(self, req, action_id):
804 Actions = req.session.model('ir.actions.actions')
806 context = req.session.eval_context(req.context)
807 action_type = Actions.read([action_id], ['type'], context)
809 action = req.session.model(action_type[0]['type']).read([action_id], False,
812 value = clean_action(action[0], req.session)
813 return {'result': value}
815 @openerpweb.jsonrequest
816 def run(self, req, action_id):
817 return clean_action(req.session.model('ir.actions.server').run(
818 [action_id], req.session.eval_context(req.context)), req.session)