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. +