Add API for product versions

This patch allows product versions to be created and managed.

Change-Id: I817db27a65a1575701aeee7b112ae5678c98b0aa
Addresses-Spec: https://review.openstack.org/#/c/353903/
This commit is contained in:
Paul Van Eck 2016-08-28 23:01:43 -07:00
parent 942f418f94
commit 540bff5b13
9 changed files with 396 additions and 8 deletions

View File

@ -35,6 +35,88 @@ LOG = log.getLogger(__name__)
CONF = cfg.CONF
class VersionsController(validation.BaseRestControllerWithValidation):
"""/v1/products/<product_id>/versions handler."""
__validator__ = validators.ProductVersionValidator
@pecan.expose('json')
def get(self, id):
"""Get all versions for a 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 product['public'] and not is_admin:
pecan.abort(403, 'Forbidden.')
return db.get_product_versions(id)
@pecan.expose('json')
def get_one(self, id, version_id):
"""Get specific version information."""
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 product['public'] and not is_admin:
pecan.abort(403, 'Forbidden.')
return db.get_product_version(version_id)
@secure(api_utils.is_authenticated)
@pecan.expose('json')
def post(self, id):
"""'secure' decorator doesn't work at store_item. it must be here."""
self.product_id = id
return super(VersionsController, self).post()
@pecan.expose('json')
def store_item(self, version_info):
"""Add a new version for the product."""
if (not api_utils.check_user_is_product_admin(self.product_id) and
not api_utils.check_user_is_foundation_admin()):
pecan.abort(403, 'Forbidden.')
creator = api_utils.get_user_id()
pecan.response.status = 201
return db.add_product_version(self.product_id, version_info['version'],
creator, version_info.get('cpid'))
@secure(api_utils.is_authenticated)
@pecan.expose('json', method='PUT')
def put(self, id, version_id, **kw):
"""Update details for a specific version.
Endpoint: /v1/products/<product_id>/versions/<version_id>
"""
if (not api_utils.check_user_is_product_admin(id) and
not api_utils.check_user_is_foundation_admin()):
pecan.abort(403, 'Forbidden.')
version_info = {'id': version_id}
if 'cpid' in kw:
version_info['cpid'] = kw['cpid']
version = db.update_product_version(version_info)
pecan.response.status = 200
return version
@secure(api_utils.is_authenticated)
@pecan.expose('json')
def delete(self, id, version_id):
"""Delete a product version.
Endpoint: /v1/products/<product_id>/versions/<version_id>
"""
if (not api_utils.check_user_is_product_admin(id) and
not api_utils.check_user_is_foundation_admin()):
pecan.abort(403, 'Forbidden.')
db.delete_product_version(version_id)
pecan.response.status = 204
class ProductsController(validation.BaseRestControllerWithValidation):
"""/v1/products handler."""
@ -44,6 +126,8 @@ class ProductsController(validation.BaseRestControllerWithValidation):
"action": ["POST"],
}
versions = VersionsController()
@pecan.expose('json')
def get(self):
"""Get information of all products."""
@ -175,10 +259,8 @@ class ProductsController(validation.BaseRestControllerWithValidation):
@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)):
not api_utils.check_user_is_product_admin(id)):
pecan.abort(403, 'Forbidden.')
db.delete_product(id)

View File

@ -330,3 +330,10 @@ def check_user_is_vendor_admin(vendor_id):
user = get_user_id()
org_users = db.get_organization_users(vendor_id)
return user in org_users
def check_user_is_product_admin(product_id):
"""Check if the current user is in the vendor group for a product."""
product = db.get_product(product_id)
vendor_id = product['organization_id']
return check_user_is_vendor_admin(vendor_id)

View File

@ -220,6 +220,7 @@ class ProductValidator(BaseValidator):
'description': {'type': 'string'},
'product_type': {'type': 'integer'},
'organization_id': {'type': 'string', 'format': 'uuid_hex'},
'version': {'type': 'string'}
},
'required': ['name', 'product_type'],
'additionalProperties': False
@ -231,3 +232,21 @@ class ProductValidator(BaseValidator):
body = json.loads(request.body)
self.check_emptyness(body, ['name', 'product_type'])
class ProductVersionValidator(BaseValidator):
"""Validate adding product versions."""
schema = {
'type': 'object',
'properties': {
'version': {'type': 'string'},
'cpid': {'type': 'string'}
},
'required': ['version'],
'additionalProperties': False
}
def validate(self, request):
"""Validate product version data."""
super(ProductVersionValidator, self).validate(request)

View File

@ -251,3 +251,37 @@ def get_products(allowed_keys=None):
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)
def get_product_by_version(product_version_id, allowed_keys=None):
"""Get product info from a product version ID."""
return IMPL.get_product_by_version(product_version_id,
allowed_keys=allowed_keys)
def get_product_version(product_version_id, allowed_keys=None):
"""Get details of a specific version given the id."""
return IMPL.get_product_version(product_version_id,
allowed_keys=allowed_keys)
def get_product_versions(product_id, allowed_keys=None):
"""Get all versions for a product."""
return IMPL.get_product_versions(product_id, allowed_keys=allowed_keys)
def add_product_version(product_id, version, creator, cpid=None,
allowed_keys=None):
"""Add a new product version."""
return IMPL.add_product_version(product_id, version, creator, cpid,
allowed_keys=allowed_keys)
def update_product_version(product_version_info):
"""Update product version from product_info_version dictionary."""
return IMPL.update_product_version(product_version_info)
def delete_product_version(product_version_id):
"""Delete a product version."""
return IMPL.delete_product_version(product_version_id)

View File

@ -0,0 +1,46 @@
"""Add Product version table.
Also product_ref_id is removed from the product table.
Revision ID: 35bf54e2c13c
Revises: 709452f38a5c
Create Date: 2016-07-30 17:59:57.912306
"""
# revision identifiers, used by Alembic.
revision = '35bf54e2c13c'
down_revision = '709452f38a5c'
MYSQL_CHARSET = 'utf8'
from alembic import op
import sqlalchemy as sa
def upgrade():
"""Upgrade DB."""
op.create_table(
'product_version',
sa.Column('updated_at', sa.DateTime()),
sa.Column('deleted_at', sa.DateTime()),
sa.Column('deleted', sa.Integer, default=0),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('created_by_user', sa.String(128), nullable=False),
sa.Column('id', sa.String(36), nullable=False),
sa.Column('product_id', sa.String(36), nullable=False),
sa.Column('version', sa.String(length=36), nullable=True),
sa.Column('cpid', sa.String(length=36)),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.ForeignKeyConstraint(['created_by_user'], ['user.openid'], ),
sa.UniqueConstraint('product_id', 'version', name='prod_ver_uc'),
mysql_charset=MYSQL_CHARSET
)
op.drop_column('product', 'product_ref_id')
def downgrade():
"""Downgrade DB."""
op.drop_table('product_version')
op.add_column('product',
sa.Column('product_ref_id', sa.String(36), nullable=True))

View File

@ -435,6 +435,7 @@ def delete_organization(organization_id):
def add_product(product_info, creator):
"""Add product."""
product = models.Product()
product.id = str(uuid.uuid4())
product.type = product_info['type']
product.product_type = product_info['product_type']
product.product_ref_id = product_info.get('product_ref_id')
@ -448,6 +449,12 @@ def add_product(product_info, creator):
session = get_session()
with session.begin():
product.save(session=session)
product_version = models.ProductVersion()
product_version.created_by_user = creator
product_version.version = product_info.get('version')
product_version.product_id = product.id
product_version.save(session=session)
return _to_dict(product)
@ -482,6 +489,9 @@ def delete_product(id):
"""delete product by id."""
session = get_session()
with session.begin():
(session.query(models.ProductVersion)
.filter_by(product_id=id)
.delete(synchronize_session=False))
(session.query(models.Product).filter_by(id=id).
delete(synchronize_session=False))
@ -588,3 +598,72 @@ def get_products_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_product_by_version(product_version_id, allowed_keys=None):
"""Get product info from a product version ID."""
session = get_session()
product = (session.query(models.Product).join(models.ProductVersion)
.filter(models.ProductVersion.id == product_version_id).first())
return _to_dict(product, allowed_keys=allowed_keys)
def get_product_version(product_version_id, allowed_keys=None):
"""Get details of a specific version given the id."""
session = get_session()
version = (
session.query(models.ProductVersion)
.filter_by(id=product_version_id).first()
)
if version is None:
raise NotFound('Version with id "%s" not found' % id)
return _to_dict(version)
def get_product_versions(product_id, allowed_keys=None):
"""Get all versions for a product."""
session = get_session()
version_info = (
session.query(models.ProductVersion).filter_by(product_id=product_id)
)
return _to_dict(version_info, allowed_keys=allowed_keys)
def add_product_version(product_id, version, creator, cpid, allowed_keys=None):
"""Add a new product version."""
product_version = models.ProductVersion()
product_version.created_by_user = creator
product_version.version = version
product_version.product_id = product_id
product_version.cpid = cpid
session = get_session()
with session.begin():
product_version.save(session=session)
return _to_dict(product_version, allowed_keys=allowed_keys)
def update_product_version(product_version_info):
"""Update product version from product_info_version dictionary."""
session = get_session()
_id = product_version_info.get('id')
version = session.query(models.ProductVersion).filter_by(id=_id).first()
if version is None:
raise NotFound('Product version with id %s not found' % _id)
# Only allow updating cpid.
keys = ['cpid']
for key in keys:
if key in product_version_info:
setattr(version, key, product_version_info[key])
with session.begin():
version.save(session=session)
return _to_dict(version)
def delete_product_version(product_version_id):
"""Delete a product version."""
session = get_session()
with session.begin():
(session.query(models.ProductVersion).filter_by(id=product_version_id).
delete(synchronize_session=False))

View File

@ -225,7 +225,6 @@ class Product(BASE, RefStackBase): # pragma: no cover
id = sa.Column(sa.String(36), primary_key=True,
default=lambda: six.text_type(uuid.uuid4()))
product_ref_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),
@ -244,3 +243,26 @@ class Product(BASE, RefStackBase): # pragma: no cover
return ('id', 'name', 'description', 'product_ref_id', 'product_type',
'public', 'properties', 'created_at', 'updated_at',
'organization_id', 'created_by_user', 'type')
class ProductVersion(BASE, RefStackBase):
"""Product Version definition."""
__tablename__ = 'product_version'
__table_args__ = (
sa.UniqueConstraint('product_id', 'version'),
)
id = sa.Column(sa.String(36), primary_key=True,
default=lambda: six.text_type(uuid.uuid4()))
product_id = sa.Column(sa.String(36), sa.ForeignKey('product.id'),
index=True, nullable=False, unique=False)
version = sa.Column(sa.String(length=36), nullable=True)
cpid = sa.Column(sa.String(36), nullable=True)
created_by_user = sa.Column(sa.String(128), sa.ForeignKey('user.openid'),
nullable=False)
@property
def default_allowed_keys(self):
"""Default keys."""
return ('id', 'product_id', 'version', 'cpid')

View File

@ -179,3 +179,94 @@ class TestProductsEndpoint(api.FunctionalTest):
"""Test get(list) request with no items in DB."""
results = self.get_json(self.URL)
self.assertEqual([], results['products'])
class TestProductVersionEndpoint(api.FunctionalTest):
"""Test case for the 'product/<product_id>/version' API endpoint."""
def setUp(self):
super(TestProductVersionEndpoint, 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)
patcher = mock.patch('refstack.api.utils.get_user_id')
self.addCleanup(patcher.stop)
self.mock_get_user = patcher.start()
self.mock_get_user.return_value = 'test-open-id'
product = json.dumps(FAKE_PRODUCT)
response = self.post_json('/v1/products/', params=product)
self.product_id = response['id']
self.URL = '/v1/products/' + self.product_id + '/versions/'
def test_get(self):
"""Test getting a list of versions."""
response = self.get_json(self.URL)
# Product created without version specified.
self.assertIsNone(response[0]['version'])
# Create a version
post_response = self.post_json(self.URL,
params=json.dumps({'version': '1.0'}))
response = self.get_json(self.URL)
self.assertEqual(2, len(response))
self.assertEqual(post_response, response[1])
def test_get_one(self):
""""Test get a specific version."""
# Create a version
post_response = self.post_json(self.URL,
params=json.dumps({'version': '2.0'}))
version_id = post_response['id']
response = self.get_json(self.URL + version_id)
self.assertEqual(post_response, response)
# Test nonexistent version.
self.assertRaises(webtest.app.AppError, self.get_json,
self.URL + 'sdsdsds')
def test_post(self):
"""Test creating a product version."""
version = {'cpid': '123', 'version': '5.0'}
post_response = self.post_json(self.URL, params=json.dumps(version))
self.assertEqual(version['cpid'], post_response['cpid'])
self.assertEqual(version['version'], post_response['version'])
self.assertEqual(self.product_id, post_response['product_id'])
self.assertIn('id', post_response)
# Test 'version' not in response body.
self.assertRaises(webtest.app.AppError, self.get_json,
self.URL + '/sdsdsds')
def test_put(self):
"""Test updating a product version."""
post_response = self.post_json(self.URL,
params=json.dumps({'version': '6.0'}))
version_id = post_response['id']
response = self.get_json(self.URL + version_id)
self.assertIsNone(response['cpid'])
props = {'cpid': '1233'}
self.put_json(self.URL + version_id, params=json.dumps(props))
response = self.get_json(self.URL + version_id)
self.assertEqual('1233', response['cpid'])
def test_delete(self):
"""Test deleting a product version."""
post_response = self.post_json(self.URL,
params=json.dumps({'version': '7.0'}))
version_id = post_response['id']
self.delete(self.URL + version_id)
self.assertRaises(webtest.app.AppError, self.get_json,
self.URL + 'version_id')

View File

@ -678,9 +678,12 @@ class DBBackendTestCase(base.BaseTestCase):
@mock.patch.object(api, 'get_session')
@mock.patch('refstack.db.sqlalchemy.models.Product')
@mock.patch('refstack.db.sqlalchemy.models.ProductVersion')
@mock.patch.object(api, '_to_dict', side_effect=lambda x: x)
def test_product_add(self, mock_to_dict, mock_product, mock_get_session):
def test_product_add(self, mock_to_dict, mock_version,
mock_product, mock_get_session):
session = mock_get_session.return_value
version = mock_version.return_value
product = mock_product.return_value
product_info = {'product_ref_id': 'hash_or_guid', 'name': 'a',
'organization_id': 'GUID0', 'type': 0,
@ -689,6 +692,9 @@ class DBBackendTestCase(base.BaseTestCase):
self.assertEqual(result, product)
self.assertIsNotNone(product.id)
self.assertIsNotNone(version.id)
self.assertIsNotNone(version.product_id)
self.assertIsNone(version.version)
mock_get_session.assert_called_once_with()
product.save.assert_called_once_with(session=session)
@ -767,11 +773,13 @@ class DBBackendTestCase(base.BaseTestCase):
@mock.patch('refstack.db.sqlalchemy.api.models')
def test_product_delete(self, mock_models, mock_get_session):
session = mock_get_session.return_value
db.delete_product('product_ref_id')
db.delete_product('product_id')
session.query.assert_called_once_with(mock_models.Product)
session.query.return_value.filter_by.assert_has_calls((
mock.call(id='product_ref_id'),
mock.call(product_id='product_id'),
mock.call().delete(synchronize_session=False)))
session.query.return_value.filter_by.assert_has_calls((
mock.call(id='product_id'),
mock.call().delete(synchronize_session=False)))
session.begin.assert_called_once_with()