diff --git a/openstack_dashboard/api/heat.py b/openstack_dashboard/api/heat.py index a8ab195adb..8411c400a4 100644 --- a/openstack_dashboard/api/heat.py +++ b/openstack_dashboard/api/heat.py @@ -64,10 +64,20 @@ def stack_get(request, stack_id): return heatclient(request).stacks.get(stack_id) +def template_get(request, stack_id): + return heatclient(request).stacks.template(stack_id) + + def stack_create(request, password=None, **kwargs): return heatclient(request, password).stacks.create(**kwargs) +def stack_update(request, stack_id, **kwargs): + if kwargs.get('password'): + kwargs.pop('password') + return heatclient(request).stacks.update(stack_id, **kwargs) + + def events_list(request, stack_name): return heatclient(request).events.list(stack_name) diff --git a/openstack_dashboard/dashboards/project/stacks/forms.py b/openstack_dashboard/dashboards/project/stacks/forms.py index c640c75d79..4fa0ebacb7 100644 --- a/openstack_dashboard/dashboards/project/stacks/forms.py +++ b/openstack_dashboard/dashboards/project/stacks/forms.py @@ -145,18 +145,39 @@ class TemplateForm(forms.SelfHandlingForm): return cleaned - def handle(self, request, data): + def create_kwargs(self, data): kwargs = {'parameters': data['template_validate'], 'template_data': data['template_data'], 'template_url': data['template_url']} + if data.get('stack_id'): + kwargs['stack_id'] = data['stack_id'] + return kwargs + + def handle(self, request, data): + kwargs = self.create_kwargs(data) # NOTE (gabriel): This is a bit of a hack, essentially rewriting this # request so that we can chain it as an input to the next view... # but hey, it totally works. request.method = 'GET' + return self.next_view.as_view()(request, **kwargs) -class StackCreateForm(forms.SelfHandlingForm): +class ChangeTemplateForm(TemplateForm): + class Meta: + name = _('Edit Template') + help_text = _('From here you can select a new template to re-launch ' + 'a stack.') + stack_id = forms.CharField(label=_('Stack ID'), + widget=forms.widgets.HiddenInput, + required=True) + stack_name = forms.CharField(label=_('Stack Name'), + widget=forms.TextInput( + attrs={'readonly': 'readonly'} + )) + + +class CreateStackForm(forms.SelfHandlingForm): param_prefix = '__param_' @@ -193,11 +214,13 @@ class StackCreateForm(forms.SelfHandlingForm): def __init__(self, *args, **kwargs): parameters = kwargs.pop('parameters') - super(StackCreateForm, self).__init__(*args, **kwargs) + # special case: load template data from API, not passed in params + if(kwargs.get('validate_me')): + parameters = kwargs.pop('validate_me') + super(CreateStackForm, self).__init__(*args, **kwargs) self._build_parameter_fields(parameters) def _build_parameter_fields(self, template_validate): - self.fields['password'] = forms.CharField( label=_('Password for user "%s"') % self.request.user.username, help_text=_('This is required for operations to be performed ' @@ -267,3 +290,49 @@ class StackCreateForm(forms.SelfHandlingForm): except Exception as e: msg = exception_to_validation_msg(e) exceptions.handle(request, msg or _('Stack creation failed.')) + + +class EditStackForm(CreateStackForm): + + class Meta: + name = _('Update Stack Parameters') + + stack_id = forms.CharField(label=_('Stack ID'), + widget=forms.widgets.HiddenInput, + required=True) + stack_name = forms.CharField(label=_('Stack Name'), + widget=forms.TextInput( + attrs={'readonly': 'readonly'} + )) + + @sensitive_variables('password') + def handle(self, request, data): + prefix_length = len(self.param_prefix) + params_list = [(k[prefix_length:], v) for (k, v) in data.iteritems() + if k.startswith(self.param_prefix)] + + stack_id = data.get('stack_id') + fields = { + 'stack_name': data.get('stack_name'), + 'timeout_mins': data.get('timeout_mins'), + 'disable_rollback': not(data.get('enable_rollback')), + 'parameters': dict(params_list), + 'password': data.get('password') + } + + # if the user went directly to this form, resubmit the existing + # template data. otherwise, submit what they had from the first form + if data.get('template_data'): + fields['template'] = data.get('template_data') + elif data.get('template_url'): + fields['template_url'] = data.get('template_url') + elif data.get('parameters'): + fields['template'] = data.get('parameters') + + try: + api.heat.stack_update(self.request, stack_id=stack_id, **fields) + messages.success(request, _("Stack update started.")) + return True + except Exception as e: + msg = exception_to_validation_msg(e) + exceptions.handle(request, msg or _('Stack update failed.')) diff --git a/openstack_dashboard/dashboards/project/stacks/tables.py b/openstack_dashboard/dashboards/project/stacks/tables.py index 475b5118b6..7848d523f8 100644 --- a/openstack_dashboard/dashboards/project/stacks/tables.py +++ b/openstack_dashboard/dashboards/project/stacks/tables.py @@ -12,9 +12,11 @@ # License for the specific language governing permissions and limitations # under the License. +from django.core import urlresolvers from django.http import Http404 # noqa from django.template.defaultfilters import timesince # noqa from django.template.defaultfilters import title # noqa +from django.utils.http import urlencode # noqa from django.utils.translation import ugettext_lazy as _ from horizon import messages @@ -34,6 +36,16 @@ class LaunchStack(tables.LinkAction): classes = ("btn-create", "ajax-modal") +class ChangeStackTemplate(tables.LinkAction): + name = "edit" + verbose_name = _("Change Stack Template") + url = "horizon:project:stacks:change_template" + classes = ("ajax-modal", "btn-edit") + + def get_link_url(self, stack): + return urlresolvers.reverse(self.url, args=[stack.id]) + + class DeleteStack(tables.BatchAction): name = "delete" action_present = _("Delete") @@ -100,7 +112,8 @@ class StacksTable(tables.DataTable): status_columns = ["status", ] row_class = StacksUpdateRow table_actions = (LaunchStack, DeleteStack,) - row_actions = (DeleteStack, ) + row_actions = (DeleteStack, + ChangeStackTemplate) class EventsTable(tables.DataTable): diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/_change_template.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_change_template.html new file mode 100644 index 0000000000..cd22962e5f --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_change_template.html @@ -0,0 +1,28 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}select_template{% endblock %} +{% block form_action %}{% url 'horizon:project:stacks:change_template' stack.id%}{% endblock %} +{% block form_attrs %}enctype="multipart/form-data"{% endblock %} + +{% block modal-header %}{% trans "Select Template" %}{% endblock %} +{% block modal_id %}select_template_modal{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description" %}:

+

{% trans "Use one of the available template source options to specify the template to be used in creating this stack." %}

+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} + diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/_update.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_update.html new file mode 100644 index 0000000000..7405932ece --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_update.html @@ -0,0 +1,26 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}update_stack{% endblock %} +{% block form_action %}{% url 'horizon:project:stacks:edit_stack' stack.id %}{% endblock %} + +{% block modal-header %}{% trans "Update Stack Parameters" %}{% endblock %} +{% block modal_id %}update_stack_modal{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description" %}:

+

{% trans "Update a stack with the provided values. Please note that any encrypted parameters, such as passwords, will be reset to default if you don't change them here." %}

+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/change_template.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/change_template.html new file mode 100644 index 0000000000..0765d840d1 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/change_template.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Change Template" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Change Template") %} +{% endblock page_header %} + +{% block main %} + {% include 'project/stacks/_change_template.html' %} +{% endblock %} + diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/update.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/update.html new file mode 100644 index 0000000000..2b21f044f4 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/update.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Update Stack Parameters" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Update Stack") %} +{% endblock page_header %} + +{% block main %} + {% include 'project/stacks/_update.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/stacks/tests.py b/openstack_dashboard/dashboards/project/stacks/tests.py index bac0e2732b..5c0711d8ce 100644 --- a/openstack_dashboard/dashboards/project/stacks/tests.py +++ b/openstack_dashboard/dashboards/project/stacks/tests.py @@ -151,7 +151,76 @@ class StackTests(test.TestCase): "__param_DBPassword": "admin", "__param_DBRootPassword": "admin", "__param_DBName": "wordpress", - 'method': forms.StackCreateForm.__name__} + 'method': forms.CreateStackForm.__name__} + res = self.client.post(url, form_data) + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({api.heat: ('stack_update', 'stack_get', + 'template_get', 'template_validate')}) + def test_edit_stack_template(self): + template = self.stack_templates.first() + stack = self.stacks.first() + + # GET to template form + api.heat.stack_get(IsA(http.HttpRequest), + stack.id).AndReturn(stack) + # POST template form, validation + api.heat.template_validate(IsA(http.HttpRequest), + template=template.data) \ + .AndReturn(json.loads(template.validate)) + + # GET to edit form + api.heat.stack_get(IsA(http.HttpRequest), + stack.id).AndReturn(stack) + api.heat.template_get(IsA(http.HttpRequest), + stack.id) \ + .AndReturn(json.loads(template.validate)) + + # POST to edit form + api.heat.stack_get(IsA(http.HttpRequest), + stack.id).AndReturn(stack) + + fields = { + 'stack_name': stack.stack_name, + 'disable_rollback': True, + 'timeout_mins': 61, + 'password': 'password', + 'template': IsA(unicode), + 'parameters': IsA(dict) + } + api.heat.stack_update(IsA(http.HttpRequest), + stack_id=stack.id, + **fields) + self.mox.ReplayAll() + + url = reverse('horizon:project:stacks:change_template', + args=[stack.id]) + res = self.client.get(url) + self.assertTemplateUsed(res, 'project/stacks/change_template.html') + + form_data = {'template_source': 'raw', + 'template_data': template.data, + 'method': forms.ChangeTemplateForm.__name__} + res = self.client.post(url, form_data) + + url = reverse('horizon:project:stacks:edit_stack', + args=[stack.id, ]) + form_data = {'template_source': 'raw', + 'template_data': template.data, + 'password': 'password', + 'parameters': template.validate, + 'stack_name': stack.stack_name, + 'stack_id': stack.id, + "timeout_mins": 61, + "disable_rollback": True, + "__param_DBUsername": "admin", + "__param_LinuxDistribution": "F17", + "__param_InstanceType": "m1.small", + "__param_KeyName": "test", + "__param_DBPassword": "admin", + "__param_DBRootPassword": "admin", + "__param_DBName": "wordpress", + 'method': forms.EditStackForm.__name__} res = self.client.post(url, form_data) self.assertRedirectsNoFollow(res, INDEX_URL) @@ -177,7 +246,7 @@ class StackTests(test.TestCase): "__param_DBPassword": "admin", "__param_DBRootPassword": "admin", "__param_DBName": "wordpress", - 'method': forms.StackCreateForm.__name__} + 'method': forms.CreateStackForm.__name__} res = self.client.post(url, form_data) error = ('Name must start with a letter and may only contain letters, ' diff --git a/openstack_dashboard/dashboards/project/stacks/urls.py b/openstack_dashboard/dashboards/project/stacks/urls.py index 1ec8ea7371..96edd57090 100644 --- a/openstack_dashboard/dashboards/project/stacks/urls.py +++ b/openstack_dashboard/dashboards/project/stacks/urls.py @@ -26,6 +26,10 @@ urlpatterns = patterns( url(r'^launch$', views.CreateStackView.as_view(), name='launch'), url(r'^stack/(?P[^/]+)/$', views.DetailView.as_view(), name='detail'), + url(r'^(?P[^/]+)/change_template$', + views.ChangeTemplateView.as_view(), name='change_template'), + url(r'^(?P[^/]+)/edit_stack$', + views.EditStackView.as_view(), name='edit_stack'), url(r'^stack/(?P[^/]+)/(?P[^/]+)/$', views.ResourceView.as_view(), name='resource'), url(r'^get_d3_data/(?P[^/]+)/$', diff --git a/openstack_dashboard/dashboards/project/stacks/views.py b/openstack_dashboard/dashboards/project/stacks/views.py index 737c4ac519..09649ce93c 100644 --- a/openstack_dashboard/dashboards/project/stacks/views.py +++ b/openstack_dashboard/dashboards/project/stacks/views.py @@ -67,8 +67,41 @@ class SelectTemplateView(forms.ModalFormView): return kwargs +class ChangeTemplateView(forms.ModalFormView): + form_class = project_forms.ChangeTemplateForm + template_name = 'project/stacks/change_template.html' + success_url = reverse_lazy('horizon:project:stacks:edit_stack') + + def get_context_data(self, **kwargs): + context = super(ChangeTemplateView, self).get_context_data(**kwargs) + context['stack'] = self.get_object() + return context + + @memoized.memoized_method + def get_object(self): + stack_id = self.kwargs['stack_id'] + try: + self._stack = api.heat.stack_get(self.request, stack_id) + except Exception: + msg = _("Unable to retrieve stack.") + redirect = reverse('horizon:project:stacks:index') + exceptions.handle(self.request, msg, redirect=redirect) + return self._stack + + def get_initial(self): + stack = self.get_object() + return {'stack_id': stack.id, + 'stack_name': stack.stack_name + } + + def get_form_kwargs(self): + kwargs = super(ChangeTemplateView, self).get_form_kwargs() + kwargs['next_view'] = EditStackView + return kwargs + + class CreateStackView(forms.ModalFormView): - form_class = project_forms.StackCreateForm + form_class = project_forms.CreateStackForm template_name = 'project/stacks/create.html' success_url = reverse_lazy('horizon:project:stacks:index') @@ -92,6 +125,42 @@ class CreateStackView(forms.ModalFormView): return kwargs +# edit stack parameters, coming from template selector +class EditStackView(CreateStackView): + form_class = project_forms.EditStackForm + template_name = 'project/stacks/update.html' + success_url = reverse_lazy('horizon:project:stacks:index') + + def get_initial(self): + initial = super(EditStackView, self).get_initial() + + initial['stack'] = self.get_object()['stack'] + if initial['stack']: + initial['stack_id'] = initial['stack'].id + initial['stack_name'] = initial['stack'].stack_name + + return initial + + def get_context_data(self, **kwargs): + context = super(EditStackView, self).get_context_data(**kwargs) + context['stack'] = self.get_object()['stack'] + return context + + @memoized.memoized_method + def get_object(self): + stack_id = self.kwargs['stack_id'] + try: + stack = {} + stack['stack'] = api.heat.stack_get(self.request, stack_id) + stack['template'] = api.heat.template_get(self.request, stack_id) + self._stack = stack + except Exception: + msg = _("Unable to retrieve stack.") + redirect = reverse('horizon:project:stacks:index') + exceptions.handle(self.request, msg, redirect=redirect) + return self._stack + + class DetailView(tabs.TabView): tab_group_class = project_tabs.StackDetailTabs template_name = 'project/stacks/detail.html' diff --git a/openstack_dashboard/test/api_tests/heat_tests.py b/openstack_dashboard/test/api_tests/heat_tests.py index 95ff3b76c0..e1ae6fcd21 100644 --- a/openstack_dashboard/test/api_tests/heat_tests.py +++ b/openstack_dashboard/test/api_tests/heat_tests.py @@ -24,6 +24,35 @@ class HeatApiTests(test.APITestCase): heatclient.stacks = self.mox.CreateMockAnything() heatclient.stacks.list().AndReturn(iter(api_stacks)) self.mox.ReplayAll() - stacks = api.heat.stacks_list(self.request) self.assertItemsEqual(stacks, api_stacks) + + def test_template_get(self): + api_stacks = self.stacks.list() + stack_id = api_stacks[0].id + mock_data_template = self.stack_templates.list()[0] + + heatclient = self.stub_heatclient() + heatclient.stacks = self.mox.CreateMockAnything() + heatclient.stacks.template(stack_id).AndReturn(mock_data_template) + self.mox.ReplayAll() + + template = api.heat.template_get(self.request, stack_id) + self.assertEqual(template.data, mock_data_template.data) + + def test_stack_update(self): + api_stacks = self.stacks.list() + stack = api_stacks[0] + stack_id = stack.id + + heatclient = self.stub_heatclient() + heatclient.stacks = self.mox.CreateMockAnything() + form_data = {'timeout_mins': 600} + heatclient.stacks.update(stack_id, **form_data).AndReturn(stack) + self.mox.ReplayAll() + + returned_stack = api.heat.stack_update(self.request, + stack_id, + **form_data) + from heatclient.v1 import stacks + self.assertIsInstance(returned_stack, stacks.Stack) diff --git a/openstack_dashboard/test/test_data/heat_data.py b/openstack_dashboard/test/test_data/heat_data.py index 4dffaf7430..70dc5b4559 100644 --- a/openstack_dashboard/test/test_data/heat_data.py +++ b/openstack_dashboard/test/test_data/heat_data.py @@ -326,6 +326,17 @@ def data(TEST): "05b4f39f-ea96-4d91-910c-e758c078a089", "rel": "self" }], + "parameters": { + 'DBUsername': '******', + 'InstanceType': 'm1.small', + 'AWS::StackId': + 'arn:openstack:heat::2ce287:stacks/teststack/88553ec', + 'DBRootPassword': '******', + 'AWS::StackName': 'teststack', + 'DBPassword': '******', + 'AWS::Region': 'ap-southeast-1', + 'DBName': u'wordpress' + }, "stack_status_reason": "Stack successfully created", "stack_name": "stack-test", "creation_time": "2013-04-22T00:11:39Z",