Add User Preferences endpoint.
In order to start saving our user preferences to the server (To support email preferences, for example), this patch adds a new endpoint under /users/ID/preferences to manage a user's specific settings. Supported types are float, int, boolean, and string. Also added some coverage for user browse. Search could not be covered since our build nodes only run mysql 5.5, and storyboard requires 5.6. Change-Id: I51e00d9dedcb392db906efd06745eab56c758261
This commit is contained in:
parent
0e657e6d4e
commit
13fb13b379
48
storyboard/api/v1/user_preferences.py
Normal file
48
storyboard/api/v1/user_preferences.py
Normal file
@ -0,0 +1,48 @@
|
||||
# Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
# implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from pecan import abort
|
||||
from pecan import request
|
||||
from pecan import rest
|
||||
from pecan.secure import secure
|
||||
import wsme.types as types
|
||||
import wsmeext.pecan as wsme_pecan
|
||||
|
||||
from storyboard.api.auth import authorization_checks as checks
|
||||
import storyboard.db.api.users as user_api
|
||||
|
||||
|
||||
class UserPreferencesController(rest.RestController):
|
||||
@secure(checks.authenticated)
|
||||
@wsme_pecan.wsexpose(types.DictType(unicode, unicode), int)
|
||||
def get_all(self, user_id):
|
||||
"""Return all preferences for the current user.
|
||||
"""
|
||||
if request.current_user_id != user_id:
|
||||
abort(403)
|
||||
return
|
||||
|
||||
return user_api.user_get_preferences(user_id)
|
||||
|
||||
@secure(checks.authenticated)
|
||||
@wsme_pecan.wsexpose(types.DictType(unicode, unicode), int,
|
||||
body=types.DictType(unicode, unicode))
|
||||
def post(self, user_id, body):
|
||||
"""Allow a user to update their preferences.
|
||||
"""
|
||||
if request.current_user_id != user_id:
|
||||
abort(403)
|
||||
|
||||
return user_api.user_update_preferences(user_id, body)
|
@ -4,7 +4,7 @@
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@ -24,9 +24,11 @@ import wsmeext.pecan as wsme_pecan
|
||||
|
||||
from storyboard.api.auth import authorization_checks as checks
|
||||
from storyboard.api.v1.search import search_engine
|
||||
from storyboard.api.v1.user_preferences import UserPreferencesController
|
||||
from storyboard.api.v1 import wmodels
|
||||
from storyboard.db.api import users as users_api
|
||||
|
||||
|
||||
CONF = cfg.CONF
|
||||
|
||||
SEARCH_ENGINE = search_engine.get_engine()
|
||||
@ -35,6 +37,9 @@ SEARCH_ENGINE = search_engine.get_engine()
|
||||
class UsersController(rest.RestController):
|
||||
"""Manages users."""
|
||||
|
||||
# Import the user preferences.
|
||||
preferences = UserPreferencesController()
|
||||
|
||||
_custom_actions = {"search": ["GET"]}
|
||||
|
||||
@secure(checks.guest)
|
||||
@ -142,7 +147,7 @@ class UsersController(rest.RestController):
|
||||
return wmodels.User.from_db_model(updated_user)
|
||||
|
||||
@secure(checks.guest)
|
||||
@wsme_pecan.wsexpose([wmodels.User], unicode, unicode, int, int)
|
||||
@wsme_pecan.wsexpose([wmodels.User], unicode, int, int)
|
||||
def search(self, q="", marker=None, limit=None):
|
||||
"""The search endpoint for users.
|
||||
|
||||
@ -156,7 +161,7 @@ class UsersController(rest.RestController):
|
||||
|
||||
@expose()
|
||||
def _route(self, args, request):
|
||||
if request.method == 'GET' and len(args) > 0:
|
||||
if request.method == 'GET' and len(args) == 1:
|
||||
# It's a request by a name or id
|
||||
something = args[0]
|
||||
|
||||
|
@ -4,7 +4,7 @@
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@ -64,3 +64,48 @@ def user_create(values):
|
||||
|
||||
def user_update(user_id, values):
|
||||
return api_base.entity_update(models.User, user_id, values)
|
||||
|
||||
|
||||
def user_get_preferences(user_id):
|
||||
preferences = api_base.entity_get_all(models.UserPreference,
|
||||
user_id=user_id)
|
||||
|
||||
pref_dict = dict()
|
||||
for pref in preferences:
|
||||
pref_dict[pref.key] = pref.cast_value
|
||||
|
||||
return pref_dict
|
||||
|
||||
|
||||
def user_update_preferences(user_id, preferences):
|
||||
for key in preferences:
|
||||
value = preferences[key]
|
||||
prefs = api_base.entity_get_all(models.UserPreference,
|
||||
user_id=user_id,
|
||||
key=key)
|
||||
|
||||
if prefs:
|
||||
pref = prefs[0]
|
||||
else:
|
||||
pref = None
|
||||
|
||||
# If the preference exists and it's null.
|
||||
if pref and value is None:
|
||||
api_base.entity_hard_delete(models.UserPreference, pref.id)
|
||||
continue
|
||||
|
||||
# If the preference exists and has a new value.
|
||||
if pref and value and pref.cast_value != value:
|
||||
pref.cast_value = value
|
||||
api_base.entity_update(models.UserPreference, pref.id, dict(pref))
|
||||
continue
|
||||
|
||||
# If the preference does not exist and a new value exists.
|
||||
if not pref and value:
|
||||
api_base.entity_create(models.UserPreference, {
|
||||
'user_id': user_id,
|
||||
'key': key,
|
||||
'cast_value': value
|
||||
})
|
||||
|
||||
return user_get_preferences(user_id)
|
||||
|
@ -0,0 +1,47 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
#
|
||||
|
||||
"""Adds a user_preferences table.
|
||||
|
||||
Revision ID: 028
|
||||
Revises: 027
|
||||
Create Date: 2014-09-14 01:00:00
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '028'
|
||||
down_revision = '027'
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
pref_type_enum = sa.Enum('string', 'int', 'bool', 'float')
|
||||
|
||||
|
||||
def upgrade(active_plugins=None, options=None):
|
||||
op.create_table(
|
||||
'user_preferences',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('key', sa.Unicode(100), nullable=False),
|
||||
sa.Column('type', pref_type_enum, nullable=False),
|
||||
sa.Column('value', sa.Unicode(255), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
|
||||
def downgrade(active_plugins=None, options=None):
|
||||
op.drop_table('user_preferences')
|
@ -5,7 +5,7 @@
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
@ -19,7 +19,6 @@ SQLAlchemy Models for storing storyboard
|
||||
|
||||
from oslo.config import cfg
|
||||
from oslo.db.sqlalchemy import models
|
||||
import six.moves.urllib.parse as urlparse
|
||||
from sqlalchemy import Boolean
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import DateTime
|
||||
@ -38,6 +37,8 @@ from sqlalchemy import Unicode
|
||||
from sqlalchemy import UnicodeText
|
||||
from sqlalchemy_fulltext import FullText
|
||||
|
||||
import six.moves.urllib.parse as urlparse
|
||||
|
||||
|
||||
CONF = cfg.CONF
|
||||
|
||||
@ -123,10 +124,52 @@ class User(FullText, ModelBuilder, Base):
|
||||
permissions = relationship("Permission", secondary="user_permissions")
|
||||
enable_login = Column(Boolean, default=True)
|
||||
|
||||
preferences = relationship("UserPreference")
|
||||
|
||||
_public_fields = ["id", "openid", "full_name", "username", "last_login",
|
||||
"enable_login"]
|
||||
|
||||
|
||||
class UserPreference(ModelBuilder, Base):
|
||||
__tablename__ = 'user_preferences'
|
||||
|
||||
_TASK_TYPES = ('string', 'int', 'bool', 'float')
|
||||
|
||||
user_id = Column(Integer, ForeignKey('users.id'))
|
||||
key = Column(Unicode(100))
|
||||
value = Column(Unicode(255))
|
||||
type = Column(Enum(*_TASK_TYPES), default='string')
|
||||
|
||||
@property
|
||||
def cast_value(self):
|
||||
try:
|
||||
cast_func = {
|
||||
'float': lambda x: float(x),
|
||||
'int': lambda x: int(x),
|
||||
'bool': lambda x: bool(x),
|
||||
'string': lambda x: str(x)
|
||||
}[self.type]
|
||||
|
||||
return cast_func(self.value)
|
||||
except ValueError:
|
||||
return self.value
|
||||
|
||||
@cast_value.setter
|
||||
def cast_value(self, value):
|
||||
if isinstance(value, bool):
|
||||
self.type = 'bool'
|
||||
elif isinstance(value, int):
|
||||
self.type = 'int'
|
||||
elif isinstance(value, float):
|
||||
self.type = 'float'
|
||||
else:
|
||||
self.type = 'string'
|
||||
|
||||
self.value = str(value)
|
||||
|
||||
_public_fields = ["id", "key", "value", "type"]
|
||||
|
||||
|
||||
class Team(ModelBuilder, Base):
|
||||
__table_args__ = (
|
||||
schema.UniqueConstraint('name', name='uniq_team_name'),
|
||||
@ -135,6 +178,7 @@ class Team(ModelBuilder, Base):
|
||||
users = relationship("User", secondary="team_membership")
|
||||
permissions = relationship("Permission", secondary="team_permissions")
|
||||
|
||||
|
||||
project_group_mapping = Table(
|
||||
'project_group_mapping', Base.metadata,
|
||||
Column('project_id', Integer, ForeignKey('projects.id')),
|
||||
@ -240,14 +284,12 @@ class StoryTag(ModelBuilder, Base):
|
||||
# Authorization models
|
||||
|
||||
class AuthorizationCode(ModelBuilder, Base):
|
||||
|
||||
code = Column(Unicode(100), nullable=False)
|
||||
state = Column(Unicode(100), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
|
||||
|
||||
class AccessToken(ModelBuilder, Base):
|
||||
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
access_token = Column(Unicode(100), nullable=False)
|
||||
expires_in = Column(Integer, nullable=False)
|
||||
@ -255,7 +297,6 @@ class AccessToken(ModelBuilder, Base):
|
||||
|
||||
|
||||
class RefreshToken(ModelBuilder, Base):
|
||||
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
refresh_token = Column(Unicode(100), nullable=False)
|
||||
expires_in = Column(Integer, nullable=False)
|
||||
|
106
storyboard/tests/api/test_user_preferences.py
Normal file
106
storyboard/tests/api/test_user_preferences.py
Normal file
@ -0,0 +1,106 @@
|
||||
# Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from storyboard.tests import base
|
||||
|
||||
|
||||
class TestUserPreferencesAsUser(base.FunctionalTest):
|
||||
def setUp(self):
|
||||
super(TestUserPreferencesAsUser, self).setUp()
|
||||
self.resource = '/users/2/preferences'
|
||||
self.default_headers['Authorization'] = 'Bearer valid_user_token'
|
||||
|
||||
def test_simple_get(self):
|
||||
"""This test asserts that the preferences controller comes back with
|
||||
'something'. Individual plugins should make sure that their own
|
||||
preferences work properly.
|
||||
"""
|
||||
response = self.get_json(self.resource)
|
||||
self.assertEqual({}, response)
|
||||
|
||||
def test_simple_post(self):
|
||||
"""This test asserts that the client can add override entire key/value
|
||||
pairs on the preferences hash.
|
||||
"""
|
||||
preferences = {
|
||||
'intPref': 1,
|
||||
'boolPref': True,
|
||||
'floatPref': 1.23,
|
||||
'stringPref': 'oh hai'
|
||||
}
|
||||
|
||||
response = self.post_json(self.resource, preferences)
|
||||
self.assertEqual(response.json['intPref'],
|
||||
preferences['intPref'])
|
||||
self.assertEqual(response.json['boolPref'],
|
||||
preferences['boolPref'])
|
||||
self.assertEqual(response.json['floatPref'],
|
||||
preferences['floatPref'])
|
||||
self.assertEqual(response.json['stringPref'],
|
||||
preferences['stringPref'])
|
||||
|
||||
def test_remove_preference(self):
|
||||
"""Assert that a user may remove individual preferences.
|
||||
"""
|
||||
|
||||
# Pre save some prefs.
|
||||
self.post_json(self.resource, {
|
||||
'foo': 'bar',
|
||||
'intPref': 1,
|
||||
'boolPref': True,
|
||||
'floatPref': 1.23,
|
||||
'stringPref': 'oh hai'
|
||||
})
|
||||
|
||||
response = self.get_json(self.resource)
|
||||
self.assertTrue(response['boolPref'])
|
||||
self.assertEqual(1, response['intPref'])
|
||||
self.assertTrue('oh hai', response['stringPref'])
|
||||
self.assertTrue('bar', response['foo'])
|
||||
|
||||
self.post_json(self.resource, {
|
||||
'foo': 'fizz',
|
||||
'intPref': None,
|
||||
'boolPref': None,
|
||||
'stringPref': None,
|
||||
'floatPref': None
|
||||
})
|
||||
|
||||
response = self.get_json(self.resource)
|
||||
self.assertEqual('fizz', response['foo'])
|
||||
self.assertFalse(hasattr(response, 'intPref'))
|
||||
self.assertFalse(hasattr(response, 'stringPref'))
|
||||
self.assertFalse(hasattr(response, 'boolPref'))
|
||||
|
||||
def test_get_unauthorized(self):
|
||||
"""This test asserts that the preferences controller comes back with
|
||||
'something'. Individual plugins should make sure that their own
|
||||
preferences work properly.
|
||||
"""
|
||||
response = self.get_json('/users/1/preferences', expect_errors=True)
|
||||
self.assertEqual(403, response.status_code)
|
||||
|
||||
|
||||
class TestUserPreferencesAsNoUser(base.FunctionalTest):
|
||||
def setUp(self):
|
||||
super(TestUserPreferencesAsNoUser, self).setUp()
|
||||
self.resource = '/users/2/preferences'
|
||||
|
||||
def test_simple_get(self):
|
||||
"""This test asserts that the preferences controller comes back with
|
||||
'something'. Individual plugins should make sure that their own
|
||||
preferences work properly.
|
||||
"""
|
||||
response = self.get_json(self.resource, expect_errors=True)
|
||||
self.assertEqual(401, response.status_code)
|
@ -54,3 +54,14 @@ class TestUsersAsUser(base.FunctionalTest):
|
||||
self.put_json(path, jenkins)
|
||||
user = user_api.user_get(user_id=2)
|
||||
self.assertTrue(user.enable_login)
|
||||
|
||||
|
||||
class TestSearchUsers(base.FunctionalTest):
|
||||
def setUp(self):
|
||||
super(TestSearchUsers, self).setUp()
|
||||
self.resource = '/users'
|
||||
self.default_headers['Authorization'] = 'Bearer valid_user_token'
|
||||
|
||||
def testBrowse(self):
|
||||
result = self.get_json(self.resource + '?username=regularuser')
|
||||
self.assertEqual(1, len(result))
|
||||
|
Loading…
x
Reference in New Issue
Block a user