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__) 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): class FloatingIpAllocate(forms.SelfHandlingForm):
tenant_name = forms.CharField(widget=forms.HiddenInput()) tenant_name = forms.CharField(widget=forms.HiddenInput())
pool = forms.ChoiceField(label=_("Pool")) pool = forms.ChoiceField(label=_("Pool"))

View File

@ -20,6 +20,7 @@ import logging
from django import shortcuts from django import shortcuts
from django.contrib import messages from django.contrib import messages
from django.core import urlresolvers from django.core import urlresolvers
from django.utils.http import urlencode
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from horizon import api from horizon import api
@ -63,6 +64,11 @@ class AssociateIP(tables.LinkAction):
return False return False
return True 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): class DisassociateIP(tables.Action):
name = "disassociate" name = "disassociate"
@ -94,7 +100,7 @@ def get_instance_info(instance):
vals = {'INSTANCE_NAME': instance.instance_name, vals = {'INSTANCE_NAME': instance.instance_name,
'INSTANCE_ID': instance.instance_id} 'INSTANCE_ID': instance.instance_id}
return info_string % vals return info_string % vals
return _("Not available") return None
def get_instance_link(datum): def get_instance_link(datum):

View File

@ -33,90 +33,90 @@ NAMESPACE = "horizon:nova:access_and_security:floating_ips"
class FloatingIpViewTests(test.TestCase): class FloatingIpViewTests(test.TestCase):
def test_associate(self): def test_associate(self):
floating_ip = self.floating_ips.first()
self.mox.StubOutWithMock(api.nova, 'server_list') 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)) \ api.nova.server_list(IsA(http.HttpRequest)) \
.AndReturn(self.servers.list()) .AndReturn(self.servers.list())
api.tenant_floating_ip_get(IsA(http.HttpRequest), api.nova.tenant_floating_ip_list(IsA(http.HttpRequest)) \
floating_ip.id).AndReturn(floating_ip) .AndReturn(self.floating_ips.list())
self.mox.ReplayAll() self.mox.ReplayAll()
url = reverse('%s:associate' % NAMESPACE, args=[floating_ip.id]) url = reverse('%s:associate' % NAMESPACE)
res = self.client.get(url) res = self.client.get(url)
self.assertTemplateUsed(res, self.assertTemplateUsed(res,
'nova/access_and_security/floating_ips/associate.html') '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): def test_associate_post(self):
floating_ip = self.floating_ips.first() floating_ip = self.floating_ips.get(id=2)
server = self.servers.first() server = self.servers.first()
self.mox.StubOutWithMock(api, 'security_group_list') self.mox.StubOutWithMock(api.nova, 'server_add_floating_ip')
self.mox.StubOutWithMock(api, 'tenant_floating_ip_list') self.mox.StubOutWithMock(api.nova, '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_list') self.mox.StubOutWithMock(api.nova, 'server_list')
self.mox.StubOutWithMock(api.nova, 'keypair_list')
api.nova.keypair_list(IsA(http.HttpRequest)) \ api.nova.tenant_floating_ip_list(IsA(http.HttpRequest)) \
.AndReturn(self.keypairs.list()) .AndReturn(self.floating_ips.list())
api.security_group_list(IsA(http.HttpRequest)) \
.AndReturn(self.security_groups.list())
api.nova.server_list(IsA(http.HttpRequest)) \ api.nova.server_list(IsA(http.HttpRequest)) \
.AndReturn(self.servers.list()) .AndReturn(self.servers.list())
api.tenant_floating_ip_list(IsA(http.HttpRequest)) \ api.nova.server_add_floating_ip(IsA(http.HttpRequest),
.AndReturn(self.floating_ips.list())
api.server_add_floating_ip(IsA(http.HttpRequest),
server.id, server.id,
floating_ip.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())
self.mox.ReplayAll() self.mox.ReplayAll()
form_data = {'instance_id': server.id, form_data = {'instance_id': server.id,
'floating_ip_id': floating_ip.id, 'ip_id': floating_ip.id}
'floating_ip': floating_ip.ip, url = reverse('%s:associate' % NAMESPACE)
'method': 'FloatingIpAssociate'}
url = reverse('%s:associate' % NAMESPACE, args=[floating_ip.id])
res = self.client.post(url, form_data) res = self.client.post(url, form_data)
self.assertRedirects(res, INDEX_URL) self.assertRedirectsNoFollow(res, INDEX_URL)
def test_associate_post_with_exception(self): def test_associate_post_with_redirect(self):
floating_ip = self.floating_ips.first() floating_ip = self.floating_ips.get(id=2)
server = self.servers.first() server = self.servers.first()
self.mox.StubOutWithMock(api, 'tenant_floating_ip_list') self.mox.StubOutWithMock(api.nova, 'server_add_floating_ip')
self.mox.StubOutWithMock(api, 'security_group_list') self.mox.StubOutWithMock(api.nova, 'tenant_floating_ip_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_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)) \ api.nova.server_list(IsA(http.HttpRequest)) \
.AndReturn(self.servers.list()) .AndReturn(self.servers.list())
api.tenant_floating_ip_list(IsA(http.HttpRequest)) \ api.nova.server_add_floating_ip(IsA(http.HttpRequest),
server.id,
floating_ip.id)
self.mox.ReplayAll()
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()) .AndReturn(self.floating_ips.list())
api.security_group_list(IsA(http.HttpRequest)) \ api.nova.server_list(IsA(http.HttpRequest)) \
.AndReturn(self.security_groups.list()) .AndReturn(self.servers.list())
api.nova.keypair_list(IsA(http.HttpRequest)) \ api.nova.server_add_floating_ip(IsA(http.HttpRequest),
.AndReturn(self.keypairs.list())
api.server_add_floating_ip(IsA(http.HttpRequest),
server.id, server.id,
floating_ip.id) \ floating_ip.id) \
.AndRaise(self.exceptions.nova) .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())
self.mox.ReplayAll() self.mox.ReplayAll()
url = reverse('%s:associate' % NAMESPACE, args=[floating_ip.id]) form_data = {'instance_id': server.id,
res = self.client.post(url, 'ip_id': floating_ip.id}
{'instance_id': 1, url = reverse('%s:associate' % NAMESPACE)
'floating_ip_id': floating_ip.id, res = self.client.post(url, form_data)
'floating_ip': floating_ip.ip, self.assertRedirectsNoFollow(res, INDEX_URL)
'method': 'FloatingIpAssociate'})
self.assertRedirects(res, INDEX_URL)
def test_disassociate_post(self): def test_disassociate_post(self):
floating_ip = self.floating_ips.first() floating_ip = self.floating_ips.first()

View File

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

View File

@ -22,60 +22,20 @@
""" """
Views for managing Nova floating IPs. Views for managing Nova floating IPs.
""" """
import logging
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from horizon import api from horizon import api
from horizon import exceptions from horizon import exceptions
from horizon import forms 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(workflows.WorkflowView):
workflow_class = IPAssociationWorkflow
template_name = "nova/access_and_security/floating_ips/associate.html"
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 AllocateView(forms.ModalFormView): 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) floating_ips)
def test_association(self): def test_association(self):
floating_ip = self.floating_ips.first()
servers = self.servers.list() servers = self.servers.list()
# Add duplicate instance name to test instance name with [IP] # 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" server3.addresses['private'][0]['addr'] = "10.0.0.5"
self.servers.add(server3) 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') self.mox.StubOutWithMock(api.nova, 'server_list')
api.tenant_floating_ip_get(IsA(http.HttpRequest), api.nova.tenant_floating_ip_list(IsA(http.HttpRequest)) \
floating_ip.id).AndReturn(floating_ip) .AndReturn(self.floating_ips.list())
api.nova.server_list(IsA(http.HttpRequest)).AndReturn(servers) api.nova.server_list(IsA(http.HttpRequest)).AndReturn(servers)
self.mox.ReplayAll() self.mox.ReplayAll()
res = self.client.get( res = self.client.get(reverse("horizon:nova:access_and_security:"
reverse("horizon:nova:access_and_security:" "floating_ips:associate"))
"floating_ips:associate",
args=[floating_ip.id]))
self.assertTemplateUsed(res, self.assertTemplateUsed(res,
'nova/access_and_security/' 'nova/access_and_security/floating_ips/associate.html')
'floating_ips/associate.html')
self.assertContains(res, '<option value="1">server_1 [1]' self.assertContains(res,
'</option>') '<option value="1">server_1 [1]</option>')
self.assertContains(res, '<option value="101">server_1 [101]' self.assertContains(res,
'</option>') '<option value="101">server_1 [101]</option>')
self.assertContains(res, '<option value="2">server_2</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.contrib import messages
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from novaclient import exceptions as novaclient_exceptions
from horizon import api from horizon import api
from horizon import exceptions from horizon import exceptions
@ -55,21 +54,19 @@ class IndexView(tables.MultiTableView):
def get_security_groups_data(self): def get_security_groups_data(self):
try: try:
security_groups = api.security_group_list(self.request) security_groups = api.security_group_list(self.request)
except novaclient_exceptions.ClientException, e: except:
security_groups = [] security_groups = []
LOG.exception("ClientException in security_groups index") exceptions.handle(self.request,
messages.error(self.request, _('Unable to retrieve security groups.'))
_('Error fetching security_groups: %s') % e)
return security_groups return security_groups
def get_floating_ips_data(self): def get_floating_ips_data(self):
try: try:
floating_ips = api.tenant_floating_ip_list(self.request) floating_ips = api.tenant_floating_ip_list(self.request)
except novaclient_exceptions.ClientException, e: except:
floating_ips = [] floating_ips = []
LOG.exception("ClientException in floating ip index") exceptions.handle(self.request,
messages.error(self.request, _('Unable to retrieve floating IP addresses.'))
_('Error fetching floating ips: %s') % e)
instances = [] instances = []
try: try:

View File

@ -17,7 +17,9 @@
import logging import logging
from django import template from django import template
from django.core import urlresolvers
from django.template.defaultfilters import title from django.template.defaultfilters import title
from django.utils.http import urlencode
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from horizon import api from horizon import api
@ -25,6 +27,8 @@ from horizon import tables
from horizon.templatetags import sizeformat from horizon.templatetags import sizeformat
from horizon.utils.filters import replace_underscores 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 from .tabs import InstanceDetailTabs, LogTab, VNCTab
@ -193,6 +197,21 @@ class LogLink(tables.LinkAction):
return "?".join([base_url, tab_query_string]) 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): class UpdateRow(tables.Row):
ajax = True ajax = True
@ -262,6 +281,6 @@ class InstancesTable(tables.DataTable):
status_columns = ["status", "task"] status_columns = ["status", "task"]
row_class = UpdateRow row_class = UpdateRow
table_actions = (LaunchLink, TerminateInstance) table_actions = (LaunchLink, TerminateInstance)
row_actions = (SnapshotLink, EditInstance, ConsoleLink, LogLink, row_actions = (SnapshotLink, AssociateIP, EditInstance, ConsoleLink,
TogglePause, ToggleSuspend, RebootInstance, LogLink, TogglePause, ToggleSuspend, RebootInstance,
TerminateInstance) TerminateInstance)

View File

@ -52,7 +52,7 @@ class SelectProjectUserAction(workflows.Action):
class SelectProjectUser(workflows.Step): class SelectProjectUser(workflows.Step):
action = SelectProjectUserAction action_class = SelectProjectUserAction
contributes = ("project_id", "user_id") contributes = ("project_id", "user_id")
@ -135,7 +135,7 @@ class VolumeOptionsAction(workflows.Action):
class VolumeOptions(workflows.Step): class VolumeOptions(workflows.Step):
action = VolumeOptionsAction action_class = VolumeOptionsAction
depends_on = ("project_id", "user_id") depends_on = ("project_id", "user_id")
contributes = ("volume_type", contributes = ("volume_type",
"volume_id", "volume_id",
@ -278,7 +278,7 @@ class SetInstanceDetailsAction(workflows.Action):
class SetInstanceDetails(workflows.Step): class SetInstanceDetails(workflows.Step):
action = SetInstanceDetailsAction action_class = SetInstanceDetailsAction
contributes = ("source_type", "source_id", "name", "count", "flavor") contributes = ("source_type", "source_id", "name", "count", "flavor")
def contribute(self, data, context): def contribute(self, data, context):
@ -338,7 +338,7 @@ class SetAccessControlsAction(workflows.Action):
class SetAccessControls(workflows.Step): class SetAccessControls(workflows.Step):
action = SetAccessControlsAction action_class = SetAccessControlsAction
depends_on = ("project_id", "user_id") depends_on = ("project_id", "user_id")
contributes = ("keypair_id", "security_group_ids") contributes = ("keypair_id", "security_group_ids")
@ -367,7 +367,7 @@ class CustomizeAction(workflows.Action):
class PostCreationStep(workflows.Step): class PostCreationStep(workflows.Step):
action = CustomizeAction action_class = CustomizeAction
contributes = ("customization_script",) 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' %} {% extends 'nova/base.html' %}
{% load i18n %} {% load i18n %}
{% block title %}Associate Floating IPs{% endblock %} {% block title %}{% trans "Associate Floating IP" %}{% endblock %}
{% block page_header %} {% 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") %} {% include "horizon/common/_page_header.html" with title=_("Associate Floating IP") %}
{% endblock page_header %} {% endblock page_header %}
{% block dash_main %} {% block dash_main %}
{% include 'nova/access_and_security/floating_ips/_associate.html' %} {% include 'horizon/common/_workflow.html' %}
{% endblock %} {% 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, "512MB RAM | 1 VCPU | 0 Disk", 1, 200)
self.assertContains(res, "Active", 1, 200) self.assertContains(res, "Active", 1, 200)
self.assertContains(res, "Running", 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 %} {% with workflow.get_entry_point as entry_point %}
<div class="workflow {% if modal %}modal hide{% else %}static_page{% endif %}"> <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 %} <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"> <div class="modal-header">
{% if modal %}<a href="#" class="close" data-dismiss="modal">&times;</a>{% endif %} {% if modal %}<a href="#" class="close" data-dismiss="modal">&times;</a>{% endif %}
<h3>{{ workflow.name }}</h3> <h3>{{ workflow.name }}</h3>

View File

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

View File

@ -262,7 +262,12 @@ def data(TEST):
'fixed_ip': '10.0.0.4', 'fixed_ip': '10.0.0.4',
'instance_id': server_1.id, 'instance_id': server_1.id,
'ip': '58.58.58.58'}) '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
usage_vals = {"tenant_id": TEST.tenant.id, usage_vals = {"tenant_id": TEST.tenant.id,

View File

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

View File

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

View File

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