From 965d010dc9ef3738e3209c3f84c11ce381a5ccf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Tue, 27 Sep 2016 11:56:19 +0200 Subject: [PATCH] Add user application issue5782 review28641002 --- CHANGELOG | 1 + doc/topics/index.rst | 1 + doc/topics/user_application.rst | 63 ++++++++++++ trytond/protocols/dispatcher.py | 24 +---- trytond/protocols/jsonrpc.py | 33 ++++--- trytond/protocols/wrappers.py | 106 ++++++++++++++++++++- trytond/protocols/xmlrpc.py | 4 +- trytond/res/__init__.py | 5 + trytond/res/routes.py | 49 ++++++++++ trytond/res/user.py | 102 +++++++++++++++++++- trytond/res/user.xml | 41 ++++++++ trytond/res/view/user_application_form.xml | 18 ++++ trytond/res/view/user_application_list.xml | 11 +++ trytond/res/view/user_form.xml | 3 + trytond/res/view/user_form_preferences.xml | 3 + trytond/wsgi.py | 6 +- 16 files changed, 427 insertions(+), 43 deletions(-) create mode 100644 doc/topics/user_application.rst create mode 100644 trytond/res/routes.py create mode 100644 trytond/res/view/user_application_form.xml create mode 100644 trytond/res/view/user_application_list.xml diff --git a/CHANGELOG b/CHANGELOG index 4eef906442..3ae7ba3e79 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add user application * Add sequence_ordered * Remove most country specific code in languages * Add support for derivative translations diff --git a/doc/topics/index.rst b/doc/topics/index.rst index cd0f72ddda..6084d42e5a 100644 --- a/doc/topics/index.rst +++ b/doc/topics/index.rst @@ -27,5 +27,6 @@ Introduction to all the key parts of trytond: wizard reports/index rpc + user_application modules/index translation diff --git a/doc/topics/user_application.rst b/doc/topics/user_application.rst new file mode 100644 index 0000000000..b5b7f3c3a0 --- /dev/null +++ b/doc/topics/user_application.rst @@ -0,0 +1,63 @@ +.. _topics-user_application: + +================ +User Application +================ + +Tryton provides a way to connect URL rules to an callable endpoint using the +decorator method `route` of the `trytond.application.app` instance. This allows +to define a custom API based on HTTP that can be used to create a specific user +application. + +The decorator takes as first parameter a string which follow the `Rule +Format`_ of Werkzeug and as second parameter sequence of HTTP methods. + +Example:: + + from trytond.application import app + + @app.route('/hello', methods=['GET']) + def hello(request): + return 'Hello world' + +.. _Rule Format: http://werkzeug.pocoo.org/docs/latest/routing/#rule-format + +Tryton also provides some wrappers in `trytond.procotols.wrappers` to ease the +creation of such route. + + - `with_pool`: which takes the first parameter as database name and + replace it by the corresponding instance of the :ref:`Pool `. + + - `with_transaction([readonly])`: which starts a :class:`Transaction` using + the :ref:`Pool ` from `with_pool`. If `readonly` is not set, + the transaction will not be readonly for `POST`, `PUT`, `DELETE` and + `PATCH` methods and readonly for all others. + + - `user_application(name[, json])`: which set the :attr:`Transaction.user` + from the `Authorization` header using the type `bearer` and a valid key + for the named user application. + +User Application Key +==================== + +Tryton also provides a easy way to manage access to user application using +keys per named application. +A key is created with a `POST` request on the `URL` +`//user/application/` which returns the key. The request must +contain as data a json object with the keys: + + - `user`: the user login + + - `application`: the name of the application + +After the creation, the key must be validated by the user from the preferences +of a Tryton client. + +A key can be deleted with a `DELETE` request on the same `URL`. The request +must contain as data a json object with the keys: + + - `user`: the user login + + - `key`: the key to delete + + - `application`: the name of the application of the key diff --git a/trytond/protocols/dispatcher.py b/trytond/protocols/dispatcher.py index f77889047e..fe81f6a4a1 100644 --- a/trytond/protocols/dispatcher.py +++ b/trytond/protocols/dispatcher.py @@ -3,13 +3,11 @@ # this repository contains the full copyright notices and license terms. import logging import pydoc -from functools import wraps from werkzeug.utils import redirect from werkzeug.exceptions import abort from sql import Table -from trytond.pool import Pool from trytond import security from trytond import backend from trytond.config import config @@ -20,6 +18,7 @@ from trytond.exceptions import ( UserError, UserWarning, ConcurrencyException, LoginException) from trytond.tools import is_instance_method from trytond.wsgi import app +from .wrappers import with_pool logger = logging.getLogger(__name__) @@ -29,19 +28,6 @@ ir_module = Table('ir_module') res_user = Table('res_user') -def with_pool(func): - @wraps(func) - def wrapper(request, database_name, *args, **kwargs): - database_list = Pool.database_list() - pool = Pool(database_name) - if database_name not in database_list: - with Transaction().start( - database_name, request.user_id, readonly=True): - pool.init() - return func(request, pool, *args, **kwargs) - return wrapper - - @app.route('//', methods=['POST']) def rpc(request, database_name): methods = { @@ -51,8 +37,8 @@ def rpc(request, database_name): 'system.methodHelp': help_method, 'system.methodSignature': lambda *a: 'signatures not supported', } - return methods.get(request.method, _dispatch)( - request, database_name, *request.params) + return methods.get(request.rpc_method, _dispatch)( + request, database_name, *request.rpc_params) def login(request, database_name, user, parameters, language=None): @@ -92,7 +78,7 @@ def root(request, *args): 'common.server.version': lambda *a: __version__, 'common.db.list': db_list, } - return methods[request.method](request, *request.params) + return methods[request.rpc_method](request, *request.rpc_params) @app.route('/', methods=['GET']) @@ -128,7 +114,7 @@ def list_method(request, pool): def get_object_method(request, pool): - method = request.method + method = request.rpc_method type, _ = method.split('.', 1) name = '.'.join(method.split('.')[1:-1]) method = method.split('.')[-1] diff --git a/trytond/protocols/jsonrpc.py b/trytond/protocols/jsonrpc.py index 74c355d16f..b91fa2cd49 100644 --- a/trytond/protocols/jsonrpc.py +++ b/trytond/protocols/jsonrpc.py @@ -10,7 +10,7 @@ import base64 from werkzeug.wrappers import Response from werkzeug.utils import cached_property -from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import BadRequest, InternalServerError from trytond.protocols.wrappers import Request from trytond.exceptions import TrytonException @@ -129,11 +129,11 @@ class JSONRequest(Request): raise BadRequest('Not a JSON request') @cached_property - def method(self): + def rpc_method(self): return self.parsed_data['method'] @cached_property - def params(self): + def rpc_params(self): return self.parsed_data['params'] @@ -146,16 +146,23 @@ class JSONProtocol: @classmethod def response(cls, data, request): - if isinstance(request, JSONRequest): - response = {'id': request.parsed_data.get('id', 0)} + try: + parsed_data = request.parsed_data + except BadRequest: + parsed_data = {} + if (isinstance(request, JSONRequest) + and set(parsed_data.keys()) == {'id', 'method', 'params'}): + response = {'id': parsed_data.get('id', 0)} + if isinstance(data, TrytonException): + response['error'] = data.args + elif isinstance(data, Exception): + # report exception back to server + response['error'] = (str(data), data.__format_traceback__) + else: + response['result'] = data else: - response = {} - if isinstance(data, TrytonException): - response['error'] = data.args - elif isinstance(data, Exception): - # report exception back to server - response['error'] = (str(data), data.__format_traceback__) - else: - response['result'] = data + if isinstance(data, Exception): + return InternalServerError() + response = data return Response(json.dumps(response, cls=JSONEncoder), content_type='application/json') diff --git a/trytond/protocols/wrappers.py b/trytond/protocols/wrappers.py index e1716e3698..ee1ac6c1e7 100644 --- a/trytond/protocols/wrappers.py +++ b/trytond/protocols/wrappers.py @@ -2,15 +2,23 @@ # this repository contains the full copyright notices and license terms. import base64 import gzip +import logging from io import BytesIO +from functools import wraps -from werkzeug.wrappers import Request as _Request +from werkzeug.wrappers import Request as _Request, Response from werkzeug.utils import cached_property from werkzeug.http import wsgi_to_bytes, bytes_to_wsgi from werkzeug.datastructures import Authorization -from werkzeug.exceptions import abort +from werkzeug.exceptions import abort, HTTPException -from trytond import security +from trytond import security, backend +from trytond.pool import Pool +from trytond.transaction import Transaction +from trytond.config import config +from trytond.cache import Cache + +logger = logging.getLogger(__name__) class Request(_Request): @@ -30,11 +38,11 @@ class Request(_Request): return self.data @property - def method(self): + def rpc_method(self): return @property - def params(self): + def rpc_params(self): return @cached_property @@ -85,3 +93,91 @@ def parse_authorization_header(value): 'userid': userid, 'session': bytes_to_wsgi(session), }) + + +def with_pool(func): + @wraps(func) + def wrapper(request, database_name, *args, **kwargs): + database_list = Pool.database_list() + pool = Pool(database_name) + if database_name not in database_list: + with Transaction().start( + database_name, request.user_id, readonly=True): + pool.init() + return func(request, pool, *args, **kwargs) + return wrapper + + +def with_transaction(readonly=None): + def decorator(func): + @wraps(func) + def wrapper(request, pool, *args, **kwargs): + DatabaseOperationalError = backend.get('DatabaseOperationalError') + readonly_ = readonly # can not modify non local + if readonly_ is None: + if request.method in {'POST', 'PUT', 'DELETE', 'PATCH'}: + readonly_ = False + else: + readonly_ = True + for count in range(config.getint('database', 'retry'), -1, -1): + with Transaction().start( + pool.database_name, 0, readonly=readonly_ + ) as transaction: + try: + Cache.clean(pool.database_name) + result = func(request, pool, *args, **kwargs) + return result + except DatabaseOperationalError: + if count and not readonly_: + transaction.rollback() + continue + logger.error('%s', request, exc_info=True) + raise + except Exception: + logger.error('%s', request, exc_info=True) + raise + # Need to commit to unlock SQLite database + transaction.commit() + Cache.resets(pool.database_name) + return wrapper + return decorator + + +def user_application(name, json=True): + from .jsonrpc import JSONEncoder, json as json_ + + def decorator(func): + @wraps(func) + def wrapper(request, *args, **kwargs): + pool = Pool() + UserApplication = pool.get('res.user.application') + + authorization = request.headers['Authorization'] + try: + auth_type, auth_info = authorization.split(None, 1) + auth_type = auth_type.lower() + except ValueError: + abort(401) + if auth_type != b'bearer': + abort(403) + + application = UserApplication.check(auth_info, name) + if not application: + abort(403) + transaction = Transaction() + # TODO language + with transaction.set_user(application.user.id), \ + transaction.set_context(_check_access=True): + try: + response = func(request, *args, **kwargs) + except Exception, e: + if isinstance(e, HTTPException): + raise + logger.error('%s', request, exc_info=True) + abort(500) + if not isinstance(response, Response) and json: + response = Response(json_.dumps(response, cls=JSONEncoder), + content_type='application/json') + return response + return wrapper + return decorator diff --git a/trytond/protocols/xmlrpc.py b/trytond/protocols/xmlrpc.py index 502f226a20..53ea6e6a69 100644 --- a/trytond/protocols/xmlrpc.py +++ b/trytond/protocols/xmlrpc.py @@ -150,11 +150,11 @@ class XMLRequest(Request): raise BadRequest('Not an XML request') @property - def method(self): + def rpc_method(self): return self.parsed_data[1] @property - def params(self): + def rpc_params(self): return self.parsed_data[0] diff --git a/trytond/res/__init__.py b/trytond/res/__init__.py index 6b157c155f..1afd874118 100644 --- a/trytond/res/__init__.py +++ b/trytond/res/__init__.py @@ -1,9 +1,13 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. from ..pool import Pool + from .group import * from .user import * from .ir import * +from . import routes + +__all__ = ['register', 'routes'] def register(): @@ -15,6 +19,7 @@ def register(): UserAction, UserGroup, Warning_, + UserApplication, UserConfigStart, UIMenuGroup, ActionGroup, diff --git a/trytond/res/routes.py b/trytond/res/routes.py new file mode 100644 index 0000000000..ed8b03dda6 --- /dev/null +++ b/trytond/res/routes.py @@ -0,0 +1,49 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +import logging +import time + +from werkzeug.exceptions import abort + +from trytond.wsgi import app +from trytond.protocols.wrappers import with_pool, with_transaction + +logger = logging.getLogger(__name__) + + +@app.route('//user/application/', methods=['POST', 'DELETE']) +@with_pool +@with_transaction(readonly=False) +def user_application(request, pool): + User = pool.get('res.user') + UserApplication = pool.get('res.user.application') + LoginAttempt = pool.get('res.user.login.attempt') + data = request.parsed_data + login = data.get('user') + + if request.method == 'POST': + users = User.search([ + ('login', '=', login), + ]) + if not users: + logger.info('User Application not found: %s', data.get('user')) + return abort(404) + user, = users + data['user'] = user.id + data.pop('key', None) + data.pop('state', None) + application, = UserApplication.create([data]) + time.sleep(2 ** UserApplication.count(application)) + return application.key + elif request.method == 'DELETE': + time.sleep(2 ** LoginAttempt.count(login) - 1) + applications = UserApplication.search([ + ('user.login', '=', login), + ('key', '=', data.get('key')), + ('application', '=', data.get('application')), + ]) + if applications: + UserApplication.delete(applications) + LoginAttempt.remove(login) + else: + LoginAttempt.add(login) diff --git a/trytond/res/user.py b/trytond/res/user.py index 898e54ef6a..f17b8982e7 100644 --- a/trytond/res/user.py +++ b/trytond/res/user.py @@ -8,6 +8,7 @@ import hashlib import time import datetime import logging +import uuid from functools import wraps from itertools import groupby, ifilter from operator import attrgetter @@ -23,7 +24,7 @@ try: except ImportError: bcrypt = None -from ..model import ModelView, ModelSQL, fields, Unique +from ..model import ModelView, ModelSQL, Workflow, fields, Unique from ..wizard import Wizard, StateView, Button, StateTransition from ..tools import grouped_slice from .. import backend @@ -31,12 +32,13 @@ from ..transaction import Transaction from ..cache import Cache from ..pool import Pool from ..config import config -from ..pyson import PYSONEncoder +from ..pyson import PYSONEncoder, Eval from ..rpc import RPC from ..exceptions import LoginException __all__ = [ 'User', 'LoginAttempt', 'UserAction', 'UserGroup', 'Warning_', + 'UserApplication', 'UserConfigStart', 'UserConfig', ] logger = logging.getLogger(__name__) @@ -62,6 +64,8 @@ class User(ModelSQL, ModelView): rule_groups = fields.Many2Many('ir.rule.group-res.user', 'user', 'rule_group', 'Rules', domain=[('global_p', '!=', True), ('default_p', '!=', True)]) + applications = fields.One2Many( + 'res.user.application', 'user', "Applications") language = fields.Many2One('ir.lang', 'Language', domain=['OR', ('translatable', '=', True), @@ -100,6 +104,7 @@ class User(ModelSQL, ModelView): 'actions', 'status_bar', 'warnings', + 'applications', ] cls._context_fields = [ 'language', @@ -698,6 +703,99 @@ class Warning_(ModelSQL, ModelView): return False +class UserApplication(Workflow, ModelSQL, ModelView): + "User Application" + __name__ = 'res.user.application' + _rec_name = 'key' + + key = fields.Char("Key", required=True, select=True) + user = fields.Many2One('res.user', "User", required=True, select=True) + application = fields.Selection([], "Application") + state = fields.Selection([ + ('requested', "Requested"), + ('validated', "Validated"), + ('cancelled', "Cancelled"), + ], "State", readonly=True) + + @classmethod + def __setup__(cls): + super(UserApplication, cls).__setup__() + cls._transitions |= set(( + ('requested', 'validated'), + ('requested', 'cancelled'), + ('validated', 'cancelled'), + )) + cls._buttons.update({ + 'validate_': { + 'invisible': Eval('state') != 'requested', + }, + 'cancel': { + 'invisible': Eval('state') == 'cancelled', + }, + }) + + @classmethod + def default_key(cls): + return ''.join(uuid.uuid4().hex for _ in xrange(4)) + + @classmethod + def default_state(cls): + return 'requested' + + @classmethod + @ModelView.button + @Workflow.transition('validated') + def validate_(cls, applications): + pass + + @classmethod + @ModelView.button + @Workflow.transition('cancelled') + def cancel(cls, applications): + pass + + @classmethod + def count(cls, application): + return cls.search([ + ('user', '=', application.user.id), + ('state', '=', 'requested'), + ], count=True) + + @classmethod + def check(cls, key, application): + records = cls.search([ + ('key', '=', key), + ('application', '=', application), + ('state', '=', 'validated'), + ], limit=1) + if not records: + return + record, = records + return record + + @classmethod + def create(cls, vlist): + pool = Pool() + User = pool.get('res.user') + applications = super(UserApplication, cls).create(vlist) + User._get_preferences_cache.clear() + return applications + + @classmethod + def write(cls, *args): + pool = Pool() + User = pool.get('res.user') + super(UserApplication, cls).write(*args) + User._get_preferences_cache.clear() + + @classmethod + def delete(cls, applications): + pool = Pool() + User = pool.get('res.user') + super(UserApplication, cls).delete(applications) + User._get_preferences_cache.clear() + + class UserConfigStart(ModelView): 'User Config Init' __name__ = 'res.user.config.start' diff --git a/trytond/res/user.xml b/trytond/res/user.xml index b077296e77..92a14abe1b 100644 --- a/trytond/res/user.xml +++ b/trytond/res/user.xml @@ -106,5 +106,46 @@ this repository contains the full copyright notices and license terms. --> + + res.user.application + form + user_application_form + + + + res.user.application + tree + user_application_list + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/trytond/res/view/user_application_form.xml b/trytond/res/view/user_application_form.xml new file mode 100644 index 0000000000..b708a06ea8 --- /dev/null +++ b/trytond/res/view/user_application_form.xml @@ -0,0 +1,18 @@ + + +
+