Adding Domain CRUD in Admin Dashboard.

Add basic support for CRUD on Domain for admin users.

This feature is only exposed if the user explicitly set
the OPENSTACK_KEYSTONE_MULTIDOMAIN_SUPPORT to True and
keystone supports V3.

Creating projects, users and groups on a specific domain
will be covered separately on domain-context blueprint.

Implements blueprint admin-domain-crud

Change-Id: If2684331776166dd9579716deebca100770a5ce0
This commit is contained in:
Lin Hua Cheng 2013-05-15 14:43:49 -07:00
parent 50b7724549
commit c119343f6a
14 changed files with 646 additions and 3 deletions

View File

@ -168,6 +168,34 @@ def keystoneclient(request, admin=False):
return conn
def domain_create(request, name, description=None, enabled=None):
manager = keystoneclient(request, admin=True).domains
return manager.create(name,
description=description,
enabled=enabled)
def domain_get(request, domain_id):
manager = keystoneclient(request, admin=True).domains
return manager.get(domain_id)
def domain_delete(request, domain_id):
manager = keystoneclient(request, admin=True).domains
return manager.delete(domain_id)
def domain_list(request):
manager = keystoneclient(request, admin=True).domains
return manager.list()
def domain_update(request, domain_id, name=None, description=None,
enabled=None):
manager = keystoneclient(request, admin=True).domains
return manager.update(domain_id, name, description, enabled)
def tenant_create(request, name, description=None, enabled=None, domain=None):
manager = VERSIONS.get_project_manager(request, admin=True)
if VERSIONS.active < 3:
@ -399,6 +427,11 @@ def get_user_ec2_credentials(request, user_id, access_token):
return keystoneclient(request).ec2.get(user_id, access_token)
def keystone_can_edit_domain():
backend_settings = getattr(settings, "OPENSTACK_KEYSTONE_BACKEND", {})
return backend_settings.get('can_edit_domain', True)
def keystone_can_edit_user():
backend_settings = getattr(settings, "OPENSTACK_KEYSTONE_BACKEND", {})
return backend_settings.get('can_edit_user', True)

View File

@ -23,7 +23,8 @@ class SystemPanels(horizon.PanelGroup):
slug = "admin"
name = _("System Panel")
panels = ('overview', 'instances', 'volumes', 'flavors',
'images', 'projects', 'users', 'networks', 'routers', 'info')
'images', 'domains', 'projects', 'users',
'networks', 'routers', 'info')
class Admin(horizon.Dashboard):

View File

@ -0,0 +1,23 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# 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.
DOMAIN_INFO_FIELDS = ("name",
"description",
"enabled")
DOMAINS_INDEX_URL = 'horizon:admin:domains:index'
DOMAINS_INDEX_VIEW_TEMPLATE = 'admin/domains/index.html'
DOMAINS_CREATE_URL = 'horizon:admin:domains:create'
DOMAINS_UPDATE_URL = 'horizon:admin:domains:update'

View File

@ -0,0 +1,37 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# 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.
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
import horizon
from openstack_dashboard.api.keystone import VERSIONS as IDENTITY_VERSIONS
from openstack_dashboard.dashboards.admin import dashboard
class Domains(horizon.Panel):
name = _("Domains")
slug = 'domains'
MULTIDOMAIN_SUPPORT = getattr(settings,
'OPENSTACK_KEYSTONE_MULTIDOMAIN_SUPPORT',
False)
if MULTIDOMAIN_SUPPORT and IDENTITY_VERSIONS.active >= 3:
dashboard.Admin.register(Domains)

View File

@ -0,0 +1,100 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
from django.utils.translation import ugettext_lazy as _
from keystoneclient.exceptions import ClientException
from horizon import messages
from horizon import tables
from openstack_dashboard import api
from .constants import DOMAINS_CREATE_URL, \
DOMAINS_UPDATE_URL
LOG = logging.getLogger(__name__)
class CreateDomainLink(tables.LinkAction):
name = "create"
verbose_name = _("Create Domain")
url = DOMAINS_CREATE_URL
classes = ("ajax-modal", "btn-create")
def allowed(self, request, domain):
return api.keystone.keystone_can_edit_domain()
class EditDomainLink(tables.LinkAction):
name = "edit"
verbose_name = _("Edit")
url = DOMAINS_UPDATE_URL
classes = ("ajax-modal", "btn-edit")
def allowed(self, request, domain):
return api.keystone.keystone_can_edit_domain()
class DeleteDomainsAction(tables.DeleteAction):
name = "delete"
data_type_singular = _("Domain")
data_type_plural = _("Domains")
def allowed(self, request, datum):
return api.keystone.keystone_can_edit_domain()
def delete(self, request, obj_id):
domain = self.table.get_object_by_id(obj_id)
if domain.enabled:
msg = _('Domain "%s" must be disabled before it can be deleted.') \
% domain.name
messages.error(request, msg)
raise ClientException(409, msg)
else:
LOG.info('Deleting domain "%s".' % obj_id)
api.keystone.domain_delete(request, obj_id)
class DomainFilterAction(tables.FilterAction):
def filter(self, table, domains, filter_string):
""" Naive case-insensitive search """
q = filter_string.lower()
def comp(domain):
if q in domain.name.lower():
return True
return False
return filter(comp, domains)
class DomainsTable(tables.DataTable):
name = tables.Column('name', verbose_name=_('Name'))
description = tables.Column(lambda obj: getattr(obj, 'description', None),
verbose_name=_('Description'))
id = tables.Column('id', verbose_name=_('Domain ID'))
enabled = tables.Column('enabled', verbose_name=_('Enabled'), status=True)
class Meta:
name = "domains"
verbose_name = _("Domains")
row_actions = (EditDomainLink, DeleteDomainsAction)
table_actions = (DomainFilterAction, CreateDomainLink,
DeleteDomainsAction)

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Domains" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Domains") %}
{% endblock page_header %}
{% block main %}
{{ table.render }}
{% endblock %}

View File

@ -0,0 +1,196 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# 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.
from django import http
from django.core.urlresolvers import reverse
from mox import IgnoreArg, IsA
from horizon.workflows.views import WorkflowView
from openstack_dashboard import api
from openstack_dashboard.test import helpers as test
from .constants import DOMAINS_INDEX_VIEW_TEMPLATE, \
DOMAINS_INDEX_URL as index_url, \
DOMAINS_CREATE_URL as create_url, \
DOMAINS_UPDATE_URL as update_url
from .workflows import CreateDomain, UpdateDomain
DOMAINS_INDEX_URL = reverse(index_url)
DOMAIN_CREATE_URL = reverse(create_url)
DOMAIN_UPDATE_URL = reverse(update_url, args=[1])
class DomainsViewTests(test.BaseAdminViewTests):
@test.create_stubs({api.keystone: ('domain_list',)})
def test_index(self):
api.keystone.domain_list(IgnoreArg()).AndReturn(self.domains.list())
self.mox.ReplayAll()
res = self.client.get(DOMAINS_INDEX_URL)
self.assertTemplateUsed(res, DOMAINS_INDEX_VIEW_TEMPLATE)
self.assertItemsEqual(res.context['table'].data, self.domains.list())
self.assertContains(res, 'Create Domain')
self.assertContains(res, 'Edit')
self.assertContains(res, 'Delete Domain')
@test.create_stubs({api.keystone: ('domain_list',
'keystone_can_edit_domain')})
def test_index_with_keystone_can_edit_domain_false(self):
api.keystone.domain_list(IgnoreArg()).AndReturn(self.domains.list())
api.keystone.keystone_can_edit_domain() \
.MultipleTimes().AndReturn(False)
self.mox.ReplayAll()
res = self.client.get(DOMAINS_INDEX_URL)
self.assertTemplateUsed(res, DOMAINS_INDEX_VIEW_TEMPLATE)
self.assertItemsEqual(res.context['table'].data, self.domains.list())
self.assertNotContains(res, 'Create Domain')
self.assertNotContains(res, 'Edit')
self.assertNotContains(res, 'Delete Domain')
@test.create_stubs({api.keystone: ('domain_list',
'domain_delete')})
def test_delete_domain(self):
domain = self.domains.get(id="2")
api.keystone.domain_list(IgnoreArg()).AndReturn(self.domains.list())
api.keystone.domain_delete(IgnoreArg(), domain.id)
self.mox.ReplayAll()
formData = {'action': 'domains__delete__%s' % domain.id}
res = self.client.post(DOMAINS_INDEX_URL, formData)
self.assertRedirectsNoFollow(res, DOMAINS_INDEX_URL)
@test.create_stubs({api.keystone: ('domain_list', )})
def test_delete_with_enabled_domain(self):
domain = self.domains.get(id="1")
api.keystone.domain_list(IgnoreArg()).AndReturn(self.domains.list())
self.mox.ReplayAll()
formData = {'action': 'domains__delete__%s' % domain.id}
res = self.client.post(DOMAINS_INDEX_URL, formData)
self.assertRedirectsNoFollow(res, DOMAINS_INDEX_URL)
self.assertMessageCount(error=2)
class CreateDomainWorkflowTests(test.BaseAdminViewTests):
def _get_domain_info(self, domain):
domain_info = {"name": domain.name,
"description": domain.description,
"enabled": domain.enabled}
return domain_info
def _get_workflow_data(self, domain):
domain_info = self._get_domain_info(domain)
return domain_info
def test_add_domain_get(self):
url = reverse('horizon:admin:domains:create')
res = self.client.get(url)
self.assertTemplateUsed(res, WorkflowView.template_name)
workflow = res.context['workflow']
self.assertEqual(res.context['workflow'].name, CreateDomain.name)
self.assertQuerysetEqual(workflow.steps,
['<CreateDomainInfo: create_domain>', ])
@test.create_stubs({api.keystone: ('domain_create', )})
def test_add_domain_post(self):
domain = self.domains.get(id="1")
api.keystone.domain_create(IsA(http.HttpRequest),
description=domain.description,
enabled=domain.enabled,
name=domain.name).AndReturn(domain)
self.mox.ReplayAll()
workflow_data = self._get_workflow_data(domain)
res = self.client.post(DOMAIN_CREATE_URL, workflow_data)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, DOMAINS_INDEX_URL)
class UpdateDomainWorkflowTests(test.BaseAdminViewTests):
def _get_domain_info(self, domain):
domain_info = {"domain_id": domain.id,
"name": domain.name,
"description": domain.description,
"enabled": domain.enabled}
return domain_info
def _get_workflow_data(self, domain):
domain_info = self._get_domain_info(domain)
return domain_info
@test.create_stubs({api.keystone: ('domain_get', )})
def test_update_domain_get(self):
domain = self.domains.get(id="1")
api.keystone.domain_get(IsA(http.HttpRequest), '1').AndReturn(domain)
self.mox.ReplayAll()
res = self.client.get(DOMAIN_UPDATE_URL)
self.assertTemplateUsed(res, WorkflowView.template_name)
workflow = res.context['workflow']
self.assertEqual(res.context['workflow'].name, UpdateDomain.name)
self.assertQuerysetEqual(workflow.steps,
['<UpdateDomainInfo: update_domain>', ])
@test.create_stubs({api.keystone: ('domain_get',
'domain_update')})
def test_update_domain_post(self):
domain = self.domains.get(id="1")
test_description = 'updated description'
api.keystone.domain_get(IsA(http.HttpRequest), '1').AndReturn(domain)
api.keystone.domain_update(IsA(http.HttpRequest),
description=test_description,
domain_id=domain.id,
enabled=domain.enabled,
name=domain.name).AndReturn(None)
self.mox.ReplayAll()
workflow_data = self._get_workflow_data(domain)
workflow_data['description'] = test_description
res = self.client.post(DOMAIN_UPDATE_URL, workflow_data)
self.assertNoFormErrors(res)
self.assertMessageCount(success=1)
self.assertRedirectsNoFollow(res, DOMAINS_INDEX_URL)

View File

@ -0,0 +1,27 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# 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.
from django.conf.urls.defaults import patterns, url
from .views import IndexView, CreateDomainView, UpdateDomainView
urlpatterns = patterns('',
url(r'^$', IndexView.as_view(), name='index'),
url(r'^create$', CreateDomainView.as_view(), name='create'),
url(r'^(?P<domain_id>[^/]+)/update/$',
UpdateDomainView.as_view(), name='update')
)

View File

@ -0,0 +1,68 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# 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.
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import tables
from horizon import workflows
from openstack_dashboard import api
from .constants import DOMAIN_INFO_FIELDS, DOMAINS_INDEX_URL, \
DOMAINS_INDEX_VIEW_TEMPLATE
from .tables import DomainsTable
from .workflows import CreateDomain, UpdateDomain
class IndexView(tables.DataTableView):
table_class = DomainsTable
template_name = DOMAINS_INDEX_VIEW_TEMPLATE
def get_data(self):
domains = []
try:
domains = api.keystone.domain_list(self.request)
except:
exceptions.handle(self.request,
_('Unable to retrieve domain list.'))
return domains
class CreateDomainView(workflows.WorkflowView):
workflow_class = CreateDomain
class UpdateDomainView(workflows.WorkflowView):
workflow_class = UpdateDomain
def get_initial(self):
initial = super(UpdateDomainView, self).get_initial()
domain_id = self.kwargs['domain_id']
initial['domain_id'] = domain_id
try:
# get initial domain info
domain_info = api.keystone.domain_get(self.request,
domain_id)
for field in DOMAIN_INFO_FIELDS:
initial[field] = getattr(domain_info, field, None)
except:
exceptions.handle(self.request,
_('Unable to retrieve domain details.'),
redirect=reverse(DOMAINS_INDEX_URL))
return initial

View File

@ -0,0 +1,127 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import workflows
from horizon import forms
from horizon import messages
from openstack_dashboard import api
from .constants import DOMAINS_INDEX_URL
LOG = logging.getLogger(__name__)
class CreateDomainInfoAction(workflows.Action):
name = forms.CharField(label=_("Name"),
required=True)
description = forms.CharField(widget=forms.widgets.Textarea(),
label=_("Description"),
required=False)
enabled = forms.BooleanField(label=_("Enabled"),
required=False,
initial=True)
class Meta:
name = _("Domain Info")
slug = "create_domain"
help_text = _("From here you can create a new domain to organize "
"projects, groups and users.")
class CreateDomainInfo(workflows.Step):
action_class = CreateDomainInfoAction
contributes = ("domain_id",
"name",
"description",
"enabled")
class CreateDomain(workflows.Workflow):
slug = "create_domain"
name = _("Create Domain")
finalize_button_name = _("Create Domain")
success_message = _('Created new domain "%s".')
failure_message = _('Unable to create domain "%s".')
success_url = DOMAINS_INDEX_URL
default_steps = (CreateDomainInfo, )
def format_status_message(self, message):
return message % self.context.get('name', 'unknown domain')
def handle(self, request, data):
# create the domain
try:
LOG.info('Creating domain with name "%s"' % data['name'])
desc = data['description']
new_domain = api.keystone.domain_create(request,
name=data['name'],
description=desc,
enabled=data['enabled'])
except:
exceptions.handle(request, ignore=True)
return False
return True
class UpdateDomainInfoAction(CreateDomainInfoAction):
class Meta:
name = _("Domain Info")
slug = 'update_domain'
help_text = _("From here you can edit the domain details.")
class UpdateDomainInfo(workflows.Step):
action_class = UpdateDomainInfoAction
depends_on = ("domain_id",)
contributes = ("name",
"description",
"enabled")
class UpdateDomain(workflows.Workflow):
slug = "update_domain"
name = _("Edit Domain")
finalize_button_name = _("Save")
success_message = _('Modified domain "%s".')
failure_message = _('Unable to modify domain "%s".')
success_url = DOMAINS_INDEX_URL
default_steps = (UpdateDomainInfo, )
def format_status_message(self, message):
return message % self.context.get('name', 'unknown domain')
def handle(self, request, data):
domain_id = data.pop('domain_id')
try:
LOG.info('Updating domain with name "%s"' % data['name'])
api.keystone.domain_update(request,
domain_id=domain_id,
name=data['name'],
description=data['description'],
enabled=data['enabled'])
except:
exceptions.handle(request, ignore=True)
return False
return True

View File

@ -136,7 +136,8 @@ OPENSTACK_KEYSTONE_DEFAULT_ROLE = "Member"
OPENSTACK_KEYSTONE_BACKEND = {
'name': 'native',
'can_edit_user': True,
'can_edit_project': True
'can_edit_project': True,
'can_edit_domain': True
}
OPENSTACK_HYPERVISOR_FEATURES = {

View File

@ -70,10 +70,14 @@ AVAILABLE_REGIONS = [
OPENSTACK_KEYSTONE_URL = "http://localhost:5000/v2.0"
OPENSTACK_KEYSTONE_DEFAULT_ROLE = "Member"
OPENSTACK_KEYSTONE_MULTIDOMAIN_SUPPORT = True
OPENSTACK_KEYSTONE_DEFAULT_DOMAIN = 'test_domain'
OPENSTACK_KEYSTONE_BACKEND = {
'name': 'native',
'can_edit_user': True,
'can_edit_project': True
'can_edit_project': True,
'can_edit_domain': True
}
OPENSTACK_QUANTUM_NETWORK = {

View File

@ -18,6 +18,7 @@ from django.conf import settings
from django.utils import datetime_safe
from keystoneclient.v2_0 import users, tenants, tokens, roles, ec2
from keystoneclient.v3 import domains
from .utils import TestDataContainer
@ -88,6 +89,7 @@ SERVICE_CATALOG = [
def data(TEST):
TEST.service_catalog = SERVICE_CATALOG
TEST.tokens = TestDataContainer()
TEST.domains = TestDataContainer()
TEST.users = TestDataContainer()
TEST.tenants = TestDataContainer()
TEST.roles = TestDataContainer()
@ -103,6 +105,19 @@ def data(TEST):
TEST.roles.admin = admin_role
TEST.roles.member = member_role
domain_dict = {'id': "1",
'name': 'test_domain',
'description': "a test domain.",
'enabled': True}
domain_dict_2 = {'id': "2",
'name': 'disabled_domain',
'description': "a disabled test domain.",
'enabled': False}
domain = domains.Domain(domains.DomainManager, domain_dict)
disabled_domain = domains.Domain(domains.DomainManager, domain_dict_2)
TEST.domains.add(domain, disabled_domain)
TEST.domain = domain # Your "current" domain
user_dict = {'id': "1",
'name': 'test_user',
'email': 'test@example.com',