diff --git a/storyboard/api/v1/user_preferences.py b/storyboard/api/v1/user_preferences.py new file mode 100644 index 00000000..ad2b0a43 --- /dev/null +++ b/storyboard/api/v1/user_preferences.py @@ -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) diff --git a/storyboard/api/v1/users.py b/storyboard/api/v1/users.py index cdafafec..14365539 100644 --- a/storyboard/api/v1/users.py +++ b/storyboard/api/v1/users.py @@ -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] diff --git a/storyboard/db/api/users.py b/storyboard/db/api/users.py index 09a7df35..d28598ed 100644 --- a/storyboard/db/api/users.py +++ b/storyboard/db/api/users.py @@ -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) diff --git a/storyboard/db/migration/alembic_migrations/versions/028_user_preferences.py b/storyboard/db/migration/alembic_migrations/versions/028_user_preferences.py new file mode 100644 index 00000000..c7ffb37f --- /dev/null +++ b/storyboard/db/migration/alembic_migrations/versions/028_user_preferences.py @@ -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') diff --git a/storyboard/db/models.py b/storyboard/db/models.py index d9f1675f..b0dbe070 100644 --- a/storyboard/db/models.py +++ b/storyboard/db/models.py @@ -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) diff --git a/storyboard/tests/api/test_user_preferences.py b/storyboard/tests/api/test_user_preferences.py new file mode 100644 index 00000000..6fc4c249 --- /dev/null +++ b/storyboard/tests/api/test_user_preferences.py @@ -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) diff --git a/storyboard/tests/api/test_users.py b/storyboard/tests/api/test_users.py index ec8f85c6..29c9450f 100644 --- a/storyboard/tests/api/test_users.py +++ b/storyboard/tests/api/test_users.py @@ -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))