Improved floating ip assocation via workflows.

Implements blueprint floating-ips-workflow.

Change-Id: I2b850aa0f3e8f4e11d9bd94c97e1dc4336fa5bb1
This commit is contained in:
Gabriel Hurley 2012-06-04 18:35:43 -07:00
parent 8fd77f047f
commit 085e0728e4
19 changed files with 364 additions and 235 deletions

View File

@ -33,43 +33,6 @@ from horizon import forms
LOG = logging.getLogger(__name__)
class FloatingIpAssociate(forms.SelfHandlingForm):
floating_ip_id = forms.CharField(widget=forms.HiddenInput())
floating_ip = forms.CharField(label=_("Floating IP"),
widget=forms.TextInput(
attrs={'readonly': 'readonly'}))
instance_id = forms.ChoiceField(label=_("Instance ID"))
def __init__(self, *args, **kwargs):
super(FloatingIpAssociate, self).__init__(*args, **kwargs)
instancelist = kwargs.get('initial', {}).get('instances', [])
if instancelist:
instancelist.insert(0, ("", _("Select an instance")))
else:
instancelist = (("", _("No instances available")),)
self.fields['instance_id'] = forms.ChoiceField(
choices=instancelist,
label=_("Instance"))
def handle(self, request, data):
ip_id = int(data['floating_ip_id'])
try:
api.server_add_floating_ip(request,
data['instance_id'],
ip_id)
LOG.info('Associating Floating IP "%s" with Instance "%s"'
% (data['floating_ip'], data['instance_id']))
messages.success(request,
_('Successfully associated Floating IP %(ip)s '
'with Instance: %(inst)s')
% {"ip": data['floating_ip'],
"inst": data['instance_id']})
except:
exceptions.handle(request,
_('Unable to associate floating IP.'))
return shortcuts.redirect('horizon:nova:access_and_security:index')
class FloatingIpAllocate(forms.SelfHandlingForm):
tenant_name = forms.CharField(widget=forms.HiddenInput())
pool = forms.ChoiceField(label=_("Pool"))

View File

@ -20,6 +20,7 @@ import logging
from django import shortcuts
from django.contrib import messages
from django.core import urlresolvers
from django.utils.http import urlencode
from django.utils.translation import ugettext_lazy as _
from horizon import api
@ -63,6 +64,11 @@ class AssociateIP(tables.LinkAction):
return False
return True
def get_link_url(self, datum):
base_url = urlresolvers.reverse(self.url)
params = urlencode({"ip_id": self.table.get_object_id(datum)})
return "?".join([base_url, params])
class DisassociateIP(tables.Action):
name = "disassociate"
@ -94,7 +100,7 @@ def get_instance_info(instance):
vals = {'INSTANCE_NAME': instance.instance_name,
'INSTANCE_ID': instance.instance_id}
return info_string % vals
return _("Not available")
return None
def get_instance_link(datum):

View File

@ -33,90 +33,90 @@ NAMESPACE = "horizon:nova:access_and_security:floating_ips"
class FloatingIpViewTests(test.TestCase):
def test_associate(self):
floating_ip = self.floating_ips.first()
self.mox.StubOutWithMock(api.nova, 'server_list')
self.mox.StubOutWithMock(api, 'tenant_floating_ip_get')
self.mox.StubOutWithMock(api.nova, 'tenant_floating_ip_list')
api.nova.server_list(IsA(http.HttpRequest)) \
.AndReturn(self.servers.list())
api.tenant_floating_ip_get(IsA(http.HttpRequest),
floating_ip.id).AndReturn(floating_ip)
api.nova.tenant_floating_ip_list(IsA(http.HttpRequest)) \
.AndReturn(self.floating_ips.list())
self.mox.ReplayAll()
url = reverse('%s:associate' % NAMESPACE, args=[floating_ip.id])
url = reverse('%s:associate' % NAMESPACE)
res = self.client.get(url)
self.assertTemplateUsed(res,
'nova/access_and_security/floating_ips/associate.html')
workflow = res.context['workflow']
choices = dict(workflow.steps[0].action.fields['ip_id'].choices)
# Verify that our "associated" floating IP isn't in the choices list.
self.assertTrue(self.floating_ips.get(id=1) not in choices)
def test_associate_post(self):
floating_ip = self.floating_ips.first()
floating_ip = self.floating_ips.get(id=2)
server = self.servers.first()
self.mox.StubOutWithMock(api, 'security_group_list')
self.mox.StubOutWithMock(api, 'tenant_floating_ip_list')
self.mox.StubOutWithMock(api, 'server_add_floating_ip')
self.mox.StubOutWithMock(api, 'tenant_floating_ip_get')
self.mox.StubOutWithMock(api.nova, 'server_add_floating_ip')
self.mox.StubOutWithMock(api.nova, 'tenant_floating_ip_list')
self.mox.StubOutWithMock(api.nova, 'server_list')
self.mox.StubOutWithMock(api.nova, 'keypair_list')
api.nova.keypair_list(IsA(http.HttpRequest)) \
.AndReturn(self.keypairs.list())
api.security_group_list(IsA(http.HttpRequest)) \
.AndReturn(self.security_groups.list())
api.nova.tenant_floating_ip_list(IsA(http.HttpRequest)) \
.AndReturn(self.floating_ips.list())
api.nova.server_list(IsA(http.HttpRequest)) \
.AndReturn(self.servers.list())
api.tenant_floating_ip_list(IsA(http.HttpRequest)) \
.AndReturn(self.floating_ips.list())
api.server_add_floating_ip(IsA(http.HttpRequest),
server.id,
floating_ip.id)
api.tenant_floating_ip_get(IsA(http.HttpRequest),
floating_ip.id).AndReturn(floating_ip)
api.nova.server_list(IsA(http.HttpRequest),
all_tenants=True).AndReturn(self.servers.list())
api.nova.server_add_floating_ip(IsA(http.HttpRequest),
server.id,
floating_ip.id)
self.mox.ReplayAll()
form_data = {'instance_id': server.id,
'floating_ip_id': floating_ip.id,
'floating_ip': floating_ip.ip,
'method': 'FloatingIpAssociate'}
url = reverse('%s:associate' % NAMESPACE, args=[floating_ip.id])
'ip_id': floating_ip.id}
url = reverse('%s:associate' % NAMESPACE)
res = self.client.post(url, form_data)
self.assertRedirects(res, INDEX_URL)
self.assertRedirectsNoFollow(res, INDEX_URL)
def test_associate_post_with_exception(self):
floating_ip = self.floating_ips.first()
def test_associate_post_with_redirect(self):
floating_ip = self.floating_ips.get(id=2)
server = self.servers.first()
self.mox.StubOutWithMock(api, 'tenant_floating_ip_list')
self.mox.StubOutWithMock(api, 'security_group_list')
self.mox.StubOutWithMock(api.nova, 'keypair_list')
self.mox.StubOutWithMock(api, 'server_add_floating_ip')
self.mox.StubOutWithMock(api, 'tenant_floating_ip_get')
self.mox.StubOutWithMock(api.nova, 'server_add_floating_ip')
self.mox.StubOutWithMock(api.nova, 'tenant_floating_ip_list')
self.mox.StubOutWithMock(api.nova, 'server_list')
api.nova.tenant_floating_ip_list(IsA(http.HttpRequest)) \
.AndReturn(self.floating_ips.list())
api.nova.server_list(IsA(http.HttpRequest)) \
.AndReturn(self.servers.list())
api.tenant_floating_ip_list(IsA(http.HttpRequest)) \
.AndReturn(self.floating_ips.list())
api.security_group_list(IsA(http.HttpRequest)) \
.AndReturn(self.security_groups.list())
api.nova.keypair_list(IsA(http.HttpRequest)) \
.AndReturn(self.keypairs.list())
api.server_add_floating_ip(IsA(http.HttpRequest),
server.id,
floating_ip.id) \
.AndRaise(self.exceptions.nova)
api.tenant_floating_ip_get(IsA(http.HttpRequest),
floating_ip.id).AndReturn(floating_ip)
api.nova.server_list(IsA(http.HttpRequest),
all_tenants=True).AndReturn(self.servers.list())
api.nova.server_add_floating_ip(IsA(http.HttpRequest),
server.id,
floating_ip.id)
self.mox.ReplayAll()
url = reverse('%s:associate' % NAMESPACE, args=[floating_ip.id])
res = self.client.post(url,
{'instance_id': 1,
'floating_ip_id': floating_ip.id,
'floating_ip': floating_ip.ip,
'method': 'FloatingIpAssociate'})
self.assertRedirects(res, INDEX_URL)
form_data = {'instance_id': server.id,
'ip_id': floating_ip.id}
url = reverse('%s:associate' % NAMESPACE)
next = reverse("horizon:nova:instances_and_volumes:index")
res = self.client.post("%s?next=%s" % (url, next), form_data)
self.assertRedirectsNoFollow(res, next)
def test_associate_post_with_exception(self):
floating_ip = self.floating_ips.get(id=2)
server = self.servers.first()
self.mox.StubOutWithMock(api.nova, 'server_add_floating_ip')
self.mox.StubOutWithMock(api.nova, 'tenant_floating_ip_list')
self.mox.StubOutWithMock(api.nova, 'server_list')
api.nova.tenant_floating_ip_list(IsA(http.HttpRequest)) \
.AndReturn(self.floating_ips.list())
api.nova.server_list(IsA(http.HttpRequest)) \
.AndReturn(self.servers.list())
api.nova.server_add_floating_ip(IsA(http.HttpRequest),
server.id,
floating_ip.id) \
.AndRaise(self.exceptions.nova)
self.mox.ReplayAll()
form_data = {'instance_id': server.id,
'ip_id': floating_ip.id}
url = reverse('%s:associate' % NAMESPACE)
res = self.client.post(url, form_data)
self.assertRedirectsNoFollow(res, INDEX_URL)
def test_disassociate_post(self):
floating_ip = self.floating_ips.first()

View File

@ -24,10 +24,6 @@ from .views import AssociateView, AllocateView
urlpatterns = patterns('',
url(r'^(?P<ip_id>[^/]+)/associate/$',
AssociateView.as_view(),
name='associate'),
url(r'^allocate/$',
AllocateView.as_view(),
name='allocate')
url(r'^associate/$', AssociateView.as_view(), name='associate'),
url(r'^allocate/$', AllocateView.as_view(), name='allocate')
)

View File

@ -22,60 +22,20 @@
"""
Views for managing Nova floating IPs.
"""
import logging
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from horizon import api
from horizon import exceptions
from horizon import forms
from .forms import FloatingIpAssociate, FloatingIpAllocate
from horizon import workflows
from .forms import FloatingIpAllocate
from .workflows import IPAssociationWorkflow
LOG = logging.getLogger(__name__)
class AssociateView(forms.ModalFormView):
form_class = FloatingIpAssociate
template_name = 'nova/access_and_security/floating_ips/associate.html'
context_object_name = 'floating_ip'
def get_object(self, *args, **kwargs):
ip_id = int(kwargs['ip_id'])
try:
return api.tenant_floating_ip_get(self.request, ip_id)
except:
redirect = reverse('horizon:nova:access_and_security:index')
exceptions.handle(self.request,
_('Unable to associate floating IP.'),
redirect=redirect)
def get_initial(self):
try:
servers = api.nova.server_list(self.request)
except:
redirect = reverse('horizon:nova:access_and_security:index')
exceptions.handle(self.request,
_('Unable to retrieve instance list.'),
redirect=redirect)
instances = []
for server in servers:
# FIXME(ttrifonov): show IP in case of non-unique names
# to be removed when nova can support unique names
server_name = server.name
if any(s.id != server.id and
s.name == server.name for s in servers):
# duplicate instance name
server_name = "%s [%s]" % (server.name, server.id)
instances.append((server.id, server_name))
# Sort instances for easy browsing
instances = sorted(instances, key=lambda x: x[1])
return {'floating_ip_id': self.object.id,
'floating_ip': self.object.ip,
'instances': instances}
class AssociateView(workflows.WorkflowView):
workflow_class = IPAssociationWorkflow
template_name = "nova/access_and_security/floating_ips/associate.html"
class AllocateView(forms.ModalFormView):

View File

@ -0,0 +1,116 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 Nebula, Inc.
# 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 import forms
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from horizon import api
from horizon import exceptions
from horizon import workflows
class AssociateIPAction(workflows.Action):
ip_id = forms.TypedChoiceField(label=_("IP Address"),
coerce=int,
empty_value=None)
instance_id = forms.ChoiceField(label=_("Instance"))
class Meta:
name = _("IP Address")
help_text = _("Select the IP address you wish to associate with "
"the selected instance.")
def populate_ip_id_choices(self, request, context):
try:
ips = api.nova.tenant_floating_ip_list(self.request)
except:
redirect = reverse('horizon:nova:access_and_security:index')
exceptions.handle(self.request,
_('Unable to retrieve floating IP addresses.'),
redirect=redirect)
options = sorted([(ip.id, ip.ip) for ip in ips if not ip.instance_id])
if options:
options.insert(0, ("", _("Select an IP address")))
else:
options = [("", _("No IP addresses available"))]
return options
def populate_instance_id_choices(self, request, context):
try:
servers = api.nova.server_list(self.request)
except:
redirect = reverse('horizon:nova:access_and_security:index')
exceptions.handle(self.request,
_('Unable to retrieve instance list.'),
redirect=redirect)
instances = []
for server in servers:
# FIXME(ttrifonov): show IP in case of non-unique names
# to be removed when nova can support unique names
server_name = server.name
if any(s.id != server.id and
s.name == server.name for s in servers):
# duplicate instance name
server_name = "%s [%s]" % (server.name, server.id)
instances.append((server.id, server_name))
# Sort instances for easy browsing
instances = sorted(instances, key=lambda x: x[1])
if instances:
instances.insert(0, ("", _("Select an instance")))
else:
instances = (("", _("No instances available")),)
return instances
class AssociateIP(workflows.Step):
action_class = AssociateIPAction
contributes = ("ip_id", "instance_id", "ip_address")
def contribute(self, data, context):
context = super(AssociateIP, self).contribute(data, context)
ip_id = data.get('ip_id', None)
if ip_id:
ip_choices = dict(self.action.fields['ip_id'].choices)
context["ip_address"] = ip_choices.get(ip_id, None)
return context
class IPAssociationWorkflow(workflows.Workflow):
slug = "ip_association"
name = _("Manage Floating IP Associations")
finalize_button_name = _("Associate")
success_message = _('IP address %s associated.')
failure_message = _('Unable to associate IP address %s.')
success_url = "horizon:nova:access_and_security:index"
default_steps = (AssociateIP,)
def format_status_message(self, message):
return message % self.context.get('ip_address', 'unknown IP address')
def handle(self, request, data):
try:
api.nova.server_add_floating_ip(request,
data['instance_id'],
data['ip_id'])
except:
exceptions.handle(request)
return False
return True

View File

@ -57,7 +57,6 @@ class AccessAndSecurityTests(test.TestCase):
floating_ips)
def test_association(self):
floating_ip = self.floating_ips.first()
servers = self.servers.list()
# Add duplicate instance name to test instance name with [IP]
@ -68,23 +67,20 @@ class AccessAndSecurityTests(test.TestCase):
server3.addresses['private'][0]['addr'] = "10.0.0.5"
self.servers.add(server3)
self.mox.StubOutWithMock(api, 'tenant_floating_ip_get')
self.mox.StubOutWithMock(api.nova, 'tenant_floating_ip_list')
self.mox.StubOutWithMock(api.nova, 'server_list')
api.tenant_floating_ip_get(IsA(http.HttpRequest),
floating_ip.id).AndReturn(floating_ip)
api.nova.tenant_floating_ip_list(IsA(http.HttpRequest)) \
.AndReturn(self.floating_ips.list())
api.nova.server_list(IsA(http.HttpRequest)).AndReturn(servers)
self.mox.ReplayAll()
res = self.client.get(
reverse("horizon:nova:access_and_security:"
"floating_ips:associate",
args=[floating_ip.id]))
res = self.client.get(reverse("horizon:nova:access_and_security:"
"floating_ips:associate"))
self.assertTemplateUsed(res,
'nova/access_and_security/'
'floating_ips/associate.html')
'nova/access_and_security/floating_ips/associate.html')
self.assertContains(res, '<option value="1">server_1 [1]'
'</option>')
self.assertContains(res, '<option value="101">server_1 [101]'
'</option>')
self.assertContains(res,
'<option value="1">server_1 [1]</option>')
self.assertContains(res,
'<option value="101">server_1 [101]</option>')
self.assertContains(res, '<option value="2">server_2</option>')

View File

@ -26,7 +26,6 @@ import logging
from django.contrib import messages
from django.utils.translation import ugettext_lazy as _
from novaclient import exceptions as novaclient_exceptions
from horizon import api
from horizon import exceptions
@ -55,21 +54,19 @@ class IndexView(tables.MultiTableView):
def get_security_groups_data(self):
try:
security_groups = api.security_group_list(self.request)
except novaclient_exceptions.ClientException, e:
except:
security_groups = []
LOG.exception("ClientException in security_groups index")
messages.error(self.request,
_('Error fetching security_groups: %s') % e)
exceptions.handle(self.request,
_('Unable to retrieve security groups.'))
return security_groups
def get_floating_ips_data(self):
try:
floating_ips = api.tenant_floating_ip_list(self.request)
except novaclient_exceptions.ClientException, e:
except:
floating_ips = []
LOG.exception("ClientException in floating ip index")
messages.error(self.request,
_('Error fetching floating ips: %s') % e)
exceptions.handle(self.request,
_('Unable to retrieve floating IP addresses.'))
instances = []
try:

View File

@ -17,7 +17,9 @@
import logging
from django import template
from django.core import urlresolvers
from django.template.defaultfilters import title
from django.utils.http import urlencode
from django.utils.translation import ugettext_lazy as _
from horizon import api
@ -25,6 +27,8 @@ from horizon import tables
from horizon.templatetags import sizeformat
from horizon.utils.filters import replace_underscores
from horizon.dashboards.nova.access_and_security \
.floating_ips.workflows import IPAssociationWorkflow
from .tabs import InstanceDetailTabs, LogTab, VNCTab
@ -193,6 +197,21 @@ class LogLink(tables.LinkAction):
return "?".join([base_url, tab_query_string])
class AssociateIP(tables.LinkAction):
name = "associate"
verbose_name = _("Associate IP")
url = "horizon:nova:access_and_security:floating_ips:associate"
classes = ("ajax-modal", "btn-associate")
def get_link_url(self, datum):
base_url = urlresolvers.reverse(self.url)
next = urlresolvers.reverse("horizon:nova:instances_and_volumes:index")
params = {"instance_id": self.table.get_object_id(datum),
IPAssociationWorkflow.redirect_param_name: next}
params = urlencode(params)
return "?".join([base_url, params])
class UpdateRow(tables.Row):
ajax = True
@ -262,6 +281,6 @@ class InstancesTable(tables.DataTable):
status_columns = ["status", "task"]
row_class = UpdateRow
table_actions = (LaunchLink, TerminateInstance)
row_actions = (SnapshotLink, EditInstance, ConsoleLink, LogLink,
TogglePause, ToggleSuspend, RebootInstance,
row_actions = (SnapshotLink, AssociateIP, EditInstance, ConsoleLink,
LogLink, TogglePause, ToggleSuspend, RebootInstance,
TerminateInstance)

View File

@ -52,7 +52,7 @@ class SelectProjectUserAction(workflows.Action):
class SelectProjectUser(workflows.Step):
action = SelectProjectUserAction
action_class = SelectProjectUserAction
contributes = ("project_id", "user_id")
@ -135,7 +135,7 @@ class VolumeOptionsAction(workflows.Action):
class VolumeOptions(workflows.Step):
action = VolumeOptionsAction
action_class = VolumeOptionsAction
depends_on = ("project_id", "user_id")
contributes = ("volume_type",
"volume_id",
@ -278,7 +278,7 @@ class SetInstanceDetailsAction(workflows.Action):
class SetInstanceDetails(workflows.Step):
action = SetInstanceDetailsAction
action_class = SetInstanceDetailsAction
contributes = ("source_type", "source_id", "name", "count", "flavor")
def contribute(self, data, context):
@ -338,7 +338,7 @@ class SetAccessControlsAction(workflows.Action):
class SetAccessControls(workflows.Step):
action = SetAccessControlsAction
action_class = SetAccessControlsAction
depends_on = ("project_id", "user_id")
contributes = ("keypair_id", "security_group_ids")
@ -367,7 +367,7 @@ class CustomizeAction(workflows.Action):
class PostCreationStep(workflows.Step):
action = CustomizeAction
action_class = CustomizeAction
contributes = ("customization_script",)

View File

@ -1,24 +0,0 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block form_id %}associate_floating_ip_form{% endblock %}
{% block form_action %}{% url horizon:nova:access_and_security:floating_ips:associate floating_ip.id %}{% endblock %}
{% block modal-header %}{% trans "Associate Floating IP" %}{% endblock %}
{% block modal-body %}
<div class="left">
<fieldset>
{% include "horizon/common/_form_fields.html" %}
</fieldset>
</div>
<div class="right">
<h3>{% trans "Description:" %}</h3>
<p>{% trans "Associate a floating ip with an instance." %}</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Associate IP" %}" />
<a href="{% url horizon:nova:access_and_security:index %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -1,12 +1,11 @@
{% extends 'nova/base.html' %}
{% load i18n %}
{% block title %}Associate Floating IPs{% endblock %}
{% block title %}{% trans "Associate Floating IP" %}{% endblock %}
{% block page_header %}
{# to make searchable false, just remove it from the include statement #}
{% include "horizon/common/_page_header.html" with title=_("Associate Floating IP") %}
{% endblock page_header %}
{% block dash_main %}
{% include 'nova/access_and_security/floating_ips/_associate.html' %}
{% include 'horizon/common/_workflow.html' %}
{% endblock %}

View File

@ -85,3 +85,69 @@ class InstanceViewTest(test.BaseAdminViewTests):
self.assertContains(res, "512MB RAM | 1 VCPU | 0 Disk", 1, 200)
self.assertContains(res, "Active", 1, 200)
self.assertContains(res, "Running", 1, 200)
def test_launch_post(self):
flavor = self.flavors.first()
image = self.images.first()
keypair = self.keypairs.first()
server = self.servers.first()
volume = self.volumes.first()
sec_group = self.security_groups.first()
customization_script = 'user data'
device_name = u'vda'
volume_choice = "%s:vol" % volume.id
block_device_mapping = {device_name: u"%s::0" % volume_choice}
self.mox.StubOutWithMock(api.glance, 'image_list_detailed')
self.mox.StubOutWithMock(api.nova, 'flavor_list')
self.mox.StubOutWithMock(api.nova, 'keypair_list')
self.mox.StubOutWithMock(api.nova, 'security_group_list')
self.mox.StubOutWithMock(api.nova, 'volume_list')
self.mox.StubOutWithMock(api.nova, 'volume_snapshot_list')
self.mox.StubOutWithMock(api.nova, 'tenant_quota_usages')
self.mox.StubOutWithMock(api.nova, 'server_create')
api.nova.flavor_list(IsA(http.HttpRequest)) \
.AndReturn(self.flavors.list())
api.nova.keypair_list(IsA(http.HttpRequest)) \
.AndReturn(self.keypairs.list())
api.nova.security_group_list(IsA(http.HttpRequest)) \
.AndReturn(self.security_groups.list())
api.glance.image_list_detailed(IsA(http.HttpRequest),
filters={'is_public': True}) \
.AndReturn([self.images.list(), False])
api.glance.image_list_detailed(IsA(http.HttpRequest),
filters={'property-owner_id': self.tenant.id}) \
.AndReturn([[], False])
api.nova.volume_list(IsA(http.HttpRequest)) \
.AndReturn(self.volumes.list())
api.nova.volume_snapshot_list(IsA(http.HttpRequest)).AndReturn([])
api.nova.server_create(IsA(http.HttpRequest),
server.name,
image.id,
flavor.id,
keypair.name,
customization_script,
[sec_group.name],
block_device_mapping,
instance_count=IsA(int))
self.mox.ReplayAll()
form_data = {'flavor': flavor.id,
'source_type': 'image_id',
'image_id': image.id,
'keypair': keypair.name,
'name': server.name,
'customization_script': customization_script,
'project_id': self.tenants.first().id,
'user_id': self.user.id,
'groups': sec_group.name,
'volume_type': 'volume_id',
'volume_id': volume_choice,
'device_name': device_name,
'count': 1}
url = reverse('horizon:syspanel:instances:launch')
res = self.client.post(url, form_data)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res,
reverse('horizon:syspanel:instances:index'))

View File

@ -2,6 +2,7 @@
{% with workflow.get_entry_point as entry_point %}
<div class="workflow {% if modal %}modal hide{% else %}static_page{% endif %}">
<form {{ workflow.attr_string|safe }} action="{{ workflow.get_absolute_url }}" method="POST">{% csrf_token %}
{% if REDIRECT_URL %}<input type="hidden" name="{{ workflow.redirect_param_name }}" value="{{ REDIRECT_URL }}"/>{% endif %}
<div class="modal-header">
{% if modal %}<a href="#" class="close" data-dismiss="modal">&times;</a>{% endif %}
<h3>{{ workflow.name }}</h3>

View File

@ -195,8 +195,8 @@ class ComputeApiTests(test.APITestCase):
'used': 2,
'flavor_fields': ['vcpus'],
'quota': 10},
'floating_ips': {'available': 0,
'used': 1,
'floating_ips': {'available': -1,
'used': 2,
'flavor_fields': [],
'quota': 1}
})

View File

@ -262,7 +262,12 @@ def data(TEST):
'fixed_ip': '10.0.0.4',
'instance_id': server_1.id,
'ip': '58.58.58.58'})
TEST.floating_ips.add(fip_1)
fip_2 = floating_ips.FloatingIP(floating_ips.FloatingIPManager(None),
{'id': 2,
'fixed_ip': None,
'instance_id': None,
'ip': '58.58.58.58'})
TEST.floating_ips.add(fip_1, fip_2)
# Usage
usage_vals = {"tenant_id": TEST.tenant.id,

View File

@ -80,12 +80,12 @@ class AdminAction(workflows.Action):
class TestStepOne(workflows.Step):
action = TestActionOne
action_class = TestActionOne
contributes = ("project_id", "user_id")
class TestStepTwo(workflows.Step):
action = TestActionTwo
action_class = TestActionTwo
depends_on = ("project_id",)
contributes = ("instance_id",)
connections = {"project_id": (local_callback_func,
@ -93,7 +93,7 @@ class TestStepTwo(workflows.Step):
class TestExtraStep(workflows.Step):
action = TestActionThree
action_class = TestActionThree
depends_on = ("project_id",)
contributes = ("extra_data",)
connections = {"project_id": (extra_callback_func,)}
@ -102,7 +102,7 @@ class TestExtraStep(workflows.Step):
class AdminStep(workflows.Step):
action = AdminAction
action_class = AdminAction
contributes = ("admin_id",)
after = TestStepOne
before = TestStepTwo
@ -188,10 +188,11 @@ class WorkflowsTests(test.TestCase):
"user_id": self.user.id,
"instance_id": self.servers.first().id}
req = self.factory.post("/", seed)
flow = TestWorkflow(req)
flow = TestWorkflow(req, context_seed={"project_id": self.tenant.id})
for step in flow.steps:
if not step._action.is_valid():
self.fail("Step %s was unexpectedly invalid." % step.slug)
if not step.action.is_valid():
self.fail("Step %s was unexpectedly invalid: %s"
% (step.slug, step.action.errors))
self.assertTrue(flow.is_valid())
# Additional items shouldn't affect validation

View File

@ -16,6 +16,7 @@
import copy
import inspect
import logging
from django import forms
from django import template
@ -32,6 +33,9 @@ from horizon.templatetags.horizon import can_haz
from horizon.utils import html
LOG = logging.getLogger(__name__)
class WorkflowContext(dict):
def __init__(self, workflow, *args, **kwargs):
super(WorkflowContext, self).__init__(*args, **kwargs)
@ -156,7 +160,7 @@ class Action(forms.Form):
context = template.RequestContext(self.request, extra_context)
text += tmpl.render(context)
else:
text += linebreaks(self.help_text)
text += linebreaks(force_unicode(self.help_text))
return safe(text)
def handle(self, request, context):
@ -254,7 +258,7 @@ class Step(object):
Inherited from the ``Action`` class.
"""
action = None
action_class = None
depends_on = ()
contributes = ()
connections = None
@ -274,13 +278,12 @@ class Step(object):
self.workflow = workflow
cls = self.__class__.__name__
if not (self.action and issubclass(self.action, Action)):
if not (self.action_class and issubclass(self.action_class, Action)):
raise AttributeError("You must specify an action for %s." % cls)
self._action = None
self.slug = self.action.slug
self.name = self.action.name
self.roles = self.action.roles
self.slug = self.action_class.slug
self.name = self.action_class.name
self.roles = self.action_class.roles
self.has_errors = False
self._handlers = {}
@ -339,8 +342,16 @@ class Step(object):
% (bits[-1], module_name, cls))
self._handlers[key].append(handler)
def _init_action(self, request, data):
self._action = self.action(request, data)
@property
def action(self):
if not getattr(self, "_action", None):
try:
self._action = self.action_class(self.workflow.request,
self.workflow.context)
except:
LOG.exception("Problem instantiating action class.")
raise
return self._action
def get_id(self):
""" Returns the ID for this step. Suitable for use in HTML markup. """
@ -350,7 +361,7 @@ class Step(object):
for key in self.contributes:
# Make sure we don't skip steps based on weird behavior of
# POST query dicts.
field = self._action.fields.get(key, None)
field = self.action.fields.get(key, None)
if field and field.required and not context.get(key):
context.pop(key, None)
failed_to_contribute = set(self.contributes)
@ -381,15 +392,15 @@ class Step(object):
def render(self):
""" Renders the step. """
step_template = template.loader.get_template(self.template_name)
extra_context = {"form": self._action,
extra_context = {"form": self.action,
"step": self}
context = template.RequestContext(self.workflow.request, extra_context)
return step_template.render(context)
def get_help_text(self):
""" Returns the help text for this step. """
text = linebreaks(self.help_text)
text += self._action.get_help_text()
text = linebreaks(force_unicode(self.help_text))
text += self.action.get_help_text()
return safe(text)
@ -470,6 +481,12 @@ class Workflow(html.HTMLElement):
Path to the template which should be used to render this workflow.
In general the default common template should be used. Default:
``"horizon/common/_workflow.html"``.
.. attribute:: redirect_param_name
The name of a parameter used for tracking the URL to redirect to upon
completion of the workflow. Defaults to ``"next"``.
"""
__metaclass__ = WorkflowMetaclass
slug = None
@ -478,6 +495,7 @@ class Workflow(html.HTMLElement):
finalize_button_name = _("Save")
success_message = _("%s completed successfully.")
failure_message = _("%s did not complete.")
redirect_param_name = "next"
_registerable_class = Step
def __unicode__(self):
@ -517,11 +535,18 @@ class Workflow(html.HTMLElement):
clean_seed = dict([(key, val)
for key, val in context_seed.items()
if key in self.contributions | self.depends_on])
self.context_seed = clean_seed
self.context.update(clean_seed)
for step in self.steps:
self.context = step.contribute(request.POST, self.context)
step._init_action(request, self.context)
if request and request.method == "POST":
for step in self.steps:
valid = step.action.is_valid()
# Be sure to use the CLEANED data if the workflow is valid.
if valid:
data = step.action.cleaned_data
else:
data = request.POST
self.context = step.contribute(data, self.context)
@property
def steps(self):
@ -634,7 +659,7 @@ class Workflow(html.HTMLElement):
# in one pass before returning.
steps_valid = True
for step in self.steps:
if not step._action.is_valid():
if not step.action.is_valid():
steps_valid = False
step.has_errors = True
if not steps_valid:
@ -651,7 +676,7 @@ class Workflow(html.HTMLElement):
partial = False
for step in self.steps:
try:
data = step._action.handle(self.request, self.context)
data = step.action.handle(self.request, self.context)
if data is True or data is None:
continue
elif data is False:

View File

@ -82,7 +82,10 @@ class WorkflowView(generic.TemplateView):
context data to the template.
"""
context = super(WorkflowView, self).get_context_data(**kwargs)
context[self.context_object_name] = self.get_workflow()
workflow = self.get_workflow()
context[self.context_object_name] = workflow
next = self.request.REQUEST.get(workflow.redirect_param_name, None)
context['REDIRECT_URL'] = next
if self.request.is_ajax():
context['modal'] = True
return context
@ -110,13 +113,13 @@ class WorkflowView(generic.TemplateView):
except:
success = False
exceptions.handle(request)
next = self.request.REQUEST.get(workflow.redirect_param_name, None)
if success:
msg = workflow.format_status_message(workflow.success_message)
messages.success(request, msg)
return shortcuts.redirect(workflow.get_success_url())
else:
msg = workflow.format_status_message(workflow.failure_message)
messages.error(request, msg)
return shortcuts.redirect(workflow.get_success_url())
return shortcuts.redirect(next or workflow.get_success_url())
else:
return self.render_to_response(context)