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