[MERGE] Forward-port of latest 7.0 bugfixes, up to rev. 9885 revid:dle@openerp.com...
[odoo/odoo.git] / addons / web / controllers / main.py
1 # -*- coding: utf-8 -*-
2
3 import ast
4 import base64
5 import csv
6 import glob
7 import itertools
8 import logging
9 import operator
10 import datetime
11 import hashlib
12 import os
13 import re
14 import simplejson
15 import time
16 import urllib
17 import urllib2
18 import urlparse
19 import xmlrpclib
20 import zlib
21 from xml.etree import ElementTree
22 from cStringIO import StringIO
23
24 import babel.messages.pofile
25 import werkzeug.utils
26 import werkzeug.wrappers
27 try:
28     import xlwt
29 except ImportError:
30     xlwt = None
31
32 import openerp
33 import openerp.modules.registry
34 from openerp.tools.translate import _
35 from openerp.tools import config
36
37 from .. import http
38
39 from openerp.addons.web.http import request
40
41 #----------------------------------------------------------
42 # OpenERP Web helpers
43 #----------------------------------------------------------
44
45 def rjsmin(script):
46     """ Minify js with a clever regex.
47     Taken from http://opensource.perlig.de/rjsmin
48     Apache License, Version 2.0 """
49     def subber(match):
50         """ Substitution callback """
51         groups = match.groups()
52         return (
53             groups[0] or
54             groups[1] or
55             groups[2] or
56             groups[3] or
57             (groups[4] and '\n') or
58             (groups[5] and ' ') or
59             (groups[6] and ' ') or
60             (groups[7] and ' ') or
61             ''
62         )
63
64     result = re.sub(
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
86     ).strip()
87     return result
88
89 db_list = http.db_list
90
91 db_monodb = http.db_monodb
92
93 def redirect_with_hash(url, code=303):
94     # Most IE and Safari versions decided not to preserve location.hash upon
95     # redirect. And even if IE10 pretends to support it, it still fails
96     # inexplicably in case of multiple redirects (and we do have some).
97     # See extensive test page at http://greenbytes.de/tech/tc/httpredirects/
98     return "<html><head><script>window.location = '%s' + location.hash;</script></head></html>" % url
99
100 def module_topological_sort(modules):
101     """ Return a list of module names sorted so that their dependencies of the
102     modules are listed before the module itself
103
104     modules is a dict of {module_name: dependencies}
105
106     :param modules: modules to sort
107     :type modules: dict
108     :returns: list(str)
109     """
110
111     dependencies = set(itertools.chain.from_iterable(modules.itervalues()))
112     # incoming edge: dependency on other module (if a depends on b, a has an
113     # incoming edge from b, aka there's an edge from b to a)
114     # outgoing edge: other module depending on this one
115
116     # [Tarjan 1976], http://en.wikipedia.org/wiki/Topological_sorting#Algorithms
117     #L ← Empty list that will contain the sorted nodes
118     L = []
119     #S ← Set of all nodes with no outgoing edges (modules on which no other
120     #    module depends)
121     S = set(module for module in modules if module not in dependencies)
122
123     visited = set()
124     #function visit(node n)
125     def visit(n):
126         #if n has not been visited yet then
127         if n not in visited:
128             #mark n as visited
129             visited.add(n)
130             #change: n not web module, can not be resolved, ignore
131             if n not in modules: return
132             #for each node m with an edge from m to n do (dependencies of n)
133             for m in modules[n]:
134                 #visit(m)
135                 visit(m)
136             #add n to L
137             L.append(n)
138     #for each node n in S do
139     for n in S:
140         #visit(n)
141         visit(n)
142     return L
143
144 def module_installed():
145     # Candidates module the current heuristic is the /static dir
146     loadable = http.addons_manifest.keys()
147     modules = {}
148
149     # Retrieve database installed modules
150     # TODO The following code should move to ir.module.module.list_installed_modules()
151     Modules = request.session.model('ir.module.module')
152     domain = [('state','=','installed'), ('name','in', loadable)]
153     for module in Modules.search_read(domain, ['name', 'dependencies_id']):
154         modules[module['name']] = []
155         deps = module.get('dependencies_id')
156         if deps:
157             deps_read = request.session.model('ir.module.module.dependency').read(deps, ['name'])
158             dependencies = [i['name'] for i in deps_read]
159             modules[module['name']] = dependencies
160
161     sorted_modules = module_topological_sort(modules)
162     return sorted_modules
163
164 def module_installed_bypass_session(dbname):
165     loadable = http.addons_manifest.keys()
166     modules = {}
167     try:
168         registry = openerp.modules.registry.RegistryManager.get(dbname)
169         with registry.cursor() as cr:
170             m = registry.get('ir.module.module')
171             # TODO The following code should move to ir.module.module.list_installed_modules()
172             domain = [('state','=','installed'), ('name','in', loadable)]
173             ids = m.search(cr, 1, [('state','=','installed'), ('name','in', loadable)])
174             for module in m.read(cr, 1, ids, ['name', 'dependencies_id']):
175                 modules[module['name']] = []
176                 deps = module.get('dependencies_id')
177                 if deps:
178                     deps_read = registry.get('ir.module.module.dependency').read(cr, 1, deps, ['name'])
179                     dependencies = [i['name'] for i in deps_read]
180                     modules[module['name']] = dependencies
181     except Exception,e:
182         pass
183     sorted_modules = module_topological_sort(modules)
184     return sorted_modules
185
186 def module_boot(db=None):
187     server_wide_modules = openerp.conf.server_wide_modules or ['web']
188     serverside = []
189     dbside = []
190     for i in server_wide_modules:
191         if i in http.addons_manifest:
192             serverside.append(i)
193     monodb = db or db_monodb()
194     if monodb:
195         dbside = module_installed_bypass_session(monodb)
196         dbside = [i for i in dbside if i not in serverside]
197     addons = serverside + dbside
198     return addons
199
200 def concat_xml(file_list):
201     """Concatenate xml files
202
203     :param list(str) file_list: list of files to check
204     :returns: (concatenation_result, checksum)
205     :rtype: (str, str)
206     """
207     checksum = hashlib.new('sha1')
208     if not file_list:
209         return '', checksum.hexdigest()
210
211     root = None
212     for fname in file_list:
213         with open(fname, 'rb') as fp:
214             contents = fp.read()
215             checksum.update(contents)
216             fp.seek(0)
217             xml = ElementTree.parse(fp).getroot()
218
219         if root is None:
220             root = ElementTree.Element(xml.tag)
221         #elif root.tag != xml.tag:
222         #    raise ValueError("Root tags missmatch: %r != %r" % (root.tag, xml.tag))
223
224         for child in xml.getchildren():
225             root.append(child)
226     return ElementTree.tostring(root, 'utf-8'), checksum.hexdigest()
227
228 def concat_files(file_list, reader=None, intersperse=""):
229     """ Concatenates contents of all provided files
230
231     :param list(str) file_list: list of files to check
232     :param function reader: reading procedure for each file
233     :param str intersperse: string to intersperse between file contents
234     :returns: (concatenation_result, checksum)
235     :rtype: (str, str)
236     """
237     checksum = hashlib.new('sha1')
238     if not file_list:
239         return '', checksum.hexdigest()
240
241     if reader is None:
242         def reader(f):
243             import codecs
244             with codecs.open(f, 'rb', "utf-8-sig") as fp:
245                 return fp.read().encode("utf-8")
246
247     files_content = []
248     for fname in file_list:
249         contents = reader(fname)
250         checksum.update(contents)
251         files_content.append(contents)
252
253     files_concat = intersperse.join(files_content)
254     return files_concat, checksum.hexdigest()
255
256 concat_js_cache = {}
257
258 def concat_js(file_list):
259     content, checksum = concat_files(file_list, intersperse=';')
260     if checksum in concat_js_cache:
261         content = concat_js_cache[checksum]
262     else:
263         content = rjsmin(content)
264         concat_js_cache[checksum] = content
265     return content, checksum
266
267 def fs2web(path):
268     """convert FS path into web path"""
269     return '/'.join(path.split(os.path.sep))
270
271 def manifest_glob(extension, addons=None, db=None, include_remotes=False):
272     if addons is None:
273         addons = module_boot(db=db)
274     else:
275         addons = addons.split(',')
276     r = []
277     for addon in addons:
278         manifest = http.addons_manifest.get(addon, None)
279         if not manifest:
280             continue
281         # ensure does not ends with /
282         addons_path = os.path.join(manifest['addons_path'], '')[:-1]
283         globlist = manifest.get(extension, [])
284         for pattern in globlist:
285             if pattern.startswith(('http://', 'https://', '//')):
286                 if include_remotes:
287                     r.append((None, pattern))
288             else:
289                 for path in glob.glob(os.path.normpath(os.path.join(addons_path, addon, pattern))):
290                     r.append((path, fs2web(path[len(addons_path):])))
291     return r
292
293 def manifest_list(extension, mods=None, db=None, debug=False):
294     """ list ressources to load specifying either:
295     mods: a comma separated string listing modules
296     db: a database name (return all installed modules in that database)
297     """
298     files = manifest_glob(extension, addons=mods, db=db, include_remotes=True)
299     if not debug:
300         path = '/web/webclient/' + extension
301         if mods is not None:
302             path += '?' + urllib.urlencode({'mods': mods})
303         elif db:
304             path += '?' + urllib.urlencode({'db': db})
305
306         remotes = [wp for fp, wp in files if fp is None]
307         return [path] + remotes
308     return [wp for _fp, wp in files]
309
310 def get_last_modified(files):
311     """ Returns the modification time of the most recently modified
312     file provided
313
314     :param list(str) files: names of files to check
315     :return: most recent modification time amongst the fileset
316     :rtype: datetime.datetime
317     """
318     files = list(files)
319     if files:
320         return max(datetime.datetime.fromtimestamp(os.path.getmtime(f))
321                    for f in files)
322     return datetime.datetime(1970, 1, 1)
323
324 def make_conditional(response, last_modified=None, etag=None):
325     """ Makes the provided response conditional based upon the request,
326     and mandates revalidation from clients
327
328     Uses Werkzeug's own :meth:`ETagResponseMixin.make_conditional`, after
329     setting ``last_modified`` and ``etag`` correctly on the response object
330
331     :param response: Werkzeug response
332     :type response: werkzeug.wrappers.Response
333     :param datetime.datetime last_modified: last modification date of the response content
334     :param str etag: some sort of checksum of the content (deep etag)
335     :return: the response object provided
336     :rtype: werkzeug.wrappers.Response
337     """
338     response.cache_control.must_revalidate = True
339     response.cache_control.max_age = 0
340     if last_modified:
341         response.last_modified = last_modified
342     if etag:
343         response.set_etag(etag)
344     return response.make_conditional(request.httprequest)
345
346 def login_and_redirect(db, login, key, redirect_url='/'):
347     request.session.authenticate(db, login, key)
348     return set_cookie_and_redirect(redirect_url)
349
350 def set_cookie_and_redirect(redirect_url):
351     redirect = werkzeug.utils.redirect(redirect_url, 303)
352     redirect.autocorrect_location_header = False
353     return redirect
354
355 def load_actions_from_ir_values(key, key2, models, meta):
356     Values = request.session.model('ir.values')
357     actions = Values.get(key, key2, models, meta, request.context)
358
359     return [(id, name, clean_action(action))
360             for id, name, action in actions]
361
362 def clean_action(action):
363     action.setdefault('flags', {})
364     action_type = action.setdefault('type', 'ir.actions.act_window_close')
365     if action_type == 'ir.actions.act_window':
366         return fix_view_modes(action)
367     return action
368
369 # I think generate_views,fix_view_modes should go into js ActionManager
370 def generate_views(action):
371     """
372     While the server generates a sequence called "views" computing dependencies
373     between a bunch of stuff for views coming directly from the database
374     (the ``ir.actions.act_window model``), it's also possible for e.g. buttons
375     to return custom view dictionaries generated on the fly.
376
377     In that case, there is no ``views`` key available on the action.
378
379     Since the web client relies on ``action['views']``, generate it here from
380     ``view_mode`` and ``view_id``.
381
382     Currently handles two different cases:
383
384     * no view_id, multiple view_mode
385     * single view_id, single view_mode
386
387     :param dict action: action descriptor dictionary to generate a views key for
388     """
389     view_id = action.get('view_id') or False
390     if isinstance(view_id, (list, tuple)):
391         view_id = view_id[0]
392
393     # providing at least one view mode is a requirement, not an option
394     view_modes = action['view_mode'].split(',')
395
396     if len(view_modes) > 1:
397         if view_id:
398             raise ValueError('Non-db action dictionaries should provide '
399                              'either multiple view modes or a single view '
400                              'mode and an optional view id.\n\n Got view '
401                              'modes %r and view id %r for action %r' % (
402                 view_modes, view_id, action))
403         action['views'] = [(False, mode) for mode in view_modes]
404         return
405     action['views'] = [(view_id, view_modes[0])]
406
407 def fix_view_modes(action):
408     """ For historical reasons, OpenERP has weird dealings in relation to
409     view_mode and the view_type attribute (on window actions):
410
411     * one of the view modes is ``tree``, which stands for both list views
412       and tree views
413     * the choice is made by checking ``view_type``, which is either
414       ``form`` for a list view or ``tree`` for an actual tree view
415
416     This methods simply folds the view_type into view_mode by adding a
417     new view mode ``list`` which is the result of the ``tree`` view_mode
418     in conjunction with the ``form`` view_type.
419
420     TODO: this should go into the doc, some kind of "peculiarities" section
421
422     :param dict action: an action descriptor
423     :returns: nothing, the action is modified in place
424     """
425     if not action.get('views'):
426         generate_views(action)
427
428     if action.pop('view_type', 'form') != 'form':
429         return action
430
431     if 'view_mode' in action:
432         action['view_mode'] = ','.join(
433             mode if mode != 'tree' else 'list'
434             for mode in action['view_mode'].split(','))
435     action['views'] = [
436         [id, mode if mode != 'tree' else 'list']
437         for id, mode in action['views']
438     ]
439
440     return action
441
442 def _local_web_translations(trans_file):
443     messages = []
444     try:
445         with open(trans_file) as t_file:
446             po = babel.messages.pofile.read_po(t_file)
447     except Exception:
448         return
449     for x in po:
450         if x.id and x.string and "openerp-web" in x.auto_comments:
451             messages.append({'id': x.id, 'string': x.string})
452     return messages
453
454 def xml2json_from_elementtree(el, preserve_whitespaces=False):
455     """ xml2json-direct
456     Simple and straightforward XML-to-JSON converter in Python
457     New BSD Licensed
458     http://code.google.com/p/xml2json-direct/
459     """
460     res = {}
461     if el.tag[0] == "{":
462         ns, name = el.tag.rsplit("}", 1)
463         res["tag"] = name
464         res["namespace"] = ns[1:]
465     else:
466         res["tag"] = el.tag
467     res["attrs"] = {}
468     for k, v in el.items():
469         res["attrs"][k] = v
470     kids = []
471     if el.text and (preserve_whitespaces or el.text.strip() != ''):
472         kids.append(el.text)
473     for kid in el:
474         kids.append(xml2json_from_elementtree(kid, preserve_whitespaces))
475         if kid.tail and (preserve_whitespaces or kid.tail.strip() != ''):
476             kids.append(kid.tail)
477     res["children"] = kids
478     return res
479
480 def content_disposition(filename):
481     filename = filename.encode('utf8')
482     escaped = urllib2.quote(filename)
483     browser = request.httprequest.user_agent.browser
484     version = int((request.httprequest.user_agent.version or '0').split('.')[0])
485     if browser == 'msie' and version < 9:
486         return "attachment; filename=%s" % escaped
487     elif browser == 'safari':
488         return "attachment; filename=%s" % filename
489     else:
490         return "attachment; filename*=UTF-8''%s" % escaped
491
492
493 #----------------------------------------------------------
494 # OpenERP Web web Controllers
495 #----------------------------------------------------------
496
497 html_template = """<!DOCTYPE html>
498 <html style="height: 100%%">
499     <head>
500         <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
501         <meta http-equiv="content-type" content="text/html; charset=utf-8" />
502         <title>OpenERP</title>
503         <link rel="shortcut icon" href="/web/static/src/img/favicon.ico" type="image/x-icon"/>
504         <link rel="stylesheet" href="/web/static/src/css/full.css" />
505         %(css)s
506         %(js)s
507         <script type="text/javascript">
508             $(function() {
509                 var s = new openerp.init(%(modules)s);
510                 %(init)s
511             });
512         </script>
513     </head>
514     <body>
515         <!--[if lte IE 8]>
516         <script src="//ajax.googleapis.com/ajax/libs/chrome-frame/1/CFInstall.min.js"></script>
517         <script>CFInstall.check({mode: "overlay"});</script>
518         <![endif]-->
519     </body>
520 </html>
521 """
522
523 class Home(http.Controller):
524
525     @http.route('/', type='http', auth="none")
526     def index(self, s_action=None, db=None, debug=False, **kw):
527         debug = debug != False
528
529         lst = http.db_list(True)
530         if db not in lst:
531             db = None
532         guessed_db = http.db_monodb(request.httprequest)
533         if guessed_db is None and len(lst) > 0:
534             guessed_db = lst[0]
535
536         def redirect(db):
537             query = dict(urlparse.parse_qsl(request.httprequest.query_string, keep_blank_values=True))
538             query.update({'db': db})
539             redirect = request.httprequest.path + '?' + urllib.urlencode(query)
540             return redirect_with_hash(redirect)
541
542         if db is None and guessed_db is not None:
543             return redirect(guessed_db)
544
545         if db is not None and db != guessed_db:
546             request.session.logout()
547             request.session.db = db
548             guessed_db = db
549
550         js = "\n        ".join('<script type="text/javascript" src="%s"></script>' % i for i in manifest_list('js', db=guessed_db, debug=debug))
551         css = "\n        ".join('<link rel="stylesheet" href="%s">' % i for i in manifest_list('css', db=guessed_db, debug=debug))
552
553         r = html_template % {
554             'js': js,
555             'css': css,
556             'modules': simplejson.dumps(module_boot(db=guessed_db)),
557             'init': 'var wc = new s.web.WebClient();wc.appendTo($(document.body));'
558         }
559         return request.make_response(r, {'Cache-Control': 'no-cache', 'Content-Type': 'text/html; charset=utf-8'})
560
561     @http.route('/login', type='http', auth="none")
562     def login(self, db, login, key):
563         return login_and_redirect(db, login, key)
564
565 class WebClient(http.Controller):
566
567     @http.route('/web/webclient/csslist', type='json', auth="none")
568     def csslist(self, mods=None):
569         return manifest_list('css', mods=mods)
570
571     @http.route('/web/webclient/jslist', type='json', auth="none")
572     def jslist(self, mods=None):
573         return manifest_list('js', mods=mods)
574
575     @http.route('/web/webclient/qweblist', type='json', auth="none")
576     def qweblist(self, mods=None):
577         return manifest_list('qweb', mods=mods)
578
579     @http.route('/web/webclient/css', type='http', auth="none")
580     def css(self, mods=None, db=None):
581         files = list(manifest_glob('css', addons=mods, db=db))
582         last_modified = get_last_modified(f[0] for f in files)
583         if request.httprequest.if_modified_since and request.httprequest.if_modified_since >= last_modified:
584             return werkzeug.wrappers.Response(status=304)
585
586         file_map = dict(files)
587
588         rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U)
589         rx_url = re.compile(r"""url\s*\(\s*('|"|)(?!'|"|/|https?://|data:)""", re.U)
590
591         def reader(f):
592             """read the a css file and absolutify all relative uris"""
593             with open(f, 'rb') as fp:
594                 data = fp.read().decode('utf-8')
595
596             path = file_map[f]
597             web_dir = os.path.dirname(path)
598
599             data = re.sub(
600                 rx_import,
601                 r"""@import \1%s/""" % (web_dir,),
602                 data,
603             )
604
605             data = re.sub(
606                 rx_url,
607                 r"url(\1%s/" % (web_dir,),
608                 data,
609             )
610             return data.encode('utf-8')
611
612         content, checksum = concat_files((f[0] for f in files), reader)
613
614         # move up all @import and @charset rules to the top
615         matches = []
616         def push(matchobj):
617             matches.append(matchobj.group(0))
618             return ''
619
620         content = re.sub(re.compile("(@charset.+;$)", re.M), push, content)
621         content = re.sub(re.compile("(@import.+;$)", re.M), push, content)
622
623         matches.append(content)
624         content = '\n'.join(matches)
625
626         return make_conditional(
627             request.make_response(content, [('Content-Type', 'text/css')]),
628             last_modified, checksum)
629
630     @http.route('/web/webclient/js', type='http', auth="none")
631     def js(self, mods=None, db=None):
632         files = [f[0] for f in manifest_glob('js', addons=mods, db=db)]
633         last_modified = get_last_modified(files)
634         if request.httprequest.if_modified_since and request.httprequest.if_modified_since >= last_modified:
635             return werkzeug.wrappers.Response(status=304)
636
637         content, checksum = concat_js(files)
638
639         return make_conditional(
640             request.make_response(content, [('Content-Type', 'application/javascript')]),
641             last_modified, checksum)
642
643     @http.route('/web/webclient/qweb', type='http', auth="none")
644     def qweb(self, mods=None, db=None):
645         files = [f[0] for f in manifest_glob('qweb', addons=mods, db=db)]
646         last_modified = get_last_modified(files)
647         if request.httprequest.if_modified_since and request.httprequest.if_modified_since >= last_modified:
648             return werkzeug.wrappers.Response(status=304)
649
650         content, checksum = concat_xml(files)
651
652         return make_conditional(
653             request.make_response(content, [('Content-Type', 'text/xml')]),
654             last_modified, checksum)
655
656     @http.route('/web/webclient/bootstrap_translations', type='json', auth="none")
657     def bootstrap_translations(self, mods):
658         """ Load local translations from *.po files, as a temporary solution
659             until we have established a valid session. This is meant only
660             for translating the login page and db management chrome, using
661             the browser's language. """
662         # For performance reasons we only load a single translation, so for
663         # sub-languages (that should only be partially translated) we load the
664         # main language PO instead - that should be enough for the login screen.
665         lang = request.lang.split('_')[0]
666
667         translations_per_module = {}
668         for addon_name in mods:
669             if http.addons_manifest[addon_name].get('bootstrap'):
670                 addons_path = http.addons_manifest[addon_name]['addons_path']
671                 f_name = os.path.join(addons_path, addon_name, "i18n", lang + ".po")
672                 if not os.path.exists(f_name):
673                     continue
674                 translations_per_module[addon_name] = {'messages': _local_web_translations(f_name)}
675
676         return {"modules": translations_per_module,
677                 "lang_parameters": None}
678
679     @http.route('/web/webclient/translations', type='json', auth="admin")
680     def translations(self, mods=None, lang=None):
681         if mods is None:
682             m = request.registry.get('ir.module.module')
683             mods = [x['name'] for x in m.search_read(request.cr, request.uid,
684                 [('state','=','installed')], ['name'])]
685         if lang is None:
686             lang = request.context["lang"]
687         res_lang = request.registry.get('res.lang')
688         ids = res_lang.search(request.cr, request.uid, [("code", "=", lang)])
689         lang_params = None
690         if ids:
691             lang_params = res_lang.read(request.cr, request.uid, ids[0], ["direction", "date_format", "time_format",
692                                                 "grouping", "decimal_point", "thousands_sep"])
693
694         # Regional languages (ll_CC) must inherit/override their parent lang (ll), but this is
695         # done server-side when the language is loaded, so we only need to load the user's lang.
696         ir_translation = request.registry.get('ir.translation')
697         translations_per_module = {}
698         messages = ir_translation.search_read(request.cr, request.uid, [('module','in',mods),('lang','=',lang),
699                                                ('comments','like','openerp-web'),('value','!=',False),
700                                                ('value','!=','')],
701                                               ['module','src','value','lang'], order='module')
702         for mod, msg_group in itertools.groupby(messages, key=operator.itemgetter('module')):
703             translations_per_module.setdefault(mod,{'messages':[]})
704             translations_per_module[mod]['messages'].extend({'id': m['src'],
705                                                              'string': m['value']} \
706                                                                 for m in msg_group)
707         return {"modules": translations_per_module,
708                 "lang_parameters": lang_params}
709
710     @http.route('/web/webclient/version_info', type='json', auth="none")
711     def version_info(self):
712         return openerp.service.common.exp_version()
713
714 class Proxy(http.Controller):
715
716     @http.route('/web/proxy/load', type='json', auth="none")
717     def load(self, path):
718         """ Proxies an HTTP request through a JSON request.
719
720         It is strongly recommended to not request binary files through this,
721         as the result will be a binary data blob as well.
722
723         :param path: actual request path
724         :return: file content
725         """
726         from werkzeug.test import Client
727         from werkzeug.wrappers import BaseResponse
728
729         return Client(request.httprequest.app, BaseResponse).get(path).data
730
731 class Database(http.Controller):
732
733     @http.route('/web/database/get_list', type='json', auth="none")
734     def get_list(self):
735         # TODO change js to avoid calling this method if in monodb mode
736         try:
737             return http.db_list()
738         except openerp.exceptions.AccessDenied:
739             monodb = db_monodb()
740             if monodb:
741                 return [monodb]
742             raise
743
744     @http.route('/web/database/create', type='json', auth="none")
745     def create(self, fields):
746         params = dict(map(operator.itemgetter('name', 'value'), fields))
747         return request.session.proxy("db").create_database(
748             params['super_admin_pwd'],
749             params['db_name'],
750             bool(params.get('demo_data')),
751             params['db_lang'],
752             params['create_admin_pwd'])
753
754     @http.route('/web/database/duplicate', type='json', auth="none")
755     def duplicate(self, fields):
756         params = dict(map(operator.itemgetter('name', 'value'), fields))
757         duplicate_attrs = (
758             params['super_admin_pwd'],
759             params['db_original_name'],
760             params['db_name'],
761         )
762
763         return request.session.proxy("db").duplicate_database(*duplicate_attrs)
764
765     @http.route('/web/database/drop', type='json', auth="none")
766     def drop(self, fields):
767         password, db = operator.itemgetter(
768             'drop_pwd', 'drop_db')(
769                 dict(map(operator.itemgetter('name', 'value'), fields)))
770         
771         try:
772             if request.session.proxy("db").drop(password, db):
773                 return True
774             else:
775                 return False
776         except openerp.exceptions.AccessDenied:
777             return {'error': 'AccessDenied', 'title': 'Drop Database'}
778         except Exception:
779             return {'error': _('Could not drop database !'), 'title': _('Drop Database')}
780
781     @http.route('/web/database/backup', type='http', auth="none")
782     def backup(self, backup_db, backup_pwd, token):
783         try:
784             db_dump = base64.b64decode(
785                 request.session.proxy("db").dump(backup_pwd, backup_db))
786             filename = "%(db)s_%(timestamp)s.dump" % {
787                 'db': backup_db,
788                 'timestamp': datetime.datetime.utcnow().strftime(
789                     "%Y-%m-%d_%H-%M-%SZ")
790             }
791             return request.make_response(db_dump,
792                [('Content-Type', 'application/octet-stream; charset=binary'),
793                ('Content-Disposition', content_disposition(filename))],
794                {'fileToken': token}
795             )
796         except Exception, e:
797             return simplejson.dumps([[],[{'error': openerp.tools.ustr(e), 'title': _('Backup Database')}]])
798
799     @http.route('/web/database/restore', type='http', auth="none")
800     def restore(self, db_file, restore_pwd, new_db):
801         try:
802             data = base64.b64encode(db_file.read())
803             request.session.proxy("db").restore(restore_pwd, new_db, data)
804             return ''
805         except openerp.exceptions.AccessDenied, e:
806             raise Exception("AccessDenied")
807
808     @http.route('/web/database/change_password', type='json', auth="none")
809     def change_password(self, fields):
810         old_password, new_password = operator.itemgetter(
811             'old_pwd', 'new_pwd')(
812                 dict(map(operator.itemgetter('name', 'value'), fields)))
813         try:
814             return request.session.proxy("db").change_admin_password(old_password, new_password)
815         except openerp.exceptions.AccessDenied:
816             return {'error': 'AccessDenied', 'title': _('Change Password')}
817         except Exception:
818             return {'error': _('Error, password not changed !'), 'title': _('Change Password')}
819
820 class Session(http.Controller):
821
822     def session_info(self):
823         request.session.ensure_valid()
824         return {
825             "session_id": request.session_id,
826             "uid": request.session.uid,
827             "user_context": request.session.get_context() if request.session.uid else {},
828             "db": request.session.db,
829             "username": request.session.login,
830         }
831
832     @http.route('/web/session/get_session_info', type='json', auth="none")
833     def get_session_info(self):
834         request.uid = request.session.uid
835         request.disable_db = False
836         return self.session_info()
837
838     @http.route('/web/session/authenticate', type='json', auth="none")
839     def authenticate(self, db, login, password, base_location=None):
840         request.session.authenticate(db, login, password)
841
842         return self.session_info()
843
844     @http.route('/web/session/change_password', type='json', auth="user")
845     def change_password(self, fields):
846         old_password, new_password,confirm_password = operator.itemgetter('old_pwd', 'new_password','confirm_pwd')(
847                 dict(map(operator.itemgetter('name', 'value'), fields)))
848         if not (old_password.strip() and new_password.strip() and confirm_password.strip()):
849             return {'error':_('You cannot leave any password empty.'),'title': _('Change Password')}
850         if new_password != confirm_password:
851             return {'error': _('The new password and its confirmation must be identical.'),'title': _('Change Password')}
852         try:
853             if request.session.model('res.users').change_password(
854                 old_password, new_password):
855                 return {'new_password':new_password}
856         except Exception:
857             return {'error': _('The old password you provided is incorrect, your password was not changed.'), 'title': _('Change Password')}
858         return {'error': _('Error, password not changed !'), 'title': _('Change Password')}
859
860     @http.route('/web/session/sc_list', type='json', auth="user")
861     def sc_list(self):
862         return request.session.model('ir.ui.view_sc').get_sc(
863             request.session.uid, "ir.ui.menu", request.context)
864
865     @http.route('/web/session/get_lang_list', type='json', auth="none")
866     def get_lang_list(self):
867         try:
868             return request.session.proxy("db").list_lang() or []
869         except Exception, e:
870             return {"error": e, "title": _("Languages")}
871
872     @http.route('/web/session/modules', type='json', auth="user")
873     def modules(self):
874         # return all installed modules. Web client is smart enough to not load a module twice
875         return module_installed()
876
877     @http.route('/web/session/save_session_action', type='json', auth="user")
878     def save_session_action(self, the_action):
879         """
880         This method store an action object in the session object and returns an integer
881         identifying that action. The method get_session_action() can be used to get
882         back the action.
883
884         :param the_action: The action to save in the session.
885         :type the_action: anything
886         :return: A key identifying the saved action.
887         :rtype: integer
888         """
889         saved_actions = request.httpsession.get('saved_actions')
890         if not saved_actions:
891             saved_actions = {"next":1, "actions":{}}
892             request.httpsession['saved_actions'] = saved_actions
893         # we don't allow more than 10 stored actions
894         if len(saved_actions["actions"]) >= 10:
895             del saved_actions["actions"][min(saved_actions["actions"])]
896         key = saved_actions["next"]
897         saved_actions["actions"][key] = the_action
898         saved_actions["next"] = key + 1
899         return key
900
901     @http.route('/web/session/get_session_action', type='json', auth="user")
902     def get_session_action(self, key):
903         """
904         Gets back a previously saved action. This method can return None if the action
905         was saved since too much time (this case should be handled in a smart way).
906
907         :param key: The key given by save_session_action()
908         :type key: integer
909         :return: The saved action or None.
910         :rtype: anything
911         """
912         saved_actions = request.httpsession.get('saved_actions')
913         if not saved_actions:
914             return None
915         return saved_actions["actions"].get(key)
916
917     @http.route('/web/session/check', type='json', auth="user")
918     def check(self):
919         request.session.assert_valid()
920         return None
921
922     @http.route('/web/session/destroy', type='json', auth="user")
923     def destroy(self):
924         request.session.logout()
925
926 class Menu(http.Controller):
927
928     @http.route('/web/menu/get_user_roots', type='json', auth="user")
929     def get_user_roots(self):
930         """ Return all root menu ids visible for the session user.
931
932         :return: the root menu ids
933         :rtype: list(int)
934         """
935         s = request.session
936         Menus = s.model('ir.ui.menu')
937         # If a menu action is defined use its domain to get the root menu items
938         user_menu_id = s.model('res.users').read([s.uid], ['menu_id'],
939                                                  request.context)[0]['menu_id']
940
941         menu_domain = [('parent_id', '=', False)]
942         if user_menu_id:
943             domain_string = s.model('ir.actions.act_window').read(
944                 [user_menu_id[0]], ['domain'],request.context)[0]['domain']
945             if domain_string:
946                 menu_domain = ast.literal_eval(domain_string)
947
948         return Menus.search(menu_domain, 0, False, False, request.context)
949
950     @http.route('/web/menu/load', type='json', auth="user")
951     def load(self):
952         """ Loads all menu items (all applications and their sub-menus).
953
954         :return: the menu root
955         :rtype: dict('children': menu_nodes)
956         """
957         Menus = request.session.model('ir.ui.menu')
958
959         fields = ['name', 'sequence', 'parent_id', 'action']
960         menu_root_ids = self.get_user_roots()
961         menu_roots = Menus.read(menu_root_ids, fields, request.context) if menu_root_ids else []
962         menu_root = {
963             'id': False,
964             'name': 'root',
965             'parent_id': [-1, ''],
966             'children': menu_roots,
967             'all_menu_ids': menu_root_ids,
968         }
969         if not menu_roots:
970             return menu_root
971
972         # menus are loaded fully unlike a regular tree view, cause there are a
973         # limited number of items (752 when all 6.1 addons are installed)
974         menu_ids = Menus.search([('id', 'child_of', menu_root_ids)], 0, False, False, request.context)
975         menu_items = Menus.read(menu_ids, fields, request.context)
976         # adds roots at the end of the sequence, so that they will overwrite
977         # equivalent menu items from full menu read when put into id:item
978         # mapping, resulting in children being correctly set on the roots.
979         menu_items.extend(menu_roots)
980         menu_root['all_menu_ids'] = menu_ids # includes menu_root_ids!
981
982         # make a tree using parent_id
983         menu_items_map = dict(
984             (menu_item["id"], menu_item) for menu_item in menu_items)
985         for menu_item in menu_items:
986             if menu_item['parent_id']:
987                 parent = menu_item['parent_id'][0]
988             else:
989                 parent = False
990             if parent in menu_items_map:
991                 menu_items_map[parent].setdefault(
992                     'children', []).append(menu_item)
993
994         # sort by sequence a tree using parent_id
995         for menu_item in menu_items:
996             menu_item.setdefault('children', []).sort(
997                 key=operator.itemgetter('sequence'))
998
999         return menu_root
1000
1001     @http.route('/web/menu/load_needaction', type='json', auth="user")
1002     def load_needaction(self, menu_ids):
1003         """ Loads needaction counters for specific menu ids.
1004
1005             :return: needaction data
1006             :rtype: dict(menu_id: {'needaction_enabled': boolean, 'needaction_counter': int})
1007         """
1008         return request.session.model('ir.ui.menu').get_needaction_data(menu_ids, request.context)
1009
1010     @http.route('/web/menu/action', type='json', auth="user")
1011     def action(self, menu_id):
1012         # still used by web_shortcut
1013         actions = load_actions_from_ir_values('action', 'tree_but_open',
1014                                              [('ir.ui.menu', menu_id)], False)
1015         return {"action": actions}
1016
1017 class DataSet(http.Controller):
1018
1019     @http.route('/web/dataset/search_read', type='json', auth="user")
1020     def search_read(self, model, fields=False, offset=0, limit=False, domain=None, sort=None):
1021         return self.do_search_read(model, fields, offset, limit, domain, sort)
1022     def do_search_read(self, model, fields=False, offset=0, limit=False, domain=None
1023                        , sort=None):
1024         """ Performs a search() followed by a read() (if needed) using the
1025         provided search criteria
1026
1027         :param str model: the name of the model to search on
1028         :param fields: a list of the fields to return in the result records
1029         :type fields: [str]
1030         :param int offset: from which index should the results start being returned
1031         :param int limit: the maximum number of records to return
1032         :param list domain: the search domain for the query
1033         :param list sort: sorting directives
1034         :returns: A structure (dict) with two keys: ids (all the ids matching
1035                   the (domain, context) pair) and records (paginated records
1036                   matching fields selection set)
1037         :rtype: list
1038         """
1039         Model = request.session.model(model)
1040
1041         ids = Model.search(domain, offset or 0, limit or False, sort or False,
1042                            request.context)
1043         if limit and len(ids) == limit:
1044             length = Model.search_count(domain, request.context)
1045         else:
1046             length = len(ids) + (offset or 0)
1047         if fields and fields == ['id']:
1048             # shortcut read if we only want the ids
1049             return {
1050                 'length': length,
1051                 'records': [{'id': id} for id in ids]
1052             }
1053
1054         records = Model.read(ids, fields or False, request.context)
1055         records.sort(key=lambda obj: ids.index(obj['id']))
1056         return {
1057             'length': length,
1058             'records': records
1059         }
1060
1061     @http.route('/web/dataset/load', type='json', auth="user")
1062     def load(self, model, id, fields):
1063         m = request.session.model(model)
1064         value = {}
1065         r = m.read([id], False, request.context)
1066         if r:
1067             value = r[0]
1068         return {'value': value}
1069
1070     def call_common(self, model, method, args, domain_id=None, context_id=None):
1071         return self._call_kw(model, method, args, {})
1072
1073     def _call_kw(self, model, method, args, kwargs):
1074         # Temporary implements future display_name special field for model#read()
1075         if method == 'read' and kwargs.get('context', {}).get('future_display_name'):
1076             if 'display_name' in args[1]:
1077                 names = dict(request.session.model(model).name_get(args[0], **kwargs))
1078                 args[1].remove('display_name')
1079                 records = request.session.model(model).read(*args, **kwargs)
1080                 for record in records:
1081                     record['display_name'] = \
1082                         names.get(record['id']) or "%s#%d" % (model, (record['id']))
1083                 return records
1084
1085         if method.startswith('_'):
1086             raise Exception("Access denied")
1087
1088         return getattr(request.registry.get(model), method)(request.cr, request.uid, *args, **kwargs)
1089
1090     @http.route('/web/dataset/call', type='json', auth="user")
1091     def call(self, model, method, args, domain_id=None, context_id=None):
1092         return self._call_kw(model, method, args, {})
1093
1094     @http.route(['/web/dataset/call_kw', '/web/dataset/call_kw/<path:path>'], type='json', auth="user")
1095     def call_kw(self, model, method, args, kwargs, path=None):
1096         return self._call_kw(model, method, args, kwargs)
1097
1098     @http.route('/web/dataset/call_button', type='json', auth="user")
1099     def call_button(self, model, method, args, domain_id=None, context_id=None):
1100         action = self._call_kw(model, method, args, {})
1101         if isinstance(action, dict) and action.get('type') != '':
1102             return clean_action(action)
1103         return False
1104
1105     @http.route('/web/dataset/exec_workflow', type='json', auth="user")
1106     def exec_workflow(self, model, id, signal):
1107         return request.session.exec_workflow(model, id, signal)
1108
1109     @http.route('/web/dataset/resequence', type='json', auth="user")
1110     def resequence(self, model, ids, field='sequence', offset=0):
1111         """ Re-sequences a number of records in the model, by their ids
1112
1113         The re-sequencing starts at the first model of ``ids``, the sequence
1114         number is incremented by one after each record and starts at ``offset``
1115
1116         :param ids: identifiers of the records to resequence, in the new sequence order
1117         :type ids: list(id)
1118         :param str field: field used for sequence specification, defaults to
1119                           "sequence"
1120         :param int offset: sequence number for first record in ``ids``, allows
1121                            starting the resequencing from an arbitrary number,
1122                            defaults to ``0``
1123         """
1124         m = request.session.model(model)
1125         if not m.fields_get([field]):
1126             return False
1127         # python 2.6 has no start parameter
1128         for i, id in enumerate(ids):
1129             m.write(id, { field: i + offset })
1130         return True
1131
1132 class View(http.Controller):
1133
1134     @http.route('/web/view/add_custom', type='json', auth="user")
1135     def add_custom(self, view_id, arch):
1136         CustomView = request.session.model('ir.ui.view.custom')
1137         CustomView.create({
1138             'user_id': request.session.uid,
1139             'ref_id': view_id,
1140             'arch': arch
1141         }, request.context)
1142         return {'result': True}
1143
1144     @http.route('/web/view/undo_custom', type='json', auth="user")
1145     def undo_custom(self, view_id, reset=False):
1146         CustomView = request.session.model('ir.ui.view.custom')
1147         vcustom = CustomView.search([('user_id', '=', request.session.uid), ('ref_id' ,'=', view_id)],
1148                                     0, False, False, request.context)
1149         if vcustom:
1150             if reset:
1151                 CustomView.unlink(vcustom, request.context)
1152             else:
1153                 CustomView.unlink([vcustom[0]], request.context)
1154             return {'result': True}
1155         return {'result': False}
1156
1157 class TreeView(View):
1158
1159     @http.route('/web/treeview/action', type='json', auth="user")
1160     def action(self, model, id):
1161         return load_actions_from_ir_values(
1162             'action', 'tree_but_open',[(model, id)],
1163             False)
1164
1165 class Binary(http.Controller):
1166
1167     @http.route('/web/binary/image', type='http', auth="user")
1168     def image(self, model, id, field, **kw):
1169         last_update = '__last_update'
1170         Model = request.session.model(model)
1171         headers = [('Content-Type', 'image/png')]
1172         etag = request.httprequest.headers.get('If-None-Match')
1173         hashed_session = hashlib.md5(request.session_id).hexdigest()
1174         retag = hashed_session
1175         id = None if not id else simplejson.loads(id)
1176         if type(id) is list:
1177             id = id[0] # m2o
1178         try:
1179             if etag:
1180                 if not id and hashed_session == etag:
1181                     return werkzeug.wrappers.Response(status=304)
1182                 else:
1183                     date = Model.read([id], [last_update], request.context)[0].get(last_update)
1184                     if hashlib.md5(date).hexdigest() == etag:
1185                         return werkzeug.wrappers.Response(status=304)
1186
1187             if not id:
1188                 res = Model.default_get([field], request.context).get(field)
1189                 image_base64 = res
1190             else:
1191                 res = Model.read([id], [last_update, field], request.context)[0]
1192                 retag = hashlib.md5(res.get(last_update)).hexdigest()
1193                 image_base64 = res.get(field)
1194
1195             if kw.get('resize'):
1196                 resize = kw.get('resize').split(',')
1197                 if len(resize) == 2 and int(resize[0]) and int(resize[1]):
1198                     width = int(resize[0])
1199                     height = int(resize[1])
1200                     # resize maximum 500*500
1201                     if width > 500: width = 500
1202                     if height > 500: height = 500
1203                     image_base64 = openerp.tools.image_resize_image(base64_source=image_base64, size=(width, height), encoding='base64', filetype='PNG')
1204
1205             image_data = base64.b64decode(image_base64)
1206
1207         except Exception:
1208             image_data = self.placeholder()
1209         headers.append(('ETag', retag))
1210         headers.append(('Content-Length', len(image_data)))
1211         try:
1212             ncache = int(kw.get('cache'))
1213             headers.append(('Cache-Control', 'no-cache' if ncache == 0 else 'max-age=%s' % (ncache)))
1214         except:
1215             pass
1216         return request.make_response(image_data, headers)
1217
1218     def placeholder(self, image='placeholder.png'):
1219         addons_path = http.addons_manifest['web']['addons_path']
1220         return open(os.path.join(addons_path, 'web', 'static', 'src', 'img', image), 'rb').read()
1221
1222     @http.route('/web/binary/saveas', type='http', auth="user")
1223     def saveas(self, model, field, id=None, filename_field=None, **kw):
1224         """ Download link for files stored as binary fields.
1225
1226         If the ``id`` parameter is omitted, fetches the default value for the
1227         binary field (via ``default_get``), otherwise fetches the field for
1228         that precise record.
1229
1230         :param str model: name of the model to fetch the binary from
1231         :param str field: binary field
1232         :param str id: id of the record from which to fetch the binary
1233         :param str filename_field: field holding the file's name, if any
1234         :returns: :class:`werkzeug.wrappers.Response`
1235         """
1236         Model = request.session.model(model)
1237         fields = [field]
1238         if filename_field:
1239             fields.append(filename_field)
1240         if id:
1241             res = Model.read([int(id)], fields, request.context)[0]
1242         else:
1243             res = Model.default_get(fields, request.context)
1244         filecontent = base64.b64decode(res.get(field, ''))
1245         if not filecontent:
1246             return request.not_found()
1247         else:
1248             filename = '%s_%s' % (model.replace('.', '_'), id)
1249             if filename_field:
1250                 filename = res.get(filename_field, '') or filename
1251             return request.make_response(filecontent,
1252                 [('Content-Type', 'application/octet-stream'),
1253                  ('Content-Disposition', content_disposition(filename))])
1254
1255     @http.route('/web/binary/saveas_ajax', type='http', auth="user")
1256     def saveas_ajax(self, data, token):
1257         jdata = simplejson.loads(data)
1258         model = jdata['model']
1259         field = jdata['field']
1260         data = jdata['data']
1261         id = jdata.get('id', None)
1262         filename_field = jdata.get('filename_field', None)
1263         context = jdata.get('context', {})
1264
1265         Model = request.session.model(model)
1266         fields = [field]
1267         if filename_field:
1268             fields.append(filename_field)
1269         if data:
1270             res = { field: data }
1271         elif id:
1272             res = Model.read([int(id)], fields, context)[0]
1273         else:
1274             res = Model.default_get(fields, context)
1275         filecontent = base64.b64decode(res.get(field, ''))
1276         if not filecontent:
1277             raise ValueError(_("No content found for field '%s' on '%s:%s'") %
1278                 (field, model, id))
1279         else:
1280             filename = '%s_%s' % (model.replace('.', '_'), id)
1281             if filename_field:
1282                 filename = res.get(filename_field, '') or filename
1283             return request.make_response(filecontent,
1284                 headers=[('Content-Type', 'application/octet-stream'),
1285                         ('Content-Disposition', content_disposition(filename))],
1286                 cookies={'fileToken': token})
1287
1288     @http.route('/web/binary/upload', type='http', auth="user")
1289     def upload(self, callback, ufile):
1290         # TODO: might be useful to have a configuration flag for max-length file uploads
1291         out = """<script language="javascript" type="text/javascript">
1292                     var win = window.top.window;
1293                     win.jQuery(win).trigger(%s, %s);
1294                 </script>"""
1295         try:
1296             data = ufile.read()
1297             args = [len(data), ufile.filename,
1298                     ufile.content_type, base64.b64encode(data)]
1299         except Exception, e:
1300             args = [False, e.message]
1301         return out % (simplejson.dumps(callback), simplejson.dumps(args))
1302
1303     @http.route('/web/binary/upload_attachment', type='http', auth="user")
1304     def upload_attachment(self, callback, model, id, ufile):
1305         Model = request.session.model('ir.attachment')
1306         out = """<script language="javascript" type="text/javascript">
1307                     var win = window.top.window;
1308                     win.jQuery(win).trigger(%s, %s);
1309                 </script>"""
1310         try:
1311             attachment_id = Model.create({
1312                 'name': ufile.filename,
1313                 'datas': base64.encodestring(ufile.read()),
1314                 'datas_fname': ufile.filename,
1315                 'res_model': model,
1316                 'res_id': int(id)
1317             }, request.context)
1318             args = {
1319                 'filename': ufile.filename,
1320                 'id':  attachment_id
1321             }
1322         except Exception:
1323             args = {'error': "Something horrible happened"}
1324         return out % (simplejson.dumps(callback), simplejson.dumps(args))
1325
1326     @http.route('/web/binary/company_logo', type='http', auth="none")
1327     def company_logo(self, dbname=None):
1328         # TODO add etag, refactor to use /image code for etag
1329         uid = None
1330         if request.session.db:
1331             dbname = request.session.db
1332             uid = request.session.uid
1333         elif dbname is None:
1334             dbname = db_monodb()
1335
1336         if not uid:
1337             uid = openerp.SUPERUSER_ID
1338
1339         if not dbname:
1340             image_data = self.placeholder('logo.png')
1341         else:
1342             try:
1343                 # create an empty registry
1344                 registry = openerp.modules.registry.Registry(dbname)
1345                 with registry.cursor() as cr:
1346                     cr.execute("""SELECT c.logo_web
1347                                     FROM res_users u
1348                                LEFT JOIN res_company c
1349                                       ON c.id = u.company_id
1350                                    WHERE u.id = %s
1351                                """, (uid,))
1352                     row = cr.fetchone()
1353                     if row and row[0]:
1354                         image_data = str(row[0]).decode('base64')
1355                     else:
1356                         image_data = self.placeholder('nologo.png')
1357             except Exception:
1358                 image_data = self.placeholder('logo.png')
1359
1360         headers = [
1361             ('Content-Type', 'image/png'),
1362             ('Content-Length', len(image_data)),
1363         ]
1364         return request.make_response(image_data, headers)
1365
1366 class Action(http.Controller):
1367
1368     @http.route('/web/action/load', type='json', auth="user")
1369     def load(self, action_id, do_not_eval=False):
1370         Actions = request.session.model('ir.actions.actions')
1371         value = False
1372         try:
1373             action_id = int(action_id)
1374         except ValueError:
1375             try:
1376                 module, xmlid = action_id.split('.', 1)
1377                 model, action_id = request.session.model('ir.model.data').get_object_reference(module, xmlid)
1378                 assert model.startswith('ir.actions.')
1379             except Exception:
1380                 action_id = 0   # force failed read
1381
1382         base_action = Actions.read([action_id], ['type'], request.context)
1383         if base_action:
1384             ctx = {}
1385             action_type = base_action[0]['type']
1386             if action_type == 'ir.actions.report.xml':
1387                 ctx.update({'bin_size': True})
1388             ctx.update(request.context)
1389             action = request.session.model(action_type).read([action_id], False, ctx)
1390             if action:
1391                 value = clean_action(action[0])
1392         return value
1393
1394     @http.route('/web/action/run', type='json', auth="user")
1395     def run(self, action_id):
1396         return_action = request.session.model('ir.actions.server').run(
1397             [action_id], request.context)
1398         if return_action:
1399             return clean_action(return_action)
1400         else:
1401             return False
1402
1403 class Export(http.Controller):
1404
1405     @http.route('/web/export/formats', type='json', auth="user")
1406     def formats(self):
1407         """ Returns all valid export formats
1408
1409         :returns: for each export format, a pair of identifier and printable name
1410         :rtype: [(str, str)]
1411         """
1412         return [
1413             {'tag': 'csv', 'label': 'CSV'},
1414             {'tag': 'xls', 'label': 'Excel', 'error': None if xlwt else "XLWT required"},
1415         ]
1416
1417     def fields_get(self, model):
1418         Model = request.session.model(model)
1419         fields = Model.fields_get(False, request.context)
1420         return fields
1421
1422     @http.route('/web/export/get_fields', type='json', auth="user")
1423     def get_fields(self, model, prefix='', parent_name= '',
1424                    import_compat=True, parent_field_type=None,
1425                    exclude=None):
1426
1427         if import_compat and parent_field_type == "many2one":
1428             fields = {}
1429         else:
1430             fields = self.fields_get(model)
1431
1432         if import_compat:
1433             fields.pop('id', None)
1434         else:
1435             fields['.id'] = fields.pop('id', {'string': 'ID'})
1436
1437         fields_sequence = sorted(fields.iteritems(),
1438             key=lambda field: field[1].get('string', ''))
1439
1440         records = []
1441         for field_name, field in fields_sequence:
1442             if import_compat:
1443                 if exclude and field_name in exclude:
1444                     continue
1445                 if field.get('readonly'):
1446                     # If none of the field's states unsets readonly, skip the field
1447                     if all(dict(attrs).get('readonly', True)
1448                            for attrs in field.get('states', {}).values()):
1449                         continue
1450             if not field.get('exportable', True):
1451                 continue
1452
1453             id = prefix + (prefix and '/'or '') + field_name
1454             name = parent_name + (parent_name and '/' or '') + field['string']
1455             record = {'id': id, 'string': name,
1456                       'value': id, 'children': False,
1457                       'field_type': field.get('type'),
1458                       'required': field.get('required'),
1459                       'relation_field': field.get('relation_field')}
1460             records.append(record)
1461
1462             if len(name.split('/')) < 3 and 'relation' in field:
1463                 ref = field.pop('relation')
1464                 record['value'] += '/id'
1465                 record['params'] = {'model': ref, 'prefix': id, 'name': name}
1466
1467                 if not import_compat or field['type'] == 'one2many':
1468                     # m2m field in import_compat is childless
1469                     record['children'] = True
1470
1471         return records
1472
1473     @http.route('/web/export/namelist', type='json', auth="user")
1474     def namelist(self, model, export_id):
1475         # TODO: namelist really has no reason to be in Python (although itertools.groupby helps)
1476         export = request.session.model("ir.exports").read([export_id])[0]
1477         export_fields_list = request.session.model("ir.exports.line").read(
1478             export['export_fields'])
1479
1480         fields_data = self.fields_info(
1481             model, map(operator.itemgetter('name'), export_fields_list))
1482
1483         return [
1484             {'name': field['name'], 'label': fields_data[field['name']]}
1485             for field in export_fields_list
1486         ]
1487
1488     def fields_info(self, model, export_fields):
1489         info = {}
1490         fields = self.fields_get(model)
1491         if ".id" in export_fields:
1492             fields['.id'] = fields.pop('id', {'string': 'ID'})
1493
1494         # To make fields retrieval more efficient, fetch all sub-fields of a
1495         # given field at the same time. Because the order in the export list is
1496         # arbitrary, this requires ordering all sub-fields of a given field
1497         # together so they can be fetched at the same time
1498         #
1499         # Works the following way:
1500         # * sort the list of fields to export, the default sorting order will
1501         #   put the field itself (if present, for xmlid) and all of its
1502         #   sub-fields right after it
1503         # * then, group on: the first field of the path (which is the same for
1504         #   a field and for its subfields and the length of splitting on the
1505         #   first '/', which basically means grouping the field on one side and
1506         #   all of the subfields on the other. This way, we have the field (for
1507         #   the xmlid) with length 1, and all of the subfields with the same
1508         #   base but a length "flag" of 2
1509         # * if we have a normal field (length 1), just add it to the info
1510         #   mapping (with its string) as-is
1511         # * otherwise, recursively call fields_info via graft_subfields.
1512         #   all graft_subfields does is take the result of fields_info (on the
1513         #   field's model) and prepend the current base (current field), which
1514         #   rebuilds the whole sub-tree for the field
1515         #
1516         # result: because we're not fetching the fields_get for half the
1517         # database models, fetching a namelist with a dozen fields (including
1518         # relational data) falls from ~6s to ~300ms (on the leads model).
1519         # export lists with no sub-fields (e.g. import_compatible lists with
1520         # no o2m) are even more efficient (from the same 6s to ~170ms, as
1521         # there's a single fields_get to execute)
1522         for (base, length), subfields in itertools.groupby(
1523                 sorted(export_fields),
1524                 lambda field: (field.split('/', 1)[0], len(field.split('/', 1)))):
1525             subfields = list(subfields)
1526             if length == 2:
1527                 # subfields is a seq of $base/*rest, and not loaded yet
1528                 info.update(self.graft_subfields(
1529                     fields[base]['relation'], base, fields[base]['string'],
1530                     subfields
1531                 ))
1532             else:
1533                 info[base] = fields[base]['string']
1534
1535         return info
1536
1537     def graft_subfields(self, model, prefix, prefix_string, fields):
1538         export_fields = [field.split('/', 1)[1] for field in fields]
1539         return (
1540             (prefix + '/' + k, prefix_string + '/' + v)
1541             for k, v in self.fields_info(model, export_fields).iteritems())
1542
1543 class ExportFormat(object):
1544     @property
1545     def content_type(self):
1546         """ Provides the format's content type """
1547         raise NotImplementedError()
1548
1549     def filename(self, base):
1550         """ Creates a valid filename for the format (with extension) from the
1551          provided base name (exension-less)
1552         """
1553         raise NotImplementedError()
1554
1555     def from_data(self, fields, rows):
1556         """ Conversion method from OpenERP's export data to whatever the
1557         current export class outputs
1558
1559         :params list fields: a list of fields to export
1560         :params list rows: a list of records to export
1561         :returns:
1562         :rtype: bytes
1563         """
1564         raise NotImplementedError()
1565
1566     def base(self, data, token):
1567         model, fields, ids, domain, import_compat = \
1568             operator.itemgetter('model', 'fields', 'ids', 'domain',
1569                                 'import_compat')(
1570                 simplejson.loads(data))
1571
1572         Model = request.session.model(model)
1573         ids = ids or Model.search(domain, 0, False, False, request.context)
1574
1575         field_names = map(operator.itemgetter('name'), fields)
1576         import_data = Model.export_data(ids, field_names, request.context).get('datas',[])
1577
1578         if import_compat:
1579             columns_headers = field_names
1580         else:
1581             columns_headers = [val['label'].strip() for val in fields]
1582
1583
1584         return request.make_response(self.from_data(columns_headers, import_data),
1585             headers=[('Content-Disposition',
1586                             content_disposition(self.filename(model))),
1587                      ('Content-Type', self.content_type)],
1588             cookies={'fileToken': token})
1589
1590 class CSVExport(ExportFormat, http.Controller):
1591
1592     @http.route('/web/export/csv', type='http', auth="user")
1593     def index(self, data, token):
1594         return self.base(data, token)
1595
1596     @property
1597     def content_type(self):
1598         return 'text/csv;charset=utf8'
1599
1600     def filename(self, base):
1601         return base + '.csv'
1602
1603     def from_data(self, fields, rows):
1604         fp = StringIO()
1605         writer = csv.writer(fp, quoting=csv.QUOTE_ALL)
1606
1607         writer.writerow([name.encode('utf-8') for name in fields])
1608
1609         for data in rows:
1610             row = []
1611             for d in data:
1612                 if isinstance(d, basestring):
1613                     d = d.replace('\n',' ').replace('\t',' ')
1614                     try:
1615                         d = d.encode('utf-8')
1616                     except UnicodeError:
1617                         pass
1618                 if d is False: d = None
1619                 row.append(d)
1620             writer.writerow(row)
1621
1622         fp.seek(0)
1623         data = fp.read()
1624         fp.close()
1625         return data
1626
1627 class ExcelExport(ExportFormat, http.Controller):
1628
1629     @http.route('/web/export/xls', type='http', auth="user")
1630     def index(self, data, token):
1631         return self.base(data, token)
1632
1633     @property
1634     def content_type(self):
1635         return 'application/vnd.ms-excel'
1636
1637     def filename(self, base):
1638         return base + '.xls'
1639
1640     def from_data(self, fields, rows):
1641         workbook = xlwt.Workbook()
1642         worksheet = workbook.add_sheet('Sheet 1')
1643
1644         for i, fieldname in enumerate(fields):
1645             worksheet.write(0, i, fieldname)
1646             worksheet.col(i).width = 8000 # around 220 pixels
1647
1648         style = xlwt.easyxf('align: wrap yes')
1649
1650         for row_index, row in enumerate(rows):
1651             for cell_index, cell_value in enumerate(row):
1652                 if isinstance(cell_value, basestring):
1653                     cell_value = re.sub("\r", " ", cell_value)
1654                 if cell_value is False: cell_value = None
1655                 worksheet.write(row_index + 1, cell_index, cell_value, style)
1656
1657         fp = StringIO()
1658         workbook.save(fp)
1659         fp.seek(0)
1660         data = fp.read()
1661         fp.close()
1662         return data
1663
1664 class Reports(http.Controller):
1665     POLLING_DELAY = 0.25
1666     TYPES_MAPPING = {
1667         'doc': 'application/vnd.ms-word',
1668         'html': 'text/html',
1669         'odt': 'application/vnd.oasis.opendocument.text',
1670         'pdf': 'application/pdf',
1671         'sxw': 'application/vnd.sun.xml.writer',
1672         'xls': 'application/vnd.ms-excel',
1673     }
1674
1675     @http.route('/web/report', type='http', auth="user")
1676     def index(self, action, token):
1677         action = simplejson.loads(action)
1678
1679         report_srv = request.session.proxy("report")
1680         context = dict(request.context)
1681         context.update(action["context"])
1682
1683         report_data = {}
1684         report_ids = context.get("active_ids", None)
1685         if 'report_type' in action:
1686             report_data['report_type'] = action['report_type']
1687         if 'datas' in action:
1688             if 'ids' in action['datas']:
1689                 report_ids = action['datas'].pop('ids')
1690             report_data.update(action['datas'])
1691
1692         report_id = report_srv.report(
1693             request.session.db, request.session.uid, request.session.password,
1694             action["report_name"], report_ids,
1695             report_data, context)
1696
1697         report_struct = None
1698         while True:
1699             report_struct = report_srv.report_get(
1700                 request.session.db, request.session.uid, request.session.password, report_id)
1701             if report_struct["state"]:
1702                 break
1703
1704             time.sleep(self.POLLING_DELAY)
1705
1706         report = base64.b64decode(report_struct['result'])
1707         if report_struct.get('code') == 'zlib':
1708             report = zlib.decompress(report)
1709         report_mimetype = self.TYPES_MAPPING.get(
1710             report_struct['format'], 'octet-stream')
1711         file_name = action.get('name', 'report')
1712         if 'name' not in action:
1713             reports = request.session.model('ir.actions.report.xml')
1714             res_id = reports.search([('report_name', '=', action['report_name']),],
1715                                     0, False, False, context)
1716             if len(res_id) > 0:
1717                 file_name = reports.read(res_id[0], ['name'], context)['name']
1718             else:
1719                 file_name = action['report_name']
1720         file_name = '%s.%s' % (file_name, report_struct['format'])
1721
1722         return request.make_response(report,
1723              headers=[
1724                  ('Content-Disposition', content_disposition(file_name)),
1725                  ('Content-Type', report_mimetype),
1726                  ('Content-Length', len(report))],
1727              cookies={'fileToken': token})
1728
1729 # vim:expandtab:tabstop=4:softtabstop=4:shiftwidth=4: