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