Supports extra properties in project and user

This change is to support extra properties in project and user.
To show an extra property in project/user table, you can customize.
The explanation of the method to add an extra property is added
by this patch.
In addition, when you do a create or update user, you can specify a
value of extra property by setting in local_settings.py

Change-Id: Ifee491f2a55d9207fe5da70136f749e1fc4bab82
Implements: blueprint support-extra-prop-for-project-and-user
This commit is contained in:
Kenji Ishii 2016-03-02 09:18:23 +09:00
parent 0e0b01fd27
commit ab11eef92a
13 changed files with 206 additions and 16 deletions

View File

@ -402,6 +402,8 @@ files to be added. There are some example files available within this folder, w
customization as much as possible, and support for this is given preference over more customization as much as possible, and support for this is given preference over more
exotic methods such as monkey patching and overrides files. exotic methods such as monkey patching and overrides files.
.. _horizon-customization-module:
Horizon customization module (overrides) Horizon customization module (overrides)
======================================== ========================================
@ -515,6 +517,35 @@ similar way, add the new column definition and then use the ``Meta``
WSGIDaemonProcess [... existing options ...] python-path=/opt/python WSGIDaemonProcess [... existing options ...] python-path=/opt/python
Customize the project and user table columns
===========================================
Keystone V3 has a place to store extra information regarding project and user.
Using the override mechanism described in :ref:`horizon-customization-module`,
Horizon is able to show these extra information as a custom column.
For example, if a user in Keystone has an attribute ``phone_num``, you could
define new column::
from django.utils.translation import ugettext_lazy as _
from horizon import forms
from horizon import tables
from openstack_dashboard.dashboards.identity.users import tables as user_tables
from openstack_dashboard.dashboards.identity.users import views
class MyUsersTable(user_tables.UsersTable):
phone_num = tables.Column('phone_num',
verbose_name=_('Phone Number'),
form_field=forms.CharField(),)
class Meta(user_tables.UsersTable.Meta):
columns = ('name', 'description', 'phone_num')
views.IndexView.table_class = MyUsersTable
Icons Icons
===== =====

View File

@ -1700,6 +1700,34 @@ This setting controls the behavior of the operation log.
* %(param)s * %(param)s
``PROJECT_TABLE_EXTRA_INFO``
----------------------
.. versionadded:: 10.0.0(Newton)
Default: ``{}``
Add additional information for project as an extra attribute.
Project and user can have any attributes by keystone mechanism.
This setting can treat these attributes on Horizon when only
using Keystone v3.
For example::
PROJECT_TABLE_EXTRA_INFO = {
'phone_num': _('Phone Number'),
}
``USER_TABLE_EXTRA_INFO``
-------------------
.. versionadded:: 10.0.0(Newton)
Default: ``{}``
Same as ``PROJECT_TABLE_EXTRA_INFO``, add additional information for user.
Django Settings (Partial) Django Settings (Partial)
========================= =========================

View File

@ -375,7 +375,7 @@ def user_list(request, project=None, domain=None, group=None, filters=None):
def user_create(request, name=None, email=None, password=None, project=None, def user_create(request, name=None, email=None, password=None, project=None,
enabled=None, domain=None, description=None): enabled=None, domain=None, description=None, **data):
manager = keystoneclient(request, admin=True).users manager = keystoneclient(request, admin=True).users
try: try:
if VERSIONS.active < 3: if VERSIONS.active < 3:
@ -384,7 +384,8 @@ def user_create(request, name=None, email=None, password=None, project=None,
else: else:
return manager.create(name, password=password, email=email, return manager.create(name, password=password, email=email,
default_project=project, enabled=enabled, default_project=project, enabled=enabled,
domain=domain, description=description) domain=domain, description=description,
**data)
except keystone_exceptions.Conflict: except keystone_exceptions.Conflict:
raise exceptions.Conflict() raise exceptions.Conflict()

View File

@ -10,6 +10,12 @@
<dd>{{ project.enabled|yesno|capfirst }}</dd> <dd>{{ project.enabled|yesno|capfirst }}</dd>
<dt>{% trans "Description" %}</dt> <dt>{% trans "Description" %}</dt>
<dd>{{ project.description|default:_("None") }}</dd> <dd>{{ project.description|default:_("None") }}</dd>
{% if extras %}
{% for key, value in extras.items %}
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
{% endfor %}
{% endif %}
</dl> </dl>
</div> </div>

View File

@ -299,6 +299,7 @@ class CreateProjectWorkflowTests(test.BaseAdminViewTests):
self.assertEqual(step.action.initial['subnet'], self.assertEqual(step.action.initial['subnet'],
neutron_quotas.get('subnet').limit) neutron_quotas.get('subnet').limit)
@override_settings(PROJECT_TABLE_EXTRA_INFO={'phone_num': 'Phone Number'})
@test.create_stubs({api.keystone: ('get_default_role', @test.create_stubs({api.keystone: ('get_default_role',
'add_tenant_user_role', 'add_tenant_user_role',
'tenant_create', 'tenant_create',
@ -321,6 +322,8 @@ class CreateProjectWorkflowTests(test.BaseAdminViewTests):
users = self._get_all_users(domain_id) users = self._get_all_users(domain_id)
groups = self._get_all_groups(domain_id) groups = self._get_all_groups(domain_id)
roles = self.roles.list() roles = self.roles.list()
# extra info
phone_number = "+81-3-1234-5678"
# init # init
quotas.get_disabled_quotas(IsA(http.HttpRequest)) \ quotas.get_disabled_quotas(IsA(http.HttpRequest)) \
@ -338,6 +341,8 @@ class CreateProjectWorkflowTests(test.BaseAdminViewTests):
# handle # handle
project_details = self._get_project_info(project) project_details = self._get_project_info(project)
# add extra info
project_details.update({'phone_num': phone_number})
quota_data = self._get_quota_info(quota) quota_data = self._get_quota_info(quota)
api.keystone.tenant_create(IsA(http.HttpRequest), **project_details) \ api.keystone.tenant_create(IsA(http.HttpRequest), **project_details) \
@ -377,6 +382,7 @@ class CreateProjectWorkflowTests(test.BaseAdminViewTests):
self.mox.ReplayAll() self.mox.ReplayAll()
workflow_data.update(self._get_workflow_data(project, quota)) workflow_data.update(self._get_workflow_data(project, quota))
workflow_data.update({'phone_num': phone_number})
url = reverse('horizon:identity:projects:create') url = reverse('horizon:identity:projects:create')
res = self.client.post(url, workflow_data) res = self.client.post(url, workflow_data)

View File

@ -16,6 +16,7 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -198,8 +199,13 @@ class UpdateProjectView(workflows.WorkflowView):
for field in PROJECT_INFO_FIELDS: for field in PROJECT_INFO_FIELDS:
initial[field] = getattr(project_info, field, None) initial[field] = getattr(project_info, field, None)
# Retrieve the domain name where the project belong
if keystone.VERSIONS.active >= 3: if keystone.VERSIONS.active >= 3:
# get extra columns info
ex_info = getattr(settings, 'PROJECT_TABLE_EXTRA_INFO', {})
for ex_field in ex_info:
initial[ex_field] = getattr(project_info, ex_field, None)
# Retrieve the domain name where the project belong
try: try:
if policy.check((("identity", "identity:get_domain"),), if policy.check((("identity", "identity:get_domain"),),
self.request): self.request):
@ -245,6 +251,12 @@ class DetailProjectView(views.HorizonTemplateView):
context["project"] = project context["project"] = project
context["url"] = reverse(INDEX_URL) context["url"] = reverse(INDEX_URL)
context["actions"] = table.render_row_actions(project) context["actions"] = table.render_row_actions(project)
if keystone.VERSIONS.active >= 3:
extra_info = getattr(settings, 'PROJECT_TABLE_EXTRA_INFO', {})
context['extras'] = dict(
(display_key, getattr(project, key, ''))
for key, display_key in extra_info.items())
return context return context
@memoized.memoized_method @memoized.memoized_method

View File

@ -169,6 +169,14 @@ class CreateProjectInfoAction(workflows.Action):
readonlyInput = forms.TextInput(attrs={'readonly': 'readonly'}) readonlyInput = forms.TextInput(attrs={'readonly': 'readonly'})
self.fields["domain_id"].widget = readonlyInput self.fields["domain_id"].widget = readonlyInput
self.fields["domain_name"].widget = readonlyInput self.fields["domain_name"].widget = readonlyInput
self.add_extra_fields()
def add_extra_fields(self):
# add extra column defined by setting
EXTRA_INFO = getattr(settings, 'PROJECT_TABLE_EXTRA_INFO', {})
for key, value in EXTRA_INFO.items():
form = forms.CharField(label=value, required=False,)
self.fields[key] = form
class Meta(object): class Meta(object):
name = _("Project Information") name = _("Project Information")
@ -185,6 +193,12 @@ class CreateProjectInfo(workflows.Step):
"description", "description",
"enabled") "enabled")
def __init__(self, workflow):
super(CreateProjectInfo, self).__init__(workflow)
if keystone.VERSIONS.active >= 3:
EXTRA_INFO = getattr(settings, 'PROJECT_TABLE_EXTRA_INFO', {})
self.contributes += tuple(EXTRA_INFO.keys())
class UpdateProjectMembersAction(workflows.MembershipAction): class UpdateProjectMembersAction(workflows.MembershipAction):
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):
@ -445,12 +459,20 @@ class CreateProject(CommonQuotaWorkflow):
# create the project # create the project
domain_id = data['domain_id'] domain_id = data['domain_id']
try: try:
# add extra information
if keystone.VERSIONS.active >= 3:
EXTRA_INFO = getattr(settings, 'PROJECT_TABLE_EXTRA_INFO', {})
kwargs = dict((key, data.get(key)) for key in EXTRA_INFO)
else:
kwargs = {}
desc = data['description'] desc = data['description']
self.object = api.keystone.tenant_create(request, self.object = api.keystone.tenant_create(request,
name=data['name'], name=data['name'],
description=desc, description=desc,
enabled=data['enabled'], enabled=data['enabled'],
domain=domain_id) domain=domain_id,
**kwargs)
return self.object return self.object
except exceptions.Conflict: except exceptions.Conflict:
msg = _('Project name "%s" is already used.') % data['name'] msg = _('Project name "%s" is already used.') % data['name']
@ -608,6 +630,12 @@ class UpdateProjectInfo(workflows.Step):
"description", "description",
"enabled") "enabled")
def __init__(self, workflow):
super(UpdateProjectInfo, self).__init__(workflow)
if keystone.VERSIONS.active >= 3:
EXTRA_INFO = getattr(settings, 'PROJECT_TABLE_EXTRA_INFO', {})
self.contributes += tuple(EXTRA_INFO.keys())
class UpdateProject(CommonQuotaWorkflow): class UpdateProject(CommonQuotaWorkflow):
slug = "update_project" slug = "update_project"
@ -649,13 +677,22 @@ class UpdateProject(CommonQuotaWorkflow):
domain_id = api.keystone.get_effective_domain_id(self.request) domain_id = api.keystone.get_effective_domain_id(self.request)
try: try:
project_id = data['project_id'] project_id = data['project_id']
# add extra information
if keystone.VERSIONS.active >= 3:
EXTRA_INFO = getattr(settings, 'PROJECT_TABLE_EXTRA_INFO', {})
kwargs = dict((key, data.get(key)) for key in EXTRA_INFO)
else:
kwargs = {}
return api.keystone.tenant_update( return api.keystone.tenant_update(
request, request,
project_id, project_id,
name=data['name'], name=data['name'],
description=data['description'], description=data['description'],
enabled=data['enabled'], enabled=data['enabled'],
domain=domain_id) domain=domain_id,
**kwargs)
except exceptions.Conflict: except exceptions.Conflict:
msg = _('Project name "%s" is already used.') % data['name'] msg = _('Project name "%s" is already used.') % data['name']
self.failure_message = msg self.failure_message = msg

View File

@ -93,10 +93,22 @@ class BaseUserForm(forms.SelfHandlingForm):
LOG.debug("User: %s has no projects" % user_id) LOG.debug("User: %s has no projects" % user_id)
class AddExtraColumnMixIn(object):
def add_extra_fields(self, ordering=None):
if api.keystone.VERSIONS.active >= 3:
# add extra column defined by setting
EXTRA_INFO = getattr(settings, 'USER_TABLE_EXTRA_INFO', {})
for key, value in EXTRA_INFO.items():
self.fields[key] = forms.CharField(label=value,
required=False)
if ordering:
ordering.append(key)
ADD_PROJECT_URL = "horizon:identity:projects:create" ADD_PROJECT_URL = "horizon:identity:projects:create"
class CreateUserForm(PasswordMixin, BaseUserForm): class CreateUserForm(PasswordMixin, BaseUserForm, AddExtraColumnMixIn):
# Hide the domain_id and domain_name by default # Hide the domain_id and domain_name by default
domain_id = forms.CharField(label=_("Domain ID"), domain_id = forms.CharField(label=_("Domain ID"),
required=False, required=False,
@ -129,6 +141,7 @@ class CreateUserForm(PasswordMixin, BaseUserForm):
"description", "email", "password", "description", "email", "password",
"confirm_password", "project", "role_id", "confirm_password", "project", "role_id",
"enabled"] "enabled"]
self.add_extra_fields(ordering)
self.fields = collections.OrderedDict( self.fields = collections.OrderedDict(
(key, self.fields[key]) for key in ordering) (key, self.fields[key]) for key in ordering)
role_choices = [(role.id, role.name) for role in roles] role_choices = [(role.id, role.name) for role in roles]
@ -153,6 +166,14 @@ class CreateUserForm(PasswordMixin, BaseUserForm):
desc = data["description"] desc = data["description"]
if "email" in data: if "email" in data:
data['email'] = data['email'] or None data['email'] = data['email'] or None
# add extra information
if api.keystone.VERSIONS.active >= 3:
EXTRA_INFO = getattr(settings, 'USER_TABLE_EXTRA_INFO', {})
kwargs = dict((key, data.get(key)) for key in EXTRA_INFO)
else:
kwargs = {}
new_user = \ new_user = \
api.keystone.user_create(request, api.keystone.user_create(request,
name=data['name'], name=data['name'],
@ -161,7 +182,8 @@ class CreateUserForm(PasswordMixin, BaseUserForm):
password=data['password'], password=data['password'],
project=data['project'] or None, project=data['project'] or None,
enabled=data['enabled'], enabled=data['enabled'],
domain=domain.id) domain=domain.id,
**kwargs)
messages.success(request, messages.success(request,
_('User "%s" was successfully created.') _('User "%s" was successfully created.')
% data['name']) % data['name'])
@ -189,7 +211,7 @@ class CreateUserForm(PasswordMixin, BaseUserForm):
exceptions.handle(request, _('Unable to create user.')) exceptions.handle(request, _('Unable to create user.'))
class UpdateUserForm(BaseUserForm): class UpdateUserForm(BaseUserForm, AddExtraColumnMixIn):
# Hide the domain_id and domain_name by default # Hide the domain_id and domain_name by default
domain_id = forms.CharField(label=_("Domain ID"), domain_id = forms.CharField(label=_("Domain ID"),
required=False, required=False,
@ -211,7 +233,7 @@ class UpdateUserForm(BaseUserForm):
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):
super(UpdateUserForm, self).__init__(request, *args, **kwargs) super(UpdateUserForm, self).__init__(request, *args, **kwargs)
self.add_extra_fields()
if api.keystone.keystone_can_edit_user() is False: if api.keystone.keystone_can_edit_user() is False:
for field in ('name', 'email'): for field in ('name', 'email'):
self.fields.pop(field) self.fields.pop(field)

View File

@ -28,6 +28,12 @@
<dt>{% trans "Primary Project Name" %}</dt> <dt>{% trans "Primary Project Name" %}</dt>
<dd>{{ tenant_name }}</dd> <dd>{{ tenant_name }}</dd>
{% endif %} {% endif %}
{% if extras %}
{% for key, value in extras.items %}
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
{% endfor %}
{% endif %}
</dl> </dl>
</div> </div>

View File

@ -84,6 +84,7 @@ class UsersViewTests(test.BaseAdminViewTests):
domain_context_name=domain.name) domain_context_name=domain.name)
self.test_index() self.test_index()
@override_settings(USER_TABLE_EXTRA_INFO={'phone_num': 'Phone Number'})
@test.create_stubs({api.keystone: ('user_create', @test.create_stubs({api.keystone: ('user_create',
'get_default_domain', 'get_default_domain',
'tenant_list', 'tenant_list',
@ -95,6 +96,7 @@ class UsersViewTests(test.BaseAdminViewTests):
user = self.users.get(id="1") user = self.users.get(id="1")
domain = self._get_default_domain() domain = self._get_default_domain()
domain_id = domain.id domain_id = domain.id
phone_number = "+81-3-1234-5678"
role = self.roles.first() role = self.roles.first()
@ -112,6 +114,7 @@ class UsersViewTests(test.BaseAdminViewTests):
IgnoreArg(), user=None).AndReturn( IgnoreArg(), user=None).AndReturn(
[self.tenants.list(), False]) [self.tenants.list(), False])
kwargs = {'phone_num': phone_number}
api.keystone.user_create(IgnoreArg(), api.keystone.user_create(IgnoreArg(),
name=user.name, name=user.name,
description=user.description, description=user.description,
@ -119,7 +122,8 @@ class UsersViewTests(test.BaseAdminViewTests):
password=user.password, password=user.password,
project=self.tenant.id, project=self.tenant.id,
enabled=True, enabled=True,
domain=domain_id).AndReturn(user) domain=domain_id,
**kwargs).AndReturn(user)
api.keystone.role_list(IgnoreArg()).AndReturn(self.roles.list()) api.keystone.role_list(IgnoreArg()).AndReturn(self.roles.list())
api.keystone.get_default_role(IgnoreArg()).AndReturn(role) api.keystone.get_default_role(IgnoreArg()).AndReturn(role)
api.keystone.roles_for_user(IgnoreArg(), user.id, self.tenant.id) api.keystone.roles_for_user(IgnoreArg(), user.id, self.tenant.id)
@ -137,7 +141,8 @@ class UsersViewTests(test.BaseAdminViewTests):
'project': self.tenant.id, 'project': self.tenant.id,
'role_id': self.roles.first().id, 'role_id': self.roles.first().id,
'enabled': True, 'enabled': True,
'confirm_password': user.password} 'confirm_password': user.password,
'phone_num': phone_number}
res = self.client.post(USER_CREATE_URL, formData) res = self.client.post(USER_CREATE_URL, formData)
self.assertNoFormErrors(res) self.assertNoFormErrors(res)
@ -370,6 +375,7 @@ class UsersViewTests(test.BaseAdminViewTests):
res, "form", 'password', res, "form", 'password',
['Password must be between 8 and 18 characters.']) ['Password must be between 8 and 18 characters.'])
@override_settings(USER_TABLE_EXTRA_INFO={'phone_num': 'Phone Number'})
@test.create_stubs({api.keystone: ('user_get', @test.create_stubs({api.keystone: ('user_get',
'domain_get', 'domain_get',
'tenant_list', 'tenant_list',
@ -381,6 +387,7 @@ class UsersViewTests(test.BaseAdminViewTests):
user = self.users.get(id="1") user = self.users.get(id="1")
domain_id = user.domain_id domain_id = user.domain_id
domain = self.domains.get(id=domain_id) domain = self.domains.get(id=domain_id)
phone_number = "+81-3-1234-5678"
api.keystone.user_get(IsA(http.HttpRequest), '1', api.keystone.user_get(IsA(http.HttpRequest), '1',
admin=True).AndReturn(user) admin=True).AndReturn(user)
@ -396,10 +403,12 @@ class UsersViewTests(test.BaseAdminViewTests):
IgnoreArg(), user=user.id).AndReturn( IgnoreArg(), user=user.id).AndReturn(
[self.tenants.list(), False]) [self.tenants.list(), False])
kwargs = {'phone_num': phone_number}
api.keystone.user_update(IsA(http.HttpRequest), api.keystone.user_update(IsA(http.HttpRequest),
user.id, user.id,
email=user.email, email=user.email,
name=user.name).AndReturn(None) name=user.name,
**kwargs).AndReturn(None)
self.mox.ReplayAll() self.mox.ReplayAll()
@ -408,8 +417,8 @@ class UsersViewTests(test.BaseAdminViewTests):
'name': user.name, 'name': user.name,
'description': user.description, 'description': user.description,
'email': user.email, 'email': user.email,
'project': self.tenant.id} 'project': self.tenant.id,
'phone_num': phone_number}
res = self.client.post(USER_UPDATE_URL, formData) res = self.client.post(USER_UPDATE_URL, formData)
self.assertNoFormErrors(res) self.assertNoFormErrors(res)

View File

@ -19,6 +19,7 @@
import logging import logging
import operator import operator
from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.urlresolvers import reverse_lazy from django.core.urlresolvers import reverse_lazy
from django.utils.decorators import method_decorator # noqa from django.utils.decorators import method_decorator # noqa
@ -129,13 +130,18 @@ class UpdateView(forms.ModalFormView):
except Exception: except Exception:
exceptions.handle(self.request, exceptions.handle(self.request,
_('Unable to retrieve project domain.')) _('Unable to retrieve project domain.'))
return {'domain_id': domain_id,
data = {'domain_id': domain_id,
'domain_name': domain_name, 'domain_name': domain_name,
'id': user.id, 'id': user.id,
'name': user.name, 'name': user.name,
'project': user.project_id, 'project': user.project_id,
'email': getattr(user, 'email', None), 'email': getattr(user, 'email', None),
'description': getattr(user, 'description', None)} 'description': getattr(user, 'description', None)}
if api.keystone.VERSIONS.active >= 3:
for key in getattr(settings, 'USER_TABLE_EXTRA_INFO', {}):
data[key] = getattr(user, key, None)
return data
class CreateView(forms.ModalFormView): class CreateView(forms.ModalFormView):
@ -200,7 +206,10 @@ class DetailView(views.HorizonTemplateView):
exceptions.handle(self.request, exceptions.handle(self.request,
_('Unable to retrieve project domain.')) _('Unable to retrieve project domain.'))
context["description"] = getattr(user, "description", _("None")) context["description"] = getattr(user, "description", _("None"))
extra_info = getattr(settings, 'USER_TABLE_EXTRA_INFO', {})
context['extras'] = dict(
(display_key, getattr(user, key, ''))
for key, display_key in extra_info.items())
context["user"] = user context["user"] = user
if tenant: if tenant:
context["tenant_name"] = tenant.name context["tenant_name"] = tenant.name

View File

@ -796,3 +796,19 @@ REST_API_REQUIRED_SETTINGS = ['OPENSTACK_HYPERVISOR_FEATURES',
# 'ipv6': ['fc00::/7'] # 'ipv6': ['fc00::/7']
#} #}
ALLOWED_PRIVATE_SUBNET_CIDR = {'ipv4': [], 'ipv6': []} ALLOWED_PRIVATE_SUBNET_CIDR = {'ipv4': [], 'ipv6': []}
# Project and user can have any attributes by keystone v3 mechanism.
# This settings can treat these attributes on Horizon.
# It means, when you show Create/Update modal, attribute below is
# shown and you can specify any value.
# If you'd like to display these extra data in project or user index table,
# Keystone v3 allows you to add extra properties to Project and Users.
# Horizon's customization (http://docs.openstack.org/developer/horizon/topics/customizing.html#horizon-customization-module-overrides)
# allows you to display this extra information in the Create/Update modal and
# the corresponding tables.
#PROJECT_TABLE_EXTRA_INFO = {
# 'phone_num': _('Phone Number'),
#}
#USER_TABLE_EXTRA_INFO = {
# 'phone_num': _('Phone Number'),
#}

View File

@ -0,0 +1,7 @@
---
features:
- >
[`blueprint Supports extra properties in project and user <https://blueprints.launchpad.net/horizon/+spec/support-extra-prop-for-project-and-user>`_]
Support an ability to treat additional information for
project and user as an extra attribute.