Trunks panel: item and batch delete

Add delete buttons to the Project/Network/Trunks panel. There is one
button per each trunk item deleting only that trunk. Plus a select and
delete many trunks at once by checkboxes and then delete all selected.
The usual extras (confirmations and toast notifications) are included.

Change-Id: Ie88e169072a563fa238bf870664b71aa7f2a883d
Partially-Implements: bp/neutron-trunk-ui
This commit is contained in:
Bence Romsics 2017-04-03 15:40:50 +02:00
parent fcd30d95e8
commit 9120b40038
11 changed files with 608 additions and 9 deletions

View File

@ -780,6 +780,12 @@ def trunk_list(request, **params):
return [Trunk(t) for t in trunks]
@profiler.trace
def trunk_delete(request, trunk_id):
LOG.debug("trunk_delete(): trunk_id=%s", trunk_id)
neutronclient(request).delete_trunk(trunk_id)
@profiler.trace
def network_list(request, **params):
LOG.debug("network_list(): params=%s", params)

View File

@ -137,6 +137,16 @@ class Ports(generic.View):
return{'items': [n.to_dict() for n in result]}
@urls.register
class Trunk(generic.View):
"""API for a single neutron Trunk"""
url_regex = r'neutron/trunks/(?P<trunk_id>[^/]+)/$'
@rest_utils.ajax()
def delete(self, request, trunk_id):
api.neutron.trunk_delete(request, trunk_id)
@urls.register
class Trunks(generic.View):
"""API for neutron Trunks"""

View File

@ -35,17 +35,18 @@
*/
function neutronAPI(apiService, toastService) {
var service = {
getNetworks: getNetworks,
createNetwork: createNetwork,
getSubnets: getSubnets,
createSubnet: createSubnet,
getPorts: getPorts,
deleteTrunk: deleteTrunk,
getAgents: getAgents,
getExtensions: getExtensions,
getDefaultQuotaSets: getDefaultQuotaSets,
updateProjectQuota: updateProjectQuota,
getExtensions: getExtensions,
getNetworks: getNetworks,
getPorts: getPorts,
getQoSPolicies: getQoSPolicies,
getSubnets: getSubnets,
getTrunks: getTrunks,
getQoSPolicies: getQoSPolicies
updateProjectQuota: updateProjectQuota
};
return service;
@ -368,5 +369,21 @@
toastService.add('error', gettext('Unable to retrieve the trunks.'));
});
}
/**
* @name deleteTrunk
* @description
* Delete a single neutron trunk.
* @param {string} trunkId
* UUID of a trunk to be deleted.
*/
function deleteTrunk(trunkId) {
var promise = apiService.delete('/api/neutron/trunks/' + trunkId + '/');
return promise.error(function() {
var msg = gettext('Unable to delete trunk: %(id)s');
toastService.add('error', interpolate(msg, { id: trunkId }, true));
});
}
}
}());

View File

@ -143,8 +143,21 @@
"func": "getTrunks",
"method": "get",
"path": "/api/neutron/trunks/",
"data": {"params": 42},
"error": "Unable to retrieve the trunks.",
"data": {
"params": {
"project_id": 1
}
},
"testInput": [
{"project_id": 1}
],
"error": "Unable to retrieve the trunks."
},
{
"func": "deleteTrunk",
"method": "delete",
"path": "/api/neutron/trunks/42/",
"error": "Unable to delete trunk: 42",
"testInput": [
42
]

View File

@ -0,0 +1,69 @@
/**
* Copyright 2017 Ericsson
*
* 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';
/**
* @ngdoc overview
* @ngname horizon.app.core.trunks.actions
*
* @description
* Provides all trunk actions.
*/
angular.module('horizon.app.core.trunks.actions', [
'horizon.framework.conf',
'horizon.app.core.trunks'
])
.run(registerTrunkActions);
registerTrunkActions.$inject = [
'horizon.framework.conf.resource-type-registry.service',
'horizon.app.core.trunks.actions.delete.service',
'horizon.app.core.trunks.resourceType'
];
function registerTrunkActions(
registry,
deleteService,
trunkResourceTypeCode
) {
var trunkResourceType = registry.getResourceType(trunkResourceTypeCode);
trunkResourceType.itemActions
.append({
id: 'deleteTrunkAction',
service: deleteService,
template: {
text: gettext('Delete Trunk'),
type: 'delete'
}
});
trunkResourceType.batchActions
.append({
id: 'batchDeleteTrunkAction',
service: deleteService,
template: {
text: gettext('Delete Trunks'),
type: 'delete-selected'
}
});
}
})();

View File

@ -0,0 +1,50 @@
/**
* Copyright 2017 Ericsson
*
* 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';
describe('trunk actions module', function() {
var registry;
beforeEach(module('horizon.app.core.trunks.actions'));
beforeEach(inject(function($injector) {
registry = $injector.get('horizon.framework.conf.resource-type-registry.service');
}));
it('registers Delete Trunk as an item action', function() {
var actions = registry.getResourceType('OS::Neutron::Trunk').itemActions;
expect(actionHasId(actions, 'deleteTrunkAction')).toBe(true);
});
it('registers Delete Trunk as a batch action', function() {
var actions = registry.getResourceType('OS::Neutron::Trunk').batchActions;
expect(actionHasId(actions, 'batchDeleteTrunkAction')).toBe(true);
});
function actionHasId(list, value) {
return list.filter(matchesId).length === 1;
function matchesId(action) {
if (action.id === value) {
return true;
}
}
}
});
})();

View File

@ -0,0 +1,153 @@
/**
* Copyright 2017 Ericsson
*
* 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('horizon.app.core.trunks')
.factory(
'horizon.app.core.trunks.actions.delete.service',
deleteTrunkService
);
deleteTrunkService.$inject = [
'$q',
'horizon.app.core.openstack-service-api.neutron',
'horizon.app.core.openstack-service-api.userSession',
'horizon.app.core.openstack-service-api.policy',
'horizon.framework.util.actions.action-result.service',
'horizon.framework.util.i18n.gettext',
'horizon.framework.util.q.extensions',
'horizon.framework.widgets.modal.deleteModalService',
'horizon.framework.widgets.toast.service',
'horizon.app.core.trunks.resourceType'
];
function deleteTrunkService(
$q,
neutron,
userSessionService,
policy,
actionResultService,
gettext,
$qExtensions,
deleteModal,
toast,
trunkResourceType
) {
var scope, context, deleteTrunkPromise;
var service = {
initScope: initScope,
allowed: allowed,
perform: perform
};
return service;
function initScope(newScope) {
scope = newScope;
context = {};
deleteTrunkPromise = policy.ifAllowed(
{rules: [['trunk', 'delete_trunk']]});
}
function perform(items) {
var Trunks = angular.isArray(items) ? items : [items];
context.labels = labelize(Trunks.length);
context.deleteEntity = neutron.deleteTrunk;
return $qExtensions.allSettled(Trunks.map(checkPermission))
.then(afterCheck);
}
function allowed(trunk) {
if (trunk) {
return $q.all([
deleteTrunkPromise,
userSessionService.isCurrentProject(trunk.project_id)
]);
} else {
return deleteTrunkPromise;
}
}
function checkPermission(trunk) {
return {promise: allowed(trunk), context: trunk};
}
function afterCheck(result) {
var outcome = $q.reject();
if (result.fail.length > 0) {
var msg = interpolate(
gettext("You are not allowed to delete trunks: %s"),
[result.fail.map(function (x) {return x.context.name;}).join(", ")]);
toast.add('error', msg, result.fail);
outcome = $q.reject(result.fail);
}
if (result.pass.length > 0) {
outcome = deleteModal.open(
scope,
result.pass.map(function (x) {return x.context;}),
context)
.then(createResult);
}
return outcome;
}
function createResult(deleteModalResult) {
var actionResult = actionResultService.getActionResult();
deleteModalResult.pass.forEach(function markDeleted(item) {
actionResult.deleted(trunkResourceType, item.context.id);
});
deleteModalResult.fail.forEach(function markFailed(item) {
actionResult.failed(trunkResourceType, item.context.id);
});
return actionResult.result;
}
function labelize(count) {
return {
title: ngettext(
'Confirm Delete Trunk',
'Confirm Delete Trunks',
count),
message: ngettext(
'You have selected "%s". Deleted Trunk is not recoverable.',
'You have selected "%s". Deleted Trunks are not recoverable.',
count),
submit: ngettext(
'Delete Trunk',
'Delete Trunks',
count),
success: ngettext(
'Deleted Trunk: %s.',
'Deleted Trunks: %s.',
count),
error: ngettext(
'Unable to delete Trunk: %s.',
'Unable to delete Trunks: %s.',
count)
};
}
}
})();

View File

@ -0,0 +1,262 @@
/**
* Copyright 2017 Ericsson
*
* 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';
describe('horizon.app.core.trunks.actions.delete.service', function() {
var deleteModalService = {
open: function () {
deferredModal.resolve({
pass: [{context: {id: 'a'}}],
fail: [{context: {id: 'b'}}]
});
return deferredModal.promise;
}
};
var neutronAPI = {
deleteTrunk: function() {
return;
}
};
var policyAPI = {
ifAllowed: function() {
return {
success: function(callback) {
callback({allowed: true});
}
};
}
};
var userSession = {
isCurrentProject: function() {
deferred.resolve();
return deferred.promise;
}
};
var deferred, service, $scope, deferredModal;
///////////////////////
beforeEach(module('horizon.app.core'));
beforeEach(module('horizon.app.core.trunks'));
beforeEach(module('horizon.framework'));
beforeEach(module('horizon.framework.widgets.modal', function($provide) {
$provide.value('horizon.framework.widgets.modal.deleteModalService', deleteModalService);
}));
beforeEach(module('horizon.app.core.openstack-service-api', function($provide) {
$provide.value('horizon.app.core.openstack-service-api.neutron', neutronAPI);
$provide.value('horizon.app.core.openstack-service-api.policy', policyAPI);
$provide.value('horizon.app.core.openstack-service-api.userSession', userSession);
spyOn(policyAPI, 'ifAllowed').and.callThrough();
spyOn(userSession, 'isCurrentProject').and.callThrough();
}));
beforeEach(inject(function($injector, _$rootScope_, $q) {
$scope = _$rootScope_.$new();
service = $injector.get('horizon.app.core.trunks.actions.delete.service');
deferred = $q.defer();
deferredModal = $q.defer();
}));
function generateTrunk(trunkCount) {
var trunks = [];
var data = {
owner: 'project',
name: '',
id: ''
};
for (var index = 0; index < trunkCount; index++) {
var trunk = angular.copy(data);
trunk.id = String(index);
trunk.name = 'trunk' + index;
trunks.push(trunk);
}
return trunks;
}
describe('perform method', function() {
beforeEach(function() {
spyOn(deleteModalService, 'open').and.callThrough();
service.initScope($scope, labelize);
});
function labelize(count) {
return {
title: ngettext('title', 'titles', count),
message: ngettext('message', 'messages', count),
submit: ngettext('submit', 'submits', count),
success: ngettext('success', 'successes', count),
error: ngettext('error', 'errors', count)
};
}
////////////
it('should open the delete modal and show correct singular labels', testSingleLabels);
it('should open the delete modal and show correct labels, one object', testSingleObject);
it('should open the delete modal and show correct plural labels', testpluralLabels);
it('should open the delete modal with correct entities', testEntities);
it('should only delete trunks that are valid', testValids);
it('should fail if this project is not owner', testOwner);
it('should pass in a function that deletes a trunk', testNeutron);
////////////
function testSingleObject() {
var trunks = generateTrunk(1);
service.perform(trunks[0]);
$scope.$apply();
var labels = deleteModalService.open.calls.argsFor(0)[2].labels;
expect(deleteModalService.open).toHaveBeenCalled();
angular.forEach(labels, function eachLabel(label) {
expect(label.toLowerCase()).toContain('trunk');
});
}
function testSingleLabels() {
var trunks = generateTrunk(1);
service.perform(trunks);
$scope.$apply();
var labels = deleteModalService.open.calls.argsFor(0)[2].labels;
expect(deleteModalService.open).toHaveBeenCalled();
angular.forEach(labels, function eachLabel(label) {
expect(label.toLowerCase()).toContain('trunk');
});
}
function testpluralLabels() {
var trunks = generateTrunk(2);
service.perform(trunks);
$scope.$apply();
var labels = deleteModalService.open.calls.argsFor(0)[2].labels;
expect(deleteModalService.open).toHaveBeenCalled();
angular.forEach(labels, function eachLabel(label) {
expect(label.toLowerCase()).toContain('trunks');
});
}
function testEntities() {
var count = 3;
var trunks = generateTrunk(count);
service.perform(trunks);
$scope.$apply();
var entities = deleteModalService.open.calls.argsFor(0)[1];
expect(deleteModalService.open).toHaveBeenCalled();
expect(entities.length).toEqual(count);
}
function testValids() {
var count = 2;
var trunks = generateTrunk(count);
service.perform(trunks);
$scope.$apply();
var entities = deleteModalService.open.calls.argsFor(0)[1];
expect(deleteModalService.open).toHaveBeenCalled();
expect(entities.length).toBe(count);
expect(entities[0].name).toEqual('trunk0');
expect(entities[1].name).toEqual('trunk1');
}
function testOwner() {
var trunks = generateTrunk(1);
deferred.reject();
service.perform(trunks);
$scope.$apply();
expect(deleteModalService.open).not.toHaveBeenCalled();
}
function testNeutron() {
spyOn(neutronAPI, 'deleteTrunk');
var count = 1;
var trunks = generateTrunk(count);
var trunk = trunks[0];
service.perform(trunks);
$scope.$apply();
var contextArg = deleteModalService.open.calls.argsFor(0)[2];
var deleteFunction = contextArg.deleteEntity;
deleteFunction(trunk.id);
expect(neutronAPI.deleteTrunk).toHaveBeenCalledWith(trunk.id);
}
}); // end of delete modal
describe('allow method', function() {
var resolver = {
success: function() {},
error: function() {}
};
beforeEach(function() {
spyOn(resolver, 'success');
spyOn(resolver, 'error');
service.initScope($scope);
});
////////////
it('should use default policy if batch action', testBatch);
it('allows delete if trunk can be deleted', testValid);
it('disallows delete if trunk is not owned by user', testOwner);
////////////
function testBatch() {
service.allowed();
$scope.$apply();
expect(policyAPI.ifAllowed).toHaveBeenCalled();
expect(resolver.success).not.toHaveBeenCalled();
expect(resolver.error).not.toHaveBeenCalled();
}
function testValid() {
var trunk = generateTrunk(1)[0];
service.allowed(trunk).then(resolver.success, resolver.error);
$scope.$apply();
expect(resolver.success).toHaveBeenCalled();
}
function testOwner() {
var trunk = generateTrunk(1)[0];
deferred.reject();
service.allowed(trunk).then(resolver.success, resolver.error);
$scope.$apply();
expect(resolver.error).toHaveBeenCalled();
}
}); // end of allow method
}); // end of delete.service
})();

View File

@ -29,6 +29,7 @@
.module('horizon.app.core.trunks', [
'ngRoute',
'horizon.framework.conf',
'horizon.app.core.trunks.actions',
'horizon.app.core'
])
.constant('horizon.app.core.trunks.resourceType', 'OS::Neutron::Trunk')

View File

@ -159,10 +159,19 @@ class NeutronPortsTestCase(test.TestCase):
request, network_id=TEST.api_networks.first().get("id"))
class NeutronTrunkTestCase(test.TestCase):
@mock.patch.object(neutron.api, 'neutron')
def test_trunk_delete(self, client):
request = self.mock_rest_request()
neutron.Trunk().delete(request, 1)
client.trunk_delete.assert_called_once_with(request, 1)
class NeutronTrunksTestCase(test.TestCase):
@mock.patch.object(neutron.api, 'neutron')
def test_get(self, client):
def test_trunks_get(self, client):
request = self.mock_rest_request(GET={})
client.trunk_list.return_value = self.trunks.list()
response = neutron.Trunks().get(request)

View File

@ -447,6 +447,15 @@ class NeutronApiTests(test.APITestCase):
self.assertEqual(obj.name_or_id, trunk_dict['name_or_id'])
self.assertEqual(2, trunk_dict['subport_count'])
def test_trunk_delete(self):
trunk_id = self.api_trunks.first()['id']
neutronclient = self.stub_neutronclient()
neutronclient.delete_trunk(trunk_id)
self.mox.ReplayAll()
api.neutron.trunk_delete(self.request, trunk_id)
def test_router_list(self):
routers = {'routers': self.api_routers.list()}