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:
Michael Krotscheck 2014-10-14 12:13:21 -07:00
parent 0e657e6d4e
commit 13fb13b379
7 changed files with 312 additions and 9 deletions

View 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)

View File

@ -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]

View File

@ -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)

View File

@ -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')

View File

@ -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)

View 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)

View File

@ -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))