1 # -*- coding: utf-8 -*-
20 from xml.etree import ElementTree
21 from cStringIO import StringIO
23 import babel.messages.pofile
25 import werkzeug.wrappers
32 import openerp.modules.registry
33 from openerp.tools.translate import _
34 from openerp import http
36 from openerp.http import request, serialize_exception as _serialize_exception
38 _logger = logging.getLogger(__name__)
40 env = jinja2.Environment(
41 loader=jinja2.PackageLoader('openerp.addons.web', "views"),
44 env.filters["json"] = simplejson.dumps
46 #----------------------------------------------------------
48 #----------------------------------------------------------
51 """ Minify js with a clever regex.
52 Taken from http://opensource.perlig.de/rjsmin
53 Apache License, Version 2.0 """
55 """ Substitution callback """
56 groups = match.groups()
62 (groups[4] and '\n') or
63 (groups[5] and ' ') or
64 (groups[6] and ' ') or
65 (groups[7] and ' ') or
70 r'([^\047"/\000-\040]+)|((?:(?:\047[^\047\\\r\n]*(?:\\(?:[^\r\n]|\r?'
71 r'\n|\r)[^\047\\\r\n]*)*\047)|(?:"[^"\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|'
72 r'\r)[^"\\\r\n]*)*"))[^\047"/\000-\040]*)|(?:(?<=[(,=:\[!&|?{};\r\n]'
73 r')(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/'
74 r'))*((?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*'
75 r'(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*'
76 r'))|(?:(?<=[\000-#%-,./:-@\[-^`{-~-]return)(?:[\000-\011\013\014\01'
77 r'6-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*((?:/(?![\r\n/*])[^/'
78 r'\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]'
79 r'*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*))|(?<=[^\000-!#%&(*,./'
80 r':-@\[\\^`{|~])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/'
81 r'*][^*]*\*+)*/))*(?:((?:(?://[^\r\n]*)?[\r\n]))(?:[\000-\011\013\01'
82 r'4\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-\040"#'
83 r'%-\047)*,./:-@\\-^`|-~])|(?<=[^\000-#%-,./:-@\[-^`{-~-])((?:[\000-'
84 r'\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=[^'
85 r'\000-#%-,./:-@\[-^`{-~-])|(?<=\+)((?:[\000-\011\013\014\016-\040]|'
86 r'(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=\+)|(?<=-)((?:[\000-\011\0'
87 r'13\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=-)|(?:[\0'
88 r'00-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))+|(?:'
89 r'(?:(?://[^\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*'
90 r']*\*+(?:[^/*][^*]*\*+)*/))*)+', subber, '\n%s\n' % script
94 db_list = http.db_list
96 db_monodb = http.db_monodb
98 def serialize_exception(f):
100 def wrap(*args, **kwargs):
102 return f(*args, **kwargs)
104 _logger.exception("An exception occured during an http request")
105 se = _serialize_exception(e)
108 'message': "OpenERP Server Error",
111 return werkzeug.exceptions.InternalServerError(simplejson.dumps(error))
114 def redirect_with_hash(*args, **kw):
118 Use the ``http.redirect_with_hash()`` function instead.
120 return http.redirect_with_hash(*args, **kw)
122 def abort_and_redirect(url):
123 r = request.httprequest
124 response = werkzeug.utils.redirect(url, 302)
125 response = r.app.get_response(r, response, explicit_session=False)
126 werkzeug.exceptions.abort(response)
128 def ensure_db(redirect='/web/database/selector'):
129 # This helper should be used in web client auth="none" routes
130 # if those routes needs a db to work with.
131 # If the heuristics does not find any database, then the users will be
132 # redirected to db selector or any url specified by `redirect` argument.
133 # If the db is taken out of a query parameter, it will be checked against
134 # `http.db_filter()` in order to ensure it's legit and thus avoid db
135 # forgering that could lead to xss attacks.
136 db = request.params.get('db')
139 if db and db not in http.db_filter([db]):
142 if db and not request.session.db:
143 # User asked a specific database on a new session.
144 # That mean the nodb router has been used to find the route
145 # Depending on installed module in the database, the rendering of the page
146 # may depend on data injected by the database route dispatcher.
147 # Thus, we redirect the user to the same page but with the session cookie set.
148 # This will force using the database route dispatcher...
149 request.session.db = db
150 abort_and_redirect(request.httprequest.url)
152 # if db not provided, use the session one
154 db = request.session.db
156 # if no database provided and no database in session, use monodb
158 db = db_monodb(request.httprequest)
160 # if no db can be found til here, send to the database selector
161 # the database selector will redirect to database manager if needed
163 werkzeug.exceptions.abort(werkzeug.utils.redirect(redirect, 303))
165 # always switch the session to the computed db
166 if db != request.session.db:
167 request.session.logout()
168 abort_and_redirect(request.httprequest.url)
170 request.session.db = db
172 def module_topological_sort(modules):
173 """ Return a list of module names sorted so that their dependencies of the
174 modules are listed before the module itself
176 modules is a dict of {module_name: dependencies}
178 :param modules: modules to sort
183 dependencies = set(itertools.chain.from_iterable(modules.itervalues()))
184 # incoming edge: dependency on other module (if a depends on b, a has an
185 # incoming edge from b, aka there's an edge from b to a)
186 # outgoing edge: other module depending on this one
188 # [Tarjan 1976], http://en.wikipedia.org/wiki/Topological_sorting#Algorithms
189 #L ← Empty list that will contain the sorted nodes
191 #S ← Set of all nodes with no outgoing edges (modules on which no other
193 S = set(module for module in modules if module not in dependencies)
196 #function visit(node n)
198 #if n has not been visited yet then
202 #change: n not web module, can not be resolved, ignore
203 if n not in modules: return
204 #for each node m with an edge from m to n do (dependencies of n)
210 #for each node n in S do
216 def module_installed():
217 # Candidates module the current heuristic is the /static dir
218 loadable = http.addons_manifest.keys()
221 # Retrieve database installed modules
222 # TODO The following code should move to ir.module.module.list_installed_modules()
223 Modules = request.session.model('ir.module.module')
224 domain = [('state','=','installed'), ('name','in', loadable)]
225 for module in Modules.search_read(domain, ['name', 'dependencies_id']):
226 modules[module['name']] = []
227 deps = module.get('dependencies_id')
229 deps_read = request.session.model('ir.module.module.dependency').read(deps, ['name'])
230 dependencies = [i['name'] for i in deps_read]
231 modules[module['name']] = dependencies
233 sorted_modules = module_topological_sort(modules)
234 return sorted_modules
236 def module_installed_bypass_session(dbname):
237 loadable = http.addons_manifest.keys()
240 registry = openerp.modules.registry.RegistryManager.get(dbname)
241 with registry.cursor() as cr:
242 m = registry.get('ir.module.module')
243 # TODO The following code should move to ir.module.module.list_installed_modules()
244 domain = [('state','=','installed'), ('name','in', loadable)]
245 ids = m.search(cr, 1, [('state','=','installed'), ('name','in', loadable)])
246 for module in m.read(cr, 1, ids, ['name', 'dependencies_id']):
247 modules[module['name']] = []
248 deps = module.get('dependencies_id')
250 deps_read = registry.get('ir.module.module.dependency').read(cr, 1, deps, ['name'])
251 dependencies = [i['name'] for i in deps_read]
252 modules[module['name']] = dependencies
255 sorted_modules = module_topological_sort(modules)
256 return sorted_modules
258 def module_boot(db=None):
259 server_wide_modules = openerp.conf.server_wide_modules or ['web']
262 for i in server_wide_modules:
263 if i in http.addons_manifest:
265 monodb = db or db_monodb()
267 dbside = module_installed_bypass_session(monodb)
268 dbside = [i for i in dbside if i not in serverside]
269 addons = serverside + dbside
272 def concat_xml(file_list):
273 """Concatenate xml files
275 :param list(str) file_list: list of files to check
276 :returns: (concatenation_result, checksum)
279 checksum = hashlib.new('sha1')
281 return '', checksum.hexdigest()
284 for fname in file_list:
285 with open(fname, 'rb') as fp:
287 checksum.update(contents)
289 xml = ElementTree.parse(fp).getroot()
292 root = ElementTree.Element(xml.tag)
293 #elif root.tag != xml.tag:
294 # raise ValueError("Root tags missmatch: %r != %r" % (root.tag, xml.tag))
296 for child in xml.getchildren():
298 return ElementTree.tostring(root, 'utf-8'), checksum.hexdigest()
300 def concat_files(file_list, reader=None, intersperse=""):
301 """ Concatenates contents of all provided files
303 :param list(str) file_list: list of files to check
304 :param function reader: reading procedure for each file
305 :param str intersperse: string to intersperse between file contents
306 :returns: (concatenation_result, checksum)
309 checksum = hashlib.new('sha1')
311 return '', checksum.hexdigest()
316 with codecs.open(f, 'rb', "utf-8-sig") as fp:
317 return fp.read().encode("utf-8")
320 for fname in file_list:
321 contents = reader(fname)
322 checksum.update(contents)
323 files_content.append(contents)
325 files_concat = intersperse.join(files_content)
326 return files_concat, checksum.hexdigest()
330 def concat_js(file_list):
331 content, checksum = concat_files(file_list, intersperse=';')
332 if checksum in concat_js_cache:
333 content = concat_js_cache[checksum]
335 content = rjsmin(content)
336 concat_js_cache[checksum] = content
337 return content, checksum
340 """convert FS path into web path"""
341 return '/'.join(path.split(os.path.sep))
343 def manifest_glob(extension, addons=None, db=None, include_remotes=False):
345 addons = module_boot(db=db)
347 addons = addons.split(',')
350 manifest = http.addons_manifest.get(addon, None)
353 # ensure does not ends with /
354 addons_path = os.path.join(manifest['addons_path'], '')[:-1]
355 globlist = manifest.get(extension, [])
356 for pattern in globlist:
357 if pattern.startswith(('http://', 'https://', '//')):
359 r.append((None, pattern))
361 for path in glob.glob(os.path.normpath(os.path.join(addons_path, addon, pattern))):
362 r.append((path, fs2web(path[len(addons_path):])))
365 def manifest_list(extension, mods=None, db=None, debug=False):
366 """ list ressources to load specifying either:
367 mods: a comma separated string listing modules
368 db: a database name (return all installed modules in that database)
370 files = manifest_glob(extension, addons=mods, db=db, include_remotes=True)
372 path = '/web/webclient/' + extension
374 path += '?' + werkzeug.url_encode({'mods': mods})
376 path += '?' + werkzeug.url_encode({'db': db})
378 remotes = [wp for fp, wp in files if fp is None]
379 return [path] + remotes
380 return [wp for _fp, wp in files]
382 def get_last_modified(files):
383 """ Returns the modification time of the most recently modified
386 :param list(str) files: names of files to check
387 :return: most recent modification time amongst the fileset
388 :rtype: datetime.datetime
392 return max(datetime.datetime.fromtimestamp(os.path.getmtime(f))
394 return datetime.datetime(1970, 1, 1)
396 def make_conditional(response, last_modified=None, etag=None):
397 """ Makes the provided response conditional based upon the request,
398 and mandates revalidation from clients
400 Uses Werkzeug's own :meth:`ETagResponseMixin.make_conditional`, after
401 setting ``last_modified`` and ``etag`` correctly on the response object
403 :param response: Werkzeug response
404 :type response: werkzeug.wrappers.Response
405 :param datetime.datetime last_modified: last modification date of the response content
406 :param str etag: some sort of checksum of the content (deep etag)
407 :return: the response object provided
408 :rtype: werkzeug.wrappers.Response
410 response.cache_control.must_revalidate = True
411 response.cache_control.max_age = 0
413 response.last_modified = last_modified
415 response.set_etag(etag)
416 return response.make_conditional(request.httprequest)
418 def login_and_redirect(db, login, key, redirect_url='/web'):
419 request.session.authenticate(db, login, key)
420 return set_cookie_and_redirect(redirect_url)
422 def set_cookie_and_redirect(redirect_url):
423 redirect = werkzeug.utils.redirect(redirect_url, 303)
424 redirect.autocorrect_location_header = False
427 def load_actions_from_ir_values(key, key2, models, meta):
428 Values = request.session.model('ir.values')
429 actions = Values.get(key, key2, models, meta, request.context)
431 return [(id, name, clean_action(action))
432 for id, name, action in actions]
434 def clean_action(action):
435 action.setdefault('flags', {})
436 action_type = action.setdefault('type', 'ir.actions.act_window_close')
437 if action_type == 'ir.actions.act_window':
438 return fix_view_modes(action)
441 # I think generate_views,fix_view_modes should go into js ActionManager
442 def generate_views(action):
444 While the server generates a sequence called "views" computing dependencies
445 between a bunch of stuff for views coming directly from the database
446 (the ``ir.actions.act_window model``), it's also possible for e.g. buttons
447 to return custom view dictionaries generated on the fly.
449 In that case, there is no ``views`` key available on the action.
451 Since the web client relies on ``action['views']``, generate it here from
452 ``view_mode`` and ``view_id``.
454 Currently handles two different cases:
456 * no view_id, multiple view_mode
457 * single view_id, single view_mode
459 :param dict action: action descriptor dictionary to generate a views key for
461 view_id = action.get('view_id') or False
462 if isinstance(view_id, (list, tuple)):
465 # providing at least one view mode is a requirement, not an option
466 view_modes = action['view_mode'].split(',')
468 if len(view_modes) > 1:
470 raise ValueError('Non-db action dictionaries should provide '
471 'either multiple view modes or a single view '
472 'mode and an optional view id.\n\n Got view '
473 'modes %r and view id %r for action %r' % (
474 view_modes, view_id, action))
475 action['views'] = [(False, mode) for mode in view_modes]
477 action['views'] = [(view_id, view_modes[0])]
479 def fix_view_modes(action):
480 """ For historical reasons, OpenERP has weird dealings in relation to
481 view_mode and the view_type attribute (on window actions):
483 * one of the view modes is ``tree``, which stands for both list views
485 * the choice is made by checking ``view_type``, which is either
486 ``form`` for a list view or ``tree`` for an actual tree view
488 This methods simply folds the view_type into view_mode by adding a
489 new view mode ``list`` which is the result of the ``tree`` view_mode
490 in conjunction with the ``form`` view_type.
492 TODO: this should go into the doc, some kind of "peculiarities" section
494 :param dict action: an action descriptor
495 :returns: nothing, the action is modified in place
497 if not action.get('views'):
498 generate_views(action)
500 if action.pop('view_type', 'form') != 'form':
503 if 'view_mode' in action:
504 action['view_mode'] = ','.join(
505 mode if mode != 'tree' else 'list'
506 for mode in action['view_mode'].split(','))
508 [id, mode if mode != 'tree' else 'list']
509 for id, mode in action['views']
514 def _local_web_translations(trans_file):
517 with open(trans_file) as t_file:
518 po = babel.messages.pofile.read_po(t_file)
522 if x.id and x.string and "openerp-web" in x.auto_comments:
523 messages.append({'id': x.id, 'string': x.string})
526 def xml2json_from_elementtree(el, preserve_whitespaces=False):
528 Simple and straightforward XML-to-JSON converter in Python
530 http://code.google.com/p/xml2json-direct/
534 ns, name = el.tag.rsplit("}", 1)
536 res["namespace"] = ns[1:]
540 for k, v in el.items():
543 if el.text and (preserve_whitespaces or el.text.strip() != ''):
546 kids.append(xml2json_from_elementtree(kid, preserve_whitespaces))
547 if kid.tail and (preserve_whitespaces or kid.tail.strip() != ''):
548 kids.append(kid.tail)
549 res["children"] = kids
552 def content_disposition(filename):
553 filename = filename.encode('utf8')
554 escaped = urllib2.quote(filename)
555 browser = request.httprequest.user_agent.browser
556 version = int((request.httprequest.user_agent.version or '0').split('.')[0])
557 if browser == 'msie' and version < 9:
558 return "attachment; filename=%s" % escaped
559 elif browser == 'safari':
560 return "attachment; filename=%s" % filename
562 return "attachment; filename*=UTF-8''%s" % escaped
565 #----------------------------------------------------------
566 # OpenERP Web web Controllers
567 #----------------------------------------------------------
569 # TODO: to remove once the database manager has been migrated server side
570 # and `edi` + `pos` addons has been adapted to use render_bootstrap_template()
571 html_template = """<!DOCTYPE html>
572 <html style="height: 100%%">
574 <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
575 <meta http-equiv="content-type" content="text/html; charset=utf-8" />
576 <title>OpenERP</title>
577 <link rel="shortcut icon" href="/web/static/src/img/favicon.ico" type="image/x-icon"/>
578 <link rel="stylesheet" href="/web/static/src/css/full.css" />
581 <script type="text/javascript">
583 var s = new openerp.init(%(modules)s);
590 <script src="//ajax.googleapis.com/ajax/libs/chrome-frame/1/CFInstall.min.js"></script>
591 <script>CFInstall.check({mode: "overlay"});</script>
597 def render_bootstrap_template(template, values=None, debug=False, db=None, **kw):
604 values['debug'] = debug
605 values['current_db'] = db
607 values['databases'] = http.db_list()
608 except openerp.exceptions.AccessDenied:
609 values['databases'] = None
611 for res in ['js', 'css']:
612 if res not in values:
613 values[res] = manifest_list(res, db=db, debug=debug)
615 if 'modules' not in values:
616 values['modules'] = module_boot(db=db)
617 values['modules'] = simplejson.dumps(values['modules'])
619 return request.render(template, values, **kw)
621 class Home(http.Controller):
623 @http.route('/', type='http', auth="none")
624 def index(self, s_action=None, db=None, **kw):
625 return http.local_redirect('/web', query=request.params)
627 @http.route('/web', type='http', auth="none")
628 def web_client(self, s_action=None, **kw):
631 if request.session.uid:
633 'Cache-Control': 'no-cache',
634 'Content-Type': 'text/html; charset=utf-8',
636 return render_bootstrap_template("web.webclient_bootstrap", headers=headers)
638 return http.local_redirect('/web/login', query=request.params)
640 @http.route('/web/login', type='http', auth="none")
641 def web_login(self, redirect=None, **kw):
645 request.uid = openerp.SUPERUSER_ID
647 values = request.params.copy()
649 redirect = '/web?' + request.httprequest.query_string
650 values['redirect'] = redirect
651 if request.httprequest.method == 'POST':
652 uid = request.session.authenticate(request.session.db, request.params['login'], request.params['password'])
654 return http.redirect_with_hash(redirect)
655 values['error'] = "Wrong login/password"
656 return render_bootstrap_template('web.login', values)
658 @http.route('/login', type='http', auth="none")
659 def login(self, db, login, key, redirect="/web", **kw):
660 return login_and_redirect(db, login, key, redirect_url=redirect)
662 class WebClient(http.Controller):
664 @http.route('/web/webclient/csslist', type='json', auth="none")
665 def csslist(self, mods=None):
666 return manifest_list('css', mods=mods)
668 @http.route('/web/webclient/jslist', type='json', auth="none")
669 def jslist(self, mods=None):
670 return manifest_list('js', mods=mods)
672 @http.route('/web/webclient/qweblist', type='json', auth="none")
673 def qweblist(self, mods=None):
674 return manifest_list('qweb', mods=mods)
676 @http.route('/web/webclient/css', type='http', auth="none")
677 def css(self, mods=None, db=None):
678 files = list(manifest_glob('css', addons=mods, db=db))
679 last_modified = get_last_modified(f[0] for f in files)
680 if request.httprequest.if_modified_since and request.httprequest.if_modified_since >= last_modified:
681 return werkzeug.wrappers.Response(status=304)
683 file_map = dict(files)
685 rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U)
686 rx_url = re.compile(r"""url\s*\(\s*('|"|)(?!'|"|/|https?://|data:)""", re.U)
689 """read the a css file and absolutify all relative uris"""
690 with open(f, 'rb') as fp:
691 data = fp.read().decode('utf-8')
694 web_dir = os.path.dirname(path)
698 r"""@import \1%s/""" % (web_dir,),
704 r"url(\1%s/" % (web_dir,),
707 return data.encode('utf-8')
709 content, checksum = concat_files((f[0] for f in files), reader)
711 # move up all @import and @charset rules to the top
714 matches.append(matchobj.group(0))
717 content = re.sub(re.compile("(@charset.+;$)", re.M), push, content)
718 content = re.sub(re.compile("(@import.+;$)", re.M), push, content)
720 matches.append(content)
721 content = '\n'.join(matches)
723 return make_conditional(
724 request.make_response(content, [('Content-Type', 'text/css')]),
725 last_modified, checksum)
727 @http.route('/web/webclient/js', type='http', auth="none")
728 def js(self, mods=None, db=None):
729 files = [f[0] for f in manifest_glob('js', addons=mods, db=db)]
730 last_modified = get_last_modified(files)
731 if request.httprequest.if_modified_since and request.httprequest.if_modified_since >= last_modified:
732 return werkzeug.wrappers.Response(status=304)
734 content, checksum = concat_js(files)
736 return make_conditional(
737 request.make_response(content, [('Content-Type', 'application/javascript')]),
738 last_modified, checksum)
740 @http.route('/web/webclient/qweb', type='http', auth="none")
741 def qweb(self, mods=None, db=None):
742 files = [f[0] for f in manifest_glob('qweb', addons=mods, db=db)]
743 last_modified = get_last_modified(files)
744 if request.httprequest.if_modified_since and request.httprequest.if_modified_since >= last_modified:
745 return werkzeug.wrappers.Response(status=304)
747 content, checksum = concat_xml(files)
749 return make_conditional(
750 request.make_response(content, [('Content-Type', 'text/xml')]),
751 last_modified, checksum)
753 @http.route('/web/webclient/bootstrap_translations', type='json', auth="none")
754 def bootstrap_translations(self, mods):
755 """ Load local translations from *.po files, as a temporary solution
756 until we have established a valid session. This is meant only
757 for translating the login page and db management chrome, using
758 the browser's language. """
759 # For performance reasons we only load a single translation, so for
760 # sub-languages (that should only be partially translated) we load the
761 # main language PO instead - that should be enough for the login screen.
762 lang = request.lang.split('_')[0]
764 translations_per_module = {}
765 for addon_name in mods:
766 if http.addons_manifest[addon_name].get('bootstrap'):
767 addons_path = http.addons_manifest[addon_name]['addons_path']
768 f_name = os.path.join(addons_path, addon_name, "i18n", lang + ".po")
769 if not os.path.exists(f_name):
771 translations_per_module[addon_name] = {'messages': _local_web_translations(f_name)}
773 return {"modules": translations_per_module,
774 "lang_parameters": None}
776 @http.route('/web/webclient/translations', type='json', auth="none")
777 def translations(self, mods=None, lang=None):
778 request.disable_db = False
779 uid = openerp.SUPERUSER_ID
781 m = request.registry.get('ir.module.module')
782 mods = [x['name'] for x in m.search_read(request.cr, uid,
783 [('state','=','installed')], ['name'])]
785 lang = request.context["lang"]
786 res_lang = request.registry.get('res.lang')
787 ids = res_lang.search(request.cr, uid, [("code", "=", lang)])
790 lang_params = res_lang.read(request.cr, uid, ids[0], ["direction", "date_format", "time_format",
791 "grouping", "decimal_point", "thousands_sep"])
793 # Regional languages (ll_CC) must inherit/override their parent lang (ll), but this is
794 # done server-side when the language is loaded, so we only need to load the user's lang.
795 ir_translation = request.registry.get('ir.translation')
796 translations_per_module = {}
797 messages = ir_translation.search_read(request.cr, uid, [('module','in',mods),('lang','=',lang),
798 ('comments','like','openerp-web'),('value','!=',False),
800 ['module','src','value','lang'], order='module')
801 for mod, msg_group in itertools.groupby(messages, key=operator.itemgetter('module')):
802 translations_per_module.setdefault(mod,{'messages':[]})
803 translations_per_module[mod]['messages'].extend({'id': m['src'],
804 'string': m['value']} \
806 return {"modules": translations_per_module,
807 "lang_parameters": lang_params}
809 @http.route('/web/webclient/version_info', type='json', auth="none")
810 def version_info(self):
811 return openerp.service.common.exp_version()
813 class Proxy(http.Controller):
815 @http.route('/web/proxy/load', type='json', auth="none")
816 def load(self, path):
817 """ Proxies an HTTP request through a JSON request.
819 It is strongly recommended to not request binary files through this,
820 as the result will be a binary data blob as well.
822 :param path: actual request path
823 :return: file content
825 from werkzeug.test import Client
826 from werkzeug.wrappers import BaseResponse
828 return Client(request.httprequest.app, BaseResponse).get(path).data
830 class Database(http.Controller):
832 @http.route('/web/database/selector', type='http', auth="none")
833 def selector(self, **kw):
837 return http.local_redirect('/web/database/manager')
838 except openerp.exceptions.AccessDenied:
840 return env.get_template("database_selector.html").render({
842 'debug': request.debug,
845 @http.route('/web/database/manager', type='http', auth="none")
846 def manager(self, **kw):
847 # TODO: migrate the webclient's database manager to server side views
848 request.session.logout()
849 js = "\n ".join('<script type="text/javascript" src="%s"></script>' % i for i in manifest_list('js', debug=request.debug))
850 css = "\n ".join('<link rel="stylesheet" href="%s">' % i for i in manifest_list('css', debug=request.debug))
852 r = html_template % {
855 'modules': simplejson.dumps(module_boot()),
857 var wc = new s.web.WebClient(null, { action: 'database_manager' });
858 wc.appendTo($(document.body));
863 @http.route('/web/database/get_list', type='json', auth="none")
865 # TODO change js to avoid calling this method if in monodb mode
867 return http.db_list()
868 except openerp.exceptions.AccessDenied:
874 @http.route('/web/database/create', type='json', auth="none")
875 def create(self, fields):
876 params = dict(map(operator.itemgetter('name', 'value'), fields))
877 db_created = request.session.proxy("db").create_database(
878 params['super_admin_pwd'],
880 bool(params.get('demo_data')),
882 params['create_admin_pwd'])
884 request.session.authenticate(params['db_name'], 'admin', params['create_admin_pwd'])
887 @http.route('/web/database/duplicate', type='json', auth="none")
888 def duplicate(self, fields):
889 params = dict(map(operator.itemgetter('name', 'value'), fields))
891 params['super_admin_pwd'],
892 params['db_original_name'],
896 return request.session.proxy("db").duplicate_database(*duplicate_attrs)
898 @http.route('/web/database/drop', type='json', auth="none")
899 def drop(self, fields):
900 password, db = operator.itemgetter(
901 'drop_pwd', 'drop_db')(
902 dict(map(operator.itemgetter('name', 'value'), fields)))
905 if request.session.proxy("db").drop(password, db):
909 except openerp.exceptions.AccessDenied:
910 return {'error': 'AccessDenied', 'title': 'Drop Database'}
912 return {'error': _('Could not drop database !'), 'title': _('Drop Database')}
914 @http.route('/web/database/backup', type='http', auth="none")
915 def backup(self, backup_db, backup_pwd, token):
917 db_dump = base64.b64decode(
918 request.session.proxy("db").dump(backup_pwd, backup_db))
919 filename = "%(db)s_%(timestamp)s.dump" % {
921 'timestamp': datetime.datetime.utcnow().strftime(
922 "%Y-%m-%d_%H-%M-%SZ")
924 return request.make_response(db_dump,
925 [('Content-Type', 'application/octet-stream; charset=binary'),
926 ('Content-Disposition', content_disposition(filename))],
930 return simplejson.dumps([[],[{'error': openerp.tools.ustr(e), 'title': _('Backup Database')}]])
932 @http.route('/web/database/restore', type='http', auth="none")
933 def restore(self, db_file, restore_pwd, new_db):
935 data = base64.b64encode(db_file.read())
936 request.session.proxy("db").restore(restore_pwd, new_db, data)
938 except openerp.exceptions.AccessDenied, e:
939 raise Exception("AccessDenied")
941 @http.route('/web/database/change_password', type='json', auth="none")
942 def change_password(self, fields):
943 old_password, new_password = operator.itemgetter(
944 'old_pwd', 'new_pwd')(
945 dict(map(operator.itemgetter('name', 'value'), fields)))
947 return request.session.proxy("db").change_admin_password(old_password, new_password)
948 except openerp.exceptions.AccessDenied:
949 return {'error': 'AccessDenied', 'title': _('Change Password')}
951 return {'error': _('Error, password not changed !'), 'title': _('Change Password')}
953 class Session(http.Controller):
955 def session_info(self):
956 request.session.ensure_valid()
958 "session_id": request.session_id,
959 "uid": request.session.uid,
960 "user_context": request.session.get_context() if request.session.uid else {},
961 "db": request.session.db,
962 "username": request.session.login,
965 @http.route('/web/session/get_session_info', type='json', auth="none")
966 def get_session_info(self):
967 request.uid = request.session.uid
968 request.disable_db = False
969 return self.session_info()
971 @http.route('/web/session/authenticate', type='json', auth="none")
972 def authenticate(self, db, login, password, base_location=None):
973 request.session.authenticate(db, login, password)
975 return self.session_info()
977 @http.route('/web/session/change_password', type='json', auth="user")
978 def change_password(self, fields):
979 old_password, new_password,confirm_password = operator.itemgetter('old_pwd', 'new_password','confirm_pwd')(
980 dict(map(operator.itemgetter('name', 'value'), fields)))
981 if not (old_password.strip() and new_password.strip() and confirm_password.strip()):
982 return {'error':_('You cannot leave any password empty.'),'title': _('Change Password')}
983 if new_password != confirm_password:
984 return {'error': _('The new password and its confirmation must be identical.'),'title': _('Change Password')}
986 if request.session.model('res.users').change_password(
987 old_password, new_password):
988 return {'new_password':new_password}
990 return {'error': _('The old password you provided is incorrect, your password was not changed.'), 'title': _('Change Password')}
991 return {'error': _('Error, password not changed !'), 'title': _('Change Password')}
993 @http.route('/web/session/get_lang_list', type='json', auth="none")
994 def get_lang_list(self):
996 return request.session.proxy("db").list_lang() or []
998 return {"error": e, "title": _("Languages")}
1000 @http.route('/web/session/modules', type='json', auth="user")
1002 # return all installed modules. Web client is smart enough to not load a module twice
1003 return module_installed()
1005 @http.route('/web/session/save_session_action', type='json', auth="user")
1006 def save_session_action(self, the_action):
1008 This method store an action object in the session object and returns an integer
1009 identifying that action. The method get_session_action() can be used to get
1012 :param the_action: The action to save in the session.
1013 :type the_action: anything
1014 :return: A key identifying the saved action.
1017 return request.httpsession.save_action(the_action)
1019 @http.route('/web/session/get_session_action', type='json', auth="user")
1020 def get_session_action(self, key):
1022 Gets back a previously saved action. This method can return None if the action
1023 was saved since too much time (this case should be handled in a smart way).
1025 :param key: The key given by save_session_action()
1027 :return: The saved action or None.
1030 return request.httpsession.get_action(key)
1032 @http.route('/web/session/check', type='json', auth="user")
1034 request.session.assert_valid()
1037 @http.route('/web/session/destroy', type='json', auth="user")
1039 request.session.logout()
1041 @http.route('/web/session/logout', type='http', auth="none")
1042 def logout(self, redirect='/web'):
1043 request.session.logout(keep_db=True)
1044 return werkzeug.utils.redirect(redirect, 303)
1046 class Menu(http.Controller):
1048 @http.route('/web/menu/get_user_roots', type='json', auth="user")
1049 def get_user_roots(self):
1050 """ Return all root menu ids visible for the session user.
1052 :return: the root menu ids
1056 Menus = s.model('ir.ui.menu')
1057 # If a menu action is defined use its domain to get the root menu items
1058 user_menu_id = s.model('res.users').read([s.uid], ['menu_id'],
1059 request.context)[0]['menu_id']
1061 menu_domain = [('parent_id', '=', False)]
1063 domain_string = s.model('ir.actions.act_window').read(
1064 [user_menu_id[0]], ['domain'],request.context)[0]['domain']
1066 menu_domain = ast.literal_eval(domain_string)
1068 return Menus.search(menu_domain, 0, False, False, request.context)
1070 @http.route('/web/menu/load', type='json', auth="user")
1072 """ Loads all menu items (all applications and their sub-menus).
1074 :return: the menu root
1075 :rtype: dict('children': menu_nodes)
1077 Menus = request.session.model('ir.ui.menu')
1079 fields = ['name', 'sequence', 'parent_id', 'action']
1080 menu_root_ids = self.get_user_roots()
1081 menu_roots = Menus.read(menu_root_ids, fields, request.context) if menu_root_ids else []
1085 'parent_id': [-1, ''],
1086 'children': menu_roots,
1087 'all_menu_ids': menu_root_ids,
1092 # menus are loaded fully unlike a regular tree view, cause there are a
1093 # limited number of items (752 when all 6.1 addons are installed)
1094 menu_ids = Menus.search([('id', 'child_of', menu_root_ids)], 0, False, False, request.context)
1095 menu_items = Menus.read(menu_ids, fields, request.context)
1096 # adds roots at the end of the sequence, so that they will overwrite
1097 # equivalent menu items from full menu read when put into id:item
1098 # mapping, resulting in children being correctly set on the roots.
1099 menu_items.extend(menu_roots)
1100 menu_root['all_menu_ids'] = menu_ids # includes menu_root_ids!
1102 # make a tree using parent_id
1103 menu_items_map = dict(
1104 (menu_item["id"], menu_item) for menu_item in menu_items)
1105 for menu_item in menu_items:
1106 if menu_item['parent_id']:
1107 parent = menu_item['parent_id'][0]
1110 if parent in menu_items_map:
1111 menu_items_map[parent].setdefault(
1112 'children', []).append(menu_item)
1114 # sort by sequence a tree using parent_id
1115 for menu_item in menu_items:
1116 menu_item.setdefault('children', []).sort(
1117 key=operator.itemgetter('sequence'))
1121 @http.route('/web/menu/load_needaction', type='json', auth="user")
1122 def load_needaction(self, menu_ids):
1123 """ Loads needaction counters for specific menu ids.
1125 :return: needaction data
1126 :rtype: dict(menu_id: {'needaction_enabled': boolean, 'needaction_counter': int})
1128 return request.session.model('ir.ui.menu').get_needaction_data(menu_ids, request.context)
1130 class DataSet(http.Controller):
1132 @http.route('/web/dataset/search_read', type='json', auth="user")
1133 def search_read(self, model, fields=False, offset=0, limit=False, domain=None, sort=None):
1134 return self.do_search_read(model, fields, offset, limit, domain, sort)
1135 def do_search_read(self, model, fields=False, offset=0, limit=False, domain=None
1137 """ Performs a search() followed by a read() (if needed) using the
1138 provided search criteria
1140 :param str model: the name of the model to search on
1141 :param fields: a list of the fields to return in the result records
1143 :param int offset: from which index should the results start being returned
1144 :param int limit: the maximum number of records to return
1145 :param list domain: the search domain for the query
1146 :param list sort: sorting directives
1147 :returns: A structure (dict) with two keys: ids (all the ids matching
1148 the (domain, context) pair) and records (paginated records
1149 matching fields selection set)
1152 Model = request.session.model(model)
1154 records = Model.search_read(domain, fields, offset or 0, limit or False, sort or False,
1161 if limit and len(records) == limit:
1162 length = Model.search_count(domain, request.context)
1164 length = len(records) + (offset or 0)
1170 @http.route('/web/dataset/load', type='json', auth="user")
1171 def load(self, model, id, fields):
1172 m = request.session.model(model)
1174 r = m.read([id], False, request.context)
1177 return {'value': value}
1179 def call_common(self, model, method, args, domain_id=None, context_id=None):
1180 return self._call_kw(model, method, args, {})
1182 def _call_kw(self, model, method, args, kwargs):
1183 # Temporary implements future display_name special field for model#read()
1184 if method == 'read' and kwargs.get('context', {}).get('future_display_name'):
1185 if 'display_name' in args[1]:
1186 names = dict(request.session.model(model).name_get(args[0], **kwargs))
1187 args[1].remove('display_name')
1188 records = request.session.model(model).read(*args, **kwargs)
1189 for record in records:
1190 record['display_name'] = \
1191 names.get(record['id']) or "%s#%d" % (model, (record['id']))
1194 if method.startswith('_'):
1195 raise Exception("Access Denied: Underscore prefixed methods cannot be remotely called")
1197 return getattr(request.registry.get(model), method)(request.cr, request.uid, *args, **kwargs)
1199 @http.route('/web/dataset/call', type='json', auth="user")
1200 def call(self, model, method, args, domain_id=None, context_id=None):
1201 return self._call_kw(model, method, args, {})
1203 @http.route(['/web/dataset/call_kw', '/web/dataset/call_kw/<path:path>'], type='json', auth="user")
1204 def call_kw(self, model, method, args, kwargs, path=None):
1205 return self._call_kw(model, method, args, kwargs)
1207 @http.route('/web/dataset/call_button', type='json', auth="user")
1208 def call_button(self, model, method, args, domain_id=None, context_id=None):
1209 action = self._call_kw(model, method, args, {})
1210 if isinstance(action, dict) and action.get('type') != '':
1211 return clean_action(action)
1214 @http.route('/web/dataset/exec_workflow', type='json', auth="user")
1215 def exec_workflow(self, model, id, signal):
1216 return request.session.exec_workflow(model, id, signal)
1218 @http.route('/web/dataset/resequence', type='json', auth="user")
1219 def resequence(self, model, ids, field='sequence', offset=0):
1220 """ Re-sequences a number of records in the model, by their ids
1222 The re-sequencing starts at the first model of ``ids``, the sequence
1223 number is incremented by one after each record and starts at ``offset``
1225 :param ids: identifiers of the records to resequence, in the new sequence order
1227 :param str field: field used for sequence specification, defaults to
1229 :param int offset: sequence number for first record in ``ids``, allows
1230 starting the resequencing from an arbitrary number,
1233 m = request.session.model(model)
1234 if not m.fields_get([field]):
1236 # python 2.6 has no start parameter
1237 for i, id in enumerate(ids):
1238 m.write(id, { field: i + offset })
1241 class View(http.Controller):
1243 @http.route('/web/view/add_custom', type='json', auth="user")
1244 def add_custom(self, view_id, arch):
1245 CustomView = request.session.model('ir.ui.view.custom')
1247 'user_id': request.session.uid,
1251 return {'result': True}
1253 @http.route('/web/view/undo_custom', type='json', auth="user")
1254 def undo_custom(self, view_id, reset=False):
1255 CustomView = request.session.model('ir.ui.view.custom')
1256 vcustom = CustomView.search([('user_id', '=', request.session.uid), ('ref_id' ,'=', view_id)],
1257 0, False, False, request.context)
1260 CustomView.unlink(vcustom, request.context)
1262 CustomView.unlink([vcustom[0]], request.context)
1263 return {'result': True}
1264 return {'result': False}
1266 class TreeView(View):
1268 @http.route('/web/treeview/action', type='json', auth="user")
1269 def action(self, model, id):
1270 return load_actions_from_ir_values(
1271 'action', 'tree_but_open',[(model, id)],
1274 class Binary(http.Controller):
1276 @http.route('/web/binary/image', type='http', auth="user")
1277 def image(self, model, id, field, **kw):
1278 last_update = '__last_update'
1279 Model = request.session.model(model)
1280 headers = [('Content-Type', 'image/png')]
1281 etag = request.httprequest.headers.get('If-None-Match')
1282 hashed_session = hashlib.md5(request.session_id).hexdigest()
1283 retag = hashed_session
1284 id = None if not id else simplejson.loads(id)
1285 if type(id) is list:
1289 if not id and hashed_session == etag:
1290 return werkzeug.wrappers.Response(status=304)
1292 date = Model.read([id], [last_update], request.context)[0].get(last_update)
1293 if hashlib.md5(date).hexdigest() == etag:
1294 return werkzeug.wrappers.Response(status=304)
1297 res = Model.default_get([field], request.context).get(field)
1300 res = Model.read([id], [last_update, field], request.context)[0]
1301 retag = hashlib.md5(res.get(last_update)).hexdigest()
1302 image_base64 = res.get(field)
1304 if kw.get('resize'):
1305 resize = kw.get('resize').split(',')
1306 if len(resize) == 2 and int(resize[0]) and int(resize[1]):
1307 width = int(resize[0])
1308 height = int(resize[1])
1309 # resize maximum 500*500
1310 if width > 500: width = 500
1311 if height > 500: height = 500
1312 image_base64 = openerp.tools.image_resize_image(base64_source=image_base64, size=(width, height), encoding='base64', filetype='PNG')
1314 image_data = base64.b64decode(image_base64)
1317 image_data = self.placeholder()
1318 headers.append(('ETag', retag))
1319 headers.append(('Content-Length', len(image_data)))
1321 ncache = int(kw.get('cache'))
1322 headers.append(('Cache-Control', 'no-cache' if ncache == 0 else 'max-age=%s' % (ncache)))
1325 return request.make_response(image_data, headers)
1327 def placeholder(self, image='placeholder.png'):
1328 addons_path = http.addons_manifest['web']['addons_path']
1329 return open(os.path.join(addons_path, 'web', 'static', 'src', 'img', image), 'rb').read()
1331 @http.route('/web/binary/saveas', type='http', auth="user")
1332 @serialize_exception
1333 def saveas(self, model, field, id=None, filename_field=None, **kw):
1334 """ Download link for files stored as binary fields.
1336 If the ``id`` parameter is omitted, fetches the default value for the
1337 binary field (via ``default_get``), otherwise fetches the field for
1338 that precise record.
1340 :param str model: name of the model to fetch the binary from
1341 :param str field: binary field
1342 :param str id: id of the record from which to fetch the binary
1343 :param str filename_field: field holding the file's name, if any
1344 :returns: :class:`werkzeug.wrappers.Response`
1346 Model = request.session.model(model)
1349 fields.append(filename_field)
1351 res = Model.read([int(id)], fields, request.context)[0]
1353 res = Model.default_get(fields, request.context)
1354 filecontent = base64.b64decode(res.get(field, ''))
1356 return request.not_found()
1358 filename = '%s_%s' % (model.replace('.', '_'), id)
1360 filename = res.get(filename_field, '') or filename
1361 return request.make_response(filecontent,
1362 [('Content-Type', 'application/octet-stream'),
1363 ('Content-Disposition', content_disposition(filename))])
1365 @http.route('/web/binary/saveas_ajax', type='http', auth="user")
1366 @serialize_exception
1367 def saveas_ajax(self, data, token):
1368 jdata = simplejson.loads(data)
1369 model = jdata['model']
1370 field = jdata['field']
1371 data = jdata['data']
1372 id = jdata.get('id', None)
1373 filename_field = jdata.get('filename_field', None)
1374 context = jdata.get('context', {})
1376 Model = request.session.model(model)
1379 fields.append(filename_field)
1381 res = { field: data }
1383 res = Model.read([int(id)], fields, context)[0]
1385 res = Model.default_get(fields, context)
1386 filecontent = base64.b64decode(res.get(field, ''))
1388 raise ValueError(_("No content found for field '%s' on '%s:%s'") %
1391 filename = '%s_%s' % (model.replace('.', '_'), id)
1393 filename = res.get(filename_field, '') or filename
1394 return request.make_response(filecontent,
1395 headers=[('Content-Type', 'application/octet-stream'),
1396 ('Content-Disposition', content_disposition(filename))],
1397 cookies={'fileToken': token})
1399 @http.route('/web/binary/upload', type='http', auth="user")
1400 @serialize_exception
1401 def upload(self, callback, ufile):
1402 # TODO: might be useful to have a configuration flag for max-length file uploads
1403 out = """<script language="javascript" type="text/javascript">
1404 var win = window.top.window;
1405 win.jQuery(win).trigger(%s, %s);
1409 args = [len(data), ufile.filename,
1410 ufile.content_type, base64.b64encode(data)]
1411 except Exception, e:
1412 args = [False, e.message]
1413 return out % (simplejson.dumps(callback), simplejson.dumps(args))
1415 @http.route('/web/binary/upload_attachment', type='http', auth="user")
1416 @serialize_exception
1417 def upload_attachment(self, callback, model, id, ufile):
1418 Model = request.session.model('ir.attachment')
1419 out = """<script language="javascript" type="text/javascript">
1420 var win = window.top.window;
1421 win.jQuery(win).trigger(%s, %s);
1424 attachment_id = Model.create({
1425 'name': ufile.filename,
1426 'datas': base64.encodestring(ufile.read()),
1427 'datas_fname': ufile.filename,
1432 'filename': ufile.filename,
1436 args = {'error': "Something horrible happened"}
1437 return out % (simplejson.dumps(callback), simplejson.dumps(args))
1440 '/web/binary/company_logo',
1443 ], type='http', auth="none")
1444 def company_logo(self, dbname=None):
1445 # TODO add etag, refactor to use /image code for etag
1447 if request.session.db:
1448 dbname = request.session.db
1449 uid = request.session.uid
1450 elif dbname is None:
1451 dbname = db_monodb()
1454 uid = openerp.SUPERUSER_ID
1457 image_data = self.placeholder('logo.png')
1460 # create an empty registry
1461 registry = openerp.modules.registry.Registry(dbname)
1462 with registry.cursor() as cr:
1463 cr.execute("""SELECT c.logo_web
1465 LEFT JOIN res_company c
1466 ON c.id = u.company_id
1471 image_data = str(row[0]).decode('base64')
1473 image_data = self.placeholder('nologo.png')
1475 image_data = self.placeholder('logo.png')
1478 ('Content-Type', 'image/png'),
1479 ('Content-Length', len(image_data)),
1481 return request.make_response(image_data, headers)
1483 class Action(http.Controller):
1485 @http.route('/web/action/load', type='json', auth="user")
1486 def load(self, action_id, do_not_eval=False):
1487 Actions = request.session.model('ir.actions.actions')
1490 action_id = int(action_id)
1493 module, xmlid = action_id.split('.', 1)
1494 model, action_id = request.session.model('ir.model.data').get_object_reference(module, xmlid)
1495 assert model.startswith('ir.actions.')
1497 action_id = 0 # force failed read
1499 base_action = Actions.read([action_id], ['type'], request.context)
1502 action_type = base_action[0]['type']
1503 if action_type == 'ir.actions.report.xml':
1504 ctx.update({'bin_size': True})
1505 ctx.update(request.context)
1506 action = request.session.model(action_type).read([action_id], False, ctx)
1508 value = clean_action(action[0])
1511 @http.route('/web/action/run', type='json', auth="user")
1512 def run(self, action_id):
1513 return_action = request.session.model('ir.actions.server').run(
1514 [action_id], request.context)
1516 return clean_action(return_action)
1520 class Export(http.Controller):
1522 @http.route('/web/export/formats', type='json', auth="user")
1524 """ Returns all valid export formats
1526 :returns: for each export format, a pair of identifier and printable name
1527 :rtype: [(str, str)]
1530 {'tag': 'csv', 'label': 'CSV'},
1531 {'tag': 'xls', 'label': 'Excel', 'error': None if xlwt else "XLWT required"},
1534 def fields_get(self, model):
1535 Model = request.session.model(model)
1536 fields = Model.fields_get(False, request.context)
1539 @http.route('/web/export/get_fields', type='json', auth="user")
1540 def get_fields(self, model, prefix='', parent_name= '',
1541 import_compat=True, parent_field_type=None,
1544 if import_compat and parent_field_type == "many2one":
1547 fields = self.fields_get(model)
1550 fields.pop('id', None)
1552 fields['.id'] = fields.pop('id', {'string': 'ID'})
1554 fields_sequence = sorted(fields.iteritems(),
1555 key=lambda field: field[1].get('string', ''))
1558 for field_name, field in fields_sequence:
1560 if exclude and field_name in exclude:
1562 if field.get('readonly'):
1563 # If none of the field's states unsets readonly, skip the field
1564 if all(dict(attrs).get('readonly', True)
1565 for attrs in field.get('states', {}).values()):
1567 if not field.get('exportable', True):
1570 id = prefix + (prefix and '/'or '') + field_name
1571 name = parent_name + (parent_name and '/' or '') + field['string']
1572 record = {'id': id, 'string': name,
1573 'value': id, 'children': False,
1574 'field_type': field.get('type'),
1575 'required': field.get('required'),
1576 'relation_field': field.get('relation_field')}
1577 records.append(record)
1579 if len(name.split('/')) < 3 and 'relation' in field:
1580 ref = field.pop('relation')
1581 record['value'] += '/id'
1582 record['params'] = {'model': ref, 'prefix': id, 'name': name}
1584 if not import_compat or field['type'] == 'one2many':
1585 # m2m field in import_compat is childless
1586 record['children'] = True
1590 @http.route('/web/export/namelist', type='json', auth="user")
1591 def namelist(self, model, export_id):
1592 # TODO: namelist really has no reason to be in Python (although itertools.groupby helps)
1593 export = request.session.model("ir.exports").read([export_id])[0]
1594 export_fields_list = request.session.model("ir.exports.line").read(
1595 export['export_fields'])
1597 fields_data = self.fields_info(
1598 model, map(operator.itemgetter('name'), export_fields_list))
1601 {'name': field_name, 'label': fields_data[field_name]}
1602 for field_name in fields_data.keys()
1605 def fields_info(self, model, export_fields):
1607 fields = self.fields_get(model)
1608 if ".id" in export_fields:
1609 fields['.id'] = fields.pop('id', {'string': 'ID'})
1611 # To make fields retrieval more efficient, fetch all sub-fields of a
1612 # given field at the same time. Because the order in the export list is
1613 # arbitrary, this requires ordering all sub-fields of a given field
1614 # together so they can be fetched at the same time
1616 # Works the following way:
1617 # * sort the list of fields to export, the default sorting order will
1618 # put the field itself (if present, for xmlid) and all of its
1619 # sub-fields right after it
1620 # * then, group on: the first field of the path (which is the same for
1621 # a field and for its subfields and the length of splitting on the
1622 # first '/', which basically means grouping the field on one side and
1623 # all of the subfields on the other. This way, we have the field (for
1624 # the xmlid) with length 1, and all of the subfields with the same
1625 # base but a length "flag" of 2
1626 # * if we have a normal field (length 1), just add it to the info
1627 # mapping (with its string) as-is
1628 # * otherwise, recursively call fields_info via graft_subfields.
1629 # all graft_subfields does is take the result of fields_info (on the
1630 # field's model) and prepend the current base (current field), which
1631 # rebuilds the whole sub-tree for the field
1633 # result: because we're not fetching the fields_get for half the
1634 # database models, fetching a namelist with a dozen fields (including
1635 # relational data) falls from ~6s to ~300ms (on the leads model).
1636 # export lists with no sub-fields (e.g. import_compatible lists with
1637 # no o2m) are even more efficient (from the same 6s to ~170ms, as
1638 # there's a single fields_get to execute)
1639 for (base, length), subfields in itertools.groupby(
1640 sorted(export_fields),
1641 lambda field: (field.split('/', 1)[0], len(field.split('/', 1)))):
1642 subfields = list(subfields)
1644 # subfields is a seq of $base/*rest, and not loaded yet
1645 info.update(self.graft_subfields(
1646 fields[base]['relation'], base, fields[base]['string'],
1649 elif base in fields:
1650 info[base] = fields[base]['string']
1654 def graft_subfields(self, model, prefix, prefix_string, fields):
1655 export_fields = [field.split('/', 1)[1] for field in fields]
1657 (prefix + '/' + k, prefix_string + '/' + v)
1658 for k, v in self.fields_info(model, export_fields).iteritems())
1660 class ExportFormat(object):
1662 def content_type(self):
1663 """ Provides the format's content type """
1664 raise NotImplementedError()
1666 def filename(self, base):
1667 """ Creates a valid filename for the format (with extension) from the
1668 provided base name (exension-less)
1670 raise NotImplementedError()
1672 def from_data(self, fields, rows):
1673 """ Conversion method from OpenERP's export data to whatever the
1674 current export class outputs
1676 :params list fields: a list of fields to export
1677 :params list rows: a list of records to export
1681 raise NotImplementedError()
1683 def base(self, data, token):
1684 model, fields, ids, domain, import_compat = \
1685 operator.itemgetter('model', 'fields', 'ids', 'domain',
1687 simplejson.loads(data))
1689 Model = request.session.model(model)
1690 ids = ids or Model.search(domain, 0, False, False, request.context)
1692 field_names = map(operator.itemgetter('name'), fields)
1693 import_data = Model.export_data(ids, field_names, request.context).get('datas',[])
1696 columns_headers = field_names
1698 columns_headers = [val['label'].strip() for val in fields]
1701 return request.make_response(self.from_data(columns_headers, import_data),
1702 headers=[('Content-Disposition',
1703 content_disposition(self.filename(model))),
1704 ('Content-Type', self.content_type)],
1705 cookies={'fileToken': token})
1707 class CSVExport(ExportFormat, http.Controller):
1709 @http.route('/web/export/csv', type='http', auth="user")
1710 @serialize_exception
1711 def index(self, data, token):
1712 return self.base(data, token)
1715 def content_type(self):
1716 return 'text/csv;charset=utf8'
1718 def filename(self, base):
1719 return base + '.csv'
1721 def from_data(self, fields, rows):
1723 writer = csv.writer(fp, quoting=csv.QUOTE_ALL)
1725 writer.writerow([name.encode('utf-8') for name in fields])
1730 if isinstance(d, basestring):
1731 d = d.replace('\n',' ').replace('\t',' ')
1733 d = d.encode('utf-8')
1734 except UnicodeError:
1736 if d is False: d = None
1738 writer.writerow(row)
1745 class ExcelExport(ExportFormat, http.Controller):
1747 @http.route('/web/export/xls', type='http', auth="user")
1748 @serialize_exception
1749 def index(self, data, token):
1750 return self.base(data, token)
1753 def content_type(self):
1754 return 'application/vnd.ms-excel'
1756 def filename(self, base):
1757 return base + '.xls'
1759 def from_data(self, fields, rows):
1760 workbook = xlwt.Workbook()
1761 worksheet = workbook.add_sheet('Sheet 1')
1763 for i, fieldname in enumerate(fields):
1764 worksheet.write(0, i, fieldname)
1765 worksheet.col(i).width = 8000 # around 220 pixels
1767 style = xlwt.easyxf('align: wrap yes')
1769 for row_index, row in enumerate(rows):
1770 for cell_index, cell_value in enumerate(row):
1771 if isinstance(cell_value, basestring):
1772 cell_value = re.sub("\r", " ", cell_value)
1773 if cell_value is False: cell_value = None
1774 worksheet.write(row_index + 1, cell_index, cell_value, style)
1783 class Reports(http.Controller):
1784 POLLING_DELAY = 0.25
1786 'doc': 'application/vnd.ms-word',
1787 'html': 'text/html',
1788 'odt': 'application/vnd.oasis.opendocument.text',
1789 'pdf': 'application/pdf',
1790 'sxw': 'application/vnd.sun.xml.writer',
1791 'xls': 'application/vnd.ms-excel',
1794 @http.route('/web/report', type='http', auth="user")
1795 @serialize_exception
1796 def index(self, action, token):
1797 action = simplejson.loads(action)
1799 report_srv = request.session.proxy("report")
1800 context = dict(request.context)
1801 context.update(action["context"])
1804 report_ids = context.get("active_ids", None)
1805 if 'report_type' in action:
1806 report_data['report_type'] = action['report_type']
1807 if 'datas' in action:
1808 if 'ids' in action['datas']:
1809 report_ids = action['datas'].pop('ids')
1810 report_data.update(action['datas'])
1812 report_id = report_srv.report(
1813 request.session.db, request.session.uid, request.session.password,
1814 action["report_name"], report_ids,
1815 report_data, context)
1817 report_struct = None
1819 report_struct = report_srv.report_get(
1820 request.session.db, request.session.uid, request.session.password, report_id)
1821 if report_struct["state"]:
1824 time.sleep(self.POLLING_DELAY)
1826 report = base64.b64decode(report_struct['result'])
1827 if report_struct.get('code') == 'zlib':
1828 report = zlib.decompress(report)
1829 report_mimetype = self.TYPES_MAPPING.get(
1830 report_struct['format'], 'octet-stream')
1831 file_name = action.get('name', 'report')
1832 if 'name' not in action:
1833 reports = request.session.model('ir.actions.report.xml')
1834 res_id = reports.search([('report_name', '=', action['report_name']),],
1835 0, False, False, context)
1837 file_name = reports.read(res_id[0], ['name'], context)['name']
1839 file_name = action['report_name']
1840 file_name = '%s.%s' % (file_name, report_struct['format'])
1842 return request.make_response(report,
1844 ('Content-Disposition', content_disposition(file_name)),
1845 ('Content-Type', report_mimetype),
1846 ('Content-Length', len(report))],
1847 cookies={'fileToken': token})
1849 class Apps(http.Controller):
1850 @http.route('/apps/<app>', auth='user')
1851 def get_app_url(self, req, app):
1852 act_window_obj = request.session.model('ir.actions.act_window')
1853 ir_model_data = request.session.model('ir.model.data')
1855 action_id = ir_model_data.get_object_reference('base', 'open_module_tree')[1]
1856 action = act_window_obj.read(action_id, ['name', 'type', 'res_model', 'view_mode', 'view_type', 'context', 'views', 'domain'])
1857 action['target'] = 'current'
1861 app_id = ir_model_data.get_object_reference('base', 'module_%s' % app)[1]
1865 if action and app_id:
1866 action['res_id'] = app_id
1867 action['view_mode'] = 'form'
1868 action['views'] = [(False, u'form')]
1870 sakey = Session().save_session_action(action)
1871 debug = '?debug' if req.debug else ''
1872 return werkzeug.utils.redirect('/web{0}#sa={1}'.format(debug, sakey))
1876 # vim:expandtab:tabstop=4:softtabstop=4:shiftwidth=4: