Add user application

issue5782
review28641002
This commit is contained in:
Cédric Krier
2016-09-27 11:56:19 +02:00
parent e2afba1e9a
commit 965d010dc9
16 changed files with 427 additions and 43 deletions
+1
View File
@@ -1,3 +1,4 @@
* Add user application
* Add sequence_ordered
* Remove most country specific code in languages
* Add support for derivative translations
+1
View File
@@ -27,5 +27,6 @@ Introduction to all the key parts of trytond:
wizard
reports/index
rpc
user_application
modules/index
translation
+63
View File
@@ -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 <ref-pool>`.
- `with_transaction([readonly])`: which starts a :class:`Transaction` using
the :ref:`Pool <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`
`/<database_name>/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
+5 -19
View File
@@ -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('/<string:database_name>/', 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]
+20 -13
View File
@@ -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')
+101 -5
View File
@@ -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
+2 -2
View File
@@ -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]
+5
View File
@@ -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,
+49
View File
@@ -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('/<database_name>/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)
+100 -2
View File
@@ -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'
+41
View File
@@ -106,5 +106,46 @@ this repository contains the full copyright notices and license terms. -->
<field name="rule_group" ref="rule_group_user_warning"/>
</record>
<record model="ir.ui.view" id="user_application_view_form">
<field name="model">res.user.application</field>
<field name="type">form</field>
<field name="name">user_application_form</field>
</record>
<record model="ir.ui.view" id="user_application_view_list">
<field name="model">res.user.application</field>
<field name="type">tree</field>
<field name="name">user_application_list</field>
</record>
<record model="ir.rule.group" id="rule_group_user_application">
<field name="model"
search="[('model', '=', 'res.user.application')]"/>
<field name="global_p" eval="False"/>
<field name="default_p" eval="True"/>
</record>
<record model="ir.rule" id="rule_user_application1">
<field name="domain"
eval="[('user', '=', Eval('user', {}).get('id', -1))]"
pyson="1"/>
<field name="rule_group" ref="rule_group_user_application"/>
</record>
<record model="ir.rule.group" id="rule_group_user_application_admin">
<field name="model"
search="[('model', '=', 'res.user.application')]"/>
<field name="global_p" eval="False"/>
<field name="default_p" eval="False"/>
</record>
<record model="ir.rule" id="rule_user_application_admin1">
<field name="domain" eval="[]" pyson="1"/>
<field name="rule_group" ref="rule_group_user_application_admin"/>
</record>
<record model="ir.rule.group-res.group"
id="rule_user_application_admin_admin">
<field name="rule_group" ref="rule_group_user_application_admin"/>
<field name="group" ref="group_admin"/>
</record>
</data>
</tryton>
@@ -0,0 +1,18 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<form>
<label name="user"/>
<field name="user"/>
<newline/>
<label name="key"/>
<field name="key"/>
<label name="application"/>
<field name="application"/>
<label name="state"/>
<field name="state"/>
<group id="buttons" colspan="2" col="2">
<button name="cancel" string="Cancel" icon="tryton-cancel"/>
<button name="validate_" string="Validate" icon="tryton-ok"/>
</group>
</form>
@@ -0,0 +1,11 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tree>
<field name="user" expand="1"/>
<field name="key" expand="1"/>
<field name="application" expand="1"/>
<field name="state"/>
<button name="cancel" string="Cancel"/>
<button name="validate_" string="Validate"/>
</tree>
+3
View File
@@ -26,6 +26,9 @@ this repository contains the full copyright notices and license terms. -->
<field name="groups"/>
<field name="rule_groups"/>
</page>
<page name="applications" col="1">
<field name="applications"/>
</page>
<page string="Preferences" col="2" id="preferences">
<label name="language"/>
<field name="language" widget="selection"/>
@@ -21,6 +21,9 @@ this repository contains the full copyright notices and license terms. -->
<page string="Group Membership" col="1" id="membership">
<field name="groups"/>
</page>
<page name="applications" col="1">
<field name="applications"/>
</page>
<page string="Preferences" col="2" id="preferences">
<label name="language"/>
<field name="language" widget="selection"/>
+4 -2
View File
@@ -6,7 +6,7 @@ import traceback
from werkzeug.wrappers import Response
from werkzeug.routing import Map, Rule
from werkzeug.exceptions import abort
from werkzeug.exceptions import abort, HTTPException
import wrapt
@@ -45,6 +45,8 @@ class TrytondWSGI(object):
try:
endpoint, request.view_args = adapter.match()
return endpoint(request, **request.view_args)
except HTTPException, e:
return e
except Exception, e:
tb_s = ''.join(traceback.format_exception(*sys.exc_info()))
for path in sys.path:
@@ -65,7 +67,7 @@ class TrytondWSGI(object):
else:
request = Request(environ)
data = self.dispatch_request(request)
if not isinstance(data, Response):
if not isinstance(data, (Response, HTTPException)):
for cls in self.protocols:
for mimetype in request.accept_mimetypes:
if cls.content_type in mimetype: