From 9888cf2bc1bab2357d1e6bde55e6059167d84f92 Mon Sep 17 00:00:00 2001 From: Andrey Pavlov Date: Thu, 12 May 2016 15:55:32 +0300 Subject: [PATCH] Add products REST API This patchset add base methods for products REST API as described in spec. Change-Id: I58e07c940886411705c143077b6871522baed9cb Addresses-Spec: https://review.openstack.org/#/c/292526/ --- refstack/api/controllers/products.py | 184 ++++++++++++++++++ refstack/api/controllers/v1.py | 2 + refstack/api/controllers/vendors.py | 2 +- refstack/api/utils.py | 2 + refstack/api/validators.py | 39 +++- refstack/db/api.py | 15 ++ ..._product_table_make_product_id_nullable.py | 21 ++ refstack/db/sqlalchemy/api.py | 55 +++++- refstack/db/sqlalchemy/models.py | 7 +- refstack/tests/api/test_products.py | 181 +++++++++++++++++ refstack/tests/unit/test_db.py | 6 +- 11 files changed, 495 insertions(+), 19 deletions(-) create mode 100644 refstack/api/controllers/products.py create mode 100644 refstack/db/migrations/alembic/versions/7093ca478d35_product_table_make_product_id_nullable.py create mode 100644 refstack/tests/api/test_products.py diff --git a/refstack/api/controllers/products.py b/refstack/api/controllers/products.py new file mode 100644 index 00000000..fd5d3891 --- /dev/null +++ b/refstack/api/controllers/products.py @@ -0,0 +1,184 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# 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. + +"""Product controller.""" + +import json +import uuid + +from oslo_config import cfg +from oslo_log import log +import pecan +from pecan.secure import secure +import six + +from refstack.api import constants as const +from refstack.api.controllers import validation +from refstack.api import utils as api_utils +from refstack.api import validators +from refstack import db + +LOG = log.getLogger(__name__) + +CONF = cfg.CONF + + +class ProductsController(validation.BaseRestControllerWithValidation): + """/v1/products handler.""" + + __validator__ = validators.ProductValidator + + _custom_actions = { + "action": ["POST"], + } + + @pecan.expose('json') + def get(self): + """Get information of all products.""" + allowed_keys = ['id', 'name', 'description', 'product_id', 'type', + 'product_type', 'public', 'organization_id'] + user = api_utils.get_user_id() + is_admin = user in db.get_foundation_users() + try: + if is_admin: + products = db.get_products(allowed_keys=allowed_keys) + for s in products: + s['can_manage'] = True + else: + result = dict() + products = db.get_public_products(allowed_keys=allowed_keys) + for s in products: + _id = s['id'] + result[_id] = s + result[_id]['can_manage'] = False + + products = db.get_products_by_user(user, + allowed_keys=allowed_keys) + for s in products: + _id = s['id'] + if _id not in result: + result[_id] = s + result[_id]['can_manage'] = True + products = result.values() + except Exception as ex: + LOG.exception('An error occurred during ' + 'operation with database: %s' % ex) + pecan.abort(400) + + products.sort(key=lambda x: x['name']) + return {'products': products} + + @pecan.expose('json') + def get_one(self, id): + """Get information about product.""" + product = db.get_product(id) + vendor_id = product['organization_id'] + is_admin = (api_utils.check_user_is_foundation_admin() or + api_utils.check_user_is_vendor_admin(vendor_id)) + if not is_admin and not product['public']: + pecan.abort(403, 'Forbidden.') + + if not is_admin: + allowed_keys = ['id', 'name', 'description', 'product_id', 'type', + 'product_type', 'public', 'organization_id'] + for key in product.keys(): + if key not in allowed_keys: + product.pop(key) + + product['can_manage'] = is_admin + return product + + @secure(api_utils.is_authenticated) + @pecan.expose('json') + def post(self): + """'secure' decorator doesn't work at store_item. it must be here.""" + return super(ProductsController, self).post() + + @pecan.expose('json') + def store_item(self, product): + """Handler for storing item. Should return new item id.""" + creator = api_utils.get_user_id() + product['type'] = (const.SOFTWARE + if product['product_type'] == const.DISTRO + else const.CLOUD) + if product['type'] == const.SOFTWARE: + product['product_id'] = six.text_type(uuid.uuid4()) + vendor_id = product.pop('organization_id', None) + if not vendor_id: + # find or create default vendor for new product + # TODO(andrey-mp): maybe just fill with info here and create + # at DB layer in one transaction + default_vendor_name = 'vendor_' + creator + vendors = db.get_organizations_by_user(creator) + for v in vendors: + if v['name'] == default_vendor_name: + vendor_id = v['id'] + break + else: + vendor = {'name': default_vendor_name} + vendor = db.add_organization(vendor, creator) + vendor_id = vendor['id'] + product['organization_id'] = vendor_id + product = db.add_product(product, creator) + return {'id': product['id']} + + @secure(api_utils.is_authenticated) + @pecan.expose('json', method='PUT') + def put(self, id, **kw): + """Handler for update item. Should return full info with updates.""" + product = db.get_product(id) + vendor_id = product['organization_id'] + vendor = db.get_organization(vendor_id) + is_admin = (api_utils.check_user_is_foundation_admin() + or api_utils.check_user_is_vendor_admin(vendor_id)) + if not is_admin: + pecan.abort(403, 'Forbidden.') + + product_info = {'id': id} + if 'name' in kw: + product_info['name'] = kw['name'] + if 'description' in kw: + product_info['description'] = kw['description'] + if 'product_id' in kw: + product_info['product_id'] = kw['product_id'] + if 'public' in kw: + # user can mark product as public only if + # his/her vendor is public(official) + public = api_utils.str_to_bool(kw['public']) + if (vendor['type'] not in (const.OFFICIAL_VENDOR, const.FOUNDATION) + and public): + pecan.abort(403, 'Forbidden.') + product_info['public'] = public + if 'properties' in kw: + product_info['properties'] = json.dumps(kw['properties']) + db.update_product(product_info) + + pecan.response.status = 200 + product = db.get_product(id) + product['can_manage'] = True + return product + + @secure(api_utils.is_authenticated) + @pecan.expose('json') + def delete(self, id): + """Delete product.""" + product = db.get_product(id) + vendor_id = product['organization_id'] + if (not api_utils.check_user_is_foundation_admin() and + not api_utils.check_user_is_vendor_admin(vendor_id)): + pecan.abort(403, 'Forbidden.') + + db.delete_product(id) + pecan.response.status = 204 diff --git a/refstack/api/controllers/v1.py b/refstack/api/controllers/v1.py index 1f78e2fa..d820bb9c 100644 --- a/refstack/api/controllers/v1.py +++ b/refstack/api/controllers/v1.py @@ -17,6 +17,7 @@ from refstack.api.controllers import auth from refstack.api.controllers import guidelines +from refstack.api.controllers import products from refstack.api.controllers import results from refstack.api.controllers import user from refstack.api.controllers import vendors @@ -29,4 +30,5 @@ class V1Controller(object): guidelines = guidelines.GuidelinesController() auth = auth.AuthController() profile = user.ProfileController() + products = products.ProductsController() vendors = vendors.VendorsController() diff --git a/refstack/api/controllers/vendors.py b/refstack/api/controllers/vendors.py index b284d629..9f7e08ed 100644 --- a/refstack/api/controllers/vendors.py +++ b/refstack/api/controllers/vendors.py @@ -128,7 +128,7 @@ class VendorsController(validation.BaseRestControllerWithValidation): vendor_info['properties'] = json.dumps(kw['properties']) db.update_organization(vendor_info) - pecan.response.status = 201 + pecan.response.status = 200 vendor = db.get_organization(vendor_id) vendor['can_manage'] = True return vendor diff --git a/refstack/api/utils.py b/refstack/api/utils.py index b45f3e7e..a0e92850 100644 --- a/refstack/api/utils.py +++ b/refstack/api/utils.py @@ -92,6 +92,8 @@ def parse_input_params(expected_input_params): def str_to_bool(param): """Check if a string value should be evaluated as True or False.""" + if isinstance(param, bool): + return param return param.lower() in ("true", "yes", "1") diff --git a/refstack/api/validators.py b/refstack/api/validators.py index 518a149e..8d1cc8c2 100644 --- a/refstack/api/validators.py +++ b/refstack/api/validators.py @@ -16,6 +16,7 @@ """Validators module.""" import binascii +import six import uuid import json @@ -72,6 +73,17 @@ class BaseValidator(object): raise api_exc.ValidationError( 'Request doesn''t correspond to schema', e) + def check_emptyness(self, body, keys): + """Check that all values are not empty.""" + for key in keys: + value = body[key] + if isinstance(value, six.string_types): + value = value.strip() + if not value: + raise api_exc.ValidationError(key + ' should not be empty') + elif value is None: + raise api_exc.ValidationError(key + ' must be present') + class TestResultValidator(BaseValidator): """Validator for incoming test results.""" @@ -195,6 +207,27 @@ class VendorValidator(BaseValidator): super(VendorValidator, self).validate(request) body = json.loads(request.body) - name = body['name'].strip() - if not name: - raise api_exc.ValidationError('Name should not be empty.') + self.check_emptyness(body, ['name']) + + +class ProductValidator(BaseValidator): + """Validate uploaded product data.""" + + schema = { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'description': {'type': 'string'}, + 'product_type': {'type': 'integer'}, + 'organization_id': {'type': 'string', 'format': 'uuid_hex'}, + }, + 'required': ['name', 'product_type'], + 'additionalProperties': False + } + + def validate(self, request): + """Validate uploaded test results.""" + super(ProductValidator, self).validate(request) + body = json.loads(request.body) + + self.check_emptyness(body, ['name', 'product_type']) diff --git a/refstack/db/api.py b/refstack/db/api.py index 70ba5788..27238066 100644 --- a/refstack/db/api.py +++ b/refstack/db/api.py @@ -236,3 +236,18 @@ def get_organizations_by_user(user_openid, allowed_keys=None): """Get organizations for specified user.""" return IMPL.get_organizations_by_user(user_openid, allowed_keys=allowed_keys) + + +def get_public_products(allowed_keys=None): + """Get all public products.""" + return IMPL.get_public_products(allowed_keys=allowed_keys) + + +def get_products(allowed_keys=None): + """Get all products.""" + return IMPL.get_products(allowed_keys=allowed_keys) + + +def get_products_by_user(user_openid, allowed_keys=None): + """Get all products that user can manage.""" + return IMPL.get_products_by_user(user_openid, allowed_keys=allowed_keys) diff --git a/refstack/db/migrations/alembic/versions/7093ca478d35_product_table_make_product_id_nullable.py b/refstack/db/migrations/alembic/versions/7093ca478d35_product_table_make_product_id_nullable.py new file mode 100644 index 00000000..6f846750 --- /dev/null +++ b/refstack/db/migrations/alembic/versions/7093ca478d35_product_table_make_product_id_nullable.py @@ -0,0 +1,21 @@ +"""Make product_id nullable in product table. + +Revision ID: 7093ca478d35 +Revises: 7092392cbb8e +Create Date: 2016-05-12 13:10:00 + +""" + +# revision identifiers, used by Alembic. +revision = '7093ca478d35' +down_revision = '7092392cbb8e' +MYSQL_CHARSET = 'utf8' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + """Upgrade DB.""" + op.alter_column('product', 'product_id', nullable=True, + type_=sa.String(36)) diff --git a/refstack/db/sqlalchemy/api.py b/refstack/db/sqlalchemy/api.py index 03b54235..ddadb0bf 100644 --- a/refstack/db/sqlalchemy/api.py +++ b/refstack/db/sqlalchemy/api.py @@ -437,7 +437,7 @@ def add_product(product_info, creator): product = models.Product() product.type = product_info['type'] product.product_type = product_info['product_type'] - product.product_id = product_info['product_id'] + product.product_id = product_info.get('product_id') product.name = product_info['name'] product.description = product_info.get('description') product.organization_id = product_info['organization_id'] @@ -452,17 +452,17 @@ def add_product(product_info, creator): def update_product(product_info): - """Update product by product_id.""" + """Update product by id.""" session = get_session() - _id = product_info.get('product_id') - product = session.query(models.Product).filter_by(product_id=_id).first() + _id = product_info.get('id') + product = session.query(models.Product).filter_by(id=_id).first() if product is None: - raise NotFound('Product with product_id %s not found' % _id) + raise NotFound('Product with id %s not found' % _id) - product.name = product_info.get('name', product.name) - product.description = product_info.get('description', product.description) - product.public = product_info.get('public', product.public) - product.properties = product_info.get('properties', product.properties) + keys = ['name', 'description', 'product_id', 'public', 'properties'] + for key in keys: + if key in product_info: + setattr(product, key, product_info[key]) with session.begin(): product.save(session=session) @@ -551,3 +551,40 @@ def get_organizations_by_user(user_openid, allowed_keys=None): .order_by(models.Organization.created_at.desc()).all()) items = [item[0] for item in items] return _to_dict(items, allowed_keys=allowed_keys) + + +def get_public_products(allowed_keys=None): + """Get public products.""" + session = get_session() + items = ( + session.query(models.Product) + .filter_by(public=True) + .order_by(models.Product.created_at.desc()).all()) + return _to_dict(items, allowed_keys=allowed_keys) + + +def get_products(allowed_keys=None): + """Get all products.""" + session = get_session() + items = ( + session.query(models.Product) + .order_by(models.Product.created_at.desc()).all()) + return _to_dict(items, allowed_keys=allowed_keys) + + +def get_products_by_user(user_openid, allowed_keys=None): + """Get all products that user can manage.""" + session = get_session() + items = ( + session.query(models.Product, models.Organization, models.Group, + models.UserToGroup) + .join(models.Organization, + models.Organization.id == models.Product.organization_id) + .join(models.Group, + models.Group.id == models.Organization.group_id) + .join(models.UserToGroup, + models.Group.id == models.UserToGroup.group_id) + .filter(models.UserToGroup.user_openid == user_openid) + .order_by(models.Organization.created_at.desc()).all()) + items = [item[0] for item in items] + return _to_dict(items, allowed_keys=allowed_keys) diff --git a/refstack/db/sqlalchemy/models.py b/refstack/db/sqlalchemy/models.py index 183ee9fc..566f03cb 100644 --- a/refstack/db/sqlalchemy/models.py +++ b/refstack/db/sqlalchemy/models.py @@ -225,7 +225,7 @@ class Product(BASE, RefStackBase): # pragma: no cover id = sa.Column(sa.String(36), primary_key=True, default=lambda: six.text_type(uuid.uuid4())) - product_id = sa.Column(sa.String(36), nullable=False) + product_id = sa.Column(sa.String(36), nullable=True) name = sa.Column(sa.String(80), nullable=False) description = sa.Column(sa.Text()) organization_id = sa.Column(sa.String(36), @@ -241,5 +241,6 @@ class Product(BASE, RefStackBase): # pragma: no cover @property def default_allowed_keys(self): """Default keys.""" - return ('id', 'product_id', 'name', 'description', 'organization_id', - 'created_by_user', 'properties', 'type', 'product_type') + return ('id', 'name', 'description', 'product_id', 'product_type', + 'public', 'properties', 'created_at', 'updated_at', + 'organization_id', 'created_by_user', 'type') diff --git a/refstack/tests/api/test_products.py b/refstack/tests/api/test_products.py new file mode 100644 index 00000000..c20a04c8 --- /dev/null +++ b/refstack/tests/api/test_products.py @@ -0,0 +1,181 @@ +# All Rights Reserved. +# +# 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. + +import json +import uuid + +import mock +from oslo_config import fixture as config_fixture +import webtest.app + +from refstack.api import constants as api_const +from refstack import db +from refstack.tests import api + +FAKE_PRODUCT = { + 'name': 'product name', + 'description': 'product description', + 'product_type': api_const.CLOUD, +} + + +class TestProductsEndpoint(api.FunctionalTest): + """Test case for the 'products' API endpoint.""" + + URL = '/v1/products/' + + def setUp(self): + super(TestProductsEndpoint, self).setUp() + self.config_fixture = config_fixture.Config() + self.CONF = self.useFixture(self.config_fixture).conf + + self.user_info = { + 'openid': 'test-open-id', + 'email': 'foo@bar.com', + 'fullname': 'Foo Bar' + } + db.user_save(self.user_info) + + @mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id') + def test_post(self, mock_get_user): + """Test products endpoint with post request.""" + product = json.dumps(FAKE_PRODUCT) + actual_response = self.post_json(self.URL, params=product) + self.assertIn('id', actual_response) + try: + uuid.UUID(actual_response.get('id'), version=4) + except ValueError: + self.fail("actual_response doesn't contain new item id") + + @mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id') + def test_post_with_empty_object(self, mock_get_user): + """Test products endpoint with empty product request.""" + results = json.dumps(dict()) + self.assertRaises(webtest.app.AppError, + self.post_json, + self.URL, + params=results) + + @mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id') + def test_post_with_invalid_schema(self, mock_get_user): + """Test post request with invalid schema.""" + products = json.dumps({ + 'foo': 'bar', + }) + self.assertRaises(webtest.app.AppError, + self.post_json, + self.URL, + params=products) + + @mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id') + def test_vendor_was_created(self, mock_get_user): + """Test get_one request.""" + product = json.dumps(FAKE_PRODUCT) + post_response = self.post_json(self.URL, params=product) + + get_response = self.get_json(self.URL + post_response.get('id')) + vendor_id = get_response.get('organization_id') + self.assertIsNotNone(vendor_id) + + # check vendor is present + get_response = self.get_json('/v1/vendors/' + vendor_id) + + @mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id') + def test_using_default_vendor(self, mock_get_user): + """Test get_one request.""" + product = json.dumps(FAKE_PRODUCT) + post_response = self.post_json(self.URL, params=product) + + get_response = self.get_json(self.URL + post_response.get('id')) + vendor_id = get_response.get('organization_id') + self.assertIsNotNone(vendor_id) + + # check vendor is present + get_response = self.get_json('/v1/vendors/' + vendor_id) + + # create one more product + product = json.dumps(FAKE_PRODUCT) + post_response = self.post_json(self.URL, params=product) + + @mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id') + def test_get_one(self, mock_get_user): + """Test get_one request.""" + product = json.dumps(FAKE_PRODUCT) + post_response = self.post_json(self.URL, params=product) + + get_response = self.get_json(self.URL + post_response.get('id')) + # some of these fields are only exposed to the owner/foundation. + self.assertIn('created_by_user', get_response) + self.assertIn('properties', get_response) + self.assertIn('created_at', get_response) + self.assertIn('updated_at', get_response) + self.assertEqual(FAKE_PRODUCT['name'], + get_response['name']) + self.assertEqual(FAKE_PRODUCT['description'], + get_response['description']) + self.assertEqual(api_const.PUBLIC_CLOUD, + get_response['type']) + self.assertEqual(api_const.CLOUD, + get_response['product_type']) + + # reset auth and check return result for anonymous + mock_get_user.return_value = None + self.assertRaises(webtest.app.AppError, + self.get_json, + self.URL + post_response.get('id')) + + @mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id') + def test_delete(self, mock_get_user): + """Test delete request.""" + product = json.dumps(FAKE_PRODUCT) + post_response = self.post_json(self.URL, params=product) + self.delete(self.URL + post_response.get('id')) + + @mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id') + def test_update(self, mock_get_user): + """Test put(update) request.""" + product = json.dumps(FAKE_PRODUCT) + post_response = self.post_json(self.URL, params=product) + id = post_response.get('id') + + # check update of properties + props = {'properties': {'fake01': 'value01'}} + post_response = self.put_json(self.URL + id, + params=json.dumps(props)) + get_response = self.get_json(self.URL + id) + self.assertEqual(FAKE_PRODUCT['name'], + get_response['name']) + self.assertEqual(FAKE_PRODUCT['description'], + get_response['description']) + self.assertEqual(props['properties'], + json.loads(get_response['properties'])) + + # check second update of properties + props = {'properties': {'fake02': 'value03'}} + post_response = self.put_json(self.URL + id, + params=json.dumps(props)) + get_response = self.get_json(self.URL + id) + self.assertEqual(props['properties'], + json.loads(get_response['properties'])) + + def test_get_one_invalid_url(self): + """Test get request with invalid url.""" + self.assertRaises(webtest.app.AppError, + self.get_json, + self.URL + 'fake_id') + + def test_get_with_empty_database(self): + """Test get(list) request with no items in DB.""" + results = self.get_json(self.URL) + self.assertEqual([], results['products']) diff --git a/refstack/tests/unit/test_db.py b/refstack/tests/unit/test_db.py index 6a8b5338..71c775bf 100644 --- a/refstack/tests/unit/test_db.py +++ b/refstack/tests/unit/test_db.py @@ -707,15 +707,15 @@ class DBBackendTestCase(base.BaseTestCase): query = session.query.return_value filtered = query.filter_by.return_value product = models.Product() - product.product_id = '123' + product.id = '123' filtered.first.return_value = product product_info = {'product_id': '098', 'name': 'a', 'description': 'b', 'creator_openid': 'abc', 'organization_id': '1', - 'type': 0, 'product_type': 0} + 'type': 0, 'product_type': 0, 'id': '123'} api.update_product(product_info) - self.assertEqual('123', product.product_id) + self.assertEqual('098', product.product_id) self.assertIsNone(product.created_by_user) self.assertIsNone(product.organization_id) self.assertIsNone(product.type)