Add "Edit Router" to allow to change router type

Neutron DVR implementation allows to change router type from
centralized to distributed. This commit adds "Edit Router" form
which is not implemented so far to allow this feature.

This commit also adds:
- admin_state field to the router detail.
- documentation on a new option enable_distributed_router

Completes blueprint enhance-horizon-for-dvr
Change-Id: I4b46e44c417726217ed034e305827b102ba656f8
This commit is contained in:
Akihiro Motoki 2014-08-22 21:34:20 +09:00
parent f3da51632b
commit 6a8ea3385c
19 changed files with 345 additions and 11 deletions

View File

@ -493,6 +493,7 @@ by cinder. Currently only the backup service is available.
Default:: Default::
{ {
'enable_distributed_router': False,
'enable_lb': False, 'enable_lb': False,
'enable_quotas': False, 'enable_quotas': False,
'enable_firewall': False, 'enable_firewall': False,
@ -507,6 +508,19 @@ by Neutron and configure Neutron specific features. The following options are
available. available.
``enable_distributed_router``:
.. versionadded:: 2014.2(Juno)
Default: ``False``
Enable or disable Neutron distributed virtual router (DVR) feature in
the Router panel. For the DVR feature to be enabled, this option needs
to be set to True and your Neutron deployment must support DVR. Even
when your Neutron plugin (like ML2 plugin) supports DVR feature, DVR
feature depends on l3-agent configuration, so deployers should set this
option appropriately depending on your deployment.
``enable_lb``: ``enable_lb``:
.. versionadded:: 2013.1(Grizzly) .. versionadded:: 2013.1(Grizzly)

View File

@ -121,8 +121,8 @@ class Router(NeutronAPIDictWrapper):
"""Wrapper for neutron routers.""" """Wrapper for neutron routers."""
def __init__(self, apiresource): def __init__(self, apiresource):
# apiresource['admin_state'] = \ apiresource['admin_state'] = \
# 'UP' if apiresource['admin_state_up'] else 'DOWN' 'UP' if apiresource['admin_state_up'] else 'DOWN'
super(Router, self).__init__(apiresource) super(Router, self).__init__(apiresource)
@ -911,9 +911,11 @@ def get_dvr_permission(request, operation):
if not network_config.get('enable_distributed_router', False): if not network_config.get('enable_distributed_router', False):
return False return False
policy_check = getattr(settings, "POLICY_CHECK_FUNCTION", None) policy_check = getattr(settings, "POLICY_CHECK_FUNCTION", None)
if operation not in ("get", "create"): allowed_operations = ("get", "create", "update")
if operation not in allowed_operations:
raise ValueError(_("The 'operation' parameter for get_dvr_permission " raise ValueError(_("The 'operation' parameter for get_dvr_permission "
"is invalid. It should be 'get' or 'create'.")) "is invalid. It should be one of %s")
% ' '.join(allowed_operations))
role = (("network", "%s_router:distributed" % operation),) role = (("network", "%s_router:distributed" % operation),)
if policy_check: if policy_check:
has_permission = policy.check(role, request) has_permission = policy.check(role, request)

View File

@ -147,6 +147,7 @@
"delete_router": "rule:admin_or_owner", "delete_router": "rule:admin_or_owner",
"get_router:distributed": "rule:admin_only", "get_router:distributed": "rule:admin_only",
"create_router:distributed": "rule:admin_only", "create_router:distributed": "rule:admin_only",
"update_router:distributed": "rule:admin_only",
"create_floatingip": "rule:regular_user", "create_floatingip": "rule:regular_user",
"update_floatingip": "rule:admin_or_owner", "update_floatingip": "rule:admin_or_owner",

View File

@ -0,0 +1,19 @@
# 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.core.urlresolvers import reverse_lazy
from openstack_dashboard.dashboards.project.routers import forms as r_forms
class UpdateForm(r_forms.UpdateForm):
redirect_url = reverse_lazy('horizon:admin:routers:index')

View File

@ -42,6 +42,10 @@ class DeleteRouter(r_tables.DeleteRouter):
return True return True
class EditRouter(r_tables.EditRouter):
url = "horizon:admin:routers:update"
class UpdateRow(tables.Row): class UpdateRow(tables.Row):
ajax = True ajax = True
@ -62,5 +66,5 @@ class RoutersTable(r_tables.RoutersTable):
status_columns = ["status"] status_columns = ["status"]
row_class = UpdateRow row_class = UpdateRow
table_actions = (DeleteRouter,) table_actions = (DeleteRouter,)
row_actions = (DeleteRouter,) row_actions = (EditRouter, DeleteRouter,)
Columns = ('tenant', 'name', 'status', 'distributed', 'ext_net') Columns = ('tenant', 'name', 'status', 'distributed', 'ext_net')

View File

@ -12,6 +12,8 @@
<dd>{{ router.tenant_id }}</dd> <dd>{{ router.tenant_id }}</dd>
<dt>{% trans "Status" %}</dt> <dt>{% trans "Status" %}</dt>
<dd>{{ router.status|capfirst }}</dd> <dd>{{ router.status|capfirst }}</dd>
<dt>{% trans "Admin State" %}</dt>
<dd>{{ router.admin_state|default:_("Unknown") }}</dd>
{% if dvr_supported %} {% if dvr_supported %}
<dt>{% trans "Distributed" %}</dt> <dt>{% trans "Distributed" %}</dt>
<dd>{{ router.distributed|yesno|capfirst }}</dd> <dd>{{ router.distributed|yesno|capfirst }}</dd>

View File

@ -0,0 +1,25 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% load url from future %}
{% block form_id %}update_router_form{% endblock %}
{% block form_action %}{% url 'horizon:admin:routers:update' router_id %}{% endblock %}
{% block modal-header %}{% trans "Edit Router" %}{% 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 "You may update the editable properties of your router here." %}</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Save Changes" %}" />
<a href="{% url 'horizon:admin:routers:index' %}" class="btn btn-default secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Update Router" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Update Router") %}
{% endblock page_header %}
{% block main %}
{% include 'admin/routers/_update.html' %}
{% endblock %}

View File

@ -18,9 +18,15 @@ from django.conf.urls import url # noqa
from openstack_dashboard.dashboards.admin.routers import views from openstack_dashboard.dashboards.admin.routers import views
ROUTER_URL = r'^(?P<router_id>[^/]+)/%s'
urlpatterns = patterns('horizon.dashboards.admin.routers.views', urlpatterns = patterns('horizon.dashboards.admin.routers.views',
url(r'^$', views.IndexView.as_view(), name='index'), url(r'^$', views.IndexView.as_view(), name='index'),
url(r'^(?P<router_id>[^/]+)/$', url(ROUTER_URL % '$',
views.DetailView.as_view(), views.DetailView.as_view(),
name='detail'), name='detail'),
url(ROUTER_URL % 'update',
views.UpdateView.as_view(),
name='update'),
) )

View File

@ -22,6 +22,7 @@ from django.utils.translation import ugettext_lazy as _
from horizon import exceptions from horizon import exceptions
from openstack_dashboard import api from openstack_dashboard import api
from openstack_dashboard.dashboards.admin.networks import views as n_views from openstack_dashboard.dashboards.admin.networks import views as n_views
from openstack_dashboard.dashboards.admin.routers import forms as rforms
from openstack_dashboard.dashboards.admin.routers import tables as rtbl from openstack_dashboard.dashboards.admin.routers import tables as rtbl
from openstack_dashboard.dashboards.admin.routers import tabs as rtabs from openstack_dashboard.dashboards.admin.routers import tabs as rtabs
from openstack_dashboard.dashboards.project.routers import views as r_views from openstack_dashboard.dashboards.project.routers import views as r_views
@ -61,3 +62,9 @@ class DetailView(r_views.DetailView):
tab_group_class = rtabs.RouterDetailTabs tab_group_class = rtabs.RouterDetailTabs
template_name = 'admin/routers/detail.html' template_name = 'admin/routers/detail.html'
failure_url = reverse_lazy('horizon:admin:routers:index') failure_url = reverse_lazy('horizon:admin:routers:index')
class UpdateView(r_views.UpdateView):
form_class = rforms.UpdateForm
template_name = 'admin/routers/update.html'
success_url = reverse_lazy("horizon:admin:routers:index")

View File

@ -19,6 +19,7 @@ Views for managing Neutron Routers.
import logging import logging
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.urlresolvers import reverse_lazy
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from horizon import exceptions from horizon import exceptions
@ -66,3 +67,48 @@ class CreateForm(forms.SelfHandlingForm):
redirect = reverse(self.failure_url) redirect = reverse(self.failure_url)
exceptions.handle(request, msg, redirect=redirect) exceptions.handle(request, msg, redirect=redirect)
return False return False
class UpdateForm(forms.SelfHandlingForm):
name = forms.CharField(label=_("Name"), required=False)
admin_state = forms.BooleanField(label=_("Admin State"), required=False)
router_id = forms.CharField(label=_("ID"),
widget=forms.HiddenInput())
mode = forms.ChoiceField(label=_("Router Type"))
redirect_url = reverse_lazy('horizon:project:routers:index')
def __init__(self, request, *args, **kwargs):
super(UpdateForm, self).__init__(request, *args, **kwargs)
self.dvr_allowed = api.neutron.get_dvr_permission(self.request,
"update")
if not self.dvr_allowed:
del self.fields['mode']
elif kwargs.get('initial', {}).get('mode') == 'distributed':
# Neutron supports only changing from centralized to
# distributed now.
mode_choices = [('distributed', _('Distributed'))]
self.fields['mode'].widget = forms.TextInput(attrs={'readonly':
'readonly'})
self.fields['mode'].choices = mode_choices
else:
mode_choices = [('centralized', _('Centralized')),
('distributed', _('Distributed'))]
self.fields['mode'].choices = mode_choices
def handle(self, request, data):
try:
params = {'admin_state_up': data['admin_state'],
'name': data['name']}
if self.dvr_allowed:
params['distributed'] = (data['mode'] == 'distributed')
router = api.neutron.router_update(request, data['router_id'],
**params)
msg = _('Router %s was successfully updated.') % data['name']
LOG.debug(msg)
messages.success(request, msg)
return router
except Exception:
msg = _('Failed to update router %s') % data['name']
LOG.info(msg)
exceptions.handle(request, msg, redirect=self.redirect_url)

View File

@ -69,6 +69,21 @@ class CreateRouter(tables.LinkAction):
policy_rules = (("network", "create_router"),) policy_rules = (("network", "create_router"),)
class EditRouter(tables.LinkAction):
name = "update"
verbose_name = _("Edit Router")
url = "horizon:project:routers:update"
classes = ("ajax-modal",)
icon = "pencil"
policy_rules = (("network", "update_router"),)
def get_policy_target(self, request, datum=None):
project_id = None
if datum:
project_id = getattr(datum, 'tenant_id', None)
return {"project_id": project_id}
class SetGateway(tables.LinkAction): class SetGateway(tables.LinkAction):
name = "setgateway" name = "setgateway"
verbose_name = _("Set Gateway") verbose_name = _("Set Gateway")
@ -174,4 +189,4 @@ class RoutersTable(tables.DataTable):
status_columns = ["status"] status_columns = ["status"]
row_class = UpdateRow row_class = UpdateRow
table_actions = (CreateRouter, DeleteRouter) table_actions = (CreateRouter, DeleteRouter)
row_actions = (SetGateway, ClearGateway, DeleteRouter) row_actions = (SetGateway, ClearGateway, EditRouter, DeleteRouter)

View File

@ -10,6 +10,8 @@
<dd>{{ router.id|default:_("None") }}</dd> <dd>{{ router.id|default:_("None") }}</dd>
<dt>{% trans "Status" %}</dt> <dt>{% trans "Status" %}</dt>
<dd>{{ router.status|default:_("Unknown") }}</dd> <dd>{{ router.status|default:_("Unknown") }}</dd>
<dt>{% trans "Admin State" %}</dt>
<dd>{{ router.admin_state|default:_("Unknown") }}</dd>
{% if dvr_supported %} {% if dvr_supported %}
<dt>{% trans "Distributed" %}</dt> <dt>{% trans "Distributed" %}</dt>
<dd>{{ router.distributed|yesno|capfirst }}</dd> <dd>{{ router.distributed|yesno|capfirst }}</dd>

View File

@ -0,0 +1,25 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% load url from future %}
{% block form_id %}update_router_form{% endblock %}
{% block form_action %}{% url 'horizon:project:routers:update' router_id %}{% endblock %}
{% block modal-header %}{% trans "Edit Router" %}{% 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 "You may update the editable properties of your router here." %}</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Save Changes" %}" />
<a href="{% url 'horizon:project:routers:index' %}" class="btn btn-default secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Update Router" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Update Router") %}
{% endblock page_header %}
{% block main %}
{% include 'project/routers/_update.html' %}
{% endblock %}

View File

@ -223,6 +223,107 @@ class RouterActionTests(test.TestCase):
self.assertNoFormErrors(res) self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, self.INDEX_URL) self.assertRedirectsNoFollow(res, self.INDEX_URL)
@test.create_stubs({api.neutron: ('router_get',
'get_dvr_permission')})
def _test_router_update_get(self, dvr_enabled=False,
current_dvr=False):
router = [r for r in self.routers.list()
if r.distributed == current_dvr][0]
api.neutron.router_get(IsA(http.HttpRequest), router.id)\
.AndReturn(router)
api.neutron.get_dvr_permission(IsA(http.HttpRequest), "update")\
.AndReturn(dvr_enabled)
self.mox.ReplayAll()
url = reverse('horizon:%s:routers:update' % self.DASHBOARD,
args=[router.id])
return self.client.get(url)
def test_router_update_get_dvr_disabled(self):
res = self._test_router_update_get(dvr_enabled=False)
self.assertTemplateUsed(res, 'project/routers/update.html')
self.assertNotContains(res, 'Router Type')
self.assertNotContains(res, 'id="id_mode"')
def test_router_update_get_dvr_enabled_mode_centralized(self):
res = self._test_router_update_get(dvr_enabled=True, current_dvr=False)
self.assertTemplateUsed(res, 'project/routers/update.html')
self.assertContains(res, 'Router Type')
# Check both menu are displayed.
self.assertContains(
res,
'<option value="centralized" selected="selected">'
'Centralized</option>',
html=True)
self.assertContains(
res,
'<option value="distributed">Distributed</option>',
html=True)
def test_router_update_get_dvr_enabled_mode_distributed(self):
res = self._test_router_update_get(dvr_enabled=True, current_dvr=True)
self.assertTemplateUsed(res, 'project/routers/update.html')
self.assertContains(res, 'Router Type')
self.assertContains(
res,
'<input class=" form-control" id="id_mode" name="mode" '
'readonly="readonly" type="text" value="distributed" />',
html=True)
self.assertNotContains(res, 'centralized')
@test.create_stubs({api.neutron: ('router_get',
'router_update',
'get_dvr_permission')})
def test_router_update_post_dvr_disabled(self):
router = self.routers.first()
api.neutron.get_dvr_permission(IsA(http.HttpRequest), "update")\
.AndReturn(False)
api.neutron.router_update(IsA(http.HttpRequest), router.id,
name=router.name,
admin_state_up=router.admin_state_up)\
.AndReturn(router)
api.neutron.router_get(IsA(http.HttpRequest), router.id)\
.AndReturn(router)
self.mox.ReplayAll()
form_data = {'router_id': router.id,
'name': router.name,
'admin_state': router.admin_state_up}
url = reverse('horizon:%s:routers:update' % self.DASHBOARD,
args=[router.id])
res = self.client.post(url, form_data)
self.assertRedirectsNoFollow(res, self.INDEX_URL)
@test.create_stubs({api.neutron: ('router_get',
'router_update',
'get_dvr_permission')})
def test_router_update_post_dvr_enabled(self):
router = self.routers.first()
api.neutron.get_dvr_permission(IsA(http.HttpRequest), "update")\
.AndReturn(True)
api.neutron.router_update(IsA(http.HttpRequest), router.id,
name=router.name,
admin_state_up=router.admin_state_up,
distributed=True)\
.AndReturn(router)
api.neutron.router_get(IsA(http.HttpRequest), router.id)\
.AndReturn(router)
self.mox.ReplayAll()
form_data = {'router_id': router.id,
'name': router.name,
'admin_state': router.admin_state_up,
'mode': 'distributed'}
url = reverse('horizon:%s:routers:update' % self.DASHBOARD,
args=[router.id])
res = self.client.post(url, form_data)
self.assertRedirectsNoFollow(res, self.INDEX_URL)
def _mock_network_list(self, tenant_id): def _mock_network_list(self, tenant_id):
api.neutron.network_list( api.neutron.network_list(
IsA(http.HttpRequest), IsA(http.HttpRequest),

View File

@ -22,19 +22,25 @@ from openstack_dashboard.dashboards.project.routers.ports \
from openstack_dashboard.dashboards.project.routers import views from openstack_dashboard.dashboards.project.routers import views
ROUTER_URL = r'^(?P<router_id>[^/]+)/%s'
urlpatterns = patterns('horizon.dashboards.project.routers.views', urlpatterns = patterns('horizon.dashboards.project.routers.views',
url(r'^$', views.IndexView.as_view(), name='index'), url(r'^$', views.IndexView.as_view(), name='index'),
url(r'^create/$', views.CreateView.as_view(), name='create'), url(r'^create/$', views.CreateView.as_view(), name='create'),
url(r'^(?P<router_id>[^/]+)/$', url(ROUTER_URL % '$',
views.DetailView.as_view(), views.DetailView.as_view(),
name='detail'), name='detail'),
url(r'^(?P<router_id>[^/]+)/addinterface', url(ROUTER_URL % 'update',
views.UpdateView.as_view(),
name='update'),
url(ROUTER_URL % 'addinterface',
port_views.AddInterfaceView.as_view(), port_views.AddInterfaceView.as_view(),
name='addinterface'), name='addinterface'),
url(r'^(?P<router_id>[^/]+)/addrouterrule', url(ROUTER_URL % 'addrouterrule',
rr_views.AddRouterRuleView.as_view(), rr_views.AddRouterRuleView.as_view(),
name='addrouterrule'), name='addrouterrule'),
url(r'^(?P<router_id>[^/]+)/setgateway', url(ROUTER_URL % 'setgateway',
port_views.SetGatewayView.as_view(), port_views.SetGatewayView.as_view(),
name='setgateway'), name='setgateway'),
) )

View File

@ -136,3 +136,34 @@ class CreateView(forms.ModalFormView):
form_class = project_forms.CreateForm form_class = project_forms.CreateForm
template_name = 'project/routers/create.html' template_name = 'project/routers/create.html'
success_url = reverse_lazy("horizon:project:routers:index") success_url = reverse_lazy("horizon:project:routers:index")
class UpdateView(forms.ModalFormView):
form_class = project_forms.UpdateForm
template_name = 'project/routers/update.html'
success_url = reverse_lazy("horizon:project:routers:index")
def get_context_data(self, **kwargs):
context = super(UpdateView, self).get_context_data(**kwargs)
context["router_id"] = self.kwargs['router_id']
return context
def _get_object(self, *args, **kwargs):
router_id = self.kwargs['router_id']
try:
return api.neutron.router_get(self.request, router_id)
except Exception:
redirect = self.success_url
msg = _('Unable to retrieve router details.')
exceptions.handle(self.request, msg, redirect=redirect)
def get_initial(self):
router = self._get_object()
initial = {'router_id': router['id'],
'tenant_id': router['tenant_id'],
'name': router['name'],
'admin_state': router['admin_state_up']}
if hasattr(router, 'distributed'):
initial['mode'] = ('distributed' if router.distributed
else 'centralized')
return initial

View File

@ -276,6 +276,8 @@ def data(TEST):
router_dict = {'id': '279989f7-54bb-41d9-ba42-0d61f12fda61', router_dict = {'id': '279989f7-54bb-41d9-ba42-0d61f12fda61',
'name': 'router1', 'name': 'router1',
'status': 'ACTIVE', 'status': 'ACTIVE',
'admin_state_up': True,
'distributed': True,
'external_gateway_info': 'external_gateway_info':
{'network_id': ext_net['id']}, {'network_id': ext_net['id']},
'tenant_id': '1'} 'tenant_id': '1'}
@ -284,6 +286,8 @@ def data(TEST):
router_dict = {'id': '10e3dc42-1ce1-4d48-87cf-7fc333055d6c', router_dict = {'id': '10e3dc42-1ce1-4d48-87cf-7fc333055d6c',
'name': 'router2', 'name': 'router2',
'status': 'ACTIVE', 'status': 'ACTIVE',
'admin_state_up': False,
'distributed': False,
'external_gateway_info': None, 'external_gateway_info': None,
'tenant_id': '1'} 'tenant_id': '1'}
TEST.api_routers.add(router_dict) TEST.api_routers.add(router_dict)
@ -291,6 +295,8 @@ def data(TEST):
router_dict = {'id': '7180cede-bcd8-4334-b19f-f7ef2f331f53', router_dict = {'id': '7180cede-bcd8-4334-b19f-f7ef2f331f53',
'name': 'rulerouter', 'name': 'rulerouter',
'status': 'ACTIVE', 'status': 'ACTIVE',
'admin_state_up': True,
'distributed': False,
'external_gateway_info': 'external_gateway_info':
{'network_id': ext_net['id']}, {'network_id': ext_net['id']},
'tenant_id': '1', 'tenant_id': '1',