diff --git a/addons/base/__init__.py b/addons/base/__init__.py index ee5959455ad..343b53d050d 100644 --- a/addons/base/__init__.py +++ b/addons/base/__init__.py @@ -1 +1,6 @@ +import common import controllers + +# TODO +# if we detect that we are imported from the openerp server register common.Root() as a wsgi entry point + diff --git a/addons/base/common/__init__.py b/addons/base/common/__init__.py new file mode 100644 index 00000000000..60699c86f43 --- /dev/null +++ b/addons/base/common/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/python +# TODO if from openerpserver use backendlocal +# from backendlocal import * +from backendrpc import * +from dispatch import * diff --git a/openerpweb/ast.py b/addons/base/common/ast.py similarity index 100% rename from openerpweb/ast.py rename to addons/base/common/ast.py diff --git a/addons/base/common/backendlocal.py b/addons/base/common/backendlocal.py new file mode 100644 index 00000000000..dac33991c4d --- /dev/null +++ b/addons/base/common/backendlocal.py @@ -0,0 +1,23 @@ +#---------------------------------------------------------- +# OpenERPSession local openerp backend access +#---------------------------------------------------------- +class OpenERPUnboundException(Exception): + pass + +class OpenERPConnector(object): + pass + +class OpenERPAuth(object): + pass + +class OpenERPModel(object): + def __init__(self, session, model): + self._session = session + self._model = model + + def __getattr__(self, name): + return lambda *l:self._session.execute(self._model, name, *l) + +class OpenERPSession(object): + def __init__(self, model_factory=OpenERPModel): + pass diff --git a/addons/base/common/backendrpc.py b/addons/base/common/backendrpc.py new file mode 100644 index 00000000000..e6c59000caf --- /dev/null +++ b/addons/base/common/backendrpc.py @@ -0,0 +1,199 @@ +#!/usr/bin/python +import datetime +import dateutil.relativedelta +import time +import xmlrpclib + +import nonliterals +#---------------------------------------------------------- +# OpenERPSession RPC openerp backend access +#---------------------------------------------------------- +class OpenERPUnboundException(Exception): + pass + +class OpenERPConnector(object): + pass + +class OpenERPAuth(object): + pass + +class OpenERPModel(object): + def __init__(self, session, model): + self._session = session + self._model = model + + def __getattr__(self, name): + return lambda *l:self._session.execute(self._model, name, *l) + +class OpenERPSession(object): + """ + An OpenERP RPC session, a given user can own multiple such sessions + in a web session. + + .. attribute:: context + + The session context, a ``dict``. Can be reloaded by calling + :meth:`openerpweb.openerpweb.OpenERPSession.get_context` + + .. attribute:: domains_store + + A ``dict`` matching domain keys to evaluable (but non-literal) domains. + + Used to store references to non-literal domains which need to be + round-tripped to the client browser. + """ + def __init__(self, server='127.0.0.1', port=8069, model_factory=OpenERPModel): + self._server = server + self._port = port + self._db = False + self._uid = False + self._login = False + self._password = False + self.model_factory = model_factory + self._locale = 'en_US' + self.context = {} + self.contexts_store = {} + self.domains_store = {} + self._lang = {} + self.remote_timezone = 'utc' + self.client_timezone = False + + def proxy(self, service): + s = xmlrpclib.ServerProxy('http://%s:%s/xmlrpc/%s' % (self._server, self._port, service)) + return s + + def bind(self, db, uid, password): + self._db = db + self._uid = uid + self._password = password + + def login(self, db, login, password): + uid = self.proxy('common').login(db, login, password) + self.bind(db, uid, password) + self._login = login + + if uid: self.get_context() + return uid + + def assert_valid(self): + """ + Ensures this session is valid (logged into the openerp server) + """ + if not (self._db and self._uid and self._password): + raise OpenERPUnboundException() + + def execute(self, model, func, *l, **d): + self.assert_valid() + r = self.proxy('object').execute(self._db, self._uid, self._password, model, func, *l, **d) + return r + + def exec_workflow(self, model, id, signal): + self.assert_valid() + r = self.proxy('object').exec_workflow(self._db, self._uid, self._password, model, signal, id) + return r + + def model(self, model): + """ Get an RPC proxy for the object ``model``, bound to this session. + + :param model: an OpenERP model name + :type model: str + :rtype: :class:`openerpweb.openerpweb.OpenERPModel` + """ + return self.model_factory(self, model) + + def get_context(self): + """ Re-initializes the current user's session context (based on + his preferences) by calling res.users.get_context() with the old + context + + :returns: the new context + """ + assert self._uid, "The user needs to be logged-in to initialize his context" + self.context = self.model('res.users').context_get(self.context) + self.context = self.context or {} + + self.client_timezone = self.context.get("tz", False) + # invalid code, anyway we decided the server will be in UTC + #if self.client_timezone: + # self.remote_timezone = self.execute('common', 'timezone_get') + + self._locale = self.context.get('lang','en_US') + lang_ids = self.execute('res.lang','search', [('code', '=', self._locale)]) + if lang_ids: + self._lang = self.execute('res.lang', 'read',lang_ids[0], []) + return self.context + + @property + def base_eval_context(self): + """ Default evaluation context for the session. + + Used to evaluate contexts and domains. + """ + base = dict( + uid=self._uid, + current_date=datetime.date.today().strftime('%Y-%m-%d'), + time=time, + datetime=datetime, + relativedelta=dateutil.relativedelta.relativedelta + ) + base.update(self.context) + return base + + def evaluation_context(self, context=None): + """ Returns the session's evaluation context, augmented with the + provided context if any. + + :param dict context: to add merge in the session's base eval context + :returns: the augmented context + :rtype: dict + """ + d = dict(self.base_eval_context) + if context: + d.update(context) + d['context'] = d + return d + + def eval_context(self, context_to_eval, context=None): + """ Evaluates the provided context_to_eval in the context (haha) of + the context. Also merges the evaluated context with the session's context. + + :param context_to_eval: a context to evaluate. Must be a dict or a + non-literal context. If it's a dict, will be + returned as-is + :type context_to_eval: openerpweb.nonliterals.Context + :returns: the evaluated context + :rtype: dict + + :raises: ``TypeError`` if ``context_to_eval`` is neither a dict nor + a Context + """ + ctx = dict( + self.base_eval_context, + **(context or {})) + + # adding the context of the session to send to the openerp server + ccontext = nonliterals.CompoundContext(self.context, context_to_eval or {}) + ccontext.session = self + return ccontext.evaluate(ctx) + + def eval_domain(self, domain, context=None): + """ Evaluates the provided domain using the provided context + (merged with the session's evaluation context) + + :param domain: an OpenERP domain as a list or as a + :class:`openerpweb.nonliterals.Domain` instance + + In the second case, it will be evaluated and returned. + :type domain: openerpweb.nonliterals.Domain + :param dict context: the context to use in the evaluation, if any. + :returns: the evaluated domain + :rtype: list + + :raises: ``TypeError`` if ``domain`` is neither a list nor a Domain + """ + if isinstance(domain, list): + return domain + + cdomain = nonliterals.CompoundDomain(domain) + cdomain.session = self + return cdomain.evaluate(context or {}) diff --git a/openerpweb/dates.py b/addons/base/common/dates.py similarity index 100% rename from openerpweb/dates.py rename to addons/base/common/dates.py diff --git a/addons/base/common/dispatch.py b/addons/base/common/dispatch.py new file mode 100644 index 00000000000..37207cc485d --- /dev/null +++ b/addons/base/common/dispatch.py @@ -0,0 +1,426 @@ +#!/usr/bin/python +from __future__ import with_statement + +import functools +import logging +import os +import sys +import traceback +import uuid +import xmlrpclib + +import simplejson +import werkzeug.datastructures +import werkzeug.exceptions +import werkzeug.urls +import werkzeug.utils +import werkzeug.wrappers +import werkzeug.wsgi + +import ast +import nonliterals +import http +# import backendlocal as backend +import backendrpc as backend + +__all__ = ['Root', 'jsonrequest', 'httprequest', 'Controller', + 'WebRequest', 'JsonRequest', 'HttpRequest'] + +#----------------------------------------------------------- +# Globals (wont move into a pool) +#----------------------------------------------------------- + +applicationsession = {} +addons_module = {} +addons_manifest = {} +controllers_class = {} +controllers_object = {} +controllers_path = {} + +#---------------------------------------------------------- +# OpenERP Web RequestHandler +#---------------------------------------------------------- +class WebRequest(object): + """ Parent class for all OpenERP Web request types, mostly deals with + initialization and setup of the request object (the dispatching itself has + to be handled by the subclasses) + + :param request: a wrapped werkzeug Request object + :type request: :class:`werkzeug.wrappers.BaseRequest` + :param config: configuration object + + .. attribute:: applicationsession + + an application-wide :class:`~collections.Mapping` + + .. attribute:: httprequest + + the original :class:`werkzeug.wrappers.Request` object provided to the + request + + .. attribute:: httpsession + + a :class:`~collections.Mapping` holding the HTTP session data for the + current http session + + .. attribute:: config + + config parameter provided to the request object + + .. attribute:: params + + :class:`~collections.Mapping` of request parameters, not generally + useful as they're provided directly to the handler method as keyword + arguments + + .. attribute:: session_id + + opaque identifier for the :class:`backend.OpenERPSession` instance of + the current request + + .. attribute:: session + + :class:`~backend.OpenERPSession` instance for the current request + + .. attribute:: context + + :class:`~collections.Mapping` of context values for the current request + + .. attribute:: debug + + ``bool``, indicates whether the debug mode is active on the client + """ + def __init__(self, request, config): + self.applicationsession = applicationsession + self.httprequest = request + self.httpresponse = None + self.httpsession = request.session + self.config = config + def init(self, params): + self.params = dict(params) + # OpenERP session setup + self.session_id = self.params.pop("session_id", None) or uuid.uuid4().hex + self.session = self.httpsession.setdefault( + self.session_id, backend.OpenERPSession( + self.config.server_host, self.config.server_port)) + self.context = self.params.pop('context', None) + self.debug = self.params.pop('debug', False) != False + +class JsonRequest(WebRequest): + """ JSON-RPC2 over HTTP. + + Sucessful request:: + + --> {"jsonrpc": "2.0", + "method": "call", + "params": {"session_id": "SID", + "context": {}, + "arg1": "val1" }, + "id": null} + + <-- {"jsonrpc": "2.0", + "result": { "res1": "val1" }, + "id": null} + + Request producing a error:: + + --> {"jsonrpc": "2.0", + "method": "call", + "params": {"session_id": "SID", + "context": {}, + "arg1": "val1" }, + "id": null} + + <-- {"jsonrpc": "2.0", + "error": {"code": 1, + "message": "End user error message.", + "data": {"code": "codestring", + "debug": "traceback" } }, + "id": null} + + """ + + def dispatch(self, controller, method, requestf=None, request=None): + """ Calls the method asked for by the JSON-RPC2 request + + :param controller: the instance of the controller which received the request + :param method: the method which received the request + :param requestf: a file-like object containing an encoded JSON-RPC2 request + :param request: a JSON-RPC2 request + + :returns: an utf8 encoded JSON-RPC2 reply + """ + response = {"jsonrpc": "2.0" } + error = None + try: + # Read POST content or POST Form Data named "request" + if requestf: + self.jsonrequest = simplejson.load(requestf, object_hook=nonliterals.non_literal_decoder) + else: + self.jsonrequest = simplejson.loads(request, object_hook=nonliterals.non_literal_decoder) + self.init(self.jsonrequest.get("params", {})) + if self.debug or 1: + print "--> %s.%s %s" % (controller.__class__.__name__, method.__name__, self.jsonrequest) + response['id'] = self.jsonrequest.get('id') + response["result"] = method(controller, self, **self.params) + except backend.OpenERPUnboundException: + error = { + 'code': 100, + 'message': "OpenERP Session Invalid", + 'data': { + 'type': 'session_invalid', + 'debug': traceback.format_exc() + } + } + except xmlrpclib.Fault, e: + error = { + 'code': 200, + 'message': "OpenERP Server Error", + 'data': { + 'type': 'server_exception', + 'fault_code': e.faultCode, + 'debug': "Client %s\nServer %s" % ( + "".join(traceback.format_exception("", None, sys.exc_traceback)), e.faultString) + } + } + except Exception: + logging.getLogger('openerp.JSONRequest.dispatch').exception\ + ("An error occured while handling a json request") + error = { + 'code': 300, + 'message': "OpenERP WebClient Error", + 'data': { + 'type': 'client_exception', + 'debug': "Client %s" % traceback.format_exc() + } + } + if error: + response["error"] = error + + if self.debug or 1: + print "<--", response + print + + content = simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder) + return werkzeug.wrappers.Response( + content, headers=[('Content-Type', 'application/json'), + ('Content-Length', len(content))]) + +def jsonrequest(f): + """ Decorator marking the decorated method as being a handler for a + JSON-RPC request (the exact request path is specified via the + ``$(Controller._cp_path)/$methodname`` combination. + + If the method is called, it will be provided with a :class:`JsonRequest` + instance and all ``params`` sent during the JSON-RPC request, apart from + the ``session_id``, ``context`` and ``debug`` keys (which are stripped out + beforehand) + """ + @functools.wraps(f) + def json_handler(controller, request, config): + return JsonRequest(request, config).dispatch( + controller, f, requestf=request.stream) + json_handler.exposed = True + return json_handler + +class HttpRequest(WebRequest): + """ Regular GET/POST request + """ + def dispatch(self, controller, method): + self.init(dict(self.httprequest.args, **self.httprequest.form)) + akw = {} + for key, value in self.httprequest.args.iteritems(): + if isinstance(value, basestring) and len(value) < 1024: + akw[key] = value + else: + akw[key] = type(value) + if self.debug or 1: + print "%s --> %s.%s %r" % (self.httprequest.method, controller.__class__.__name__, method.__name__, akw) + r = method(controller, self, **self.params) + if self.debug or 1: + if isinstance(r, werkzeug.wrappers.BaseResponse): + print '<--', r + else: + print "<--", 'size:', len(r) + print + return r + + def make_response(self, data, headers=None, cookies=None): + """ Helper for non-HTML responses, or HTML responses with custom + response headers or cookies. + + While handlers can just return the HTML markup of a page they want to + send as a string if non-HTML data is returned they need to create a + complete response object, or the returned data will not be correctly + interpreted by the clients. + + :param basestring data: response body + :param headers: HTTP headers to set on the response + :type headers: ``[(name, value)]`` + :param collections.Mapping cookies: cookies to set on the client + """ + response = werkzeug.wrappers.Response(data, headers=headers) + if cookies: + for k, v in cookies.iteritems(): + response.set_cookie(k, v) + return response + + def not_found(self, description=None): + """ Helper for 404 response, return its result from the method + """ + return werkzeug.exceptions.NotFound(description) + +def httprequest(f): + """ Decorator marking the decorated method as being a handler for a + normal HTTP request (the exact request path is specified via the + ``$(Controller._cp_path)/$methodname`` combination. + + If the method is called, it will be provided with a :class:`HttpRequest` + instance and all ``params`` sent during the request (``GET`` and ``POST`` + merged in the same dictionary), apart from the ``session_id``, ``context`` + and ``debug`` keys (which are stripped out beforehand) + """ + @functools.wraps(f) + def http_handler(controller, request, config): + return HttpRequest(request, config).dispatch(controller, f) + http_handler.exposed = True + return http_handler + +class ControllerType(type): + def __init__(cls, name, bases, attrs): + super(ControllerType, cls).__init__(name, bases, attrs) + controllers_class["%s.%s" % (cls.__module__, cls.__name__)] = cls + +class Controller(object): + __metaclass__ = ControllerType + +class Root(object): + """Root WSGI application for the OpenERP Web Client. + + :param options: mandatory initialization options object, must provide + the following attributes: + + ``server_host`` (``str``) + hostname of the OpenERP server to dispatch RPC to + ``server_port`` (``int``) + RPC port of the OpenERP server + ``serve_static`` (``bool | None``) + whether this application should serve the various + addons's static files + ``storage_path`` (``str``) + filesystem path where HTTP session data will be stored + ``dbfilter`` (``str``) + only used in case the list of databases is requested + by the server, will be filtered by this pattern + """ + def __init__(self, options): + self.root = werkzeug.urls.Href('/base/webclient/home') + self.config = options + + self.session_cookie = 'sessionid' + self.addons = {} + + static_dirs = self._load_addons() + if options.serve_static: + self.dispatch = werkzeug.wsgi.SharedDataMiddleware( + self.dispatch, static_dirs) + + if options.session_storage: + if not os.path.exists(options.session_storage): + os.mkdir(options.session_storage, 0700) + self.session_storage = options.session_storage + + def __call__(self, environ, start_response): + """ Handle a WSGI request + """ + return self.dispatch(environ, start_response) + + def dispatch(self, environ, start_response): + """ + Performs the actual WSGI dispatching for the application, may be + wrapped during the initialization of the object. + + Call the object directly. + """ + request = werkzeug.wrappers.Request(environ) + request.parameter_storage_class = werkzeug.datastructures.ImmutableDict + + if request.path == '/': + return werkzeug.utils.redirect( + self.root(request.args), 301)( + environ, start_response) + elif request.path == '/mobile': + return werkzeug.utils.redirect( + '/web_mobile/static/src/web_mobile.html', 301)( + environ, start_response) + + handler = self.find_handler(*(request.path.split('/')[1:])) + + if not handler: + response = werkzeug.exceptions.NotFound() + else: + with http.session(request, self.session_storage, self.session_cookie) as session: + result = handler( + request, self.config) + + if isinstance(result, basestring): + response = werkzeug.wrappers.Response( + result, headers=[('Content-Type', 'text/html; charset=utf-8'), + ('Content-Length', len(result))]) + else: + response = result + + response.set_cookie(self.session_cookie, session.sid) + + return response(environ, start_response) + + def _load_addons(self): + """ + Loads all addons at the specified addons path, returns a mapping of + static URLs to the corresponding directories + """ + statics = {} + addons_path = self.config.addons_path + if addons_path not in sys.path: + sys.path.insert(0, addons_path) + for module in os.listdir(addons_path): + if module not in addons_module: + manifest_path = os.path.join(addons_path, module, '__openerp__.py') + if os.path.isfile(manifest_path): + manifest = ast.literal_eval(open(manifest_path).read()) + print "Loading", module + m = __import__(module) + addons_module[module] = m + addons_manifest[module] = manifest + + statics['/%s/static' % module] = \ + os.path.join(addons_path, module, 'static') + for k, v in controllers_class.items(): + if k not in controllers_object: + o = v() + controllers_object[k] = o + if hasattr(o, '_cp_path'): + controllers_path[o._cp_path] = o + return statics + + def find_handler(self, *l): + """ + Tries to discover the controller handling the request for the path + specified by the provided parameters + + :param l: path sections to a controller or controller method + :returns: a callable matching the path sections, or ``None`` + :rtype: ``Controller | None`` + """ + if len(l) > 1: + for i in range(len(l), 1, -1): + ps = "/" + "/".join(l[0:i]) + if ps in controllers_path: + c = controllers_path[ps] + rest = l[i:] or ['index'] + meth = rest[0] + m = getattr(c, meth) + if getattr(m, 'exposed', False): + print "Dispatching to", ps, c, meth, m + return m + return None diff --git a/addons/base/common/http.py b/addons/base/common/http.py new file mode 100644 index 00000000000..3f6c96d1f42 --- /dev/null +++ b/addons/base/common/http.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +import contextlib + +import werkzeug.contrib.sessions + +STORES = {} + +@contextlib.contextmanager +def session(request, storage_path, session_cookie='sessionid'): + session_store = STORES.get(storage_path) + if not session_store: + session_store = werkzeug.contrib.sessions.FilesystemSessionStore( + storage_path) + STORES[storage_path] = session_store + + sid = request.cookies.get(session_cookie) + if sid: + request.session = session_store.get(sid) + else: + request.session = session_store.new() + + yield request.session + + session_store.save(request.session) diff --git a/openerpweb/nonliterals.py b/addons/base/common/nonliterals.py similarity index 99% rename from openerpweb/nonliterals.py rename to addons/base/common/nonliterals.py index 5b61f39fc83..937da06ad9e 100644 --- a/openerpweb/nonliterals.py +++ b/addons/base/common/nonliterals.py @@ -7,8 +7,6 @@ can't be sent there themselves). import binascii import hashlib import simplejson.encoder -import time -import datetime __all__ = ['Domain', 'Context', 'NonLiteralEncoder, non_literal_decoder', 'CompoundDomain', 'CompoundContext'] diff --git a/openerpweb/tests/__init__.py b/addons/base/common/tests/__init__.py similarity index 100% rename from openerpweb/tests/__init__.py rename to addons/base/common/tests/__init__.py diff --git a/openerpweb/tests/test_model.py b/addons/base/common/tests/test_model.py similarity index 100% rename from openerpweb/tests/test_model.py rename to addons/base/common/tests/test_model.py diff --git a/openerpweb/tests/test_nonliterals.py b/addons/base/common/tests/test_nonliterals.py similarity index 100% rename from openerpweb/tests/test_nonliterals.py rename to addons/base/common/tests/test_nonliterals.py diff --git a/openerpweb/tests/test_session.py b/addons/base/common/tests/test_session.py similarity index 100% rename from openerpweb/tests/test_session.py rename to addons/base/common/tests/test_session.py diff --git a/addons/base/controllers/main.py b/addons/base/controllers/main.py index f650fe3eb47..c015adda052 100644 --- a/addons/base/controllers/main.py +++ b/addons/base/controllers/main.py @@ -9,17 +9,20 @@ import re import simplejson import textwrap import xmlrpclib +import time from xml.etree import ElementTree from cStringIO import StringIO -import cherrypy - -import openerpweb -import openerpweb.ast -import openerpweb.nonliterals +import base.common.dispatch as openerpweb +import base.common.ast +import base.common.nonliterals +openerpweb.ast = base.common.ast +openerpweb.nonliterals = base.common.nonliterals from babel.messages.pofile import read_po +_REPORT_POLLER_DELAY = 0.05 + # Should move to openerpweb.Xml2Json class Xml2Json: # xml2json-direct @@ -63,16 +66,16 @@ class Xml2Json: # OpenERP Web base Controllers #---------------------------------------------------------- -def manifest_glob(addons, key): +def manifest_glob(addons_path, addons, key): files = [] for addon in addons: globlist = openerpweb.addons_manifest.get(addon, {}).get(key, []) for pattern in globlist: - for path in glob.glob(os.path.join(openerpweb.path_addons, addon, pattern)): - files.append(path[len(openerpweb.path_addons):]) + for path in glob.glob(os.path.join(addons_path, addon, pattern)): + files.append(path[len(addons_path):]) return files -def concat_files(file_list): +def concat_files(addons_path, file_list): """ Concatenate file content return (concat,timestamp) concat: concatenation of file content @@ -81,13 +84,13 @@ def concat_files(file_list): files_content = [] files_timestamp = 0 for i in file_list: - fname = os.path.join(openerpweb.path_addons, i[1:]) + fname = os.path.join(addons_path, i[1:]) ftime = os.path.getmtime(fname) if ftime > files_timestamp: files_timestamp = ftime files_content.append(open(fname).read()) files_concat = "".join(files_content) - return (files_concat,files_timestamp) + return files_concat,files_timestamp home_template = textwrap.dedent(""" @@ -117,40 +120,38 @@ class WebClient(openerpweb.Controller): @openerpweb.jsonrequest def csslist(self, req, mods='base'): - return manifest_glob(mods.split(','), 'css') + return manifest_glob(req.config.addons_path, mods.split(','), 'css') @openerpweb.jsonrequest def jslist(self, req, mods='base'): - return manifest_glob(mods.split(','), 'js') + return manifest_glob(req.config.addons_path, mods.split(','), 'js') @openerpweb.httprequest def css(self, req, mods='base'): - req.httpresponse.headers['Content-Type'] = 'text/css' - files = manifest_glob(mods.split(','), 'css') - content,timestamp = concat_files(files) + files = manifest_glob(req.config.addons_path, mods.split(','), 'css') + content,timestamp = concat_files(req.config.addons_path, files) # TODO request set the Date of last modif and Etag - return content + return req.make_response(content, [('Content-Type', 'text/css')]) @openerpweb.httprequest def js(self, req, mods='base'): - req.httpresponse.headers['Content-Type'] = 'application/javascript' - files = manifest_glob(mods.split(','), 'js') - content,timestamp = concat_files(files) + files = manifest_glob(req.config.addons_path, mods.split(','), 'js') + content,timestamp = concat_files(req.config.addons_path, files) # TODO request set the Date of last modif and Etag - return content + return req.make_response(content, [('Content-Type', 'application/javascript')]) @openerpweb.httprequest def home(self, req, s_action=None, **kw): # script tags jslist = ['/base/webclient/js'] if req.debug: - jslist = manifest_glob(['base'], 'js') + jslist = manifest_glob(req.config.addons_path, ['base'], 'js') js = "\n ".join([''%i for i in jslist]) # css tags csslist = ['/base/webclient/css'] if req.debug: - csslist = manifest_glob(['base'], 'css') + csslist = manifest_glob(req.config.addons_path, ['base'], 'css') css = "\n ".join([''%i for i in csslist]) r = home_template % { 'javascript': js, @@ -180,7 +181,7 @@ class WebClient(openerpweb.Controller): transl = {"messages":[]} transs[addon_name] = transl for l in langs: - f_name = os.path.join(openerpweb.path_addons, addon_name, "po", l + ".po") + f_name = os.path.join(req.config.addons_path, addon_name, "po", l + ".po") if not os.path.exists(f_name): continue try: @@ -203,7 +204,7 @@ class Database(openerpweb.Controller): dbs = proxy.list() h = req.httprequest.headers['Host'].split(':')[0] d = h.split('.')[0] - r = cherrypy.config['openerp.dbfilter'].replace('%h', h).replace('%d', d) + r = req.config.dbfilter.replace('%h', h).replace('%d', d) dbs = [i for i in dbs if re.match(r, i)] return {"db_list": dbs} @@ -248,11 +249,11 @@ class Database(openerpweb.Controller): try: db_dump = base64.decodestring( req.session.proxy("db").dump(backup_pwd, backup_db)) - req.httpresponse.headers['Content-Type'] = "application/octet-stream; charset=binary" - req.httpresponse.headers['Content-Disposition'] = 'attachment; filename="' + backup_db + '.dump"' - req.httpresponse.cookie['fileToken'] = int(token) - req.httpresponse.cookie['fileToken']['path'] = '/' - return db_dump + return req.make_response(db_dump, + [('Content-Type', 'application/octet-stream; charset=binary'), + ('Content-Disposition', 'attachment; filename="' + backup_db + '.dump"')], + {'fileToken': int(token)} + ) except xmlrpclib.Fault, e: if e.faultCode and e.faultCode.split(':')[0] == 'AccessDenied': return 'Backup Database|' + e.faultCode @@ -293,7 +294,21 @@ class Session(openerpweb.Controller): "uid": req.session._uid, "context": ctx } - + @openerpweb.jsonrequest + def change_password (self,req,fields): + old_password, new_password,confirm_password = operator.itemgetter('old_pwd', 'new_password','confirm_pwd')( + dict(map(operator.itemgetter('name', 'value'), fields))) + if not (old_password.strip() and new_password.strip() and confirm_password.strip()): + return {'error':'All passwords have to be filled.','title': 'Change Password'} + if new_password != confirm_password: + return {'error': 'The new password and its confirmation must be identical.','title': 'Change Password'} + try: + if req.session.model('res.users').change_password( + old_password, new_password): + return {'new_password':new_password} + except: + return {'error': 'Original password incorrect, your password was not changed.', 'title': 'Change Password'} + return {'error': 'Error, password not changed !', 'title': 'Change Password'} @openerpweb.jsonrequest def sc_list(self, req): return req.session.model('ir.ui.view_sc').get_sc( @@ -955,9 +970,9 @@ class Binary(openerpweb.Controller): res = Model.read([int(id)], [field], context)[0].get(field, '') return base64.decodestring(res) except: # TODO: what's the exception here? - return self.placeholder() - def placeholder(self): - return open(os.path.join(openerpweb.path_addons, 'base', 'static', 'src', 'img', 'placeholder.png'), 'rb').read() + return self.placeholder(req) + def placeholder(self, req): + return open(os.path.join(req.addons_path, 'base', 'static', 'src', 'img', 'placeholder.png'), 'rb').read() @openerpweb.httprequest def saveas(self, req, model, id, field, fieldname, **kw): @@ -966,18 +981,17 @@ class Binary(openerpweb.Controller): res = Model.read([int(id)], [field, fieldname], context)[0] filecontent = res.get(field, '') if not filecontent: - raise cherrypy.NotFound + return req.not_found() else: - req.httpresponse.headers['Content-Type'] = 'application/octet-stream' filename = '%s_%s' % (model.replace('.', '_'), id) if fieldname: filename = res.get(fieldname, '') or filename - req.httpresponse.headers['Content-Disposition'] = 'attachment; filename=' + filename - return base64.decodestring(filecontent) + return req.make_response(filecontent, + [('Content-Type', 'application/octet-stream'), + ('Content-Disposition', 'attachment; filename=' + filename)]) @openerpweb.httprequest def upload(self, req, callback, ufile=None): - cherrypy.response.timeout = 500 headers = {} for key, val in req.httprequest.headers.iteritems(): headers[key.lower()] = val @@ -1004,7 +1018,6 @@ class Binary(openerpweb.Controller): @openerpweb.httprequest def upload_attachment(self, req, callback, model, id, ufile=None): - cherrypy.response.timeout = 500 context = req.session.eval_context(req.context) Model = req.session.model('ir.attachment') try: diff --git a/addons/base/po/base.pot b/addons/base/po/base.pot index fb5285610d8..e59c507c415 100644 --- a/addons/base/po/base.pot +++ b/addons/base/po/base.pot @@ -365,7 +365,7 @@ msgid "(Any existing filter with the same name will be replaced)" msgstr "" #: addons/base/static/src/xml/base.xml:0 -msgid "(?)" +msgid "?" msgstr "" #: addons/base/static/src/xml/base.xml:0 diff --git a/addons/base/static/lib/jquery.contextmenu/jquery.contextmenu.r2.packed.js b/addons/base/static/lib/jquery.contextmenu/jquery.contextmenu.r2.packed.js index cb456eed14c..2356f3b606b 100644 --- a/addons/base/static/lib/jquery.contextmenu/jquery.contextmenu.r2.packed.js +++ b/addons/base/static/lib/jquery.contextmenu/jquery.contextmenu.r2.packed.js @@ -32,7 +32,7 @@ if(!menu) { menu=$('
').hide().css({ position:'absolute', - zIndex:'500' + zIndex:'2000' }).appendTo('body').bind('click', function(e) { e.stopPropagation() }) diff --git a/addons/base/static/lib/qweb/qweb2.js b/addons/base/static/lib/qweb/qweb2.js index 8987f1dc40e..cfb9c9c461d 100644 --- a/addons/base/static/lib/qweb/qweb2.js +++ b/addons/base/static/lib/qweb/qweb2.js @@ -4,9 +4,9 @@ // TODO: t-set + t-value + children node == scoped variable ? var QWeb2 = { expressions_cache: {}, - reserved_words: 'true,false,NaN,null,undefined,debugger,in,instanceof,new,function,return,this,typeof,eval,Math,RegExp,Array,Object,Date'.split(','), - actions_precedence: 'foreach,if,call,set,esc,escf,raw,rawf,js,debug,log'.split(','), - word_replacement: { + RESERVED_WORDS: 'true,false,NaN,null,undefined,debugger,in,instanceof,new,function,return,this,typeof,eval,Math,RegExp,Array,Object,Date'.split(','), + ACTIONS_PRECEDENCE: 'foreach,if,call,set,esc,escf,raw,rawf,js,debug,log'.split(','), + WORD_REPLACEMENT: { 'and': '&&', 'or': '||', 'gt': '>', @@ -204,6 +204,9 @@ QWeb2.Engine = (function() { this.dict = {}; this.tools = QWeb2.tools; this.jQuery = window.jQuery; + this.reserved_words = QWeb2.RESERVED_WORDS.slice(0); + this.actions_precedence = QWeb2.ACTIONS_PRECEDENCE.slice(0); + this.word_replacement = QWeb2.tools.extend({}, QWeb2.WORD_REPLACEMENT); for (var i = 0; i < arguments.length; i++) { this.add_template(arguments[i]); } @@ -290,6 +293,9 @@ QWeb2.Engine = (function() { xDoc.loadXML(s); return xDoc; }, + has_template : function(template) { + return !!this.templates[template]; + }, get_xhr : function() { if (window.XMLHttpRequest) { return new window.XMLHttpRequest(); @@ -519,8 +525,8 @@ QWeb2.Element = (function() { continue; } else if (c.match(/\W/) && invar.length) { // TODO: Should check for possible spaces before dot - if (chars[invar_pos - 1] !== '.' && QWeb2.tools.arrayIndexOf(QWeb2.reserved_words, invar) < 0) { - invar = QWeb2.word_replacement[invar] || ("dict['" + invar + "']"); + if (chars[invar_pos - 1] !== '.' && QWeb2.tools.arrayIndexOf(this.engine.reserved_words, invar) < 0) { + invar = this.engine.word_replacement[invar] || ("dict['" + invar + "']"); } r += invar; invar = ''; @@ -579,15 +585,18 @@ QWeb2.Element = (function() { return this._bottom.unshift(this.get_indent() + "//@string=" + this.engine.tools.js_escape(s, true) + '\n'); }, compile_element : function() { - for (var i = 0, ilen = QWeb2.actions_precedence.length; i < ilen; i++) { - var a = QWeb2.actions_precedence[i]; + for (var i = 0, ilen = this.engine.actions_precedence.length; i < ilen; i++) { + var a = this.engine.actions_precedence[i]; if (a in this.actions) { var value = this.actions[a]; var key = 'compile_action_' + a; - if (!this[key]) { + if (this[key]) { + this[key](value); + } else if (this.engine[key]) { + this.engine[key].call(this, value); + } else { this.engine.tools.exception("No handler method for action '" + a + "'"); } - this[key](value); } } if (this.tag !== this.engine.prefix) { diff --git a/addons/base/static/src/css/base.css b/addons/base/static/src/css/base.css index 46364c4fa1d..c9894e42cef 100644 --- a/addons/base/static/src/css/base.css +++ b/addons/base/static/src/css/base.css @@ -597,13 +597,13 @@ label.error { background-color: #ccc; } .openerp .searchview_group.folded .searchview_group_string { - background: url("../img/ui/group-folded.png") no-repeat scroll 0 50%; + background: url("/base/static/src/img/ui/group-folded.png") no-repeat scroll 0 50%; } .openerp .searchview_group.folded .searchview_group_content { display: none; } .openerp .searchview_group.expanded .searchview_group_string { - background: url("../img/ui/group-expanded.png") no-repeat scroll 0 50%; + background: url("/base/static/src/img/ui/group-expanded.png") no-repeat scroll 0 50%; } .openerp .searchview_group.expanded .searchview_group_content { display: block; @@ -635,13 +635,13 @@ label.error { } .openerp .oe_search-view-custom-filter-btn span { - background: url(../img/icons/gtk-add.png) repeat-y; + background: url(/base/static/src/img/icons/gtk-add.png) repeat-y; padding-left: 18px; } .openerp .searchview_extended_add_proposition span { font-size: 0.9em; - background: url(../img/icons/gtk-add.png) repeat-y; + background: url(/base/static/src/img/icons/gtk-add.png) repeat-y; padding-left: 18px; } @@ -657,7 +657,7 @@ label.error { .openerp .searchview_extended_delete_group span, .openerp .searchview_extended_delete_prop span { font-size: 0.9em; - background: url(../img/icons/gtk-close.png) repeat-y; + background: url(/base/static/src/img/icons/gtk-close.png) repeat-y; padding-left: 18px; } /* List */ @@ -832,15 +832,17 @@ label.error { float: right; } -.openerp label.oe_form_label_help { +.openerp label.oe_label_help, .openerp label.oe_label { + display: block; +} +.openerp label.oe_label_help { cursor: help; } -.openerp label.oe_form_label, .openerp label.oe_form_label_help { - display: block; +.openerp .oe_form_field label.oe_label, .openerp .oe_form_field label.oe_label_help { text-align: right; margin: 3px 0 0 10px; } -.openerp label.oe_form_label_help span { +.openerp label.oe_label_help span { font-size: 80%; color: darkgreen; vertical-align:top; @@ -895,6 +897,9 @@ label.error { position: relative; top: 4px; } +.openerp .oe_trad_field.touched { + border: 1px solid green !important; +} /* http://www.quirksmode.org/dom/inputfile.html * http://stackoverflow.com/questions/2855589/replace-input-type-file-by-an-image @@ -928,6 +933,9 @@ label.error { margin: 3px 4px 3px 1px; height: 15px; } +.openerp .oe_form_frame_cell.oe_form_separator_vertical { + border-left: 1px solid #666; +} .openerp td.required input, .openerp td.required select { background-color: #D2D2FF !important; } @@ -1088,7 +1096,7 @@ label.error { cursor: pointer; border: 1px solid #D2CFCF; display: block; - background: url(../img/toggle-a-bg.png); + background: url(/base/static/src/img/toggle-a-bg.png); width: 21px; height: 21px; z-index: 10; @@ -1103,7 +1111,7 @@ label.error { } .openerp li.oe_sidebar_print { padding-left: 20px; - background: 1px 3px url(../img/icons/gtk-print.png) no-repeat; + background: 1px 3px url(/base/static/src/img/icons/gtk-print.png) no-repeat; } .openerp.kitten-mode-activated .main_table { diff --git a/addons/base/static/src/js/boot.js b/addons/base/static/src/js/boot.js index fff710a1d68..e6c9fab0831 100644 --- a/addons/base/static/src/js/boot.js +++ b/addons/base/static/src/js/boot.js @@ -50,7 +50,9 @@ openerp.base = function(instance) { openerp.base.core(instance); - openerp.base.dates(instance); + if (openerp.base.dates) { + openerp.base.dates(instance); + } openerp.base.formats(instance); openerp.base.chrome(instance); openerp.base.data(instance); diff --git a/addons/base/static/src/js/chrome.js b/addons/base/static/src/js/chrome.js index cf3f4b0ae00..2f53dca444d 100644 --- a/addons/base/static/src/js/chrome.js +++ b/addons/base/static/src/js/chrome.js @@ -440,8 +440,6 @@ openerp.base.Login = openerp.base.Widget.extend({ this.selected_login = localStorage.getItem('last_login_login_success'); } if (jQuery.deparam(jQuery.param.querystring()).debug != undefined) { - this.selected_db = this.selected_db || "trunk"; - this.selected_login = this.selected_login || "admin"; this.selected_password = this.selected_password || "a"; } }, @@ -525,22 +523,26 @@ openerp.base.Login = openerp.base.Widget.extend({ }); openerp.base.Header = openerp.base.Widget.extend({ - init: function(parent, element_id) { - this._super(parent, element_id); - if (jQuery.deparam(jQuery.param.querystring()).debug !== undefined) { - this.qs = '?debug' - } else { - this.qs = '' - } + template: "Header", + identifier_prefix: 'oe-app-header-', + init: function(parent) { + this._super(parent); + this.qs = "?" + jQuery.param.querystring(); + this.$content = $(); }, start: function() { - return this.do_update(); + this._super(); }, do_update: function () { - this.$element.html(QWeb.render("Header", this)); + this.$content = $(QWeb.render("Header-content", {widget: this})); + this.$content.appendTo(this.$element); this.$element.find(".logout").click(this.on_logout); + this.$element.find("a.preferences").click(this.on_preferences); return this.shortcut_load(); }, + do_reset: function() { + this.$content.remove(); + }, shortcut_load :function(){ var self = this, sc = self.session.shortcuts, @@ -594,9 +596,98 @@ openerp.base.Header = openerp.base.Widget.extend({ }); }); }, + on_action: function(action) { }, - + on_preferences: function(){ + var self = this; + var action_manager = new openerp.base.ActionManager(this); + var dataset = new openerp.base.DataSet (this,'res.users',this.context); + dataset.call ('action_get','',function (result){ + self.rpc('/base/action/load', {action_id:result}, function(result){ + action_manager.do_action(_.extend(result['result'], { + res_id: self.session.uid, + res_model: 'res.users', + flags: { + action_buttons: false, + search_view: false, + sidebar: false, + views_switcher: false, + pager: false + } + })); + }); + }); + this.dialog = new openerp.base.Dialog(this,{ + modal: true, + title: 'Preferences', + width: 600, + height: 500, + buttons: { + "Change password": function(){ + self.change_password(); + }, + Cancel: function(){ + $(this).dialog('destroy'); + }, + Save: function(){ + var inner_viewmanager = action_manager.inner_viewmanager; + inner_viewmanager.views[inner_viewmanager.active_view].controller.do_save(function(){ + inner_viewmanager.start(); + }); + $(this).dialog('destroy') + } + } + }); + this.dialog.start().open(); + action_manager.appendTo(this.dialog); + action_manager.render(this.dialog); + }, + + change_password :function() { + var self = this; + this.dialog = new openerp.base.Dialog(this,{ + modal : true, + title : 'Change Password', + width : 'auto', + height : 'auto' + }); + this.dialog.start().open(); + this.dialog.$element.html(QWeb.render("Change_Pwd", self)); + this.dialog.$element.find("form[name=change_password_form]").validate({ + submitHandler: function (form) { + self.rpc("/base/session/change_password",{ + 'fields': $(form).serializeArray() + }, function(result) { + if (result.error) { + self.display_error(result); + return; + } + else { + if (result.new_password) { + self.session.password = result.new_password; + var session = new openerp.base.Session(self.session.server, self.session.port); + session.start(); + session.session_login(self.session.db, self.session.login, self.session.password) + } + } + self.notification.notify("Changed Password", "Password has been changed successfully"); + self.dialog.close(); + }); + } + }); +}, + display_error: function (error) { + return $('