diff --git a/refstack-ui/app/app.js b/refstack-ui/app/app.js
index 44ad016b..6efa35ea 100644
--- a/refstack-ui/app/app.js
+++ b/refstack-ui/app/app.js
@@ -93,6 +93,26 @@
url: '/vendor/:vendorID',
templateUrl: '/components/vendors/vendor.html',
controller: 'VendorController as ctrl'
+ }).
+ state('userProducts', {
+ url: '/user_products',
+ templateUrl: '/components/products/products.html',
+ controller: 'ProductsController as ctrl'
+ }).
+ state('publicProducts', {
+ url: '/public_products',
+ templateUrl: '/components/products/products.html',
+ controller: 'ProductsController as ctrl'
+ }).
+ state('cloud', {
+ url: '/cloud/:id',
+ templateUrl: '/components/products/cloud.html',
+ controller: 'ProductController as ctrl'
+ }).
+ state('distro', {
+ url: '/distro/:id',
+ templateUrl: '/components/products/distro.html',
+ controller: 'ProductController as ctrl'
});
}
diff --git a/refstack-ui/app/components/products/cloud.html b/refstack-ui/app/components/products/cloud.html
new file mode 100644
index 00000000..4b8af445
--- /dev/null
+++ b/refstack-ui/app/components/products/cloud.html
@@ -0,0 +1,26 @@
+
+
+
+
+ Version: {{modal.version.version}}
+
+
+
+
+ (Optional) Associate cloud provider ID (CPID) with product version for easier
+ test run associating.
+
+
+
+
+
CPID:
+
+
+
+
+
+
+
+
+
+
+ Error:
+ {{modal.error}}
+
+
+
+ Success:
+ Updated Successfully.
+
+
+
+
diff --git a/refstack-ui/app/components/products/productController.js b/refstack-ui/app/components/products/productController.js
new file mode 100644
index 00000000..dd7a284e
--- /dev/null
+++ b/refstack-ui/app/components/products/productController.js
@@ -0,0 +1,357 @@
+/*
+ * 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.
+ */
+
+(function () {
+ 'use strict';
+
+ angular
+ .module('refstackApp')
+ .controller('ProductController', ProductController);
+
+ ProductController.$inject = [
+ '$scope', '$http', '$state', '$stateParams', '$window', '$uibModal',
+ 'refstackApiUrl', 'raiseAlert'
+ ];
+
+ /**
+ * RefStack Product Controller
+ * This controller is for the '/product/' details page where owner can
+ * view details of the product.
+ */
+ function ProductController($scope, $http, $state, $stateParams,
+ $window, $uibModal, refstackApiUrl, raiseAlert) {
+ var ctrl = this;
+
+ ctrl.getProduct = getProduct;
+ ctrl.getProductVersions = getProductVersions;
+ ctrl.deleteProduct = deleteProduct;
+ ctrl.deleteProductVersion = deleteProductVersion;
+ ctrl.getProductTests = getProductTests;
+ ctrl.switchProductPublicity = switchProductPublicity;
+ ctrl.associateTestMeta = associateTestMeta;
+ ctrl.getGuidelineVersionList = getGuidelineVersionList;
+ ctrl.addProductVersion = addProductVersion;
+ ctrl.unassociateTest = unassociateTest;
+ ctrl.openVersionModal = openVersionModal;
+
+ /** The product id extracted from the URL route. */
+ ctrl.id = $stateParams.id;
+ ctrl.productVersions = [];
+
+ if (!$scope.auth.isAuthenticated) {
+ $state.go('home');
+ }
+
+ /** Mappings of DefCore components to marketing program names. */
+ ctrl.targetMappings = {
+ 'platform': 'Openstack Powered Platform',
+ 'compute': 'OpenStack Powered Compute',
+ 'object': 'OpenStack Powered Object Storage'
+ };
+
+ // Pagination controls.
+ ctrl.currentPage = 1;
+ ctrl.itemsPerPage = 20;
+ ctrl.maxSize = 5;
+
+ ctrl.getProduct();
+ ctrl.getProductVersions();
+ ctrl.getProductTests();
+
+ /**
+ * This will contact the Refstack API to get a product information.
+ */
+ function getProduct() {
+ ctrl.showError = false;
+ ctrl.product = null;
+ var content_url = refstackApiUrl + '/products/' + ctrl.id;
+ ctrl.productRequest = $http.get(content_url).success(
+ function(data) {
+ ctrl.product = data;
+ ctrl.product_properties =
+ angular.fromJson(data.properties);
+ }
+ ).error(function(error) {
+ ctrl.showError = true;
+ ctrl.error =
+ 'Error retrieving from server: ' +
+ angular.toJson(error);
+ }).then(function() {
+ var url = refstackApiUrl + '/vendors/' +
+ ctrl.product.organization_id;
+ $http.get(url).success(function(data) {
+ ctrl.vendor = data;
+ }).error(function(error) {
+ ctrl.showError = true;
+ ctrl.error =
+ 'Error retrieving from server: ' +
+ angular.toJson(error);
+ });
+ });
+ }
+
+ /**
+ * This will contact the Refstack API to get product versions.
+ */
+ function getProductVersions() {
+ ctrl.showError = false;
+ var content_url = refstackApiUrl + '/products/' + ctrl.id +
+ '/versions';
+ ctrl.productVersionsRequest = $http.get(content_url).success(
+ function(data) {
+ ctrl.productVersions = data;
+ }
+ ).error(function(error) {
+ ctrl.showError = true;
+ ctrl.error =
+ 'Error retrieving versions from server: ' +
+ angular.toJson(error);
+ });
+ }
+
+ /**
+ * This will delete the product.
+ */
+ function deleteProduct() {
+ var url = [refstackApiUrl, '/products/', ctrl.id].join('');
+ $http.delete(url).success(function () {
+ $window.location.href = '/';
+ }).error(function (error) {
+ raiseAlert('danger', 'Error: ', error.detail);
+ });
+ }
+
+ /**
+ * This will delete the given product versions.
+ */
+ function deleteProductVersion(versionId) {
+ var url = [
+ refstackApiUrl, '/products/', ctrl.id,
+ '/versions/', versionId ].join('');
+ $http.delete(url).success(function () {
+ ctrl.getProductVersions();
+ }).error(function (error) {
+ raiseAlert('danger', 'Error: ', error.detail);
+ });
+ }
+
+ /**
+ * Set a POST request to the API server to add a new version for
+ * the product.
+ */
+ function addProductVersion() {
+ var url = [refstackApiUrl, '/products/', ctrl.id,
+ '/versions'].join('');
+ ctrl.addVersionRequest = $http.post(url,
+ {'version': ctrl.newProductVersion})
+ .success(function (data) {
+ ctrl.productVersions.push(data);
+ ctrl.newProductVersion = '';
+ ctrl.showNewVersionInput = false;
+ }).error(function (error) {
+ raiseAlert('danger', error.title, error.detail);
+ });
+ }
+
+ /**
+ * Get tests runs associated with the current product.
+ */
+ function getProductTests() {
+ ctrl.showTestsError = false;
+ var content_url = refstackApiUrl + '/results' +
+ '?page=' + ctrl.currentPage + '&product_id='
+ + ctrl.id;
+
+ ctrl.testsRequest = $http.get(content_url).success(
+ function(data) {
+ ctrl.testsData = data.results;
+ ctrl.totalItems = data.pagination.total_pages *
+ ctrl.itemsPerPage;
+ ctrl.currentPage = data.pagination.current_page;
+ }
+ ).error(function(error) {
+ ctrl.showTestsError = true;
+ ctrl.testsError =
+ 'Error retrieving tests from server: ' +
+ angular.toJson(error);
+ });
+ }
+
+ /**
+ * This will switch public/private property of the product.
+ */
+ function switchProductPublicity() {
+ var url = [refstackApiUrl, '/products/', ctrl.id].join('');
+ $http.put(url, {public: !ctrl.product.public}).success(
+ function (data) {
+ ctrl.product = data;
+ ctrl.product_properties = angular.fromJson(data.properties);
+ }).error(function (error) {
+ raiseAlert('danger', 'Error: ', error.detail);
+ });
+ }
+
+ /**
+ * This will send an API request in order to associate a metadata
+ * key-value pair with the given testId
+ * @param {Number} index - index of the test object in the results list
+ * @param {String} key - metadata key
+ * @param {String} value - metadata value
+ */
+ function associateTestMeta(index, key, value) {
+ var testId = ctrl.testsData[index].id;
+ var metaUrl = [
+ refstackApiUrl, '/results/', testId, '/meta/', key
+ ].join('');
+
+ var editFlag = key + 'Edit';
+ if (value) {
+ ctrl.associateRequest = $http.post(metaUrl, value)
+ .success(function () {
+ ctrl.testsData[index][editFlag] = false;
+ }).error(function (error) {
+ raiseAlert('danger', error.title, error.detail);
+ });
+ }
+ else {
+ ctrl.unassociateRequest = $http.delete(metaUrl)
+ .success(function () {
+ ctrl.testsData[index][editFlag] = false;
+ }).error(function (error) {
+ raiseAlert('danger', error.title, error.detail);
+ });
+ }
+ }
+
+ /**
+ * Retrieve an array of available capability files from the Refstack
+ * API server, sort this array reverse-alphabetically, and store it in
+ * a scoped variable.
+ * Sample API return array: ["2015.03.json", "2015.04.json"]
+ */
+ function getGuidelineVersionList() {
+ if (ctrl.versionList) {
+ return;
+ }
+ var content_url = refstackApiUrl + '/guidelines';
+ ctrl.versionsRequest =
+ $http.get(content_url).success(function (data) {
+ ctrl.versionList = data.sort().reverse();
+ }).error(function (error) {
+ raiseAlert('danger', error.title,
+ 'Unable to retrieve version list');
+ });
+ }
+
+ /**
+ * Send a PUT request to the API server to unassociate a product with
+ * a test result.
+ */
+ function unassociateTest(index) {
+ var testId = ctrl.testsData[index].id;
+ var url = refstackApiUrl + '/results/' + testId;
+ ctrl.associateRequest = $http.put(url, {'product_version_id': null})
+ .success(function () {
+ ctrl.testsData.splice(index, 1);
+ }).error(function (error) {
+ raiseAlert('danger', error.title, error.detail);
+ });
+ }
+
+ /**
+ * This will open the modal that will allow a product version
+ * to be managed.
+ */
+ function openVersionModal(version) {
+ $uibModal.open({
+ templateUrl: '/components/products/partials' +
+ '/versionsModal.html',
+ backdrop: true,
+ windowClass: 'modal',
+ animation: true,
+ controller: 'ProductVersionModalController as modal',
+ size: 'lg',
+ resolve: {
+ version: function () {
+ return version;
+ },
+ parent: function () {
+ return ctrl;
+ }
+ }
+ });
+ }
+ }
+
+ angular
+ .module('refstackApp')
+ .controller('ProductVersionModalController',
+ ProductVersionModalController);
+
+ ProductVersionModalController.$inject = [
+ '$uibModalInstance', '$http', 'refstackApiUrl', 'version', 'parent'
+ ];
+
+ /**
+ * Product Version Modal Controller
+ * This controller is for the modal that appears if a user wants to
+ * manage a product version.
+ */
+ function ProductVersionModalController($uibModalInstance, $http,
+ refstackApiUrl, version, parent) {
+
+ var ctrl = this;
+
+ ctrl.version = version;
+ ctrl.parent = parent;
+
+ ctrl.close = close;
+ ctrl.deleteProductVersion = deleteProductVersion;
+ ctrl.saveChanges = saveChanges;
+
+ /**
+ * This function will close/dismiss the modal.
+ */
+ function close() {
+ $uibModalInstance.dismiss('exit');
+ }
+
+ /**
+ * Call the parent function to delete a version, then close the modal.
+ */
+ function deleteProductVersion() {
+ ctrl.parent.deleteProductVersion(ctrl.version.id);
+ ctrl.close();
+ }
+
+ /**
+ * This will update the current version, saving changes.
+ */
+ function saveChanges() {
+ ctrl.showSuccess = false;
+ ctrl.showError = false;
+ var url = [
+ refstackApiUrl, '/products/', ctrl.version.product_id,
+ '/versions/', ctrl.version.id ].join('');
+ var content = {'cpid': ctrl.version.cpid};
+ $http.put(url, content).success(function() {
+ ctrl.showSuccess = true;
+ }).error(function(error) {
+ ctrl.showError = true;
+ ctrl.error = error.detail;
+ });
+ }
+
+ }
+})();
diff --git a/refstack-ui/app/components/products/products.html b/refstack-ui/app/components/products/products.html
new file mode 100644
index 00000000..679adcc2
--- /dev/null
+++ b/refstack-ui/app/components/products/products.html
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+ Error:
+ {{modal.error}}
+
+
+
+ Success:
+ Changes saved successfully.
+
+
+
+
diff --git a/refstack-ui/app/components/vendors/vendor.html b/refstack-ui/app/components/vendors/vendor.html
index 4938e2c3..ad8f5f9a 100644
--- a/refstack-ui/app/components/vendors/vendor.html
+++ b/refstack-ui/app/components/vendors/vendor.html
@@ -10,12 +10,21 @@
Delete
+
Edit
Register with Foundation
Approve registration
diff --git a/refstack-ui/app/components/vendors/vendorController.js b/refstack-ui/app/components/vendors/vendorController.js
index 4ae22c3e..621bf430 100644
--- a/refstack-ui/app/components/vendors/vendorController.js
+++ b/refstack-ui/app/components/vendors/vendorController.js
@@ -21,7 +21,7 @@
VendorController.$inject = [
'$rootScope', '$scope', '$http', '$state', '$stateParams', '$window',
- 'refstackApiUrl', 'raiseAlert', 'confirmModal'
+ '$uibModal', 'refstackApiUrl', 'raiseAlert', 'confirmModal'
];
/**
@@ -30,7 +30,7 @@
* view details of the Vendor and manage users.
*/
function VendorController($rootScope, $scope, $http, $state, $stateParams,
- $window, refstackApiUrl, raiseAlert, confirmModal) {
+ $window, $uibModal, refstackApiUrl, raiseAlert, confirmModal) {
var ctrl = this;
ctrl.getVendor = getVendor;
@@ -41,6 +41,7 @@
ctrl.deleteVendor = deleteVendor;
ctrl.removeUserFromVendor = removeUserFromVendor;
ctrl.addUserToVendor = addUserToVendor;
+ ctrl.openVendorEditModal = openVendorEditModal;
/** The vendor id extracted from the URL route. */
ctrl.vendorId = $stateParams.vendorID;
@@ -62,7 +63,8 @@
$http.get(contentUrl).success(function(data) {
ctrl.vendor = data;
var isAdmin = $rootScope.auth.currentUser.is_admin;
- ctrl.vendor.canDelete = ctrl.vendor.type != 0
+ ctrl.vendor.canDelete = ctrl.vendor.canEdit =
+ ctrl.vendor.type != 0
&& (ctrl.vendor.can_manage || isAdmin);
ctrl.vendor.canRegister =
ctrl.vendor.type == 1;
@@ -181,5 +183,121 @@
error.detail);
});
}
+
+ /**
+ * This will open the modal that will allow a user to edit
+ */
+ function openVendorEditModal() {
+ $uibModal.open({
+ templateUrl: '/components/vendors/partials' +
+ '/vendorEditModal.html',
+ backdrop: true,
+ windowClass: 'modal',
+ animation: true,
+ controller: 'VendorEditModalController as modal',
+ size: 'lg',
+ resolve: {
+ vendor: function () {
+ return ctrl.vendor;
+ }
+ }
+ });
+ }
+ }
+
+ angular
+ .module('refstackApp')
+ .controller('VendorEditModalController', VendorEditModalController);
+
+ VendorEditModalController.$inject = [
+ '$uibModalInstance', '$http', '$state', 'vendor', 'refstackApiUrl'
+ ];
+
+ /**
+ * Vendor Edit Modal Controller
+ * This controls the modal that allows editing a vendor.
+ */
+ function VendorEditModalController($uibModalInstance, $http, $state,
+ vendor, refstackApiUrl) {
+
+ var ctrl = this;
+
+ ctrl.close = close;
+ ctrl.addField = addField;
+ ctrl.saveChanges = saveChanges;
+ ctrl.removeProperty = removeProperty;
+
+ ctrl.vendor = vendor;
+ ctrl.vendorProperties = [];
+
+ parseVendorProperties();
+
+ /**
+ * Close the vendor edit modal.
+ */
+ function close() {
+ $uibModalInstance.dismiss('exit');
+ }
+
+ /**
+ * Push a blank property key-value pair into the vendorProperties
+ * array. This will spawn new input boxes.
+ */
+ function addField() {
+ ctrl.vendorProperties.push({'key': '', 'value': ''});
+ }
+
+ /**
+ * Send a PUT request to the server with the changes.
+ */
+ function saveChanges() {
+ ctrl.showError = false;
+ ctrl.showSuccess = false;
+ var url = [refstackApiUrl, '/vendors/', ctrl.vendor.id].join('');
+ var properties = propertiesToJson();
+ var content = {'name': ctrl.vendor.name,
+ 'description': ctrl.vendor.description,
+ 'properties': properties};
+ $http.put(url, content).success(function() {
+ ctrl.showSuccess = true;
+ $state.reload();
+ }).error(function(error) {
+ ctrl.showError = true;
+ ctrl.error = error.detail;
+ });
+ }
+
+ /**
+ * Remove a property from the vendorProperties array at the given index.
+ */
+ function removeProperty(index) {
+ ctrl.vendorProperties.splice(index, 1);
+ }
+
+ /**
+ * Parse the vendor properties and put them in a format more suitable
+ * for forms.
+ */
+ function parseVendorProperties() {
+ var props = angular.fromJson(ctrl.vendor.properties);
+ angular.forEach(props, function(value, key) {
+ ctrl.vendorProperties.push({'key': key, 'value': value});
+ });
+ }
+
+ /**
+ * Convert the list of property objects to a dict containing the
+ * each key-value pair..
+ */
+ function propertiesToJson() {
+ var properties = {};
+ for (var i = 0, len = ctrl.vendorProperties.length; i < len; i++) {
+ var prop = ctrl.vendorProperties[i];
+ if (prop.key && prop.value) {
+ properties[prop.key] = prop.value;
+ }
+ }
+ return properties;
+ }
}
})();
diff --git a/refstack-ui/app/components/vendors/vendorsController.js b/refstack-ui/app/components/vendors/vendorsController.js
index 6386d328..d597915c 100644
--- a/refstack-ui/app/components/vendors/vendorsController.js
+++ b/refstack-ui/app/components/vendors/vendorsController.js
@@ -74,8 +74,7 @@
&& $rootScope.auth.currentUser.is_admin;
/**
- * This will contact the Refstack API to get a listing of test run
- * results.
+ * This will contact the Refstack API to get a listing of vendors
*/
function update() {
ctrl.showError = false;
diff --git a/refstack-ui/app/index.html b/refstack-ui/app/index.html
index f3111645..426f2628 100644
--- a/refstack-ui/app/index.html
+++ b/refstack-ui/app/index.html
@@ -47,6 +47,8 @@
+
+
diff --git a/refstack-ui/app/shared/filters.js b/refstack-ui/app/shared/filters.js
index 84b171b6..e38789f5 100644
--- a/refstack-ui/app/shared/filters.js
+++ b/refstack-ui/app/shared/filters.js
@@ -31,7 +31,9 @@
return function (objects) {
var array = [];
angular.forEach(objects, function (object, key) {
- object.id = key;
+ if (!('id' in object)) {
+ object.id = key;
+ }
array.push(object);
});
return array;
diff --git a/refstack-ui/app/shared/header/header.html b/refstack-ui/app/shared/header/header.html
index 8312679d..aa6984fd 100644
--- a/refstack-ui/app/shared/header/header.html
+++ b/refstack-ui/app/shared/header/header.html
@@ -19,29 +19,27 @@ RefStack
About
DefCore Guidelines
Community Results
-
- My Results
-
- Profile
- Sign Out
- Sign In / Sign Up
diff --git a/refstack-ui/app/shared/header/headerController.js b/refstack-ui/app/shared/header/headerController.js
index ac1d6104..05727d2e 100644
--- a/refstack-ui/app/shared/header/headerController.js
+++ b/refstack-ui/app/shared/header/headerController.js
@@ -56,7 +56,8 @@
* public or user one.
*/
function isCatalogActive(type) {
- return ctrl.isActive('/' + type + '_vendors');
+ return ctrl.isActive('/' + type + '_vendors')
+ || ctrl.isActive('/' + type + '_products');
}
}
})();
diff --git a/refstack-ui/tests/unit/ControllerSpec.js b/refstack-ui/tests/unit/ControllerSpec.js
index b54dd721..c8028e07 100644
--- a/refstack-ui/tests/unit/ControllerSpec.js
+++ b/refstack-ui/tests/unit/ControllerSpec.js
@@ -215,6 +215,8 @@ describe('Refstack controllers', function () {
beforeEach(inject(function ($rootScope, $controller) {
scope = $rootScope.$new();
ctrl = $controller('ResultsController', {$scope: scope});
+ $httpBackend.when('GET', fakeApiUrl +
+ '/results?page=1').respond(fakeResponse);
}));
it('should fetch the first page of results with proper URL args',
@@ -301,6 +303,51 @@ describe('Refstack controllers', function () {
expect(ctrl.versionList).toEqual(['2015.04.json',
'2015.03.json']);
});
+
+ it('should have a function to get products manageable by a user',
+ function () {
+ var prodResp = {'products': [{'id': 'abc',
+ 'can_manage': true},
+ {'id': 'foo',
+ 'can_manage': false}]};
+ ctrl.products = null;
+ $httpBackend.expectGET(fakeApiUrl + '/products')
+ .respond(200, prodResp);
+ ctrl.getUserProducts();
+ $httpBackend.flush();
+ var expected = {'abc': {'id': 'abc', 'can_manage': true}};
+ expect(ctrl.products).toEqual(expected);
+ });
+
+ it('should have a function to associate a product version to a test',
+ function () {
+ var result = {'id': 'bar',
+ 'selectedVersion': {'id': 'foo'},
+ 'selectedProduct': {'id': 'prod'}};
+ ctrl.products = null;
+ $httpBackend.expectPUT(fakeApiUrl + '/results/bar')
+ .respond(201);
+ ctrl.associateProductVersion(result);
+ $httpBackend.flush();
+ var expected = {'id': 'foo', 'product_info': {'id': 'prod'}};
+ expect(result.product_version).toEqual(expected);
+ });
+
+ it('should have a function to get product versions',
+ function () {
+ var result = {'id': 'bar',
+ 'selectedProduct': {'id': 'prod'}};
+ var verResp = [{'id': 'ver1', 'version': '1.0'},
+ {'id': 'ver2', 'version': null}];
+ ctrl.products = null;
+ $httpBackend.expectGET(fakeApiUrl + '/products/prod/versions')
+ .respond(200, verResp);
+ ctrl.getProductVersions(result);
+ $httpBackend.flush();
+ expect(result.productVersions).toEqual(verResp);
+ var expected = {'id': 'ver2', 'version': null};
+ expect(result.selectedVersion).toEqual(expected);
+ });
});
describe('ResultsReportController', function () {
@@ -883,7 +930,59 @@ describe('Refstack controllers', function () {
});
});
- describe('VendorsController', function() {
+ describe('VendorEditModalController', function() {
+ var ctrl, modalInstance, state;
+ var fakeVendor = {'name': 'Foo', 'description': 'Bar', 'id': '1234',
+ 'properties': {'key1': 'value1', 'key2': 'value2'}};
+
+ beforeEach(inject(function ($controller) {
+ modalInstance = {
+ dismiss: jasmine.createSpy('modalInstance.dismiss')
+ };
+ state = {
+ reload: jasmine.createSpy('state.reload')
+ };
+ ctrl = $controller('VendorEditModalController',
+ {$uibModalInstance: modalInstance, $state: state,
+ vendor: fakeVendor}
+ );
+ }));
+
+ it('should be able to add/remove properties',
+ function () {
+ var expected = [{'key': 'key1', 'value': 'value1'},
+ {'key': 'key2', 'value': 'value2'}];
+ expect(ctrl.vendorProperties).toEqual(expected);
+ ctrl.removeProperty(0);
+ expected = [{'key': 'key2', 'value': 'value2'}];
+ expect(ctrl.vendorProperties).toEqual(expected);
+ ctrl.addField();
+ expected = [{'key': 'key2', 'value': 'value2'},
+ {'key': '', 'value': ''}];
+ expect(ctrl.vendorProperties).toEqual(expected);
+ });
+
+ it('should have a function to save changes',
+ function () {
+ var expectedContent = {
+ 'name': 'Foo', 'description': 'Bar',
+ 'properties': {'key1': 'value1', 'key2': 'value2'}
+ };
+ $httpBackend.expectPUT(
+ fakeApiUrl + '/vendors/1234', expectedContent)
+ .respond(200, '');
+ ctrl.saveChanges();
+ $httpBackend.flush();
+ });
+
+ it('should have a function to exit the modal',
+ function () {
+ ctrl.close();
+ expect(modalInstance.dismiss).toHaveBeenCalledWith('exit');
+ });
+ });
+
+ describe('VendorsController', function () {
var rootScope, scope, ctrl;
var fakeResp = {'vendors': [{'can_manage': true,
'type': 3,
@@ -949,4 +1048,277 @@ describe('Refstack controllers', function () {
$httpBackend.flush();
});
});
+
+ describe('ProductsController', function() {
+ var rootScope, scope, ctrl;
+ var vendResp = {'vendors': [{'can_manage': true,
+ 'type': 3,
+ 'name': 'Foo',
+ 'id': '123'}]};
+ var prodResp = {'products': [{'id': 'abc',
+ 'product_type': 1,
+ 'public': 1,
+ 'name': 'Foo Product',
+ 'organization_id': '123'}]};
+
+ beforeEach(inject(function ($controller, $rootScope) {
+ scope = $rootScope.$new();
+ rootScope = $rootScope.$new();
+ rootScope.auth = {'currentUser' : {'is_admin': false,
+ 'openid': 'foo'}
+ };
+ ctrl = $controller('ProductsController',
+ {$rootScope: rootScope, $scope: scope}
+ );
+ $httpBackend.when('GET', fakeApiUrl +
+ '/vendors').respond(vendResp);
+ $httpBackend.when('GET', fakeApiUrl +
+ '/products').respond(prodResp);
+ }));
+
+ it('should have a function to get/update vendors',
+ function () {
+ $httpBackend.flush();
+ var newVendResp = {'vendors': [{'name': 'Foo',
+ 'id': '123',
+ 'can_manage': true},
+ {'name': 'Bar',
+ 'id': '345',
+ 'can_manage': false}]};
+ $httpBackend.expectGET(fakeApiUrl + '/vendors')
+ .respond(200, newVendResp);
+ ctrl.updateVendors();
+ $httpBackend.flush();
+ expect(ctrl.allVendors).toEqual({'123': {'name': 'Foo',
+ 'id': '123',
+ 'can_manage': true},
+ '345': {'name': 'Bar',
+ 'id': '345',
+ 'can_manage': false}});
+ expect(ctrl.vendors).toEqual([{'name': 'Foo',
+ 'id': '123',
+ 'can_manage': true}]);
+ });
+
+ it('should have a function to get products',
+ function () {
+ $httpBackend.expectGET(fakeApiUrl + '/products')
+ .respond(200, prodResp);
+ ctrl.update();
+ $httpBackend.flush();
+ expect(ctrl.rawData).toEqual(prodResp);
+ });
+
+ it('should have a function to update the view',
+ function () {
+ $httpBackend.flush();
+ ctrl.allVendors = {'123': {'name': 'Foo',
+ 'id': '123',
+ 'can_manage': true}};
+ ctrl.updateData();
+ var expectedData = {'products': [{'id': 'abc',
+ 'product_type': 1,
+ 'public': 1,
+ 'name': 'Foo Product',
+ 'organization_id': '123'}]};
+ expect(ctrl.data).toEqual(expectedData);
+ });
+
+ it('should have a function to map product types with descriptions',
+ function () {
+ expect(ctrl.getProductTypeDescription(0)).toEqual('Distro');
+ expect(ctrl.getProductTypeDescription(1))
+ .toEqual('Public Cloud');
+ expect(ctrl.getProductTypeDescription(2))
+ .toEqual('Hosted Private Cloud');
+ expect(ctrl.getProductTypeDescription(5)).toEqual('Unknown');
+ });
+ });
+
+ describe('ProductController', function() {
+ var rootScope, scope, stateParams, ctrl;
+ var fakeProdResp = {'product_type': 1,
+ 'product_ref_id': null,
+ 'name': 'Good Stuff',
+ 'created_at': '2016-01-01 01:02:03',
+ 'updated_at': '2016-06-15 01:02:04',
+ 'properties': null,
+ 'organization_id': 'fake-org-id',
+ 'public': true,
+ 'can_manage': true,
+ 'created_by_user': 'fake-open-id',
+ 'type': 0,
+ 'id': '1234',
+ 'description': 'some description'};
+ var fakeVersionResp = [{'id': 'asdf',
+ 'cpid': null,
+ 'version': '1.0',
+ 'product_id': '1234'}];
+ var fakeTestsResp = {'pagination': {'current_page': 1,
+ 'total_pages': 1},
+ 'results':[{'id': 'foo-test'}]};
+ var fakeVendorResp = {'id': 'fake-org-id',
+ 'type': 3,
+ 'can_manage': true,
+ 'properties' : {},
+ 'name': 'Foo Vendor',
+ 'description': 'foo bar'};
+ var fakeWindow = {
+ location: {
+ href: ''
+ }
+ };
+
+ beforeEach(inject(function ($controller, $rootScope) {
+ scope = $rootScope.$new();
+ rootScope = $rootScope.$new();
+ stateParams = {id: 1234};
+ rootScope.auth = {'currentUser' : {'is_admin': false,
+ 'openid': 'foo'}
+ };
+ ctrl = $controller('ProductController',
+ {$rootScope: rootScope, $scope: scope,
+ $stateParams: stateParams, $window: fakeWindow}
+ );
+ $httpBackend.when('GET', fakeApiUrl +
+ '/products/1234').respond(fakeProdResp);
+ $httpBackend.when('GET', fakeApiUrl +
+ '/products/1234/versions').respond(fakeVersionResp);
+ $httpBackend.when('GET', fakeApiUrl +
+ '/results?page=1&product_id=1234').respond(fakeTestsResp);
+ $httpBackend.when('GET', fakeApiUrl +
+ '/vendors/fake-org-id').respond(fakeVendorResp);
+ }));
+
+ it('should have a function to get product information',
+ function () {
+ $httpBackend.expectGET(fakeApiUrl + '/products/1234')
+ .respond(200, fakeProdResp);
+ $httpBackend.expectGET(fakeApiUrl + '/vendors/fake-org-id')
+ .respond(200, fakeVendorResp);
+ ctrl.getProduct();
+ $httpBackend.flush();
+ expect(ctrl.product).toEqual(fakeProdResp);
+ expect(ctrl.vendor).toEqual(fakeVendorResp);
+ });
+
+ it('should have a function to get a list of product versions',
+ function () {
+ $httpBackend
+ .expectGET(fakeApiUrl + '/products/1234/versions')
+ .respond(200, fakeVersionResp);
+ ctrl.getProductVersions();
+ $httpBackend.flush();
+ expect(ctrl.productVersions).toEqual(fakeVersionResp);
+ });
+
+ it('should have a function to delete a product',
+ function () {
+ $httpBackend.expectDELETE(fakeApiUrl + '/products/1234')
+ .respond(202, '');
+ ctrl.deleteProduct();
+ $httpBackend.flush();
+ expect(fakeWindow.location.href).toEqual('/');
+ });
+
+ it('should have a function to delete a product version',
+ function () {
+ $httpBackend
+ .expectDELETE(fakeApiUrl + '/products/1234/versions/abc')
+ .respond(204, '');
+ ctrl.deleteProductVersion('abc');
+ $httpBackend.flush();
+ });
+
+ it('should have a function to add a product version',
+ function () {
+ ctrl.newProductVersion = 'abc';
+ $httpBackend.expectPOST(
+ fakeApiUrl + '/products/1234/versions',
+ {version: 'abc'})
+ .respond(200, {'id': 'foo'});
+ ctrl.addProductVersion();
+ $httpBackend.flush();
+ });
+
+ it('should have a function to get tests on a product',
+ function () {
+ ctrl.getProductTests();
+ $httpBackend.flush();
+ expect(ctrl.testsData).toEqual(fakeTestsResp.results);
+ expect(ctrl.currentPage).toEqual(1);
+ });
+
+ it('should have a function to unassociate a test from a product',
+ function () {
+ ctrl.testsData = [{'id': 'foo-test'}];
+ $httpBackend.expectPUT(
+ fakeApiUrl + '/results/foo-test',
+ {product_version_id: null})
+ .respond(200, {'id': 'foo-test'});
+ ctrl.unassociateTest(0);
+ $httpBackend.flush();
+ expect(ctrl.testsData).toEqual([]);
+ });
+
+ it('should have a function to switch the publicity of a project',
+ function () {
+ ctrl.product = {'public': true};
+ $httpBackend.expectPUT(fakeApiUrl + '/products/1234',
+ {'public': false})
+ .respond(200, fakeProdResp);
+ ctrl.switchProductPublicity();
+ $httpBackend.flush();
+ });
+
+ it('should have a method to open a modal for version management',
+ function () {
+ var modal;
+ inject(function ($uibModal) {
+ modal = $uibModal;
+ });
+ spyOn(modal, 'open');
+ ctrl.openVersionModal();
+ expect(modal.open).toHaveBeenCalled();
+ });
+ });
+
+ describe('ProductVersionModalController', function() {
+
+ var ctrl, modalInstance, state, parent;
+ var fakeVersion = {'id': 'asdf', 'cpid': null,
+ 'version': '1.0','product_id': '1234'};
+
+ beforeEach(inject(function ($controller) {
+ modalInstance = {
+ dismiss: jasmine.createSpy('modalInstance.dismiss')
+ };
+ parent = {
+ deleteProductVersion: jasmine.createSpy('deleteProductVersion')
+ };
+ ctrl = $controller('ProductVersionModalController',
+ {$uibModalInstance: modalInstance, $state: state,
+ version: fakeVersion, parent: parent}
+ );
+ }));
+
+ it('should have a function to prompt a version deletion',
+ function () {
+ ctrl.deleteProductVersion();
+ expect(parent.deleteProductVersion)
+ .toHaveBeenCalledWith('asdf');
+ expect(modalInstance.dismiss).toHaveBeenCalledWith('exit');
+ });
+
+ it('should have a function to save changes',
+ function () {
+ ctrl.version.cpid = 'some-cpid';
+ var expectedContent = { 'cpid': 'some-cpid'};
+ $httpBackend.expectPUT(
+ fakeApiUrl + '/products/1234/versions/asdf',
+ expectedContent).respond(200, '');
+ ctrl.saveChanges();
+ $httpBackend.flush();
+ });
+ });
});
diff --git a/refstack/api/constants.py b/refstack/api/constants.py
index 8d773334..43560b26 100644
--- a/refstack/api/constants.py
+++ b/refstack/api/constants.py
@@ -20,6 +20,9 @@ END_DATE = 'end_date'
CPID = 'cpid'
PAGE = 'page'
SIGNED = 'signed'
+VERIFICATION_STATUS = 'verification_status'
+PRODUCT_ID = 'product_id'
+ALL_PRODUCT_TESTS = 'all_product_tests'
OPENID = 'openid'
USER_PUBKEYS = 'pubkeys'
@@ -50,6 +53,10 @@ USER_OPENID = 'user_openid'
USER = 'user'
SHARED_TEST_RUN = 'shared'
+# Test verification statuses
+TEST_NOT_VERIFIED = 0
+TEST_VERIFIED = 1
+
# Roles
ROLE_USER = 'user'
ROLE_OWNER = 'owner'
diff --git a/refstack/api/controllers/products.py b/refstack/api/controllers/products.py
index fd5d3891..08194733 100644
--- a/refstack/api/controllers/products.py
+++ b/refstack/api/controllers/products.py
@@ -19,6 +19,7 @@ import json
import uuid
from oslo_config import cfg
+from oslo_db.exception import DBReferenceError
from oslo_log import log
import pecan
from pecan.secure import secure
@@ -35,6 +36,92 @@ LOG = log.getLogger(__name__)
CONF = cfg.CONF
+class VersionsController(validation.BaseRestControllerWithValidation):
+ """/v1/products//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.')
+
+ allowed_keys = ['id', 'product_id', 'version', 'cpid']
+ return db.get_product_versions(id, allowed_keys=allowed_keys)
+
+ @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.')
+ allowed_keys = ['id', 'product_id', 'version', 'cpid']
+ return db.get_product_version(version_id, allowed_keys=allowed_keys)
+
+ @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//versions/
+ """
+ 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//versions/
+ """
+ if (not api_utils.check_user_is_product_admin(id) and
+ not api_utils.check_user_is_foundation_admin()):
+
+ pecan.abort(403, 'Forbidden.')
+ try:
+ db.delete_product_version(version_id)
+ except DBReferenceError:
+ pecan.abort(400, 'Unable to delete. There are still tests '
+ 'associated to this product version.')
+ pecan.response.status = 204
+
+
class ProductsController(validation.BaseRestControllerWithValidation):
"""/v1/products handler."""
@@ -44,10 +131,12 @@ class ProductsController(validation.BaseRestControllerWithValidation):
"action": ["POST"],
}
+ versions = VersionsController()
+
@pecan.expose('json')
def get(self):
"""Get information of all products."""
- allowed_keys = ['id', 'name', 'description', 'product_id', 'type',
+ allowed_keys = ['id', 'name', 'description', 'product_ref_id', 'type',
'product_type', 'public', 'organization_id']
user = api_utils.get_user_id()
is_admin = user in db.get_foundation_users()
@@ -83,18 +172,21 @@ class ProductsController(validation.BaseRestControllerWithValidation):
@pecan.expose('json')
def get_one(self, id):
"""Get information about product."""
- product = db.get_product(id)
+ allowed_keys = ['id', 'name', 'description',
+ 'product_ref_id', 'product_type',
+ 'public', 'properties', 'created_at', 'updated_at',
+ 'organization_id', 'created_by_user', 'type']
+ product = db.get_product(id, allowed_keys=allowed_keys)
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']
+ admin_only_keys = ['created_by_user', 'created_at', 'updated_at',
+ 'properties']
for key in product.keys():
- if key not in allowed_keys:
+ if key in admin_only_keys:
product.pop(key)
product['can_manage'] = is_admin
@@ -114,7 +206,7 @@ class ProductsController(validation.BaseRestControllerWithValidation):
if product['product_type'] == const.DISTRO
else const.CLOUD)
if product['type'] == const.SOFTWARE:
- product['product_id'] = six.text_type(uuid.uuid4())
+ product['product_ref_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
@@ -151,8 +243,8 @@ class ProductsController(validation.BaseRestControllerWithValidation):
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 'product_ref_id' in kw:
+ product_info['product_ref_id'] = kw['product_ref_id']
if 'public' in kw:
# user can mark product as public only if
# his/her vendor is public(official)
@@ -174,11 +266,12 @@ 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)
+ try:
+ db.delete_product(id)
+ except DBReferenceError:
+ pecan.abort(400, 'Unable to delete. There are still tests '
+ 'associated to versions of this product.')
pecan.response.status = 204
diff --git a/refstack/api/controllers/results.py b/refstack/api/controllers/results.py
index 6c24615e..19b3f07e 100644
--- a/refstack/api/controllers/results.py
+++ b/refstack/api/controllers/results.py
@@ -76,6 +76,10 @@ class MetadataController(rest.RestController):
@pecan.expose('json')
def post(self, test_id, key):
"""Save value for key in test run metadata."""
+ test = db.get_test(test_id)
+ if test['verification_status'] == const.TEST_VERIFIED:
+ pecan.abort(403, 'Can not add/alter a new metadata key for a '
+ 'verified test run.')
db.save_test_meta_item(test_id, key, pecan.request.body)
pecan.response.status = 201
@@ -84,6 +88,10 @@ class MetadataController(rest.RestController):
@pecan.expose('json')
def delete(self, test_id, key):
"""Delete key from test run metadata."""
+ test = db.get_test(test_id)
+ if test['verification_status'] == const.TEST_VERIFIED:
+ pecan.abort(403, 'Can not delete a metadata key for a '
+ 'verified test run.')
db.delete_test_meta_item(test_id, key)
pecan.response.status = 204
@@ -103,7 +111,9 @@ class ResultsController(validation.BaseRestControllerWithValidation):
if user_role in (const.ROLE_FOUNDATION, const.ROLE_OWNER):
test_info = db.get_test(
test_id, allowed_keys=['id', 'cpid', 'created_at',
- 'duration_seconds', 'meta']
+ 'duration_seconds', 'meta',
+ 'product_version',
+ 'verification_status']
)
else:
test_info = db.get_test(test_id)
@@ -113,6 +123,12 @@ class ResultsController(validation.BaseRestControllerWithValidation):
'user_role': user_role})
if user_role not in (const.ROLE_FOUNDATION, const.ROLE_OWNER):
+ # Don't expose product information if product is not public.
+ if (test_info.get('product_version') and
+ not test_info['product_version']['product_info']['public']):
+
+ test_info['product_version'] = None
+
test_info['meta'] = {
k: v for k, v in six.iteritems(test_info['meta'])
if k in MetadataController.rw_access_keys
@@ -142,6 +158,10 @@ class ResultsController(validation.BaseRestControllerWithValidation):
@api_utils.check_permissions(level=const.ROLE_OWNER)
def delete(self, test_id):
"""Delete test run."""
+ test = db.get_test(test_id)
+ if test['verification_status'] == const.TEST_VERIFIED:
+ pecan.abort(403, 'Can not delete a verified test run.')
+
db.delete_test(test_id)
pecan.response.status = 204
@@ -161,10 +181,23 @@ class ResultsController(validation.BaseRestControllerWithValidation):
const.START_DATE,
const.END_DATE,
const.CPID,
- const.SIGNED
+ const.SIGNED,
+ const.VERIFICATION_STATUS,
+ const.PRODUCT_ID
]
filters = api_utils.parse_input_params(expected_input_params)
+
+ if const.PRODUCT_ID in filters:
+ product = db.get_product(filters[const.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 is_admin:
+ filters[const.ALL_PRODUCT_TESTS] = True
+ elif not product['public']:
+ pecan.abort(403, 'Forbidden.')
+
records_count = db.get_test_records_count(filters)
page_number, total_pages_number = \
api_utils.get_page_number(records_count)
@@ -172,13 +205,18 @@ class ResultsController(validation.BaseRestControllerWithValidation):
try:
per_page = CONF.api.results_per_page
results = db.get_test_records(page_number, per_page, filters)
-
+ is_foundation = api_utils.check_user_is_foundation_admin()
for result in results:
- # Only show all metadata if the user is the owner or a member
- # of the Foundation group.
- if (not api_utils.check_owner(result['id']) and
- not api_utils.check_user_is_foundation_admin()):
+ if not (api_utils.check_owner(result['id']) or is_foundation):
+
+ # Don't expose product info if the product is not public.
+ if (result.get('product_version') and not
+ result['product_version']['product_info']['public']):
+
+ result['product_version'] = None
+ # Only show all metadata if the user is the owner or a
+ # member of the Foundation group.
result['meta'] = {
k: v for k, v in six.iteritems(result['meta'])
if k in MetadataController.rw_access_keys
@@ -194,7 +232,65 @@ class ResultsController(validation.BaseRestControllerWithValidation):
}}
except Exception as ex:
LOG.debug('An error occurred during '
- 'operation with database: %s' % ex)
- pecan.abort(400)
+ 'operation with database: %s' % str(ex))
+ pecan.abort(500)
return page
+
+ @api_utils.check_permissions(level=const.ROLE_OWNER)
+ @pecan.expose('json')
+ def put(self, test_id, **kw):
+ """Update a test result."""
+ test_info = {'id': test_id}
+ is_foundation_admin = api_utils.check_user_is_foundation_admin()
+
+ if 'product_version_id' in kw:
+ test = db.get_test(test_id)
+ if test['verification_status'] == const.TEST_VERIFIED:
+ pecan.abort(403, 'Can not update product_version_id for a '
+ 'verified test run.')
+
+ if kw['product_version_id']:
+ # Verify that the user is a member of the product's vendor.
+ version = db.get_product_version(kw['product_version_id'],
+ allowed_keys=['product_id'])
+ is_vendor_admin = (
+ api_utils
+ .check_user_is_product_admin(version['product_id'])
+ )
+ else:
+ # No product vendor to check membership for, so just set
+ # is_vendor_admin to True.
+ is_vendor_admin = True
+ kw['product_version_id'] = None
+
+ if not is_vendor_admin and not is_foundation_admin:
+ pecan.abort(403, 'Forbidden.')
+
+ test_info['product_version_id'] = kw['product_version_id']
+
+ if 'verification_status' in kw:
+ if not is_foundation_admin:
+ pecan.abort(403, 'You do not have permission to change a '
+ 'verification status.')
+
+ if kw['verification_status'] not in (0, 1):
+ pecan.abort(400, 'Invalid verification_status value: %d' %
+ kw['verification_status'])
+
+ # Check pre-conditions are met to mark a test verified.
+ if (kw['verification_status'] == 1 and
+ not (db.get_test_meta_key(test_id, 'target') and
+ db.get_test_meta_key(test_id, 'guideline') and
+ db.get_test_meta_key(test_id, const.SHARED_TEST_RUN))):
+
+ pecan.abort(403, 'In order to mark a test verified, the '
+ 'test must be shared and have been '
+ 'associated to a guideline and target '
+ 'program.')
+
+ test_info['verification_status'] = kw['verification_status']
+
+ test = db.update_test(test_info)
+ pecan.response.status = 201
+ return test
diff --git a/refstack/api/controllers/vendors.py b/refstack/api/controllers/vendors.py
index 9f7e08ed..8b554f96 100644
--- a/refstack/api/controllers/vendors.py
+++ b/refstack/api/controllers/vendors.py
@@ -19,6 +19,7 @@ import json
import six
from oslo_config import cfg
+from oslo_db.exception import DBReferenceError
from oslo_log import log
import pecan
from pecan import rest
@@ -196,7 +197,11 @@ class VendorsController(validation.BaseRestControllerWithValidation):
pecan.abort(403, 'Forbidden.')
_check_is_not_foundation(vendor_id)
- db.delete_organization(vendor_id)
+ try:
+ db.delete_organization(vendor_id)
+ except DBReferenceError:
+ pecan.abort(400, 'Unable to delete. There are still tests '
+ 'associated to products for this vendor.')
pecan.response.status = 204
@secure(api_utils.is_authenticated)
@@ -204,65 +209,99 @@ class VendorsController(validation.BaseRestControllerWithValidation):
def action(self, vendor_id, **kw):
"""Handler for action on Vendor object."""
params = list()
- for param in ('register', 'approve', 'deny'):
+ for param in ('register', 'approve', 'deny', 'cancel'):
if param in kw:
params.append(param)
if len(params) != 1:
- raise api_exc.ValidationError('Invalid actions in the body: ')
+ raise api_exc.ValidationError(
+ 'Invalid actions in the body: ' + str(params))
+ vendor = db.get_organization(vendor_id)
if 'register' in params:
- self.register(vendor_id)
+ self.register(vendor)
elif 'approve' in params:
- self.approve(vendor_id)
+ self.approve(vendor)
+ elif 'cancel' in params:
+ self.cancel(vendor)
else:
- self.deny(vendor_id, kw.get('reason'))
+ self.deny(vendor, kw.get('reason'))
- def register(self, vendor_id):
+ def register(self, vendor):
"""Handler for applying for registration with Foundation."""
- if not api_utils.check_user_is_vendor_admin(vendor_id):
+ if not api_utils.check_user_is_vendor_admin(vendor['id']):
pecan.abort(403, 'Forbidden.')
- _check_is_not_foundation(vendor_id)
+ _check_is_not_foundation(vendor['id'])
+
+ if vendor['type'] != const.PRIVATE_VENDOR:
+ raise api_exc.ValidationError(
+ 'Invalid organization state for this action.')
# change vendor type to pending
org_info = {
- 'id': vendor_id,
+ 'id': vendor['id'],
'type': const.PENDING_VENDOR}
db.update_organization(org_info)
- def approve(self, vendor_id):
+ def approve(self, vendor):
"""Handler for making vendor official."""
if not api_utils.check_user_is_foundation_admin():
pecan.abort(403, 'Forbidden.')
- _check_is_not_foundation(vendor_id)
+ _check_is_not_foundation(vendor['id'])
+
+ if vendor['type'] != const.PENDING_VENDOR:
+ raise api_exc.ValidationError(
+ 'Invalid organization state for this action.')
# change vendor type to public
- vendor = db.get_organization(vendor_id)
props = vendor.get('properties')
props = json.loads(props) if props else {}
props.pop('reason', None)
org_info = {
- 'id': vendor_id,
+ 'id': vendor['id'],
'type': const.OFFICIAL_VENDOR,
'properties': json.dumps(props)}
db.update_organization(org_info)
- def deny(self, vendor_id, reason):
- """Handler for denying a vendor."""
- if not reason:
- raise api_exc.ValidationError('Param "reason" can not be empty')
+ def cancel(self, vendor):
+ """Handler for canceling registration.
+ This action available to user. It allows him to cancel
+ registrationand move state of his vendor from pending
+ to private.
+ """
+ if not api_utils.check_user_is_vendor_admin(vendor['id']):
+ pecan.abort(403, 'Forbidden.')
+ _check_is_not_foundation(vendor['id'])
+
+ if vendor['type'] != const.PENDING_VENDOR:
+ raise api_exc.ValidationError(
+ 'Invalid organization state for this action.')
+
+ # change vendor type back to private
+ org_info = {
+ 'id': vendor['id'],
+ 'type': const.PRIVATE_VENDOR}
+ db.update_organization(org_info)
+
+ def deny(self, vendor, reason):
+ """Handler for denying a vendor."""
if not api_utils.check_user_is_foundation_admin():
pecan.abort(403, 'Forbidden.')
- _check_is_not_foundation(vendor_id)
+ _check_is_not_foundation(vendor['id'])
+
+ if not reason:
+ raise api_exc.ValidationError('Param "reason" can not be empty')
+ if vendor['type'] != const.PENDING_VENDOR:
+ raise api_exc.ValidationError(
+ 'Invalid organization state for this action.')
- vendor = db.get_organization(vendor_id)
props = vendor.get('properties')
props = json.loads(props) if props else {}
props['reason'] = reason
# change vendor type back to private
org_info = {
- 'id': vendor_id,
+ 'id': vendor['id'],
'type': const.PRIVATE_VENDOR,
'properties': json.dumps(props)}
db.update_organization(org_info)
diff --git a/refstack/api/utils.py b/refstack/api/utils.py
index a3d14069..fe8fc5bd 100644
--- a/refstack/api/utils.py
+++ b/refstack/api/utils.py
@@ -249,8 +249,16 @@ def check_owner(test_id):
"""Check that user has access to specified test run as owner."""
if not is_authenticated():
return False
- user = db.get_test_meta_key(test_id, const.USER)
- return user and user == get_user_id()
+
+ test = db.get_test(test_id)
+ # If the test is owned by a product.
+ if test.get('product_version_id'):
+ version = db.get_product_version(test['product_version_id'])
+ return check_user_is_product_admin(version['product_id'])
+ # Otherwise, check the user ownership.
+ else:
+ user = db.get_test_meta_key(test_id, const.USER)
+ return user and user == get_user_id()
def check_permissions(level):
@@ -330,3 +338,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)
diff --git a/refstack/api/validators.py b/refstack/api/validators.py
index 8d1cc8c2..48bc3dd8 100644
--- a/refstack/api/validators.py
+++ b/refstack/api/validators.py
@@ -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)
diff --git a/refstack/db/api.py b/refstack/db/api.py
index 27238066..a63b70e0 100644
--- a/refstack/db/api.py
+++ b/refstack/db/api.py
@@ -64,6 +64,14 @@ def delete_test(test_id):
return IMPL.delete_test(test_id)
+def update_test(test_info):
+ """Update test from the given test_info dictionary.
+
+ :param test_info: The test
+ """
+ return IMPL.update_test(test_info)
+
+
def get_test_results(test_id):
"""Get all passed tempest tests for a specified test run.
@@ -202,9 +210,9 @@ def update_product(product_info):
return IMPL.update_product(product_info)
-def get_product(id):
+def get_product(id, allowed_keys=None):
"""Get product by id."""
- return IMPL.get_product(id)
+ return IMPL.get_product(id, allowed_keys=allowed_keys)
def delete_product(id):
@@ -251,3 +259,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)
diff --git a/refstack/db/migrations/alembic/versions/23843be3da52_add_product_version_id.py b/refstack/db/migrations/alembic/versions/23843be3da52_add_product_version_id.py
new file mode 100644
index 00000000..9c3fc176
--- /dev/null
+++ b/refstack/db/migrations/alembic/versions/23843be3da52_add_product_version_id.py
@@ -0,0 +1,28 @@
+"""Add product_version_id column to test.
+
+Revision ID: 23843be3da52
+Revises: 35bf54e2c13c
+Create Date: 2016-07-30 18:15:52.429610
+"""
+
+# revision identifiers, used by Alembic.
+revision = '23843be3da52'
+down_revision = '35bf54e2c13c'
+MYSQL_CHARSET = 'utf8'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ """Upgrade DB."""
+ op.add_column('test', sa.Column('product_version_id', sa.String(36),
+ nullable=True))
+ op.create_foreign_key('fk_test_prod_version_id', 'test', 'product_version',
+ ['product_version_id'], ['id'])
+
+
+def downgrade():
+ """Downgrade DB."""
+ op.drop_constraint('fk_test_prod_version_id', 'test', type_="foreignkey")
+ op.drop_column('test', 'product_version_id')
diff --git a/refstack/db/migrations/alembic/versions/35bf54e2c13c_add_product_version.py b/refstack/db/migrations/alembic/versions/35bf54e2c13c_add_product_version.py
new file mode 100644
index 00000000..782071e5
--- /dev/null
+++ b/refstack/db/migrations/alembic/versions/35bf54e2c13c_add_product_version.py
@@ -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))
diff --git a/refstack/db/migrations/alembic/versions/59df512e82f_add_verification_status.py b/refstack/db/migrations/alembic/versions/59df512e82f_add_verification_status.py
new file mode 100644
index 00000000..89021d84
--- /dev/null
+++ b/refstack/db/migrations/alembic/versions/59df512e82f_add_verification_status.py
@@ -0,0 +1,28 @@
+"""Add verification_status field to test.
+
+Revision ID: 59df512e82f
+Revises: 23843be3da52
+Create Date: 2016-09-26 11:51:08.955006
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '59df512e82f'
+down_revision = '23843be3da52'
+MYSQL_CHARSET = 'utf8'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ """Upgrade DB."""
+ op.add_column('test', sa.Column('verification_status',
+ sa.Integer,
+ nullable=False,
+ default=0))
+
+
+def downgrade():
+ """Downgrade DB."""
+ op.drop_column('test', 'verification_status')
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
index 6f846750..4ad19912 100644
--- 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
@@ -19,3 +19,8 @@ def upgrade():
"""Upgrade DB."""
op.alter_column('product', 'product_id', nullable=True,
type_=sa.String(36))
+
+
+def downgrade():
+ """Downgrade DB."""
+ pass
diff --git a/refstack/db/migrations/alembic/versions/709452f38a5c_product_table_rename_product_id.py b/refstack/db/migrations/alembic/versions/709452f38a5c_product_table_rename_product_id.py
new file mode 100644
index 00000000..79fd31b1
--- /dev/null
+++ b/refstack/db/migrations/alembic/versions/709452f38a5c_product_table_rename_product_id.py
@@ -0,0 +1,27 @@
+"""Rename product_id to product_ref_id.
+
+Revision ID: 709452f38a5c
+Revises: 7093ca478d35
+Create Date: 2016-06-27 13:10:00
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '709452f38a5c'
+down_revision = '7093ca478d35'
+MYSQL_CHARSET = 'utf8'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ """Upgrade DB."""
+ op.alter_column('product', 'product_id', new_column_name='product_ref_id',
+ type_=sa.String(36))
+
+
+def downgrade():
+ """Downgrade DB."""
+ op.alter_column('product', 'product_ref_id', new_column_name='product_id',
+ type_=sa.String(36))
diff --git a/refstack/db/sqlalchemy/api.py b/refstack/db/sqlalchemy/api.py
index ddadb0bf..72333e02 100644
--- a/refstack/db/sqlalchemy/api.py
+++ b/refstack/db/sqlalchemy/api.py
@@ -158,6 +158,24 @@ def delete_test(test_id):
raise NotFound('Test result %s not found' % test_id)
+def update_test(test_info):
+ """Update test from the given test_info dictionary."""
+ session = get_session()
+ _id = test_info.get('id')
+ test = session.query(models.Test).filter_by(id=_id).first()
+ if test is None:
+ raise NotFound('Test result with id %s not found' % _id)
+
+ keys = ['product_version_id', 'verification_status']
+ for key in keys:
+ if key in test_info:
+ setattr(test, key, test_info[key])
+
+ with session.begin():
+ test.save(session=session)
+ return _to_dict(test)
+
+
def get_test_meta_key(test_id, key, default=None):
"""Get metadata value related to specified test run."""
session = get_session()
@@ -220,6 +238,18 @@ def _apply_filters_for_query(query, filters):
if cpid:
query = query.filter(models.Test.cpid == cpid)
+ verification_status = filters.get(api_const.VERIFICATION_STATUS)
+ if verification_status:
+ query = query.filter(models.Test.verification_status ==
+ verification_status)
+
+ if api_const.PRODUCT_ID in filters:
+ query = (query
+ .join(models.ProductVersion)
+ .filter(models.ProductVersion.product_id ==
+ filters[api_const.PRODUCT_ID]))
+
+ all_product_tests = filters.get(api_const.ALL_PRODUCT_TESTS)
signed = api_const.SIGNED in filters
# If we only want to get the user's test results.
if signed:
@@ -228,7 +258,9 @@ def _apply_filters_for_query(query, filters):
.filter(models.TestMeta.meta_key == api_const.USER)
.filter(models.TestMeta.value == filters[api_const.OPENID])
)
- else:
+ elif not all_product_tests:
+ # Get all non-signed (aka anonymously uploaded) test results
+ # along with signed but shared test results.
signed_results = (query.session
.query(models.TestMeta.test_id)
.filter_by(meta_key=api_const.USER))
@@ -237,6 +269,7 @@ def _apply_filters_for_query(query, filters):
.filter_by(meta_key=api_const.SHARED_TEST_RUN))
query = (query.filter(models.Test.id.notin_(signed_results))
.union(query.filter(models.Test.id.in_(shared_results))))
+
return query
@@ -424,6 +457,12 @@ def delete_organization(organization_id):
"""delete organization by id."""
session = get_session()
with session.begin():
+ product_ids = (session
+ .query(models.Product.id)
+ .filter_by(organization_id=organization_id))
+ (session.query(models.ProductVersion).
+ filter(models.ProductVersion.product_id.in_(product_ids)).
+ delete(synchronize_session=False))
(session.query(models.Product).
filter_by(organization_id=organization_id).
delete(synchronize_session=False))
@@ -435,9 +474,10 @@ 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_id = product_info.get('product_id')
+ product.product_ref_id = product_info.get('product_ref_id')
product.name = product_info['name']
product.description = product_info.get('description')
product.organization_id = product_info['organization_id']
@@ -448,6 +488,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)
@@ -459,7 +505,7 @@ def update_product(product_info):
if product is None:
raise NotFound('Product with id %s not found' % _id)
- keys = ['name', 'description', 'product_id', 'public', 'properties']
+ keys = ['name', 'description', 'product_ref_id', 'public', 'properties']
for key in keys:
if key in product_info:
setattr(product, key, product_info[key])
@@ -469,19 +515,22 @@ def update_product(product_info):
return _to_dict(product)
-def get_product(id):
+def get_product(id, allowed_keys=None):
"""Get product by id."""
session = get_session()
product = session.query(models.Product).filter_by(id=id).first()
if product is None:
raise NotFound('Product with id "%s" not found' % id)
- return _to_dict(product)
+ return _to_dict(product, allowed_keys=allowed_keys)
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 +637,73 @@ 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' % product_version_id)
+ return _to_dict(version, allowed_keys=allowed_keys)
+
+
+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).all()
+ )
+ 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))
diff --git a/refstack/db/sqlalchemy/models.py b/refstack/db/sqlalchemy/models.py
index 566f03cb..480910a0 100644
--- a/refstack/db/sqlalchemy/models.py
+++ b/refstack/db/sqlalchemy/models.py
@@ -59,11 +59,16 @@ class Test(BASE, RefStackBase): # pragma: no cover
duration_seconds = sa.Column(sa.Integer, nullable=False)
results = orm.relationship('TestResults', backref='test')
meta = orm.relationship('TestMeta', backref='test')
+ product_version_id = sa.Column(sa.String(36),
+ sa.ForeignKey('product_version.id'),
+ nullable=True, unique=False)
+ verification_status = sa.Column(sa.Integer, nullable=False, default=0)
+ product_version = orm.relationship('ProductVersion', backref='test')
@property
def _extra_keys(self):
"""Relation should be pointed directly."""
- return ['results', 'meta']
+ return ['results', 'meta', 'product_version']
@property
def metadata_keys(self):
@@ -74,7 +79,8 @@ class Test(BASE, RefStackBase): # pragma: no cover
@property
def default_allowed_keys(self):
"""Default keys."""
- return 'id', 'created_at', 'duration_seconds', 'meta'
+ return ('id', 'created_at', 'duration_seconds', 'meta',
+ 'verification_status', 'product_version')
class TestResults(BASE, RefStackBase): # pragma: no cover
@@ -225,7 +231,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_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,6 +246,33 @@ class Product(BASE, RefStackBase): # pragma: no cover
@property
def default_allowed_keys(self):
"""Default keys."""
- return ('id', 'name', 'description', 'product_id', 'product_type',
- 'public', 'properties', 'created_at', 'updated_at',
- 'organization_id', 'created_by_user', 'type')
+ return ('id', 'name', 'organization_id', 'public')
+
+
+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)
+ product_info = orm.relationship('Product', backref='product_version')
+
+ @property
+ def _extra_keys(self):
+ """Relation should be pointed directly."""
+ return ['product_info']
+
+ @property
+ def default_allowed_keys(self):
+ """Default keys."""
+ return ('id', 'version', 'cpid', 'product_info')
diff --git a/refstack/tests/api/test_products.py b/refstack/tests/api/test_products.py
index c20a04c8..1ccca172 100644
--- a/refstack/tests/api/test_products.py
+++ b/refstack/tests/api/test_products.py
@@ -135,6 +135,17 @@ class TestProductsEndpoint(api.FunctionalTest):
self.get_json,
self.URL + post_response.get('id'))
+ mock_get_user.return_value = 'foo-open-id'
+ # Make product public.
+ product_info = {'id': post_response.get('id'), 'public': 1}
+ db.update_product(product_info)
+
+ # Test when getting product info when not owner/foundation.
+ get_response = self.get_json(self.URL + post_response.get('id'))
+ self.assertNotIn('created_by_user', get_response)
+ self.assertNotIn('created_at', get_response)
+ self.assertNotIn('updated_at', get_response)
+
@mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id')
def test_delete(self, mock_get_user):
"""Test delete request."""
@@ -179,3 +190,97 @@ 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 'products//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['version'], response[1]['version'])
+
+ 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['version'], response['version'])
+
+ # 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))
+
+ get_response = self.get_json(self.URL + post_response['id'])
+ self.assertEqual(version['cpid'], get_response['cpid'])
+ self.assertEqual(version['version'], get_response['version'])
+ self.assertEqual(self.product_id, get_response['product_id'])
+ self.assertIn('id', get_response)
+
+ # Test 'version' not in response body.
+ response = self.post_json(self.URL, expect_errors=True,
+ params=json.dumps({'cpid': '123'}))
+ self.assertEqual(400, response.status_code)
+
+ 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')
diff --git a/refstack/tests/api/test_results.py b/refstack/tests/api/test_results.py
index 59aa430a..df9ea1dc 100644
--- a/refstack/tests/api/test_results.py
+++ b/refstack/tests/api/test_results.py
@@ -15,11 +15,14 @@
import json
import uuid
+import mock
from oslo_config import fixture as config_fixture
import six
import webtest.app
+from refstack.api import constants as api_const
from refstack.api import validators
+from refstack import db
from refstack.tests import api
FAKE_TESTS_RESULT = {
@@ -79,6 +82,107 @@ class TestResultsEndpoint(api.FunctionalTest):
self.URL,
params=results)
+ @mock.patch('refstack.api.utils.check_owner')
+ @mock.patch('refstack.api.utils.check_user_is_foundation_admin')
+ @mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id')
+ def test_put(self, mock_user, mock_check_foundation, mock_check_owner):
+ """Test results endpoint with put request."""
+ results = json.dumps(FAKE_TESTS_RESULT)
+ test_response = self.post_json(self.URL, params=results)
+ test_id = test_response.get('test_id')
+ url = self.URL + test_id
+
+ user_info = {
+ 'openid': 'test-open-id',
+ 'email': 'foo@bar.com',
+ 'fullname': 'Foo Bar'
+ }
+ db.user_save(user_info)
+
+ fake_product = {
+ 'name': 'product name',
+ 'description': 'product description',
+ 'product_type': api_const.CLOUD,
+ }
+
+ # Create a product
+ product_response = self.post_json('/v1/products/',
+ params=json.dumps(fake_product))
+ # Create a product version
+ version_url = '/v1/products/' + product_response['id'] + '/versions/'
+ version_response = self.post_json(version_url,
+ params=json.dumps({'version': '1'}))
+
+ # Test Foundation admin can put.
+ mock_check_foundation.return_value = True
+ body = {'product_version_id': version_response['id']}
+ self.put_json(url, params=json.dumps(body))
+ get_response = self.get_json(url)
+ self.assertEqual(version_response['id'],
+ get_response['product_version']['id'])
+
+ # Test when product_version_id is None.
+ body = {'product_version_id': None}
+ self.put_json(url, params=json.dumps(body))
+ get_response = self.get_json(url)
+ self.assertIsNone(get_response['product_version'])
+
+ # Test when test verification preconditions are not met.
+ body = {'verification_status': api_const.TEST_VERIFIED}
+ put_response = self.put_json(url, expect_errors=True,
+ params=json.dumps(body))
+ self.assertEqual(403, put_response.status_code)
+
+ # Share the test run.
+ db.save_test_meta_item(test_id, api_const.SHARED_TEST_RUN, True)
+ put_response = self.put_json(url, expect_errors=True,
+ params=json.dumps(body))
+ self.assertEqual(403, put_response.status_code)
+
+ # Now associate guideline and target program. Now we should be
+ # able to mark a test verified.
+ db.save_test_meta_item(test_id, 'target', 'platform')
+ db.save_test_meta_item(test_id, 'guideline', '2016.01.json')
+ put_response = self.put_json(url, params=json.dumps(body))
+ self.assertEqual(api_const.TEST_VERIFIED,
+ put_response['verification_status'])
+
+ # Unshare the test, and check that we can mark it not verified.
+ db.delete_test_meta_item(test_id, api_const.SHARED_TEST_RUN)
+ body = {'verification_status': api_const.TEST_NOT_VERIFIED}
+ put_response = self.put_json(url, params=json.dumps(body))
+ self.assertEqual(api_const.TEST_NOT_VERIFIED,
+ put_response['verification_status'])
+
+ # Test when verification_status value is invalid.
+ body = {'verification_status': 111}
+ put_response = self.put_json(url, expect_errors=True,
+ params=json.dumps(body))
+ self.assertEqual(400, put_response.status_code)
+
+ # Check test owner can put.
+ mock_check_foundation.return_value = False
+ mock_check_owner.return_value = True
+ body = {'product_version_id': version_response['id']}
+ self.put_json(url, params=json.dumps(body))
+ get_response = self.get_json(url)
+ self.assertEqual(version_response['id'],
+ get_response['product_version']['id'])
+
+ # Test non-Foundation user can't change verification_status.
+ body = {'verification_status': 1}
+ put_response = self.put_json(url, expect_errors=True,
+ params=json.dumps(body))
+ self.assertEqual(403, put_response.status_code)
+
+ # Test unauthorized put.
+ mock_check_foundation.return_value = False
+ mock_check_owner.return_value = False
+ self.assertRaises(webtest.app.AppError,
+ self.put_json,
+ url,
+ params=json.dumps(body))
+
def test_get_one(self):
"""Test get request."""
results = json.dumps(FAKE_TESTS_RESULT)
@@ -223,3 +327,83 @@ class TestResultsEndpoint(api.FunctionalTest):
url = '/v1/results?end_date=1000-01-01 12:00:00'
filtering_results = self.get_json(url)
self.assertEqual([], filtering_results['results'])
+
+ @mock.patch('refstack.api.utils.get_user_id')
+ def test_get_with_product_id(self, mock_get_user):
+ user_info = {
+ 'openid': 'test-open-id',
+ 'email': 'foo@bar.com',
+ 'fullname': 'Foo Bar'
+ }
+ db.user_save(user_info)
+
+ mock_get_user.return_value = 'test-open-id'
+
+ fake_product = {
+ 'name': 'product name',
+ 'description': 'product description',
+ 'product_type': api_const.CLOUD,
+ }
+
+ product = json.dumps(fake_product)
+ response = self.post_json('/v1/products/', params=product)
+ product_id = response['id']
+
+ # Create a version.
+ version_url = '/v1/products/' + product_id + '/versions'
+ version = {'cpid': '123', 'version': '6.0'}
+ post_response = self.post_json(version_url, params=json.dumps(version))
+ version_id = post_response['id']
+
+ # Create a test and associate it to the product version and user.
+ results = json.dumps(FAKE_TESTS_RESULT)
+ post_response = self.post_json('/v1/results', params=results)
+ test_id = post_response['test_id']
+ test_info = {'id': test_id, 'product_version_id': version_id}
+ db.update_test(test_info)
+ db.save_test_meta_item(test_id, api_const.USER, 'test-open-id')
+
+ url = self.URL + '?page=1&product_id=' + product_id
+
+ # Test GET.
+ response = self.get_json(url)
+ self.assertEqual(1, len(response['results']))
+ self.assertEqual(test_id, response['results'][0]['id'])
+
+ # Test unauthorized.
+ mock_get_user.return_value = 'test-foo-id'
+ response = self.get_json(url, expect_errors=True)
+ self.assertEqual(403, response.status_code)
+
+ # Make product public.
+ product_info = {'id': product_id, 'public': 1}
+ db.update_product(product_info)
+
+ # Test result is not shared yet, so no tests should return.
+ response = self.get_json(url)
+ self.assertFalse(response['results'])
+
+ # Share the test run.
+ db.save_test_meta_item(test_id, api_const.SHARED_TEST_RUN, 1)
+ response = self.get_json(url)
+ self.assertEqual(1, len(response['results']))
+ self.assertEqual(test_id, response['results'][0]['id'])
+
+ @mock.patch('refstack.api.utils.check_owner')
+ def test_delete(self, mock_check_owner):
+ results = json.dumps(FAKE_TESTS_RESULT)
+ test_response = self.post_json(self.URL, params=results)
+ test_id = test_response.get('test_id')
+ url = self.URL + test_id
+
+ mock_check_owner.return_value = True
+
+ # Test can't delete verified test run.
+ db.update_test({'id': test_id, 'verification_status': 1})
+ resp = self.delete(url, expect_errors=True)
+ self.assertEqual(403, resp.status_code)
+
+ # Test can delete verified test run.
+ db.update_test({'id': test_id, 'verification_status': 0})
+ resp = self.delete(url, expect_errors=True)
+ self.assertEqual(204, resp.status_code)
diff --git a/refstack/tests/unit/test_api.py b/refstack/tests/unit/test_api.py
index 85fe6d0e..deb21b15 100644
--- a/refstack/tests/unit/test_api.py
+++ b/refstack/tests/unit/test_api.py
@@ -140,7 +140,9 @@ class ResultsControllerTestCase(BaseControllerTestCase):
mock_get_test_res.assert_called_once_with('fake_arg')
mock_get_test.assert_called_once_with(
'fake_arg', allowed_keys=['id', 'cpid', 'created_at',
- 'duration_seconds', 'meta']
+ 'duration_seconds', 'meta',
+ 'product_version',
+ 'verification_status']
)
@mock.patch('refstack.db.store_results')
@@ -247,7 +249,9 @@ class ResultsControllerTestCase(BaseControllerTestCase):
const.START_DATE,
const.END_DATE,
const.CPID,
- const.SIGNED
+ const.SIGNED,
+ const.VERIFICATION_STATUS,
+ const.PRODUCT_ID
]
page_number = 1
total_pages_number = 10
@@ -285,13 +289,21 @@ class ResultsControllerTestCase(BaseControllerTestCase):
db_get_test.assert_called_once_with(page_number, per_page, filters)
+ @mock.patch('refstack.db.get_test')
@mock.patch('refstack.db.delete_test')
- def test_delete(self, mock_db_delete):
+ def test_delete(self, mock_db_delete, mock_get_test):
self.mock_get_user_role.return_value = const.ROLE_OWNER
+
self.controller.delete('test_id')
self.assertEqual(204, self.mock_response.status)
+
+ # Verified test deletion attempt should raise error.
+ mock_get_test.return_value = {'verification_status':
+ const.TEST_VERIFIED}
+ self.assertRaises(webob.exc.HTTPError,
+ self.controller.delete, 'test_id')
+
self.mock_get_user_role.return_value = const.ROLE_USER
- self.mock_abort.side_effect = webob.exc.HTTPError()
self.assertRaises(webob.exc.HTTPError,
self.controller.delete, 'test_id')
@@ -633,9 +645,13 @@ class MetadataControllerTestCase(BaseControllerTestCase):
self.mock_get_user_role.return_value = const.ROLE_FOUNDATION
self.assertEqual(42, self.controller.get_one('test_id', 'user'))
+ @mock.patch('refstack.db.get_test')
@mock.patch('refstack.db.save_test_meta_item')
- def test_post(self, mock_save_test_meta_item):
+ def test_post(self, mock_save_test_meta_item, mock_get_test):
self.mock_get_user_role.return_value = const.ROLE_OWNER
+ mock_get_test.return_value = {
+ 'verification_status': const.TEST_NOT_VERIFIED
+ }
# Test trying to post a valid key.
self.controller.post('test_id', 'shared')
@@ -653,9 +669,13 @@ class MetadataControllerTestCase(BaseControllerTestCase):
self.assertRaises(webob.exc.HTTPError,
self.controller.post, 'test_id', 'shared')
+ @mock.patch('refstack.db.get_test')
@mock.patch('refstack.db.delete_test_meta_item')
- def test_delete(self, mock_delete_test_meta_item):
+ def test_delete(self, mock_delete_test_meta_item, mock_get_test):
self.mock_get_user_role.return_value = const.ROLE_OWNER
+ mock_get_test.return_value = {
+ 'verification_status': const.TEST_NOT_VERIFIED
+ }
self.controller.delete('test_id', 'shared')
self.assertEqual(204, self.mock_response.status)
mock_delete_test_meta_item.assert_called_once_with('test_id', 'shared')
diff --git a/refstack/tests/unit/test_api_utils.py b/refstack/tests/unit/test_api_utils.py
index c0bdf35e..c318b5d6 100644
--- a/refstack/tests/unit/test_api_utils.py
+++ b/refstack/tests/unit/test_api_utils.py
@@ -336,16 +336,19 @@ class APIUtilsTestCase(base.BaseTestCase):
@mock.patch('refstack.api.utils.check_user_is_foundation_admin')
@mock.patch('pecan.abort', side_effect=exc.HTTPError)
@mock.patch('refstack.db.get_test_meta_key')
+ @mock.patch('refstack.db.get_test')
@mock.patch.object(api_utils, 'is_authenticated')
@mock.patch.object(api_utils, 'get_user_id')
def test_check_get_user_role(self, mock_get_user_id,
mock_is_authenticated,
+ mock_get_test,
mock_get_test_meta_key,
mock_pecan_abort,
mock_check_foundation):
# Check user level
mock_check_foundation.return_value = False
mock_get_test_meta_key.return_value = None
+ mock_get_test.return_value = {}
self.assertEqual(const.ROLE_USER, api_utils.get_user_role('fake_test'))
api_utils.enforce_permissions('fake_test', const.ROLE_USER)
self.assertRaises(exc.HTTPError, api_utils.enforce_permissions,
@@ -409,10 +412,12 @@ class APIUtilsTestCase(base.BaseTestCase):
@mock.patch('refstack.api.utils.check_user_is_foundation_admin')
@mock.patch('pecan.abort', side_effect=exc.HTTPError)
@mock.patch('refstack.db.get_test_meta_key')
+ @mock.patch('refstack.db.get_test')
@mock.patch.object(api_utils, 'is_authenticated')
@mock.patch.object(api_utils, 'get_user_id')
def test_check_permissions(self, mock_get_user_id,
mock_is_authenticated,
+ mock_get_test,
mock_get_test_meta_key,
mock_pecan_abort,
mock_foundation_check):
@@ -437,6 +442,7 @@ class APIUtilsTestCase(base.BaseTestCase):
private_test = 'fake_test'
mock_get_user_id.return_value = 'fake_openid'
+ mock_get_test.return_value = {}
mock_get_test_meta_key.side_effect = lambda *args: {
(public_test, const.USER): None,
(private_test, const.USER): 'fake_openid',
diff --git a/refstack/tests/unit/test_db.py b/refstack/tests/unit/test_db.py
index 71c775bf..2d51203a 100644
--- a/refstack/tests/unit/test_db.py
+++ b/refstack/tests/unit/test_db.py
@@ -254,6 +254,21 @@ class DBBackendTestCase(base.BaseTestCase):
.first.return_value = None
self.assertRaises(api.NotFound, db.delete_test, 'fake_id')
+ @mock.patch.object(api, 'get_session')
+ @mock.patch.object(api, '_to_dict', side_effect=lambda x: x)
+ def test_update_test(self, mock_to_dict, mock_get_session):
+ session = mock_get_session.return_value
+ mock_test = mock.Mock()
+ session.query.return_value.filter_by.return_value\
+ .first.return_value = mock_test
+
+ test_info = {'product_version_id': '123'}
+ api.update_test(test_info)
+
+ mock_get_session.assert_called_once_with()
+ mock_test.save.assert_called_once_with(session=session)
+ session.begin.assert_called_once_with()
+
@mock.patch('refstack.db.sqlalchemy.api.models')
@mock.patch.object(api, 'get_session')
def test_get_test_meta_key(self, mock_get_session, mock_models):
@@ -678,17 +693,23 @@ 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_id': 'hash_or_guid', 'name': 'a',
+ product_info = {'product_ref_id': 'hash_or_guid', 'name': 'a',
'organization_id': 'GUID0', 'type': 0,
'product_type': 0}
result = api.add_product(product_info, 'user-123')
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)
@@ -710,12 +731,13 @@ class DBBackendTestCase(base.BaseTestCase):
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, 'id': '123'}
+ product_info = {'product_ref_id': '098', 'name': 'a',
+ 'description': 'b', 'creator_openid': 'abc',
+ 'organization_id': '1', 'type': 0, 'product_type': 0,
+ 'id': '123'}
api.update_product(product_info)
- self.assertEqual('098', product.product_id)
+ self.assertEqual('098', product.product_ref_id)
self.assertIsNone(product.created_by_user)
self.assertIsNone(product.organization_id)
self.assertIsNone(product.type)
@@ -747,7 +769,7 @@ class DBBackendTestCase(base.BaseTestCase):
@mock.patch.object(api, 'get_session',
return_value=mock.Mock(name='session'),)
@mock.patch('refstack.db.sqlalchemy.models.Product')
- @mock.patch.object(api, '_to_dict', side_effect=lambda x: x)
+ @mock.patch.object(api, '_to_dict', side_effect=lambda x, allowed_keys: x)
def test_product_get(self, mock_to_dict, mock_model, mock_get_session):
_id = 12345
session = mock_get_session.return_value
@@ -768,7 +790,9 @@ class DBBackendTestCase(base.BaseTestCase):
session = mock_get_session.return_value
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(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)))