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:
parent
942f418f94
commit
540bff5b13
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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))
|
@ -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))
|
||||
|
@ -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')
|
||||
|
@ -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')
|
||||
|
@ -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()
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user