diff --git a/doc/source/topics/customizing.rst b/doc/source/topics/customizing.rst
index 839f081604..952f6a734d 100644
--- a/doc/source/topics/customizing.rst
+++ b/doc/source/topics/customizing.rst
@@ -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
exotic methods such as monkey patching and overrides files.
+.. _horizon-customization-module:
+
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
+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
=====
diff --git a/doc/source/topics/settings.rst b/doc/source/topics/settings.rst
index 2817a6da91..81917414ae 100755
--- a/doc/source/topics/settings.rst
+++ b/doc/source/topics/settings.rst
@@ -1700,6 +1700,34 @@ This setting controls the behavior of the operation log.
* %(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)
=========================
diff --git a/openstack_dashboard/api/keystone.py b/openstack_dashboard/api/keystone.py
index 9c3f03be8f..6914db3560 100644
--- a/openstack_dashboard/api/keystone.py
+++ b/openstack_dashboard/api/keystone.py
@@ -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,
- enabled=None, domain=None, description=None):
+ enabled=None, domain=None, description=None, **data):
manager = keystoneclient(request, admin=True).users
try:
if VERSIONS.active < 3:
@@ -384,7 +384,8 @@ def user_create(request, name=None, email=None, password=None, project=None,
else:
return manager.create(name, password=password, email=email,
default_project=project, enabled=enabled,
- domain=domain, description=description)
+ domain=domain, description=description,
+ **data)
except keystone_exceptions.Conflict:
raise exceptions.Conflict()
diff --git a/openstack_dashboard/dashboards/identity/projects/templates/projects/_detail_overview.html b/openstack_dashboard/dashboards/identity/projects/templates/projects/_detail_overview.html
index b2aeae617d..41e3a55685 100644
--- a/openstack_dashboard/dashboards/identity/projects/templates/projects/_detail_overview.html
+++ b/openstack_dashboard/dashboards/identity/projects/templates/projects/_detail_overview.html
@@ -10,6 +10,12 @@
{{ project.enabled|yesno|capfirst }}
{% trans "Description" %}
{{ project.description|default:_("None") }}
+ {% if extras %}
+ {% for key, value in extras.items %}
+ {{ key }}
+ {{ value }}
+ {% endfor %}
+ {% endif %}
diff --git a/openstack_dashboard/dashboards/identity/projects/tests.py b/openstack_dashboard/dashboards/identity/projects/tests.py
index 4e1b231efe..b91096f299 100644
--- a/openstack_dashboard/dashboards/identity/projects/tests.py
+++ b/openstack_dashboard/dashboards/identity/projects/tests.py
@@ -299,6 +299,7 @@ class CreateProjectWorkflowTests(test.BaseAdminViewTests):
self.assertEqual(step.action.initial['subnet'],
neutron_quotas.get('subnet').limit)
+ @override_settings(PROJECT_TABLE_EXTRA_INFO={'phone_num': 'Phone Number'})
@test.create_stubs({api.keystone: ('get_default_role',
'add_tenant_user_role',
'tenant_create',
@@ -321,6 +322,8 @@ class CreateProjectWorkflowTests(test.BaseAdminViewTests):
users = self._get_all_users(domain_id)
groups = self._get_all_groups(domain_id)
roles = self.roles.list()
+ # extra info
+ phone_number = "+81-3-1234-5678"
# init
quotas.get_disabled_quotas(IsA(http.HttpRequest)) \
@@ -338,6 +341,8 @@ class CreateProjectWorkflowTests(test.BaseAdminViewTests):
# handle
project_details = self._get_project_info(project)
+ # add extra info
+ project_details.update({'phone_num': phone_number})
quota_data = self._get_quota_info(quota)
api.keystone.tenant_create(IsA(http.HttpRequest), **project_details) \
@@ -377,6 +382,7 @@ class CreateProjectWorkflowTests(test.BaseAdminViewTests):
self.mox.ReplayAll()
workflow_data.update(self._get_workflow_data(project, quota))
+ workflow_data.update({'phone_num': phone_number})
url = reverse('horizon:identity:projects:create')
res = self.client.post(url, workflow_data)
diff --git a/openstack_dashboard/dashboards/identity/projects/views.py b/openstack_dashboard/dashboards/identity/projects/views.py
index f51cdc2e7b..0b68fe2b52 100644
--- a/openstack_dashboard/dashboards/identity/projects/views.py
+++ b/openstack_dashboard/dashboards/identity/projects/views.py
@@ -16,6 +16,7 @@
# License for the specific language governing permissions and limitations
# under the License.
+from django.conf import settings
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
@@ -198,8 +199,13 @@ class UpdateProjectView(workflows.WorkflowView):
for field in PROJECT_INFO_FIELDS:
initial[field] = getattr(project_info, field, None)
- # Retrieve the domain name where the project belong
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:
if policy.check((("identity", "identity:get_domain"),),
self.request):
@@ -245,6 +251,12 @@ class DetailProjectView(views.HorizonTemplateView):
context["project"] = project
context["url"] = reverse(INDEX_URL)
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
@memoized.memoized_method
diff --git a/openstack_dashboard/dashboards/identity/projects/workflows.py b/openstack_dashboard/dashboards/identity/projects/workflows.py
index e8e92836aa..42aeb70b17 100644
--- a/openstack_dashboard/dashboards/identity/projects/workflows.py
+++ b/openstack_dashboard/dashboards/identity/projects/workflows.py
@@ -169,6 +169,14 @@ class CreateProjectInfoAction(workflows.Action):
readonlyInput = forms.TextInput(attrs={'readonly': 'readonly'})
self.fields["domain_id"].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):
name = _("Project Information")
@@ -185,6 +193,12 @@ class CreateProjectInfo(workflows.Step):
"description",
"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):
def __init__(self, request, *args, **kwargs):
@@ -445,12 +459,20 @@ class CreateProject(CommonQuotaWorkflow):
# create the project
domain_id = data['domain_id']
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']
self.object = api.keystone.tenant_create(request,
name=data['name'],
description=desc,
enabled=data['enabled'],
- domain=domain_id)
+ domain=domain_id,
+ **kwargs)
return self.object
except exceptions.Conflict:
msg = _('Project name "%s" is already used.') % data['name']
@@ -608,6 +630,12 @@ class UpdateProjectInfo(workflows.Step):
"description",
"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):
slug = "update_project"
@@ -649,13 +677,22 @@ class UpdateProject(CommonQuotaWorkflow):
domain_id = api.keystone.get_effective_domain_id(self.request)
try:
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(
request,
project_id,
name=data['name'],
description=data['description'],
enabled=data['enabled'],
- domain=domain_id)
+ domain=domain_id,
+ **kwargs)
except exceptions.Conflict:
msg = _('Project name "%s" is already used.') % data['name']
self.failure_message = msg
diff --git a/openstack_dashboard/dashboards/identity/users/forms.py b/openstack_dashboard/dashboards/identity/users/forms.py
index bdd784fed8..b25c406fc8 100644
--- a/openstack_dashboard/dashboards/identity/users/forms.py
+++ b/openstack_dashboard/dashboards/identity/users/forms.py
@@ -93,10 +93,22 @@ class BaseUserForm(forms.SelfHandlingForm):
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"
-class CreateUserForm(PasswordMixin, BaseUserForm):
+class CreateUserForm(PasswordMixin, BaseUserForm, AddExtraColumnMixIn):
# Hide the domain_id and domain_name by default
domain_id = forms.CharField(label=_("Domain ID"),
required=False,
@@ -129,6 +141,7 @@ class CreateUserForm(PasswordMixin, BaseUserForm):
"description", "email", "password",
"confirm_password", "project", "role_id",
"enabled"]
+ self.add_extra_fields(ordering)
self.fields = collections.OrderedDict(
(key, self.fields[key]) for key in ordering)
role_choices = [(role.id, role.name) for role in roles]
@@ -153,6 +166,14 @@ class CreateUserForm(PasswordMixin, BaseUserForm):
desc = data["description"]
if "email" in data:
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 = \
api.keystone.user_create(request,
name=data['name'],
@@ -161,7 +182,8 @@ class CreateUserForm(PasswordMixin, BaseUserForm):
password=data['password'],
project=data['project'] or None,
enabled=data['enabled'],
- domain=domain.id)
+ domain=domain.id,
+ **kwargs)
messages.success(request,
_('User "%s" was successfully created.')
% data['name'])
@@ -189,7 +211,7 @@ class CreateUserForm(PasswordMixin, BaseUserForm):
exceptions.handle(request, _('Unable to create user.'))
-class UpdateUserForm(BaseUserForm):
+class UpdateUserForm(BaseUserForm, AddExtraColumnMixIn):
# Hide the domain_id and domain_name by default
domain_id = forms.CharField(label=_("Domain ID"),
required=False,
@@ -211,7 +233,7 @@ class UpdateUserForm(BaseUserForm):
def __init__(self, request, *args, **kwargs):
super(UpdateUserForm, self).__init__(request, *args, **kwargs)
-
+ self.add_extra_fields()
if api.keystone.keystone_can_edit_user() is False:
for field in ('name', 'email'):
self.fields.pop(field)
diff --git a/openstack_dashboard/dashboards/identity/users/templates/users/_detail_overview.html b/openstack_dashboard/dashboards/identity/users/templates/users/_detail_overview.html
index 38083c358a..c0511e8058 100644
--- a/openstack_dashboard/dashboards/identity/users/templates/users/_detail_overview.html
+++ b/openstack_dashboard/dashboards/identity/users/templates/users/_detail_overview.html
@@ -28,6 +28,12 @@
{% trans "Primary Project Name" %}
{{ tenant_name }}
{% endif %}
+ {% if extras %}
+ {% for key, value in extras.items %}
+ {{ key }}
+ {{ value }}
+ {% endfor %}
+ {% endif %}
diff --git a/openstack_dashboard/dashboards/identity/users/tests.py b/openstack_dashboard/dashboards/identity/users/tests.py
index b12cfde46c..95bf2bb371 100644
--- a/openstack_dashboard/dashboards/identity/users/tests.py
+++ b/openstack_dashboard/dashboards/identity/users/tests.py
@@ -84,6 +84,7 @@ class UsersViewTests(test.BaseAdminViewTests):
domain_context_name=domain.name)
self.test_index()
+ @override_settings(USER_TABLE_EXTRA_INFO={'phone_num': 'Phone Number'})
@test.create_stubs({api.keystone: ('user_create',
'get_default_domain',
'tenant_list',
@@ -95,6 +96,7 @@ class UsersViewTests(test.BaseAdminViewTests):
user = self.users.get(id="1")
domain = self._get_default_domain()
domain_id = domain.id
+ phone_number = "+81-3-1234-5678"
role = self.roles.first()
@@ -112,6 +114,7 @@ class UsersViewTests(test.BaseAdminViewTests):
IgnoreArg(), user=None).AndReturn(
[self.tenants.list(), False])
+ kwargs = {'phone_num': phone_number}
api.keystone.user_create(IgnoreArg(),
name=user.name,
description=user.description,
@@ -119,7 +122,8 @@ class UsersViewTests(test.BaseAdminViewTests):
password=user.password,
project=self.tenant.id,
enabled=True,
- domain=domain_id).AndReturn(user)
+ domain=domain_id,
+ **kwargs).AndReturn(user)
api.keystone.role_list(IgnoreArg()).AndReturn(self.roles.list())
api.keystone.get_default_role(IgnoreArg()).AndReturn(role)
api.keystone.roles_for_user(IgnoreArg(), user.id, self.tenant.id)
@@ -137,7 +141,8 @@ class UsersViewTests(test.BaseAdminViewTests):
'project': self.tenant.id,
'role_id': self.roles.first().id,
'enabled': True,
- 'confirm_password': user.password}
+ 'confirm_password': user.password,
+ 'phone_num': phone_number}
res = self.client.post(USER_CREATE_URL, formData)
self.assertNoFormErrors(res)
@@ -370,6 +375,7 @@ class UsersViewTests(test.BaseAdminViewTests):
res, "form", 'password',
['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',
'domain_get',
'tenant_list',
@@ -381,6 +387,7 @@ class UsersViewTests(test.BaseAdminViewTests):
user = self.users.get(id="1")
domain_id = user.domain_id
domain = self.domains.get(id=domain_id)
+ phone_number = "+81-3-1234-5678"
api.keystone.user_get(IsA(http.HttpRequest), '1',
admin=True).AndReturn(user)
@@ -396,10 +403,12 @@ class UsersViewTests(test.BaseAdminViewTests):
IgnoreArg(), user=user.id).AndReturn(
[self.tenants.list(), False])
+ kwargs = {'phone_num': phone_number}
api.keystone.user_update(IsA(http.HttpRequest),
user.id,
email=user.email,
- name=user.name).AndReturn(None)
+ name=user.name,
+ **kwargs).AndReturn(None)
self.mox.ReplayAll()
@@ -408,8 +417,8 @@ class UsersViewTests(test.BaseAdminViewTests):
'name': user.name,
'description': user.description,
'email': user.email,
- 'project': self.tenant.id}
-
+ 'project': self.tenant.id,
+ 'phone_num': phone_number}
res = self.client.post(USER_UPDATE_URL, formData)
self.assertNoFormErrors(res)
diff --git a/openstack_dashboard/dashboards/identity/users/views.py b/openstack_dashboard/dashboards/identity/users/views.py
index 92c0539373..7a3be1f9b4 100644
--- a/openstack_dashboard/dashboards/identity/users/views.py
+++ b/openstack_dashboard/dashboards/identity/users/views.py
@@ -19,6 +19,7 @@
import logging
import operator
+from django.conf import settings
from django.core.urlresolvers import reverse
from django.core.urlresolvers import reverse_lazy
from django.utils.decorators import method_decorator # noqa
@@ -129,13 +130,18 @@ class UpdateView(forms.ModalFormView):
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve project domain.'))
- return {'domain_id': domain_id,
+
+ data = {'domain_id': domain_id,
'domain_name': domain_name,
'id': user.id,
'name': user.name,
'project': user.project_id,
'email': getattr(user, 'email', 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):
@@ -200,7 +206,10 @@ class DetailView(views.HorizonTemplateView):
exceptions.handle(self.request,
_('Unable to retrieve project domain.'))
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
if tenant:
context["tenant_name"] = tenant.name
diff --git a/openstack_dashboard/local/local_settings.py.example b/openstack_dashboard/local/local_settings.py.example
index 89f5183c57..6b36bd70fe 100644
--- a/openstack_dashboard/local/local_settings.py.example
+++ b/openstack_dashboard/local/local_settings.py.example
@@ -796,3 +796,19 @@ REST_API_REQUIRED_SETTINGS = ['OPENSTACK_HYPERVISOR_FEATURES',
# 'ipv6': ['fc00::/7']
#}
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'),
+#}
diff --git a/releasenotes/notes/bp-support-extra-prop-for-project-and-user-e8a4578c395a8ade.yaml b/releasenotes/notes/bp-support-extra-prop-for-project-and-user-e8a4578c395a8ade.yaml
new file mode 100644
index 0000000000..7af273d779
--- /dev/null
+++ b/releasenotes/notes/bp-support-extra-prop-for-project-and-user-e8a4578c395a8ade.yaml
@@ -0,0 +1,7 @@
+---
+features:
+ - >
+ [`blueprint Supports extra properties in project and user `_]
+ Support an ability to treat additional information for
+ project and user as an extra attribute.
+