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