1 # -*- coding: utf-8 -*-
21 from xml.etree import ElementTree
22 from cStringIO import StringIO
24 import babel.messages.pofile
26 import werkzeug.wrappers
33 import openerp.modules.registry
34 from openerp.tools.translate import _
35 from openerp.tools import config
39 from openerp.addons.web.http import request
41 #----------------------------------------------------------
43 #----------------------------------------------------------
46 """ Minify js with a clever regex.
47 Taken from http://opensource.perlig.de/rjsmin
48 Apache License, Version 2.0 """
50 """ Substitution callback """
51 groups = match.groups()
57 (groups[4] and '\n') or
58 (groups[5] and ' ') or
59 (groups[6] and ' ') or
60 (groups[7] and ' ') or
65 r'([^\047"/\000-\040]+)|((?:(?:\047[^\047\\\r\n]*(?:\\(?:[^\r\n]|\r?'
66 r'\n|\r)[^\047\\\r\n]*)*\047)|(?:"[^"\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|'
67 r'\r)[^"\\\r\n]*)*"))[^\047"/\000-\040]*)|(?:(?<=[(,=:\[!&|?{};\r\n]'
68 r')(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/'
69 r'))*((?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*'
70 r'(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*'
71 r'))|(?:(?<=[\000-#%-,./:-@\[-^`{-~-]return)(?:[\000-\011\013\014\01'
72 r'6-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*((?:/(?![\r\n/*])[^/'
73 r'\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]'
74 r'*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*))|(?<=[^\000-!#%&(*,./'
75 r':-@\[\\^`{|~])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/'
76 r'*][^*]*\*+)*/))*(?:((?:(?://[^\r\n]*)?[\r\n]))(?:[\000-\011\013\01'
77 r'4\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-\040"#'
78 r'%-\047)*,./:-@\\-^`|-~])|(?<=[^\000-#%-,./:-@\[-^`{-~-])((?:[\000-'
79 r'\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=[^'
80 r'\000-#%-,./:-@\[-^`{-~-])|(?<=\+)((?:[\000-\011\013\014\016-\040]|'
81 r'(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=\+)|(?<=-)((?:[\000-\011\0'
82 r'13\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=-)|(?:[\0'
83 r'00-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))+|(?:'
84 r'(?:(?://[^\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*'
85 r']*\*+(?:[^/*][^*]*\*+)*/))*)+', subber, '\n%s\n' % script
89 db_list = http.db_list
91 def db_monodb_redirect():
94 if request.params.get('db'):
98 # redirect to the chosen db if multiple are available
100 if db and len(dbs) > 1:
101 query = dict(urlparse.parse_qsl(request.httprequest.query_string, keep_blank_values=True))
102 query.update({ 'db': db })
103 redirect = request.httprequest.path + '?' + urllib.urlencode(query)
104 return (db, redirect)
106 db_monodb = http.db_monodb
108 def redirect_with_hash(url, code=303):
109 if request.httprequest.user_agent.browser == 'msie':
111 version = float(request.httprequest.user_agent.version)
113 return "<html><head><script>window.location = '%s#' + location.hash;</script></head></html>" % url
116 return werkzeug.utils.redirect(url, code)
118 def module_topological_sort(modules):
119 """ Return a list of module names sorted so that their dependencies of the
120 modules are listed before the module itself
122 modules is a dict of {module_name: dependencies}
124 :param modules: modules to sort
129 dependencies = set(itertools.chain.from_iterable(modules.itervalues()))
130 # incoming edge: dependency on other module (if a depends on b, a has an
131 # incoming edge from b, aka there's an edge from b to a)
132 # outgoing edge: other module depending on this one
134 # [Tarjan 1976], http://en.wikipedia.org/wiki/Topological_sorting#Algorithms
135 #L ← Empty list that will contain the sorted nodes
137 #S ← Set of all nodes with no outgoing edges (modules on which no other
139 S = set(module for module in modules if module not in dependencies)
142 #function visit(node n)
144 #if n has not been visited yet then
148 #change: n not web module, can not be resolved, ignore
149 if n not in modules: return
150 #for each node m with an edge from m to n do (dependencies of n)
156 #for each node n in S do
162 def module_installed():
163 # Candidates module the current heuristic is the /static dir
164 loadable = http.addons_manifest.keys()
167 # Retrieve database installed modules
168 # TODO The following code should move to ir.module.module.list_installed_modules()
169 Modules = request.session.model('ir.module.module')
170 domain = [('state','=','installed'), ('name','in', loadable)]
171 for module in Modules.search_read(domain, ['name', 'dependencies_id']):
172 modules[module['name']] = []
173 deps = module.get('dependencies_id')
175 deps_read = request.session.model('ir.module.module.dependency').read(deps, ['name'])
176 dependencies = [i['name'] for i in deps_read]
177 modules[module['name']] = dependencies
179 sorted_modules = module_topological_sort(modules)
180 return sorted_modules
182 def module_installed_bypass_session(dbname):
183 loadable = http.addons_manifest.keys()
186 registry = openerp.modules.registry.RegistryManager.get(dbname)
187 with registry.cursor() as cr:
188 m = registry.get('ir.module.module')
189 # TODO The following code should move to ir.module.module.list_installed_modules()
190 domain = [('state','=','installed'), ('name','in', loadable)]
191 ids = m.search(cr, 1, [('state','=','installed'), ('name','in', loadable)])
192 for module in m.read(cr, 1, ids, ['name', 'dependencies_id']):
193 modules[module['name']] = []
194 deps = module.get('dependencies_id')
196 deps_read = registry.get('ir.module.module.dependency').read(cr, 1, deps, ['name'])
197 dependencies = [i['name'] for i in deps_read]
198 modules[module['name']] = dependencies
201 sorted_modules = module_topological_sort(modules)
202 return sorted_modules
204 def module_boot(db=None):
205 server_wide_modules = openerp.conf.server_wide_modules or ['web']
208 for i in server_wide_modules:
209 if i in http.addons_manifest:
211 monodb = db or db_monodb()
213 dbside = module_installed_bypass_session(monodb)
214 dbside = [i for i in dbside if i not in serverside]
215 addons = serverside + dbside
218 def concat_xml(file_list):
219 """Concatenate xml files
221 :param list(str) file_list: list of files to check
222 :returns: (concatenation_result, checksum)
225 checksum = hashlib.new('sha1')
227 return '', checksum.hexdigest()
230 for fname in file_list:
231 with open(fname, 'rb') as fp:
233 checksum.update(contents)
235 xml = ElementTree.parse(fp).getroot()
238 root = ElementTree.Element(xml.tag)
239 #elif root.tag != xml.tag:
240 # raise ValueError("Root tags missmatch: %r != %r" % (root.tag, xml.tag))
242 for child in xml.getchildren():
244 return ElementTree.tostring(root, 'utf-8'), checksum.hexdigest()
246 def concat_files(file_list, reader=None, intersperse=""):
247 """ Concatenates contents of all provided files
249 :param list(str) file_list: list of files to check
250 :param function reader: reading procedure for each file
251 :param str intersperse: string to intersperse between file contents
252 :returns: (concatenation_result, checksum)
255 checksum = hashlib.new('sha1')
257 return '', checksum.hexdigest()
262 with codecs.open(f, 'rb', "utf-8-sig") as fp:
263 return fp.read().encode("utf-8")
266 for fname in file_list:
267 contents = reader(fname)
268 checksum.update(contents)
269 files_content.append(contents)
271 files_concat = intersperse.join(files_content)
272 return files_concat, checksum.hexdigest()
276 def concat_js(file_list):
277 content, checksum = concat_files(file_list, intersperse=';')
278 if checksum in concat_js_cache:
279 content = concat_js_cache[checksum]
281 content = rjsmin(content)
282 concat_js_cache[checksum] = content
283 return content, checksum
286 """convert FS path into web path"""
287 return '/'.join(path.split(os.path.sep))
289 def manifest_glob(extension, addons=None, db=None, include_remotes=False):
291 addons = module_boot(db=db)
293 addons = addons.split(',')
296 manifest = http.addons_manifest.get(addon, None)
299 # ensure does not ends with /
300 addons_path = os.path.join(manifest['addons_path'], '')[:-1]
301 globlist = manifest.get(extension, [])
302 for pattern in globlist:
303 if pattern.startswith(('http://', 'https://', '//')):
305 r.append((None, pattern))
307 for path in glob.glob(os.path.normpath(os.path.join(addons_path, addon, pattern))):
308 r.append((path, fs2web(path[len(addons_path):])))
311 def manifest_list(extension, mods=None, db=None):
312 """ list ressources to load specifying either:
313 mods: a comma separated string listing modules
314 db: a database name (return all installed modules in that database)
316 files = manifest_glob(extension, addons=mods, db=db, include_remotes=True)
317 if not request.debug:
318 path = '/web/webclient/' + extension
320 path += '?' + urllib.urlencode({'mods': mods})
322 path += '?' + urllib.urlencode({'db': db})
324 remotes = [wp for fp, wp in files if fp is None]
325 return [path] + remotes
326 return [wp for _fp, wp in files]
328 def get_last_modified(files):
329 """ Returns the modification time of the most recently modified
332 :param list(str) files: names of files to check
333 :return: most recent modification time amongst the fileset
334 :rtype: datetime.datetime
338 return max(datetime.datetime.fromtimestamp(os.path.getmtime(f))
340 return datetime.datetime(1970, 1, 1)
342 def make_conditional(response, last_modified=None, etag=None):
343 """ Makes the provided response conditional based upon the request,
344 and mandates revalidation from clients
346 Uses Werkzeug's own :meth:`ETagResponseMixin.make_conditional`, after
347 setting ``last_modified`` and ``etag`` correctly on the response object
349 :param response: Werkzeug response
350 :type response: werkzeug.wrappers.Response
351 :param datetime.datetime last_modified: last modification date of the response content
352 :param str etag: some sort of checksum of the content (deep etag)
353 :return: the response object provided
354 :rtype: werkzeug.wrappers.Response
356 response.cache_control.must_revalidate = True
357 response.cache_control.max_age = 0
359 response.last_modified = last_modified
361 response.set_etag(etag)
362 return response.make_conditional(request.httprequest)
364 def login_and_redirect(db, login, key, redirect_url='/'):
365 wsgienv = request.httprequest.environ
367 base_location=request.httprequest.url_root.rstrip('/'),
368 HTTP_HOST=wsgienv['HTTP_HOST'],
369 REMOTE_ADDR=wsgienv['REMOTE_ADDR'],
371 request.session.authenticate(db, login, key, env)
372 return set_cookie_and_redirect(redirect_url)
374 def set_cookie_and_redirect(redirect_url):
375 redirect = werkzeug.utils.redirect(redirect_url, 303)
376 redirect.autocorrect_location_header = False
377 cookie_val = urllib2.quote(simplejson.dumps(request.session_id))
378 redirect.set_cookie('instance0|session_id', cookie_val)
381 def load_actions_from_ir_values(key, key2, models, meta):
382 Values = request.session.model('ir.values')
383 actions = Values.get(key, key2, models, meta, request.context)
385 return [(id, name, clean_action(action))
386 for id, name, action in actions]
388 def clean_action(action):
389 action.setdefault('flags', {})
390 action_type = action.setdefault('type', 'ir.actions.act_window_close')
391 if action_type == 'ir.actions.act_window':
392 return fix_view_modes(action)
395 # I think generate_views,fix_view_modes should go into js ActionManager
396 def generate_views(action):
398 While the server generates a sequence called "views" computing dependencies
399 between a bunch of stuff for views coming directly from the database
400 (the ``ir.actions.act_window model``), it's also possible for e.g. buttons
401 to return custom view dictionaries generated on the fly.
403 In that case, there is no ``views`` key available on the action.
405 Since the web client relies on ``action['views']``, generate it here from
406 ``view_mode`` and ``view_id``.
408 Currently handles two different cases:
410 * no view_id, multiple view_mode
411 * single view_id, single view_mode
413 :param dict action: action descriptor dictionary to generate a views key for
415 view_id = action.get('view_id') or False
416 if isinstance(view_id, (list, tuple)):
419 # providing at least one view mode is a requirement, not an option
420 view_modes = action['view_mode'].split(',')
422 if len(view_modes) > 1:
424 raise ValueError('Non-db action dictionaries should provide '
425 'either multiple view modes or a single view '
426 'mode and an optional view id.\n\n Got view '
427 'modes %r and view id %r for action %r' % (
428 view_modes, view_id, action))
429 action['views'] = [(False, mode) for mode in view_modes]
431 action['views'] = [(view_id, view_modes[0])]
433 def fix_view_modes(action):
434 """ For historical reasons, OpenERP has weird dealings in relation to
435 view_mode and the view_type attribute (on window actions):
437 * one of the view modes is ``tree``, which stands for both list views
439 * the choice is made by checking ``view_type``, which is either
440 ``form`` for a list view or ``tree`` for an actual tree view
442 This methods simply folds the view_type into view_mode by adding a
443 new view mode ``list`` which is the result of the ``tree`` view_mode
444 in conjunction with the ``form`` view_type.
446 TODO: this should go into the doc, some kind of "peculiarities" section
448 :param dict action: an action descriptor
449 :returns: nothing, the action is modified in place
451 if not action.get('views'):
452 generate_views(action)
454 if action.pop('view_type', 'form') != 'form':
457 if 'view_mode' in action:
458 action['view_mode'] = ','.join(
459 mode if mode != 'tree' else 'list'
460 for mode in action['view_mode'].split(','))
462 [id, mode if mode != 'tree' else 'list']
463 for id, mode in action['views']
468 def _local_web_translations(trans_file):
471 with open(trans_file) as t_file:
472 po = babel.messages.pofile.read_po(t_file)
476 if x.id and x.string and "openerp-web" in x.auto_comments:
477 messages.append({'id': x.id, 'string': x.string})
480 def xml2json_from_elementtree(el, preserve_whitespaces=False):
482 Simple and straightforward XML-to-JSON converter in Python
484 http://code.google.com/p/xml2json-direct/
488 ns, name = el.tag.rsplit("}", 1)
490 res["namespace"] = ns[1:]
494 for k, v in el.items():
497 if el.text and (preserve_whitespaces or el.text.strip() != ''):
500 kids.append(xml2json_from_elementtree(kid, preserve_whitespaces))
501 if kid.tail and (preserve_whitespaces or kid.tail.strip() != ''):
502 kids.append(kid.tail)
503 res["children"] = kids
506 def content_disposition(filename):
507 filename = filename.encode('utf8')
508 escaped = urllib2.quote(filename)
509 browser = request.httprequest.user_agent.browser
510 version = int((request.httprequest.user_agent.version or '0').split('.')[0])
511 if browser == 'msie' and version < 9:
512 return "attachment; filename=%s" % escaped
513 elif browser == 'safari':
514 return "attachment; filename=%s" % filename
516 return "attachment; filename*=UTF-8''%s" % escaped
519 #----------------------------------------------------------
520 # OpenERP Web web Controllers
521 #----------------------------------------------------------
523 html_template = """<!DOCTYPE html>
524 <html style="height: 100%%">
526 <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
527 <meta http-equiv="content-type" content="text/html; charset=utf-8" />
528 <title>OpenERP</title>
529 <link rel="shortcut icon" href="/web/static/src/img/favicon.ico" type="image/x-icon"/>
530 <link rel="stylesheet" href="/web/static/src/css/full.css" />
533 <script type="text/javascript">
535 var s = new openerp.init(%(modules)s);
542 <script src="//ajax.googleapis.com/ajax/libs/chrome-frame/1/CFInstall.min.js"></script>
543 <script>CFInstall.check({mode: "overlay"});</script>
549 class Home(http.Controller):
551 @http.route('/', type='http', auth="none")
552 def index(self, s_action=None, db=None, **kw):
553 db, redir = db_monodb_redirect()
555 return redirect_with_hash(redir)
557 js = "\n ".join('<script type="text/javascript" src="%s"></script>' % i for i in manifest_list('js', db=db))
558 css = "\n ".join('<link rel="stylesheet" href="%s">' % i for i in manifest_list('css', db=db))
560 r = html_template % {
563 'modules': simplejson.dumps(module_boot(db=db)),
564 'init': 'var wc = new s.web.WebClient();wc.appendTo($(document.body));'
568 @http.route('/login', type='http', auth="user")
569 def login(self, db, login, key):
570 return login_and_redirect(db, login, key)
572 class WebClient(http.Controller):
574 @http.route('/web/webclient/csslist', type='json', auth="none")
575 def csslist(self, mods=None):
576 return manifest_list('css', mods=mods)
578 @http.route('/web/webclient/jslist', type='json', auth="none")
579 def jslist(self, mods=None):
580 return manifest_list('js', mods=mods)
582 @http.route('/web/webclient/qweblist', type='json', auth="none")
583 def qweblist(self, mods=None):
584 return manifest_list('qweb', mods=mods)
586 @http.route('/web/webclient/css', type='http', auth="none")
587 def css(self, mods=None, db=None):
588 files = list(manifest_glob('css', addons=mods, db=db))
589 last_modified = get_last_modified(f[0] for f in files)
590 if request.httprequest.if_modified_since and request.httprequest.if_modified_since >= last_modified:
591 return werkzeug.wrappers.Response(status=304)
593 file_map = dict(files)
595 rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U)
596 rx_url = re.compile(r"""url\s*\(\s*('|"|)(?!'|"|/|https?://|data:)""", re.U)
599 """read the a css file and absolutify all relative uris"""
600 with open(f, 'rb') as fp:
601 data = fp.read().decode('utf-8')
604 web_dir = os.path.dirname(path)
608 r"""@import \1%s/""" % (web_dir,),
614 r"""url(\1%s/""" % (web_dir,),
617 return data.encode('utf-8')
619 content, checksum = concat_files((f[0] for f in files), reader)
621 # move up all @import and @charset rules to the top
624 matches.append(matchobj.group(0))
627 content = re.sub(re.compile("(@charset.+;$)", re.M), push, content)
628 content = re.sub(re.compile("(@import.+;$)", re.M), push, content)
630 matches.append(content)
631 content = '\n'.join(matches)
633 return make_conditional(
634 request.make_response(content, [('Content-Type', 'text/css')]),
635 last_modified, checksum)
637 @http.route('/web/webclient/js', type='http', auth="none")
638 def js(self, mods=None, db=None):
639 files = [f[0] for f in manifest_glob('js', addons=mods, db=db)]
640 last_modified = get_last_modified(files)
641 if request.httprequest.if_modified_since and request.httprequest.if_modified_since >= last_modified:
642 return werkzeug.wrappers.Response(status=304)
644 content, checksum = concat_js(files)
646 return make_conditional(
647 request.make_response(content, [('Content-Type', 'application/javascript')]),
648 last_modified, checksum)
650 @http.route('/web/webclient/qweb', type='http', auth="none")
651 def qweb(self, mods=None, db=None):
652 files = [f[0] for f in manifest_glob('qweb', addons=mods, db=db)]
653 last_modified = get_last_modified(files)
654 if request.httprequest.if_modified_since and request.httprequest.if_modified_since >= last_modified:
655 return werkzeug.wrappers.Response(status=304)
657 content, checksum = concat_xml(files)
659 return make_conditional(
660 request.make_response(content, [('Content-Type', 'text/xml')]),
661 last_modified, checksum)
663 @http.route('/web/webclient/bootstrap_translations', type='json', auth="none")
664 def bootstrap_translations(self, mods):
665 """ Load local translations from *.po files, as a temporary solution
666 until we have established a valid session. This is meant only
667 for translating the login page and db management chrome, using
668 the browser's language. """
669 # For performance reasons we only load a single translation, so for
670 # sub-languages (that should only be partially translated) we load the
671 # main language PO instead - that should be enough for the login screen.
672 lang = request.lang.split('_')[0]
674 translations_per_module = {}
675 for addon_name in mods:
676 if http.addons_manifest[addon_name].get('bootstrap'):
677 addons_path = http.addons_manifest[addon_name]['addons_path']
678 f_name = os.path.join(addons_path, addon_name, "i18n", lang + ".po")
679 if not os.path.exists(f_name):
681 translations_per_module[addon_name] = {'messages': _local_web_translations(f_name)}
683 return {"modules": translations_per_module,
684 "lang_parameters": None}
686 @http.route('/web/webclient/translations', type='json', auth="user")
687 def translations(self, mods, lang):
688 res_lang = request.session.model('res.lang')
689 ids = res_lang.search([("code", "=", lang)])
692 lang_params = res_lang.read(ids[0], ["direction", "date_format", "time_format",
693 "grouping", "decimal_point", "thousands_sep"])
695 # Regional languages (ll_CC) must inherit/override their parent lang (ll), but this is
696 # done server-side when the language is loaded, so we only need to load the user's lang.
697 ir_translation = request.session.model('ir.translation')
698 translations_per_module = {}
699 messages = ir_translation.search_read([('module','in',mods),('lang','=',lang),
700 ('comments','like','openerp-web'),('value','!=',False),
702 ['module','src','value','lang'], order='module')
703 for mod, msg_group in itertools.groupby(messages, key=operator.itemgetter('module')):
704 translations_per_module.setdefault(mod,{'messages':[]})
705 translations_per_module[mod]['messages'].extend({'id': m['src'],
706 'string': m['value']} \
708 return {"modules": translations_per_module,
709 "lang_parameters": lang_params}
711 @http.route('/web/webclient/version_info', type='json', auth="none")
712 def version_info(self):
713 return openerp.service.common.exp_version()
715 class Proxy(http.Controller):
717 @http.route('/web/proxy/load', type='json', auth="none")
718 def load(self, path):
719 """ Proxies an HTTP request through a JSON request.
721 It is strongly recommended to not request binary files through this,
722 as the result will be a binary data blob as well.
724 :param path: actual request path
725 :return: file content
727 from werkzeug.test import Client
728 from werkzeug.wrappers import BaseResponse
730 return Client(request.httprequest.app, BaseResponse).get(path).data
732 class Database(http.Controller):
734 @http.route('/web/database/get_list', type='json', auth="none")
736 # TODO change js to avoid calling this method if in monodb mode
739 except openerp.exceptions.AccessDenied:
740 monodb = db_monodb(req)
745 @http.route('/web/database/create', type='json', auth="none")
746 def create(self, fields):
747 params = dict(map(operator.itemgetter('name', 'value'), fields))
748 return request.session.proxy("db").create_database(
749 params['super_admin_pwd'],
751 bool(params.get('demo_data')),
753 params['create_admin_pwd'])
755 @http.route('/web/database/duplicate', type='json', auth="none")
756 def duplicate(self, fields):
757 params = dict(map(operator.itemgetter('name', 'value'), fields))
759 params['super_admin_pwd'],
760 params['db_original_name'],
764 return request.session.proxy("db").duplicate_database(*duplicate_attrs)
766 @http.route('/web/database/drop', type='json', auth="none")
767 def drop(self, fields):
768 password, db = operator.itemgetter(
769 'drop_pwd', 'drop_db')(
770 dict(map(operator.itemgetter('name', 'value'), fields)))
773 if request.session.proxy("db").drop(password, db):
777 except openerp.exceptions.AccessDenied:
778 return {'error': 'AccessDenied', 'title': 'Drop Database'}
780 return {'error': _('Could not drop database !'), 'title': _('Drop Database')}
782 @http.route('/web/database/backup', type='http', auth="none")
783 def backup(self, backup_db, backup_pwd, token):
785 db_dump = base64.b64decode(
786 request.session.proxy("db").dump(backup_pwd, backup_db))
787 filename = "%(db)s_%(timestamp)s.dump" % {
789 'timestamp': datetime.datetime.utcnow().strftime(
790 "%Y-%m-%d_%H-%M-%SZ")
792 return request.make_response(db_dump,
793 [('Content-Type', 'application/octet-stream; charset=binary'),
794 ('Content-Disposition', content_disposition(filename))],
795 {'fileToken': int(token)}
798 return simplejson.dumps([[],[{'error': openerp.tools.ustr(e), 'title': _('Backup Database')}]])
800 @http.route('/web/database/restore', type='http', auth="none")
801 def restore(self, db_file, restore_pwd, new_db):
803 data = base64.b64encode(db_file.read())
804 request.session.proxy("db").restore(restore_pwd, new_db, data)
806 except openerp.exceptions.AccessDenied, e:
807 raise Exception("AccessDenied")
809 @http.route('/web/database/change_password', type='json', auth="none")
810 def change_password(self, fields):
811 old_password, new_password = operator.itemgetter(
812 'old_pwd', 'new_pwd')(
813 dict(map(operator.itemgetter('name', 'value'), fields)))
815 return request.session.proxy("db").change_admin_password(old_password, new_password)
816 except openerp.exceptions.AccessDenied:
817 return {'error': 'AccessDenied', 'title': _('Change Password')}
819 return {'error': _('Error, password not changed !'), 'title': _('Change Password')}
821 class Session(http.Controller):
823 def session_info(self):
824 request.session.ensure_valid()
826 "session_id": request.session_id,
827 "uid": request.session._uid,
828 "user_context": request.session.get_context() if request.session._uid else {},
829 "db": request.session._db,
830 "username": request.session._login,
833 @http.route('/web/session/get_session_info', type='json', auth="none")
834 def get_session_info(self):
835 request.uid = request.session._uid
836 request.db = request.session._db
837 return self.session_info()
839 @http.route('/web/session/authenticate', type='json', auth="none")
840 def authenticate(self, db, login, password, base_location=None):
841 wsgienv = request.httprequest.environ
843 base_location=base_location,
844 HTTP_HOST=wsgienv['HTTP_HOST'],
845 REMOTE_ADDR=wsgienv['REMOTE_ADDR'],
847 request.session.authenticate(db, login, password, env)
849 return self.session_info()
851 @http.route('/web/session/change_password', type='json', auth="user")
852 def change_password(self, fields):
853 old_password, new_password,confirm_password = operator.itemgetter('old_pwd', 'new_password','confirm_pwd')(
854 dict(map(operator.itemgetter('name', 'value'), fields)))
855 if not (old_password.strip() and new_password.strip() and confirm_password.strip()):
856 return {'error':_('You cannot leave any password empty.'),'title': _('Change Password')}
857 if new_password != confirm_password:
858 return {'error': _('The new password and its confirmation must be identical.'),'title': _('Change Password')}
860 if request.session.model('res.users').change_password(
861 old_password, new_password):
862 return {'new_password':new_password}
864 return {'error': _('The old password you provided is incorrect, your password was not changed.'), 'title': _('Change Password')}
865 return {'error': _('Error, password not changed !'), 'title': _('Change Password')}
867 @http.route('/web/session/sc_list', type='json', auth="user")
869 return request.session.model('ir.ui.view_sc').get_sc(
870 request.session._uid, "ir.ui.menu", request.context)
872 @http.route('/web/session/get_lang_list', type='json', auth="none")
873 def get_lang_list(self):
875 return request.session.proxy("db").list_lang() or []
877 return {"error": e, "title": _("Languages")}
879 @http.route('/web/session/modules', type='json', auth="user")
881 # return all installed modules. Web client is smart enough to not load a module twice
882 return module_installed()
884 @http.route('/web/session/save_session_action', type='json', auth="user")
885 def save_session_action(self, the_action):
887 This method store an action object in the session object and returns an integer
888 identifying that action. The method get_session_action() can be used to get
891 :param the_action: The action to save in the session.
892 :type the_action: anything
893 :return: A key identifying the saved action.
896 saved_actions = request.httpsession.get('saved_actions')
897 if not saved_actions:
898 saved_actions = {"next":1, "actions":{}}
899 request.httpsession['saved_actions'] = saved_actions
900 # we don't allow more than 10 stored actions
901 if len(saved_actions["actions"]) >= 10:
902 del saved_actions["actions"][min(saved_actions["actions"])]
903 key = saved_actions["next"]
904 saved_actions["actions"][key] = the_action
905 saved_actions["next"] = key + 1
908 @http.route('/web/session/get_session_action', type='json', auth="user")
909 def get_session_action(self, key):
911 Gets back a previously saved action. This method can return None if the action
912 was saved since too much time (this case should be handled in a smart way).
914 :param key: The key given by save_session_action()
916 :return: The saved action or None.
919 saved_actions = request.httpsession.get('saved_actions')
920 if not saved_actions:
922 return saved_actions["actions"].get(key)
924 @http.route('/web/session/check', type='json', auth="user")
926 request.session.assert_valid()
929 @http.route('/web/session/destroy', type='json', auth="user")
931 request.session._suicide = True
933 class Menu(http.Controller):
935 @http.route('/web/menu/get_user_roots', type='json', auth="user")
936 def get_user_roots(self):
937 """ Return all root menu ids visible for the session user.
939 :return: the root menu ids
943 Menus = s.model('ir.ui.menu')
944 # If a menu action is defined use its domain to get the root menu items
945 user_menu_id = s.model('res.users').read([s._uid], ['menu_id'],
946 request.context)[0]['menu_id']
948 menu_domain = [('parent_id', '=', False)]
950 domain_string = s.model('ir.actions.act_window').read(
951 [user_menu_id[0]], ['domain'],request.context)[0]['domain']
953 menu_domain = ast.literal_eval(domain_string)
955 return Menus.search(menu_domain, 0, False, False, request.context)
957 @http.route('/web/menu/load', type='json', auth="user")
959 """ Loads all menu items (all applications and their sub-menus).
961 :return: the menu root
962 :rtype: dict('children': menu_nodes)
964 Menus = request.session.model('ir.ui.menu')
966 fields = ['name', 'sequence', 'parent_id', 'action']
967 menu_root_ids = self.get_user_roots()
968 menu_roots = Menus.read(menu_root_ids, fields, request.context) if menu_root_ids else []
972 'parent_id': [-1, ''],
973 'children': menu_roots,
974 'all_menu_ids': menu_root_ids,
979 # menus are loaded fully unlike a regular tree view, cause there are a
980 # limited number of items (752 when all 6.1 addons are installed)
981 menu_ids = Menus.search([('id', 'child_of', menu_root_ids)], 0, False, False, request.context)
982 menu_items = Menus.read(menu_ids, fields, request.context)
983 # adds roots at the end of the sequence, so that they will overwrite
984 # equivalent menu items from full menu read when put into id:item
985 # mapping, resulting in children being correctly set on the roots.
986 menu_items.extend(menu_roots)
987 menu_root['all_menu_ids'] = menu_ids # includes menu_root_ids!
989 # make a tree using parent_id
990 menu_items_map = dict(
991 (menu_item["id"], menu_item) for menu_item in menu_items)
992 for menu_item in menu_items:
993 if menu_item['parent_id']:
994 parent = menu_item['parent_id'][0]
997 if parent in menu_items_map:
998 menu_items_map[parent].setdefault(
999 'children', []).append(menu_item)
1001 # sort by sequence a tree using parent_id
1002 for menu_item in menu_items:
1003 menu_item.setdefault('children', []).sort(
1004 key=operator.itemgetter('sequence'))
1008 @http.route('/web/menu/load_needaction', type='json', auth="user")
1009 def load_needaction(self, menu_ids):
1010 """ Loads needaction counters for specific menu ids.
1012 :return: needaction data
1013 :rtype: dict(menu_id: {'needaction_enabled': boolean, 'needaction_counter': int})
1015 return request.session.model('ir.ui.menu').get_needaction_data(menu_ids, request.context)
1017 @http.route('/web/menu/action', type='json', auth="user")
1018 def action(self, menu_id):
1019 # still used by web_shortcut
1020 actions = load_actions_from_ir_values('action', 'tree_but_open',
1021 [('ir.ui.menu', menu_id)], False)
1022 return {"action": actions}
1024 class DataSet(http.Controller):
1026 @http.route('/web/dataset/search_read', type='json', auth="user")
1027 def search_read(self, model, fields=False, offset=0, limit=False, domain=None, sort=None):
1028 return self.do_search_read(model, fields, offset, limit, domain, sort)
1029 def do_search_read(self, model, fields=False, offset=0, limit=False, domain=None
1031 """ Performs a search() followed by a read() (if needed) using the
1032 provided search criteria
1034 :param str model: the name of the model to search on
1035 :param fields: a list of the fields to return in the result records
1037 :param int offset: from which index should the results start being returned
1038 :param int limit: the maximum number of records to return
1039 :param list domain: the search domain for the query
1040 :param list sort: sorting directives
1041 :returns: A structure (dict) with two keys: ids (all the ids matching
1042 the (domain, context) pair) and records (paginated records
1043 matching fields selection set)
1046 Model = request.session.model(model)
1048 ids = Model.search(domain, offset or 0, limit or False, sort or False,
1050 if limit and len(ids) == limit:
1051 length = Model.search_count(domain, request.context)
1053 length = len(ids) + (offset or 0)
1054 if fields and fields == ['id']:
1055 # shortcut read if we only want the ids
1058 'records': [{'id': id} for id in ids]
1061 records = Model.read(ids, fields or False, request.context)
1062 records.sort(key=lambda obj: ids.index(obj['id']))
1068 @http.route('/web/dataset/load', type='json', auth="user")
1069 def load(self, model, id, fields):
1070 m = request.session.model(model)
1072 r = m.read([id], False, request.context)
1075 return {'value': value}
1077 def call_common(self, model, method, args, domain_id=None, context_id=None):
1078 return self._call_kw(model, method, args, {})
1080 def _call_kw(self, model, method, args, kwargs):
1081 # Temporary implements future display_name special field for model#read()
1082 if method == 'read' and kwargs.get('context', {}).get('future_display_name'):
1083 if 'display_name' in args[1]:
1084 names = dict(request.session.model(model).name_get(args[0], **kwargs))
1085 args[1].remove('display_name')
1086 records = request.session.model(model).read(*args, **kwargs)
1087 for record in records:
1088 record['display_name'] = \
1089 names.get(record['id']) or "%s#%d" % (model, (record['id']))
1092 return getattr(request.session.model(model), method)(*args, **kwargs)
1094 @http.route('/web/dataset/call', type='json', auth="user")
1095 def call(self, model, method, args, domain_id=None, context_id=None):
1096 return self._call_kw(model, method, args, {})
1098 @http.route(['/web/dataset/call_kw', '/web/dataset/call_kw/<path:path>'], type='json', auth="user")
1099 def call_kw(self, model, method, args, kwargs, path=None):
1100 return self._call_kw(model, method, args, kwargs)
1102 @http.route('/web/dataset/call_button', type='json', auth="user")
1103 def call_button(self, model, method, args, domain_id=None, context_id=None):
1104 action = self._call_kw(model, method, args, {})
1105 if isinstance(action, dict) and action.get('type') != '':
1106 return clean_action(action)
1109 @http.route('/web/dataset/exec_workflow', type='json', auth="user")
1110 def exec_workflow(self, model, id, signal):
1111 return request.session.exec_workflow(model, id, signal)
1113 @http.route('/web/dataset/resequence', type='json', auth="user")
1114 def resequence(self, model, ids, field='sequence', offset=0):
1115 """ Re-sequences a number of records in the model, by their ids
1117 The re-sequencing starts at the first model of ``ids``, the sequence
1118 number is incremented by one after each record and starts at ``offset``
1120 :param ids: identifiers of the records to resequence, in the new sequence order
1122 :param str field: field used for sequence specification, defaults to
1124 :param int offset: sequence number for first record in ``ids``, allows
1125 starting the resequencing from an arbitrary number,
1128 m = request.session.model(model)
1129 if not m.fields_get([field]):
1131 # python 2.6 has no start parameter
1132 for i, id in enumerate(ids):
1133 m.write(id, { field: i + offset })
1136 class View(http.Controller):
1138 @http.route('/web/view/add_custom', type='json', auth="user")
1139 def add_custom(self, view_id, arch):
1140 CustomView = request.session.model('ir.ui.view.custom')
1142 'user_id': request.session._uid,
1146 return {'result': True}
1148 @http.route('/web/view/undo_custom', type='json', auth="user")
1149 def undo_custom(self, view_id, reset=False):
1150 CustomView = request.session.model('ir.ui.view.custom')
1151 vcustom = CustomView.search([('user_id', '=', request.session._uid), ('ref_id' ,'=', view_id)],
1152 0, False, False, request.context)
1155 CustomView.unlink(vcustom, request.context)
1157 CustomView.unlink([vcustom[0]], request.context)
1158 return {'result': True}
1159 return {'result': False}
1161 class TreeView(View):
1163 @http.route('/web/treeview/action', type='json', auth="user")
1164 def action(self, model, id):
1165 return load_actions_from_ir_values(
1166 'action', 'tree_but_open',[(model, id)],
1169 class Binary(http.Controller):
1171 @http.route('/web/binary/image', type='http', auth="user")
1172 def image(self, model, id, field, **kw):
1173 last_update = '__last_update'
1174 Model = request.session.model(model)
1175 headers = [('Content-Type', 'image/png')]
1176 etag = request.httprequest.headers.get('If-None-Match')
1177 hashed_session = hashlib.md5(request.session_id).hexdigest()
1178 retag = hashed_session
1179 id = None if not id else simplejson.loads(id)
1180 if type(id) is list:
1184 if not id and hashed_session == etag:
1185 return werkzeug.wrappers.Response(status=304)
1187 date = Model.read([id], [last_update], request.context)[0].get(last_update)
1188 if hashlib.md5(date).hexdigest() == etag:
1189 return werkzeug.wrappers.Response(status=304)
1192 res = Model.default_get([field], request.context).get(field)
1195 res = Model.read([id], [last_update, field], request.context)[0]
1196 retag = hashlib.md5(res.get(last_update)).hexdigest()
1197 image_base64 = res.get(field)
1199 if kw.get('resize'):
1200 resize = kw.get('resize').split(',')
1201 if len(resize) == 2 and int(resize[0]) and int(resize[1]):
1202 width = int(resize[0])
1203 height = int(resize[1])
1204 # resize maximum 500*500
1205 if width > 500: width = 500
1206 if height > 500: height = 500
1207 image_base64 = openerp.tools.image_resize_image(base64_source=image_base64, size=(width, height), encoding='base64', filetype='PNG')
1209 image_data = base64.b64decode(image_base64)
1212 image_data = self.placeholder()
1213 headers.append(('ETag', retag))
1214 headers.append(('Content-Length', len(image_data)))
1216 ncache = int(kw.get('cache'))
1217 headers.append(('Cache-Control', 'no-cache' if ncache == 0 else 'max-age=%s' % (ncache)))
1220 return request.make_response(image_data, headers)
1222 def placeholder(self, image='placeholder.png'):
1223 addons_path = http.addons_manifest['web']['addons_path']
1224 return open(os.path.join(addons_path, 'web', 'static', 'src', 'img', image), 'rb').read()
1226 @http.route('/web/binary/saveas', type='http', auth="user")
1227 def saveas(self, model, field, id=None, filename_field=None, **kw):
1228 """ Download link for files stored as binary fields.
1230 If the ``id`` parameter is omitted, fetches the default value for the
1231 binary field (via ``default_get``), otherwise fetches the field for
1232 that precise record.
1234 :param str model: name of the model to fetch the binary from
1235 :param str field: binary field
1236 :param str id: id of the record from which to fetch the binary
1237 :param str filename_field: field holding the file's name, if any
1238 :returns: :class:`werkzeug.wrappers.Response`
1240 Model = request.session.model(model)
1243 fields.append(filename_field)
1245 res = Model.read([int(id)], fields, request.context)[0]
1247 res = Model.default_get(fields, request.context)
1248 filecontent = base64.b64decode(res.get(field, ''))
1250 return request.not_found()
1252 filename = '%s_%s' % (model.replace('.', '_'), id)
1254 filename = res.get(filename_field, '') or filename
1255 return request.make_response(filecontent,
1256 [('Content-Type', 'application/octet-stream'),
1257 ('Content-Disposition', content_disposition(filename))])
1259 @http.route('/web/binary/saveas_ajax', type='http', auth="user")
1260 def saveas_ajax(self, data, token):
1261 jdata = simplejson.loads(data)
1262 model = jdata['model']
1263 field = jdata['field']
1264 data = jdata['data']
1265 id = jdata.get('id', None)
1266 filename_field = jdata.get('filename_field', None)
1267 context = jdata.get('context', {})
1269 Model = request.session.model(model)
1272 fields.append(filename_field)
1274 res = { field: data }
1276 res = Model.read([int(id)], fields, context)[0]
1278 res = Model.default_get(fields, context)
1279 filecontent = base64.b64decode(res.get(field, ''))
1281 raise ValueError(_("No content found for field '%s' on '%s:%s'") %
1284 filename = '%s_%s' % (model.replace('.', '_'), id)
1286 filename = res.get(filename_field, '') or filename
1287 return request.make_response(filecontent,
1288 headers=[('Content-Type', 'application/octet-stream'),
1289 ('Content-Disposition', content_disposition(filename))],
1290 cookies={'fileToken': int(token)})
1292 @http.route('/web/binary/upload', type='http', auth="user")
1293 def upload(self, callback, ufile):
1294 # TODO: might be useful to have a configuration flag for max-length file uploads
1295 out = """<script language="javascript" type="text/javascript">
1296 var win = window.top.window;
1297 win.jQuery(win).trigger(%s, %s);
1301 args = [len(data), ufile.filename,
1302 ufile.content_type, base64.b64encode(data)]
1303 except Exception, e:
1304 args = [False, e.message]
1305 return out % (simplejson.dumps(callback), simplejson.dumps(args))
1307 @http.route('/web/binary/upload_attachment', type='http', auth="user")
1308 def upload_attachment(self, callback, model, id, ufile):
1309 Model = request.session.model('ir.attachment')
1310 out = """<script language="javascript" type="text/javascript">
1311 var win = window.top.window;
1312 win.jQuery(win).trigger(%s, %s);
1315 attachment_id = Model.create({
1316 'name': ufile.filename,
1317 'datas': base64.encodestring(ufile.read()),
1318 'datas_fname': ufile.filename,
1323 'filename': ufile.filename,
1327 args = {'error': "Something horrible happened"}
1328 return out % (simplejson.dumps(callback), simplejson.dumps(args))
1330 @http.route('/web/binary/company_logo', type='http', auth="none")
1331 def company_logo(self, dbname=None):
1332 # TODO add etag, refactor to use /image code for etag
1334 if request.session._db:
1335 dbname = request.session._db
1336 uid = request.session._uid
1337 elif dbname is None:
1338 dbname = db_monodb()
1341 uid = openerp.SUPERUSER_ID
1344 image_data = self.placeholder('logo.png')
1347 # create an empty registry
1348 registry = openerp.modules.registry.Registry(dbname)
1349 with registry.cursor() as cr:
1350 cr.execute("""SELECT c.logo_web
1352 LEFT JOIN res_company c
1353 ON c.id = u.company_id
1358 image_data = str(row[0]).decode('base64')
1360 image_data = self.placeholder('nologo.png')
1362 image_data = self.placeholder('logo.png')
1365 ('Content-Type', 'image/png'),
1366 ('Content-Length', len(image_data)),
1368 return request.make_response(image_data, headers)
1370 class Action(http.Controller):
1372 @http.route('/web/action/load', type='json', auth="user")
1373 def load(self, action_id, do_not_eval=False):
1374 Actions = request.session.model('ir.actions.actions')
1377 action_id = int(action_id)
1380 module, xmlid = action_id.split('.', 1)
1381 model, action_id = request.session.model('ir.model.data').get_object_reference(module, xmlid)
1382 assert model.startswith('ir.actions.')
1384 action_id = 0 # force failed read
1386 base_action = Actions.read([action_id], ['type'], request.context)
1389 action_type = base_action[0]['type']
1390 if action_type == 'ir.actions.report.xml':
1391 ctx.update({'bin_size': True})
1392 ctx.update(request.context)
1393 action = request.session.model(action_type).read([action_id], False, ctx)
1395 value = clean_action(action[0])
1398 @http.route('/web/action/run', type='json', auth="user")
1399 def run(self, action_id):
1400 return_action = request.session.model('ir.actions.server').run(
1401 [action_id], request.context)
1403 return clean_action(return_action)
1407 class Export(http.Controller):
1409 @http.route('/web/export/formats', type='json', auth="user")
1411 """ Returns all valid export formats
1413 :returns: for each export format, a pair of identifier and printable name
1414 :rtype: [(str, str)]
1416 return ["CSV", "Excel"]
1418 def fields_get(self, model):
1419 Model = request.session.model(model)
1420 fields = Model.fields_get(False, request.context)
1423 @http.route('/web/export/get_fields', type='json', auth="user")
1424 def get_fields(self, model, prefix='', parent_name= '',
1425 import_compat=True, parent_field_type=None,
1428 if import_compat and parent_field_type == "many2one":
1431 fields = self.fields_get(model)
1434 fields.pop('id', None)
1436 fields['.id'] = fields.pop('id', {'string': 'ID'})
1438 fields_sequence = sorted(fields.iteritems(),
1439 key=lambda field: field[1].get('string', ''))
1442 for field_name, field in fields_sequence:
1444 if exclude and field_name in exclude:
1446 if field.get('readonly'):
1447 # If none of the field's states unsets readonly, skip the field
1448 if all(dict(attrs).get('readonly', True)
1449 for attrs in field.get('states', {}).values()):
1451 if not field.get('exportable', True):
1454 id = prefix + (prefix and '/'or '') + field_name
1455 name = parent_name + (parent_name and '/' or '') + field['string']
1456 record = {'id': id, 'string': name,
1457 'value': id, 'children': False,
1458 'field_type': field.get('type'),
1459 'required': field.get('required'),
1460 'relation_field': field.get('relation_field')}
1461 records.append(record)
1463 if len(name.split('/')) < 3 and 'relation' in field:
1464 ref = field.pop('relation')
1465 record['value'] += '/id'
1466 record['params'] = {'model': ref, 'prefix': id, 'name': name}
1468 if not import_compat or field['type'] == 'one2many':
1469 # m2m field in import_compat is childless
1470 record['children'] = True
1474 @http.route('/web/export/namelist', type='json', auth="user")
1475 def namelist(self, model, export_id):
1476 # TODO: namelist really has no reason to be in Python (although itertools.groupby helps)
1477 export = request.session.model("ir.exports").read([export_id])[0]
1478 export_fields_list = request.session.model("ir.exports.line").read(
1479 export['export_fields'])
1481 fields_data = self.fields_info(
1482 model, map(operator.itemgetter('name'), export_fields_list))
1485 {'name': field['name'], 'label': fields_data[field['name']]}
1486 for field in export_fields_list
1489 def fields_info(self, model, export_fields):
1491 fields = self.fields_get(model)
1492 if ".id" in export_fields:
1493 fields['.id'] = fields.pop('id', {'string': 'ID'})
1495 # To make fields retrieval more efficient, fetch all sub-fields of a
1496 # given field at the same time. Because the order in the export list is
1497 # arbitrary, this requires ordering all sub-fields of a given field
1498 # together so they can be fetched at the same time
1500 # Works the following way:
1501 # * sort the list of fields to export, the default sorting order will
1502 # put the field itself (if present, for xmlid) and all of its
1503 # sub-fields right after it
1504 # * then, group on: the first field of the path (which is the same for
1505 # a field and for its subfields and the length of splitting on the
1506 # first '/', which basically means grouping the field on one side and
1507 # all of the subfields on the other. This way, we have the field (for
1508 # the xmlid) with length 1, and all of the subfields with the same
1509 # base but a length "flag" of 2
1510 # * if we have a normal field (length 1), just add it to the info
1511 # mapping (with its string) as-is
1512 # * otherwise, recursively call fields_info via graft_subfields.
1513 # all graft_subfields does is take the result of fields_info (on the
1514 # field's model) and prepend the current base (current field), which
1515 # rebuilds the whole sub-tree for the field
1517 # result: because we're not fetching the fields_get for half the
1518 # database models, fetching a namelist with a dozen fields (including
1519 # relational data) falls from ~6s to ~300ms (on the leads model).
1520 # export lists with no sub-fields (e.g. import_compatible lists with
1521 # no o2m) are even more efficient (from the same 6s to ~170ms, as
1522 # there's a single fields_get to execute)
1523 for (base, length), subfields in itertools.groupby(
1524 sorted(export_fields),
1525 lambda field: (field.split('/', 1)[0], len(field.split('/', 1)))):
1526 subfields = list(subfields)
1528 # subfields is a seq of $base/*rest, and not loaded yet
1529 info.update(self.graft_subfields(
1530 fields[base]['relation'], base, fields[base]['string'],
1534 info[base] = fields[base]['string']
1538 def graft_subfields(self, model, prefix, prefix_string, fields):
1539 export_fields = [field.split('/', 1)[1] for field in fields]
1541 (prefix + '/' + k, prefix_string + '/' + v)
1542 for k, v in self.fields_info(model, export_fields).iteritems())
1544 class ExportFormat(object):
1546 def content_type(self):
1547 """ Provides the format's content type """
1548 raise NotImplementedError()
1550 def filename(self, base):
1551 """ Creates a valid filename for the format (with extension) from the
1552 provided base name (exension-less)
1554 raise NotImplementedError()
1556 def from_data(self, fields, rows):
1557 """ Conversion method from OpenERP's export data to whatever the
1558 current export class outputs
1560 :params list fields: a list of fields to export
1561 :params list rows: a list of records to export
1565 raise NotImplementedError()
1567 def base(self, data, token):
1568 model, fields, ids, domain, import_compat = \
1569 operator.itemgetter('model', 'fields', 'ids', 'domain',
1571 simplejson.loads(data))
1573 Model = request.session.model(model)
1574 ids = ids or Model.search(domain, 0, False, False, request.context)
1576 field_names = map(operator.itemgetter('name'), fields)
1577 import_data = Model.export_data(ids, field_names, request.context).get('datas',[])
1580 columns_headers = field_names
1582 columns_headers = [val['label'].strip() for val in fields]
1585 return request.make_response(self.from_data(columns_headers, import_data),
1586 headers=[('Content-Disposition',
1587 content_disposition(self.filename(model))),
1588 ('Content-Type', self.content_type)],
1589 cookies={'fileToken': int(token)})
1591 class CSVExport(ExportFormat, http.Controller):
1592 fmt = {'tag': 'csv', 'label': 'CSV'}
1594 @http.route('/web/export/csv', type='http', auth="user")
1595 def index(self, data, token):
1596 return self.base(data, token)
1599 def content_type(self):
1600 return 'text/csv;charset=utf8'
1602 def filename(self, base):
1603 return base + '.csv'
1605 def from_data(self, fields, rows):
1607 writer = csv.writer(fp, quoting=csv.QUOTE_ALL)
1609 writer.writerow([name.encode('utf-8') for name in fields])
1614 if isinstance(d, basestring):
1615 d = d.replace('\n',' ').replace('\t',' ')
1617 d = d.encode('utf-8')
1618 except UnicodeError:
1620 if d is False: d = None
1622 writer.writerow(row)
1629 class ExcelExport(ExportFormat, http.Controller):
1633 'error': None if xlwt else "XLWT required"
1636 @http.route('/web/export/xls', type='http', auth="user")
1637 def index(self, data, token):
1638 return self.base(data, token)
1641 def content_type(self):
1642 return 'application/vnd.ms-excel'
1644 def filename(self, base):
1645 return base + '.xls'
1647 def from_data(self, fields, rows):
1648 workbook = xlwt.Workbook()
1649 worksheet = workbook.add_sheet('Sheet 1')
1651 for i, fieldname in enumerate(fields):
1652 worksheet.write(0, i, fieldname)
1653 worksheet.col(i).width = 8000 # around 220 pixels
1655 style = xlwt.easyxf('align: wrap yes')
1657 for row_index, row in enumerate(rows):
1658 for cell_index, cell_value in enumerate(row):
1659 if isinstance(cell_value, basestring):
1660 cell_value = re.sub("\r", " ", cell_value)
1661 if cell_value is False: cell_value = None
1662 worksheet.write(row_index + 1, cell_index, cell_value, style)
1671 class Reports(http.Controller):
1672 POLLING_DELAY = 0.25
1674 'doc': 'application/vnd.ms-word',
1675 'html': 'text/html',
1676 'odt': 'application/vnd.oasis.opendocument.text',
1677 'pdf': 'application/pdf',
1678 'sxw': 'application/vnd.sun.xml.writer',
1679 'xls': 'application/vnd.ms-excel',
1682 @http.route('/web/report', type='http', auth="user")
1683 def index(self, action, token):
1684 action = simplejson.loads(action)
1686 report_srv = request.session.proxy("report")
1687 context = dict(request.context)
1688 context.update(action["context"])
1691 report_ids = context["active_ids"]
1692 if 'report_type' in action:
1693 report_data['report_type'] = action['report_type']
1694 if 'datas' in action:
1695 if 'ids' in action['datas']:
1696 report_ids = action['datas'].pop('ids')
1697 report_data.update(action['datas'])
1699 report_id = report_srv.report(
1700 request.session._db, request.session._uid, request.session._password,
1701 action["report_name"], report_ids,
1702 report_data, context)
1704 report_struct = None
1706 report_struct = report_srv.report_get(
1707 request.session._db, request.session._uid, request.session._password, report_id)
1708 if report_struct["state"]:
1711 time.sleep(self.POLLING_DELAY)
1713 report = base64.b64decode(report_struct['result'])
1714 if report_struct.get('code') == 'zlib':
1715 report = zlib.decompress(report)
1716 report_mimetype = self.TYPES_MAPPING.get(
1717 report_struct['format'], 'octet-stream')
1718 file_name = action.get('name', 'report')
1719 if 'name' not in action:
1720 reports = request.session.model('ir.actions.report.xml')
1721 res_id = reports.search([('report_name', '=', action['report_name']),],
1722 0, False, False, context)
1724 file_name = reports.read(res_id[0], ['name'], context)['name']
1726 file_name = action['report_name']
1727 file_name = '%s.%s' % (file_name, report_struct['format'])
1729 return request.make_response(report,
1731 ('Content-Disposition', content_disposition(file_name)),
1732 ('Content-Type', report_mimetype),
1733 ('Content-Length', len(report))],
1734 cookies={'fileToken': int(token)})
1736 # vim:expandtab:tabstop=4:softtabstop=4:shiftwidth=4: