diff --git a/openstack_dashboard/api/cinder.py b/openstack_dashboard/api/cinder.py index f3e9aa6f69..aeacc95f99 100644 --- a/openstack_dashboard/api/cinder.py +++ b/openstack_dashboard/api/cinder.py @@ -1139,9 +1139,13 @@ def group_type_create(request, name, description=None, is_public=None): @profiler.trace -def group_type_update(request, group_type_id, data): +def group_type_update(request, group_type_id, name=None, description=None, + is_public=None): client = _cinderclient_with_generic_groups(request) - return GroupType(client.group_types.update(group_type_id, **data)) + return GroupType(client.group_types.update(group_type_id, + name, + description, + is_public)) @profiler.trace diff --git a/openstack_dashboard/dashboards/admin/group_types/__init__.py b/openstack_dashboard/dashboards/admin/group_types/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/admin/group_types/forms.py b/openstack_dashboard/dashboards/admin/group_types/forms.py new file mode 100644 index 0000000000..6d57604181 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/group_types/forms.py @@ -0,0 +1,107 @@ +# 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.forms import ValidationError +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import messages + +from openstack_dashboard.api import cinder + + +class CreateGroupType(forms.SelfHandlingForm): + name = forms.CharField(max_length=255, label=_("Name")) + group_type_description = forms.CharField( + max_length=255, + widget=forms.Textarea(attrs={'rows': 4}), + label=_("Description"), + required=False) + is_public = forms.BooleanField( + label=_("Public"), + initial=True, + required=False, + help_text=_("By default, group type is created as public. To " + "create a private group type, uncheck this field.")) + + def clean_name(self): + cleaned_name = self.cleaned_data['name'] + if cleaned_name.isspace(): + raise ValidationError(_('Group type name can not be empty.')) + + return cleaned_name + + def handle(self, request, data): + try: + group_type = cinder.group_type_create( + request, + data['name'], + data['group_type_description'], + data['is_public']) + messages.success(request, _('Successfully created group type: %s') + % data['name']) + return group_type + except Exception as e: + if getattr(e, 'code', None) == 409: + msg = _('Group type name "%s" already ' + 'exists.') % data['name'] + self._errors['name'] = self.error_class([msg]) + else: + redirect = reverse("horizon:admin:group_types:index") + exceptions.handle(request, + _('Unable to create group type.'), + redirect=redirect) + + +class EditGroupType(forms.SelfHandlingForm): + name = forms.CharField(max_length=255, + label=_("Name")) + description = forms.CharField(max_length=255, + widget=forms.Textarea(attrs={'rows': 4}), + label=_("Description"), + required=False) + is_public = forms.BooleanField(label=_("Public"), required=False, + help_text=_( + "To make group type private, uncheck " + "this field.")) + + def clean_name(self): + cleaned_name = self.cleaned_data['name'] + if cleaned_name.isspace(): + msg = _('New name cannot be empty.') + self._errors['name'] = self.error_class([msg]) + + return cleaned_name + + def handle(self, request, data): + group_type_id = self.initial['id'] + try: + cinder.group_type_update(request, + group_type_id, + data['name'], + data['description'], + data['is_public']) + message = _('Successfully updated group type.') + messages.success(request, message) + return True + except Exception as ex: + redirect = reverse("horizon:admin:group_types:index") + if ex.code == 409: + error_message = _('New name conflicts with another ' + 'group type.') + else: + error_message = _('Unable to update group type.') + + exceptions.handle(request, error_message, + redirect=redirect) diff --git a/openstack_dashboard/dashboards/admin/group_types/panel.py b/openstack_dashboard/dashboards/admin/group_types/panel.py new file mode 100644 index 0000000000..392fd25f18 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/group_types/panel.py @@ -0,0 +1,24 @@ +# 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.utils.translation import ugettext_lazy as _ + +import horizon + + +class GroupTypes(horizon.Panel): + name = _("Group Types") + slug = 'group_types' + permissions = ( + ('openstack.services.volume', 'openstack.services.volumev3'), + ) + policy_rules = (("volume", "group:group_types_manage"),) diff --git a/openstack_dashboard/dashboards/admin/group_types/tables.py b/openstack_dashboard/dashboards/admin/group_types/tables.py new file mode 100644 index 0000000000..29eab0c2f9 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/group_types/tables.py @@ -0,0 +1,124 @@ +# 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.template import defaultfilters as filters +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ungettext_lazy + +from horizon import exceptions +from horizon import forms +from horizon import tables + +from openstack_dashboard.api import cinder + + +class CreateGroupType(tables.LinkAction): + name = "create" + verbose_name = _("Create Group Type") + url = "horizon:admin:group_types:create_type" + classes = ("ajax-modal",) + icon = "plus" + policy_rules = (("volume", "group:group_types_manage"),) + + +class EditGroupType(tables.LinkAction): + name = "edit" + verbose_name = _("Edit Group Type") + url = "horizon:admin:group_types:update_type" + classes = ("ajax-modal",) + icon = "pencil" + policy_rules = (("volume", "group:group_types_manage"),) + + +class GroupTypesFilterAction(tables.FilterAction): + + def filter(self, table, group_types, filter_string): + """Naive case-insensitive search.""" + query = filter_string.lower() + return [group_type for group_type in group_types + if query in group_type.name.lower()] + + +class DeleteGroupType(tables.DeleteAction): + @staticmethod + def action_present(count): + return ungettext_lazy( + u"Delete Group Type", + u"Delete Group Types", + count + ) + + @staticmethod + def action_past(count): + return ungettext_lazy( + u"Deleted Group Type", + u"Deleted Group Types", + count + ) + policy_rules = (("volume", "group:group_types_manage"),) + + def delete(self, request, obj_id): + try: + cinder.group_type_delete(request, obj_id) + except exceptions.BadRequest as e: + redirect_url = reverse("horizon:admin:group_types:index") + exceptions.handle(request, e, redirect=redirect_url) + + +class UpdateRow(tables.Row): + ajax = True + + def get_data(self, request, group_type_id): + try: + group_type = \ + cinder.group_type_get(request, group_type_id) + except Exception: + exceptions.handle(request, + _('Unable to retrieve group type.')) + return group_type + + +class GroupTypesTable(tables.DataTable): + name = tables.WrappingColumn("name", verbose_name=_("Name"), + form_field=forms.CharField(max_length=64)) + description = tables.Column(lambda obj: getattr(obj, 'description', None), + verbose_name=_('Description'), + form_field=forms.CharField( + widget=forms.Textarea(attrs={'rows': 4}), + required=False)) + public = tables.Column("is_public", + verbose_name=_("Public"), + filters=(filters.yesno, filters.capfirst), + form_field=forms.BooleanField( + label=_('Public'), required=False)) + + def get_object_display(self, group_type): + return group_type.name + + def get_object_id(self, group_type): + return str(group_type.id) + + class Meta(object): + name = "group_types" + verbose_name = _("Group Types") + table_actions = ( + GroupTypesFilterAction, + CreateGroupType, + DeleteGroupType, + ) + row_actions = ( + EditGroupType, + DeleteGroupType + ) + row_class = UpdateRow diff --git a/openstack_dashboard/dashboards/admin/group_types/templates/group_types/_create_group_type.html b/openstack_dashboard/dashboards/admin/group_types/templates/group_types/_create_group_type.html new file mode 100644 index 0000000000..7474fdce1c --- /dev/null +++ b/openstack_dashboard/dashboards/admin/group_types/templates/group_types/_create_group_type.html @@ -0,0 +1,16 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +

{% trans "Description:" %}

+

{% blocktrans trimmed %} + Group type is a type or label that can be selected at group creation + time in OpenStack. It usually maps to a set of capabilities of the storage + back-end driver to be used for this volume. Examples: "Performance", + "SSD", "Backup", etc. This is equivalent to the + cinder type-create command. Once the group type gets created, + click the "Extra Specs" button to set up extra specs key-value + pair(s) for that group type. + {% endblocktrans %} +

+{% endblock %} diff --git a/openstack_dashboard/dashboards/admin/group_types/templates/group_types/_update_group_type.html b/openstack_dashboard/dashboards/admin/group_types/templates/group_types/_update_group_type.html new file mode 100644 index 0000000000..aa49a7e251 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/group_types/templates/group_types/_update_group_type.html @@ -0,0 +1,20 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block form_id %}{% endblock %} +{% block form_action %}{% url 'horizon:admin:group_types:update_type' group_type.id %}{% endblock %} + +{% block modal_id %}update_group_type_modal{% endblock %} +{% block modal-header %}{% trans "Edit Group Type" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description:" %}

+

{% trans "Modify group type name, description, and public status." %}

+
+{% endblock %} diff --git a/openstack_dashboard/dashboards/admin/group_types/templates/group_types/create_group_type.html b/openstack_dashboard/dashboards/admin/group_types/templates/group_types/create_group_type.html new file mode 100644 index 0000000000..b58e133ac0 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/group_types/templates/group_types/create_group_type.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Create Group Type" %}{% endblock %} + +{% block main %} + {% include 'admin/group_types/_create_group_type.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/admin/group_types/templates/group_types/update_group_type.html b/openstack_dashboard/dashboards/admin/group_types/templates/group_types/update_group_type.html new file mode 100644 index 0000000000..0b5043480c --- /dev/null +++ b/openstack_dashboard/dashboards/admin/group_types/templates/group_types/update_group_type.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Edit Group Type" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Edit Group Type") %} +{% endblock page_header %} + +{% block main %} + {% include 'admin/group_types/_update_group_type.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/admin/group_types/tests.py b/openstack_dashboard/dashboards/admin/group_types/tests.py new file mode 100644 index 0000000000..5230035162 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/group_types/tests.py @@ -0,0 +1,114 @@ +# 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.urls import reverse + +from horizon import exceptions + +from openstack_dashboard import api +from openstack_dashboard.api import cinder +from openstack_dashboard.test import helpers as test + +INDEX_URL = reverse('horizon:admin:group_types:index') + + +class GroupTypeTests(test.BaseAdminViewTests): + @test.create_mocks({cinder: ('group_type_create',)}) + def test_create_group_type(self): + group_type = self.cinder_group_types.first() + formData = {'name': 'group type 1', + 'group_type_description': 'test desc', + 'is_public': True} + + self.mock_group_type_create.return_value = group_type + + url = reverse('horizon:admin:group_types:create_type') + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + self.mock_group_type_create.assert_called_once_with( + test.IsHttpRequest(), + formData['name'], + formData['group_type_description'], + formData['is_public']) + + @test.create_mocks({api.cinder: ('group_type_get', + 'group_type_update')}) + def _test_update_group_type(self, is_public): + group_type = self.cinder_group_types.first() + formData = {'name': group_type.name, + 'description': 'test desc updated', + 'is_public': is_public} + self.mock_group_type_get.return_value = group_type + self.mock_group_type_update.return_value = group_type + + url = reverse('horizon:admin:group_types:update_type', + args=[group_type.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + self.mock_group_type_get.assert_called_once_with( + test.IsHttpRequest(), group_type.id) + self.mock_group_type_update.assert_called_once_with( + test.IsHttpRequest(), + group_type.id, + formData['name'], + formData['description'], + formData['is_public']) + + def test_update_group_type_public_true(self): + self._test_update_group_type(True) + + def test_update_group_type_public_false(self): + self._test_update_group_type(False) + + @test.create_mocks({api.cinder: ('group_type_list', + 'group_type_delete',)}) + def test_delete_group_type(self): + group_type = self.cinder_group_types.first() + formData = {'action': 'group_types__delete__%s' % group_type.id} + + self.mock_group_type_list.return_value = \ + self.cinder_group_types.list() + self.mock_group_type_delete.return_value = None + + res = self.client.post(INDEX_URL, formData) + + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + self.mock_group_type_list.\ + assert_called_once_with(test.IsHttpRequest()) + self.mock_group_type_delete.assert_called_once_with( + test.IsHttpRequest(), group_type.id) + + @test.create_mocks({api.cinder: ('group_type_list', + 'group_type_delete',)}) + def test_delete_group_type_exception(self): + group_type = self.cinder_group_types.first() + formData = {'action': 'group_types__delete__%s' % group_type.id} + + self.mock_group_type_list.return_value = \ + self.cinder_group_types.list() + self.mock_group_type_delete.side_effect = exceptions.BadRequest() + + res = self.client.post(INDEX_URL, formData) + + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + self.mock_group_type_list.\ + assert_called_once_with(test.IsHttpRequest()) + self.mock_group_type_delete.assert_called_once_with( + test.IsHttpRequest(), group_type.id) diff --git a/openstack_dashboard/dashboards/admin/group_types/urls.py b/openstack_dashboard/dashboards/admin/group_types/urls.py new file mode 100644 index 0000000000..e1193d1fd9 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/group_types/urls.py @@ -0,0 +1,26 @@ +# 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 import url + +from openstack_dashboard.dashboards.admin.group_types \ + import views + + +urlpatterns = [ + url(r'^$', views.GroupTypesView.as_view(), name='index'), + url(r'^create_type$', views.CreateGroupTypeView.as_view(), + name='create_type'), + url(r'^(?P[^/]+)/update_type/$', + views.EditGroupTypeView.as_view(), + name='update_type'), +] diff --git a/openstack_dashboard/dashboards/admin/group_types/views.py b/openstack_dashboard/dashboards/admin/group_types/views.py new file mode 100644 index 0000000000..fe83c3895b --- /dev/null +++ b/openstack_dashboard/dashboards/admin/group_types/views.py @@ -0,0 +1,102 @@ +# 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.urls import reverse +from django.urls import reverse_lazy +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import tables +from horizon.utils import memoized + +from openstack_dashboard import api +from openstack_dashboard.dashboards.admin.group_types \ + import forms as group_types_forms +from openstack_dashboard.dashboards.admin.group_types \ + import tables as group_types_tables + +INDEX_URL = 'horizon:admin:group_types:index' + + +class GroupTypesView(tables.DataTableView): + table_class = group_types_tables.GroupTypesTable + page_title = _("Group Types") + + def get_data(self): + try: + group_types = api.cinder.group_type_list(self.request) + except Exception: + group_types = [] + exceptions.handle(self.request, + _("Unable to retrieve group types.")) + + return group_types + + +class CreateGroupTypeView(forms.ModalFormView): + form_class = group_types_forms.CreateGroupType + modal_id = "create_group_type_modal" + template_name = 'admin/group_types/create_group_type.html' + submit_label = _("Create Group Type") + submit_url = reverse_lazy("horizon:admin:group_types:create_type") + success_url = reverse_lazy('horizon:admin:group_types:index') + page_title = _("Create a Group Type") + + +class EditGroupTypeView(forms.ModalFormView): + form_class = group_types_forms.EditGroupType + template_name = 'admin/group_types/update_group_type.html' + success_url = reverse_lazy('horizon:admin:group_types:index') + cancel_url = reverse_lazy('horizon:admin:group_types:index') + submit_label = _('Edit') + + @memoized.memoized_method + def get_data(self): + try: + group_type_id = self.kwargs['type_id'] + group_type = api.cinder.group_type_get(self.request, + group_type_id) + except Exception: + error_message = _( + 'Unable to retrieve group type for: "%s"') \ + % group_type_id + exceptions.handle(self.request, + error_message, + redirect=self.success_url) + + return group_type + + def get_context_data(self, **kwargs): + context = super(EditGroupTypeView, self).get_context_data(**kwargs) + context['group_type'] = self.get_data() + + return context + + def get_initial(self): + group_type = self.get_data() + return {'id': self.kwargs['type_id'], + 'name': group_type.name, + 'is_public': getattr(group_type, 'is_public', True), + 'description': getattr(group_type, 'description', "")} + + +def _get_group_type_name(request, kwargs): + try: + group_type_list = api.cinder.group_type_list(request) + for group_type in group_type_list: + if group_type.id == kwargs['group_type_id']: + return group_type.name + except Exception: + msg = _('Unable to retrieve group type name.') + url = reverse('INDEX_URL') + exceptions.handle(request, msg, redirect=url) diff --git a/openstack_dashboard/enabled/_2270_admin_group_types_panel.py b/openstack_dashboard/enabled/_2270_admin_group_types_panel.py new file mode 100644 index 0000000000..a8fe29d270 --- /dev/null +++ b/openstack_dashboard/enabled/_2270_admin_group_types_panel.py @@ -0,0 +1,10 @@ +# The slug of the panel to be added to HORIZON_CONFIG. Required. +PANEL = 'group_types' +# The slug of the dashboard the PANEL associated with. Required. +PANEL_DASHBOARD = 'admin' +# The slug of the panel group the PANEL is associated with. +PANEL_GROUP = 'volume' + +# Python panel class of the PANEL to be added. +ADD_PANEL = \ + 'openstack_dashboard.dashboards.admin.group_types.panel.GroupTypes'