diff --git a/horizon/forms/views.py b/horizon/forms/views.py index e942227ac1..b37c4d254a 100644 --- a/horizon/forms/views.py +++ b/horizon/forms/views.py @@ -100,6 +100,8 @@ class ModalFormView(ModalFormMixin, generic.FormView): self.get_object_display(handled)] response = http.HttpResponse(json.dumps(data)) response["X-Horizon-Add-To-Field"] = field_id + elif isinstance(handled, http.HttpResponse): + return handled else: success_url = self.get_success_url() response = http.HttpResponseRedirect(success_url) diff --git a/openstack_dashboard/dashboards/project/containers/views.py b/openstack_dashboard/dashboards/project/containers/views.py index 7a614b9f21..2877746834 100644 --- a/openstack_dashboard/dashboards/project/containers/views.py +++ b/openstack_dashboard/dashboards/project/containers/views.py @@ -113,8 +113,8 @@ class ContainerView(browsers.ResourceBrowserView): context['container_name'] = self.kwargs["container_name"] context['subfolders'] = [] if self.kwargs["subfolder_path"]: - (parent, slash, folder) = self.kwargs["subfolder_path"].\ - strip('/').rpartition('/') + (parent, slash, folder) = self.kwargs["subfolder_path"] \ + .strip('/').rpartition('/') while folder: path = "%s%s%s/" % (parent, slash, folder) context['subfolders'].insert(0, (folder, path)) diff --git a/openstack_dashboard/dashboards/project/dashboard.py b/openstack_dashboard/dashboards/project/dashboard.py index 021fcd1728..673acdd3c1 100644 --- a/openstack_dashboard/dashboards/project/dashboard.py +++ b/openstack_dashboard/dashboards/project/dashboard.py @@ -44,10 +44,17 @@ class ObjectStorePanels(horizon.PanelGroup): panels = ('containers',) +class OrchestrationPanels(horizon.PanelGroup): + name = _("Orchestration") + slug = "orchestration" + panels = ('stacks',) + + class Project(horizon.Dashboard): name = _("Project") slug = "project" - panels = (BasePanels, NetworkPanels, ObjectStorePanels) + panels = ( + BasePanels, NetworkPanels, ObjectStorePanels, OrchestrationPanels) default_panel = 'overview' supports_tenants = True diff --git a/openstack_dashboard/dashboards/project/stacks/__init__.py b/openstack_dashboard/dashboards/project/stacks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/project/stacks/forms.py b/openstack_dashboard/dashboards/project/stacks/forms.py new file mode 100644 index 0000000000..3221035d12 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/forms.py @@ -0,0 +1,248 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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. + +import json +import logging +import re + +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import messages + +from openstack_dashboard import api + +LOG = logging.getLogger(__name__) + + +def exception_to_validation_msg(e): + ''' + Extracts a validation message to display to the user. + This needs to be a pattern matching approach until the Heat + API returns exception data in a parsable format. + ''' + validation_patterns = [ + "Remote error: \w* {'Error': '(.*?)'}", + 'Remote error: \w* (.*?) \[', + '400 Bad Request\n\nThe server could not comply with the request ' + 'since it is either malformed or otherwise incorrect.\n\n (.*)', + '(ParserError: .*)' + ] + + for pattern in validation_patterns: + match = re.search(pattern, str(e)) + if match: + return match.group(1) + + +class TemplateForm(forms.SelfHandlingForm): + + class Meta: + name = _('Select Template') + help_text = _('From here you can select a template to launch ' + 'a stack.') + + template_source = forms.ChoiceField(label=_('Template Source'), + choices=[('url', _('URL')), + ('file', _('File')), + ('raw', _('Direct Input'))], + widget=forms.Select(attrs={ + 'class': 'switchable', + 'data-slug': 'source'})) + template_upload = forms.FileField( + label=_('Template File'), + help_text=_('A local template to upload.'), + widget=forms.FileInput(attrs={'class': 'switched', + 'data-switch-on': 'source', + 'data-source-file': _('Template File')}), + required=False) + template_url = forms.URLField( + label=_('Template URL'), + help_text=_('An external (HTTP) URL to load the template from.'), + widget=forms.TextInput(attrs={'class': 'switched', + 'data-switch-on': 'source', + 'data-source-url': _('Template URL')}), + required=False) + template_data = forms.CharField( + label=_('Template Data'), + help_text=_('The raw contents of the template.'), + widget=forms.widgets.Textarea(attrs={ + 'class': 'switched', + 'data-switch-on': 'source', + 'data-source-raw': _('Template Data')}), + required=False) + + def __init__(self, *args, **kwargs): + self.next_view = kwargs.pop('next_view') + super(TemplateForm, self).__init__(*args, **kwargs) + + def clean(self): + cleaned = super(TemplateForm, self).clean() + template_url = cleaned.get('template_url') + template_data = cleaned.get('template_data') + files = self.request.FILES + has_upload = 'template_upload' in files + + # Uploaded file handler + if has_upload and not template_url: + log_template_name = self.request.FILES['template_upload'].name + LOG.info('got upload %s' % log_template_name) + + tpl = self.request.FILES['template_upload'].read() + if tpl.startswith('{'): + try: + json.loads(tpl) + except Exception as e: + msg = _('There was a problem parsing the template: %s') % e + raise forms.ValidationError(msg) + cleaned['template_data'] = tpl + + # URL handler + elif template_url and (has_upload or template_data): + msg = _('Please specify a template using only one source method.') + raise forms.ValidationError(msg) + + # Check for raw template input + elif not template_url and not template_data: + msg = _('You must specify a template via one of the ' + 'available sources.') + raise forms.ValidationError(msg) + + # Validate the template and get back the params. + kwargs = {} + if cleaned['template_data']: + kwargs['template'] = cleaned['template_data'] + else: + kwargs['template_url'] = cleaned['template_url'] + + try: + validated = api.heat.template_validate(self.request, **kwargs) + cleaned['template_validate'] = validated + except Exception as e: + msg = exception_to_validation_msg(e) + if not msg: + msg = _('An unknown problem occurred validating the template.') + LOG.exception(msg) + raise forms.ValidationError(msg) + + return cleaned + + def handle(self, request, data): + kwargs = {'parameters': data['template_validate'], + 'template_data': data['template_data'], + 'template_url': data['template_url']} + # 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): + + param_prefix = '__param_' + + class Meta: + name = _('Create Stack') + + template_data = forms.CharField( + widget=forms.widgets.HiddenInput, + required=False) + template_url = forms.CharField( + widget=forms.widgets.HiddenInput, + required=False) + parameters = forms.CharField( + widget=forms.widgets.HiddenInput, + required=True) + stack_name = forms.CharField( + max_length='255', + label=_('Stack Name'), + help_text=_('Name of the stack to create.'), + required=True) + timeout_mins = forms.IntegerField( + initial=60, + label=_('Creation Timeout (minutes)'), + help_text=_('Stack creation timeout in minutes.'), + required=True) + enable_rollback = forms.BooleanField( + label=_('Rollback On Failure'), + help_text=_('Enable rollback on create/update failure.'), + required=False) + + def __init__(self, *args, **kwargs): + parameters = kwargs.pop('parameters') + super(StackCreateForm, self).__init__(*args, **kwargs) + self._build_parameter_fields(parameters) + + def _build_parameter_fields(self, template_validate): + self.help_text = template_validate['Description'] + + params = template_validate.get('Parameters', {}) + + for param_key, param in params.items(): + field_key = self.param_prefix + param_key + field_args = { + 'initial': param.get('Default', None), + 'label': param_key, + 'help_text': param.get('Description', ''), + 'required': param.get('Default', None) is None + } + + param_type = param.get('Type', None) + + if 'AllowedValues' in param: + choices = map(lambda x: (x, x), param['AllowedValues']) + field_args['choices'] = choices + field = forms.ChoiceField(**field_args) + + elif param_type in ('CommaDelimitedList', 'String'): + if 'MinLength' in param: + field_args['min_length'] = int(param['MinLength']) + field_args['required'] = param.get('MinLength', 0) > 0 + if 'MaxLength' in param: + field_args['max_length'] = int(param['MaxLength']) + field = forms.CharField(**field_args) + + elif param_type == 'Number': + if 'MinValue' in param: + field_args['min_value'] = int(param['MinValue']) + if 'MaxValue' in param: + field_args['max_value'] = int(param['MaxValue']) + field = forms.IntegerField(**field_args) + + self.fields[field_key] = field + + 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)] + fields = { + 'stack_name': data.get('stack_name'), + 'timeout_mins': data.get('timeout_mins'), + 'disable_rollback': not(data.get('enable_rollback')), + 'parameters': dict(params_list) + } + + if data.get('template_data'): + fields['template'] = data.get('template_data') + else: + fields['template_url'] = data.get('template_url') + + try: + api.heat.stack_create(self.request, **fields) + messages.success(request, _("Stack creation started.")) + return True + except: + exceptions.handle(request, _('Stack creation failed.')) diff --git a/openstack_dashboard/dashboards/project/stacks/mappings.py b/openstack_dashboard/dashboards/project/stacks/mappings.py new file mode 100644 index 0000000000..5b4f9ec7e4 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/mappings.py @@ -0,0 +1,83 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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. + +import json +import logging +import urlparse + +from django.core.urlresolvers import reverse +from django.template.defaultfilters import register + +from openstack_dashboard.api.swift import FOLDER_DELIMITER + +LOG = logging.getLogger(__name__) + + +resource_urls = { + "AWS::EC2::Instance": { + 'link': 'horizon:project:instances:detail'}, + "AWS::EC2::NetworkInterface": { + 'link': 'horizon:project:networks:ports:detail'}, + "AWS::EC2::RouteTable": { + 'link': 'horizon:project:routers:detail'}, + "AWS::EC2::Subnet": { + 'link': 'horizon:project:networks:subnets:detail'}, + "AWS::EC2::Volume": { + 'link': 'horizon:project:volumes:detail'}, + "AWS::EC2::VPC": { + 'link': 'horizon:project:networks:detail'}, + "AWS::S3::Bucket": { + 'link': 'horizon:project:containers:index'}, + "OS::Quantum::Net": { + 'link': 'horizon:project:networks:detail'}, + "OS::Quantum::Port": { + 'link': 'horizon:project:networks:ports:detail'}, + "OS::Quantum::Router": { + 'link': 'horizon:project:routers:detail'}, + "OS::Quantum::Subnet": { + 'link': 'horizon:project:networks:subnets:detail'}, + "OS::Swift::Container": { + 'link': 'horizon:project:containers:index', + 'format_pattern': '%s' + FOLDER_DELIMITER}, +} + + +def resource_to_url(resource): + if not resource or not resource.physical_resource_id: + return None + + mapping = resource_urls.get(resource.resource_type, {}) + try: + if 'link' not in mapping: + return None + format_pattern = mapping.get('format_pattern') or '%s' + rid = format_pattern % resource.physical_resource_id + url = reverse(mapping['link'], args=(rid,)) + except Exception as e: + LOG.exception(e) + return None + return url + + +@register.filter +def stack_output(output): + if not output: + return u'' + if isinstance(output, dict) or isinstance(output, list): + return u'
%s' % json.dumps(output, indent=2) + if isinstance(output, basestring): + parts = urlparse.urlsplit(output) + if parts.netloc and parts.scheme in ('http', 'https'): + return u'%s' % (output, output) + return unicode(output) diff --git a/openstack_dashboard/dashboards/project/stacks/panel.py b/openstack_dashboard/dashboards/project/stacks/panel.py new file mode 100644 index 0000000000..7d9765732d --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/panel.py @@ -0,0 +1,27 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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 + +from openstack_dashboard.dashboards.project import dashboard + + +class Stacks(horizon.Panel): + name = _("Stacks") + slug = "stacks" + permissions = ('openstack.services.orchestration',) + +dashboard.Project.register(Stacks) diff --git a/openstack_dashboard/dashboards/project/stacks/tables.py b/openstack_dashboard/dashboards/project/stacks/tables.py new file mode 100644 index 0000000000..73c7215a39 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/tables.py @@ -0,0 +1,179 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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. + +import logging + +from django.http import Http404 +from django.template.defaultfilters import timesince +from django.template.defaultfilters import title +from django.utils.translation import ugettext_lazy as _ + +from horizon import messages +from horizon import tables +from horizon.utils.filters import parse_isotime +from horizon.utils.filters import replace_underscores + +from heatclient import exc + +from openstack_dashboard import api +from openstack_dashboard.dashboards.project.stacks import mappings + +LOG = logging.getLogger(__name__) + + +class LaunchStack(tables.LinkAction): + name = "launch" + verbose_name = _("Launch Stack") + url = "horizon:project:stacks:select_template" + classes = ("btn-create", "ajax-modal") + + +class DeleteStack(tables.BatchAction): + name = "delete" + action_present = _("Delete") + action_past = _("Scheduled deletion of") + data_type_singular = _("Stack") + data_type_plural = _("Stacks") + classes = ('btn-danger', 'btn-terminate') + + def action(self, request, stack_id): + api.heat.stack_delete(request, stack_id) + + +class StacksUpdateRow(tables.Row): + ajax = True + + def get_data(self, request, stack_id): + try: + return api.heat.stack_get(request, stack_id) + except exc.HTTPNotFound: + # returning 404 to the ajax call removes the + # row from the table on the ui + raise Http404 + except Exception as e: + messages.error(request, e) + + +class StacksTable(tables.DataTable): + STATUS_CHOICES = ( + ("Create Complete", True), + ("Create Failed", False), + ) + name = tables.Column("stack_name", + verbose_name=_("Stack Name"), + link="horizon:project:stacks:detail",) + created = tables.Column("creation_time", + verbose_name=_("Created"), + filters=(parse_isotime, timesince)) + updated = tables.Column("updated_time", + verbose_name=_("Updated"), + filters=(parse_isotime, timesince)) + status = tables.Column("stack_status", + filters=(title, replace_underscores), + verbose_name=_("Status"), + status=True, + status_choices=STATUS_CHOICES) + + def get_object_display(self, stack): + return stack.stack_name + + class Meta: + name = "stacks" + verbose_name = _("Stacks") + status_columns = ["status", ] + row_class = StacksUpdateRow + table_actions = (LaunchStack, DeleteStack,) + row_actions = (DeleteStack, ) + + +class EventsTable(tables.DataTable): + + logical_resource = tables.Column('logical_resource_id', + verbose_name=_("Stack Resource"), + link=lambda d: d.logical_resource_id,) + physical_resource = tables.Column('physical_resource_id', + verbose_name=_("Resource"), + link=mappings.resource_to_url) + timestamp = tables.Column('event_time', + verbose_name=_("Time Since Event"), + filters=(parse_isotime, timesince)) + status = tables.Column("resource_status", + filters=(title, replace_underscores), + verbose_name=_("Status"),) + + statusreason = tables.Column("resource_status_reason", + verbose_name=_("Status Reason"),) + + class Meta: + name = "events" + verbose_name = _("Stack Events") + + +class ResourcesUpdateRow(tables.Row): + ajax = True + + def get_data(self, request, resource_name): + try: + stack = self.table.stack + stack_identifier = '%s/%s' % (stack.stack_name, stack.id) + return api.heat.resource_get( + request, stack_identifier, resource_name) + except exc.HTTPNotFound: + # returning 404 to the ajax call removes the + # row from the table on the ui + raise Http404 + except Exception as e: + messages.error(request, e) + + +class ResourcesTable(tables.DataTable): + STATUS_CHOICES = ( + ("Create Complete", True), + ("Create Failed", False), + ) + + logical_resource = tables.Column('logical_resource_id', + verbose_name=_("Stack Resource"), + link=lambda d: d.logical_resource_id) + physical_resource = tables.Column('physical_resource_id', + verbose_name=_("Resource"), + link=mappings.resource_to_url) + resource_type = tables.Column("resource_type", + verbose_name=_("Stack Resource Type"),) + updated_time = tables.Column('updated_time', + verbose_name=_("Date Updated"), + filters=(parse_isotime, timesince)) + status = tables.Column("resource_status", + filters=(title, replace_underscores), + verbose_name=_("Status"), + status=True, + status_choices=STATUS_CHOICES) + + statusreason = tables.Column("resource_status_reason", + verbose_name=_("Status Reason"),) + + def __init__(self, request, data=None, + needs_form_wrapper=None, **kwargs): + super(ResourcesTable, self).__init__( + request, data, needs_form_wrapper, **kwargs) + self.stack = kwargs['stack'] + + def get_object_id(self, datum): + return datum.logical_resource_id + + class Meta: + name = "resources" + verbose_name = _("Stack Resources") + status_columns = ["status", ] + row_class = ResourcesUpdateRow diff --git a/openstack_dashboard/dashboards/project/stacks/tabs.py b/openstack_dashboard/dashboards/project/stacks/tabs.py new file mode 100644 index 0000000000..d59ca7502c --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/tabs.py @@ -0,0 +1,100 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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. + +import logging + +from django.utils.translation import ugettext_lazy as _ + +from horizon import messages +from horizon import tabs +from openstack_dashboard import api + +from .tables import EventsTable +from .tables import ResourcesTable + + +LOG = logging.getLogger(__name__) + + +class StackOverviewTab(tabs.Tab): + name = _("Overview") + slug = "overview" + template_name = "project/stacks/_detail_overview.html" + + def get_context_data(self, request): + return {"stack": self.tab_group.kwargs['stack']} + + +class ResourceOverviewTab(tabs.Tab): + name = _("Overview") + slug = "resource_overview" + template_name = "project/stacks/_resource_overview.html" + + def get_context_data(self, request): + return { + "resource": self.tab_group.kwargs['resource'], + "metadata": self.tab_group.kwargs['metadata']} + + +class StackEventsTab(tabs.Tab): + name = _("Events") + slug = "events" + template_name = "project/stacks/_detail_events.html" + preload = False + + def get_context_data(self, request): + stack = self.tab_group.kwargs['stack'] + try: + stack_identifier = '%s/%s' % (stack.stack_name, stack.id) + events = api.heat.events_list(self.request, stack_identifier) + LOG.debug('got events %s' % events) + except: + events = [] + messages.error(request, _( + 'Unable to get events for stack "%s".') % stack.stack_name) + return {"stack": stack, + "table": EventsTable(request, data=events), } + + +class StackResourcesTab(tabs.Tab): + name = _("Resources") + slug = "resources" + template_name = "project/stacks/_detail_resources.html" + preload = False + + def get_context_data(self, request): + stack = self.tab_group.kwargs['stack'] + try: + stack_identifier = '%s/%s' % (stack.stack_name, stack.id) + resources = api.heat.resources_list(self.request, stack_identifier) + LOG.debug('got resources %s' % resources) + except: + resources = [] + messages.error(request, _( + 'Unable to get resources for stack "%s".') % stack.stack_name) + return {"stack": stack, + "table": ResourcesTable( + request, data=resources, stack=stack), } + + +class StackDetailTabs(tabs.TabGroup): + slug = "stack_details" + tabs = (StackOverviewTab, StackResourcesTab, StackEventsTab) + sticky = True + + +class ResourceDetailTabs(tabs.TabGroup): + slug = "resource_details" + tabs = (ResourceOverviewTab,) + sticky = True diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/_create.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_create.html new file mode 100644 index 0000000000..ab2c049bbd --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_create.html @@ -0,0 +1,26 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}launch_stack{% endblock %} +{% block form_action %}{% url 'horizon:project:stacks:launch' %}{% endblock %} + +{% block modal-header %}{% trans "Launch Stack" %}{% endblock %} +{% block modal_id %}launch_stack_modal{% endblock %} + +{% block modal-body %} +
{% trans "Create a new stack with the provided values." %}
+{{ metadata }} ++
{% trans "Use one of the available template source options to specify the template to be used in creating this stack." %}
+[\n "one", \n "two", \n "three"\n]', + mappings.stack_output(['one', 'two', 'three'])) + self.assertEqual( + u'
{\n "foo": "bar"\n}', + mappings.stack_output({'foo': 'bar'})) + + self.assertEqual( + u'' + 'http://www.example.com/foo', + mappings.stack_output('http://www.example.com/foo')) + + +class StackTests(test.TestCase): + + @test.create_stubs({api.heat: ('stacks_list',)}) + def test_index(self): + stacks = self.stacks.list() + + api.heat.stacks_list(IsA(http.HttpRequest)) \ + .AndReturn(stacks) + self.mox.ReplayAll() + + res = self.client.get(INDEX_URL) + + self.assertTemplateUsed(res, 'project/stacks/index.html') + self.assertIn('table', res.context) + resp_stacks = res.context['table'].data + self.assertEqual(len(resp_stacks), len(stacks)) + + @test.create_stubs({api.heat: ('stack_create', 'template_validate')}) + def test_launch_stack(self): + template = self.stack_templates.first() + stack = self.stacks.first() + + api.heat.template_validate(IsA(http.HttpRequest), + template=template.data) \ + .AndReturn(json.loads(template.validate)) + + api.heat.stack_create(IsA(http.HttpRequest), + stack_name=stack.stack_name, + timeout_mins=60, + disable_rollback=True, + template=template.data, + parameters=IsA(dict)) + + self.mox.ReplayAll() + + url = reverse('horizon:project:stacks:select_template') + res = self.client.get(url) + self.assertTemplateUsed(res, 'project/stacks/select_template.html') + + form_data = {'template_source': 'raw', + 'template_data': template.data, + 'method': forms.TemplateForm.__name__} + res = self.client.post(url, form_data) + self.assertTemplateUsed(res, 'project/stacks/create.html') + + url = reverse('horizon:project:stacks:launch') + form_data = {'template_source': 'raw', + 'template_data': template.data, + 'parameters': template.validate, + 'stack_name': stack.stack_name, + "timeout_mins": 60, + "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.StackCreateForm.__name__} + res = self.client.post(url, form_data) + self.assertRedirectsNoFollow(res, INDEX_URL) diff --git a/openstack_dashboard/dashboards/project/stacks/urls.py b/openstack_dashboard/dashboards/project/stacks/urls.py new file mode 100644 index 0000000000..7fe337c587 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/urls.py @@ -0,0 +1,34 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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.defaults import patterns +from django.conf.urls.defaults import url + +from .views import CreateStackView +from .views import DetailView +from .views import IndexView +from .views import ResourceView +from .views import SelectTemplateView + +urlpatterns = patterns( + '', + url(r'^$', IndexView.as_view(), name='index'), + url(r'^select_template$', + SelectTemplateView.as_view(), + name='select_template'), + url(r'^launch$', CreateStackView.as_view(), name='launch'), + url(r'^stack/(?P