Flavor Extra Specs support.

Special thanks:

  * Preserves extra specs on flavor edit (Tihomir Trifonov)
  * Displays flavor name on extra specs pages (Vinay Bannai)
  * Extras specs table close (Don Dugger & Gabriel).
  * Final cleanup (Gabriel Hurley)

Change-Id: I6acb1176e5c0ca6987abc758fc45335870c55d57
This commit is contained in:
Malini Bhandaru 2012-10-13 05:18:13 -07:00
parent c61c5e28cc
commit 82c19aee05
18 changed files with 499 additions and 6 deletions

View File

@ -0,0 +1,10 @@
<div id="{% block modal_id %}{% endblock %}" class="{% block modal_class %}{% if hide %}modal hide{% else %}static_page{% endif %}{% endblock %}">
<div class="modal-header">
{% if hide %}<a href="#" class="close" data-dismiss="modal">&times;</a>{% endif %}
<h3>{% block modal-header %}{% endblock %}</h3>
</div>
<div class="modal-body clearfix">
{% block modal-body %}{% endblock %}
</div>
<div class="modal-footer">{% block modal-footer %}{% endblock %}</div>
</div>

View File

@ -167,6 +167,14 @@ class SecurityGroupRule(APIResourceWrapper):
return _('ALLOW %(from)s:%(to)s from %(cidr)s') % vals
class FlavorExtraSpec(object):
def __init__(self, flavor_id, key, val):
self.flavor_id = flavor_id
self.id = key
self.key = key
self.value = val
def novaclient(request):
insecure = getattr(settings, 'OPENSTACK_SSL_NO_VERIFY', False)
LOG.debug('novaclient connection created using token "%s" and url "%s"' %
@ -206,6 +214,28 @@ def flavor_list(request):
return novaclient(request).flavors.list()
def flavor_get_extras(request, flavor_id, raw=False):
"""Get flavor extra specs."""
flavor = novaclient(request).flavors.get(flavor_id)
extras = flavor.get_keys()
if raw:
return extras
return [FlavorExtraSpec(flavor_id, key, value) for
key, value in extras.items()]
def flavor_extra_delete(request, flavor_id, keys):
"""Unset the flavor extra spec keys."""
flavor = novaclient(request).flavors.get(flavor_id)
return flavor.unset_keys(keys)
def flavor_extra_set(request, flavor_id, metadata):
"""Set the flavor extra spec keys."""
flavor = novaclient(request).flavors.get(flavor_id)
return flavor.set_keys(metadata)
def tenant_floating_ip_list(request):
"""Fetches a list of all floating ips."""
return novaclient(request).floating_ips.list()

View File

@ -0,0 +1,66 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# All Rights Reserved.
#
# Copyright (c) 2012 Intel, 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.
import logging
from django.utils.translation import ugettext_lazy as _
from openstack_dashboard import api
from horizon import exceptions
from horizon import forms
from horizon import messages
LOG = logging.getLogger(__name__)
class CreateExtraSpec(forms.SelfHandlingForm):
key = forms.CharField(max_length="25", label=_("Key"))
value = forms.CharField(max_length="25", label=_("Value"))
flavor_id = forms.IntegerField(widget=forms.widgets.HiddenInput)
def handle(self, request, data):
try:
api.nova.flavor_extra_set(request,
data['flavor_id'],
{data['key']: data['value']})
msg = _('Created extra spec "%s".') % data['key']
messages.success(request, msg)
return True
except:
exceptions.handle(request,
_("Unable to create flavor extra spec."))
class EditExtraSpec(forms.SelfHandlingForm):
key = forms.CharField(max_length="25", label=_("Key"))
value = forms.CharField(max_length="25", label=_("Value"))
flavor_id = forms.IntegerField(widget=forms.widgets.HiddenInput)
def handle(self, request, data):
flavor_id = data['flavor_id']
try:
api.nova.flavor_extra_set(request,
flavor_id,
{data['key']: data['value']})
msg = _('Saved extra spec "%s".') % data['key']
messages.success(request, msg)
return True
except:
exceptions.handle(request, _("Unable to edit extra spec."))

View File

@ -0,0 +1,75 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright (c) 2012 Intel, 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.
import logging
import re
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from horizon import tables
from openstack_dashboard import api
LOG = logging.getLogger(__name__)
class ExtraSpecDelete(tables.DeleteAction):
data_type_singular = _("ExtraSpec")
data_type_plural = _("ExtraSpecs")
def delete(self, request, obj_ids):
flavor = api.nova.flavor_get(request, self.table.kwargs['id'])
flavor.unset_keys([obj_ids])
class ExtraSpecCreate(tables.LinkAction):
name = "create"
verbose_name = _("Create")
url = "horizon:admin:flavors:extras:create"
classes = ("btn-create", "ajax-modal")
def get_link_url(self, extra_spec=None):
return reverse(self.url, args=[self.table.kwargs['id']])
class ExtraSpecEdit(tables.LinkAction):
name = "edit"
verbose_name = _("Edit")
url = "horizon:admin:flavors:extras:edit"
classes = ("btn-edit", "ajax-modal")
def get_link_url(self, extra_spec):
return reverse(self.url, args=[self.table.kwargs['id'],
extra_spec.key])
class ExtraSpecsTable(tables.DataTable):
key = tables.Column('key', verbose_name=_('Key'))
value = tables.Column('value', verbose_name=_('Value'))
class Meta:
name = "extras"
verbose_name = _("Extra Specs")
table_actions = (ExtraSpecCreate, ExtraSpecDelete)
row_actions = (ExtraSpecEdit, ExtraSpecDelete)
def get_object_id(self, datum):
return datum.key
def get_object_display(self, datum):
return datum.key

View File

@ -0,0 +1,65 @@
from django import http
from django.core.urlresolvers import reverse
from mox import IsA
from openstack_dashboard import api
from openstack_dashboard.test import helpers as test
class FlavorExtrasTests(test.BaseAdminViewTests):
def test_list_extras_when_none_exists(self):
flavor = self.flavors.first()
extras = [api.FlavorExtraSpec(flavor.id, 'k1', 'v1')]
self.mox.StubOutWithMock(api.nova, 'flavor_get')
self.mox.StubOutWithMock(api.nova, 'flavor_get_extras')
# GET -- to determine correctness of output
api.nova.flavor_get(IsA(http.HttpRequest), flavor.id).AndReturn(flavor)
api.nova.flavor_get_extras(IsA(http.HttpRequest),
flavor.id).AndReturn(extras)
self.mox.ReplayAll()
url = reverse('horizon:admin:flavors:extras:index', args=[flavor.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertTemplateUsed(resp, "admin/flavors/extras/index.html")
def test_extra_create_post(self):
flavor = self.flavors.first()
create_url = reverse('horizon:admin:flavors:extras:create',
args=[flavor.id])
index_url = reverse('horizon:admin:flavors:extras:index',
args=[flavor.id])
self.mox.StubOutWithMock(api.nova, 'flavor_extra_set')
# GET to display the flavor_name
api.nova.flavor_extra_set(IsA(http.HttpRequest),
int(flavor.id),
{'k1': 'v1'})
self.mox.ReplayAll()
data = {'flavor_id': flavor.id,
'key': 'k1',
'value': 'v1'}
resp = self.client.post(create_url, data)
self.assertNoFormErrors(resp)
self.assertMessageCount(success=1)
self.assertRedirectsNoFollow(resp, index_url)
def test_extra_create_get(self):
flavor = self.flavors.first()
create_url = reverse('horizon:admin:flavors:extras:create',
args=[flavor.id])
self.mox.StubOutWithMock(api.nova, 'flavor_get')
api.nova.flavor_get(IsA(http.HttpRequest), flavor.id).AndReturn(flavor)
self.mox.ReplayAll()
resp = self.client.get(create_url)
self.assertEqual(resp.status_code, 200)
self.assertTemplateUsed(resp,
'admin/flavors/extras/create.html')

View File

@ -0,0 +1,29 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# All Rights Reserved.
#
# 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.conf.urls.defaults import patterns, url
from .views import IndexView, EditView, CreateView
urlpatterns = patterns('',
url(r'^$', IndexView.as_view(), name='index'),
url(r'^create/$', CreateView.as_view(), name='create'),
url(r'^(?P<key>[^/]+)/edit/$', EditView.as_view(), name='edit')
)

View File

@ -0,0 +1,93 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# All Rights Reserved.
#
# Copyright (c) 2012 Intel, 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.
import logging
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import forms
from horizon import tables
from openstack_dashboard import api
from .tables import ExtraSpecsTable
from .forms import CreateExtraSpec, EditExtraSpec
LOG = logging.getLogger(__name__)
class ExtraSpecMixin(object):
def get_context_data(self, **kwargs):
context = super(ExtraSpecMixin, self).get_context_data(**kwargs)
try:
context['flavor'] = api.nova.flavor_get(self.request,
self.kwargs['id'])
except:
exceptions.handle(self.request,
_("Unable to retrieve flavor data."))
return context
class IndexView(ExtraSpecMixin, forms.ModalFormMixin, tables.DataTableView):
table_class = ExtraSpecsTable
template_name = 'admin/flavors/extras/index.html'
def get_data(self):
try:
flavor_id = self.kwargs['id']
extras_list = api.nova.flavor_get_extras(self.request, flavor_id)
extras_list.sort(key=lambda es: (es.key,))
except:
extras_list = []
exceptions.handle(self.request,
_('Unable to retrieve extra spec list.'))
return extras_list
class CreateView(ExtraSpecMixin, forms.ModalFormView):
form_class = CreateExtraSpec
template_name = 'admin/flavors/extras/create.html'
def get_initial(self):
return {'flavor_id': self.kwargs['id']}
def get_success_url(self):
return "/admin/flavors/%s/extras/" % (self.kwargs['id'])
class EditView(ExtraSpecMixin, forms.ModalFormView):
form_class = EditExtraSpec
template_name = 'admin/flavors/extras/edit.html'
def get_initial(self):
flavor_id = self.kwargs['id']
key = self.kwargs['key']
try:
extra_specs = api.nova.flavor_get_extras(self.request,
flavor_id,
raw=True)
except:
extra_specs = {}
exceptions.handle(self.request,
_("Unable to retrieve flavor extra spec data."))
return {'flavor_id': flavor_id,
'key': key,
'value': extra_specs.get(key, '')}

View File

@ -61,19 +61,26 @@ class EditFlavor(CreateFlavor):
def handle(self, request, data):
try:
flavor_id = data['flavor_id']
# grab any existing extra specs, because flavor edit currently
# implemented as a delete followed by a create
extras_dict = api.nova.flavor_get_extras(self.request, flavor_id)
# First mark the existing flavor as deleted.
api.nova.flavor_delete(request, data['flavor_id'])
# Then create a new flavor with the same name but a new ID.
# This is in the same try/except block as the delete call
# because if the delete fails the API will error out because
# active flavors can't have the same name.
new_flavor_id = uuid.uuid4()
flavor = api.nova.flavor_create(request,
data['name'],
data['memory_mb'],
data['vcpus'],
data['disk_gb'],
uuid.uuid4(),
new_flavor_id,
ephemeral=data['eph_gb'])
if (len(extras_dict) > 0):
api.nova.flavor_extra_set(request, new_flavor_id, extras_dict)
msg = _('Updated flavor "%s".') % data['name']
messages.success(request, msg)
return flavor

View File

@ -32,6 +32,13 @@ class EditFlavor(tables.LinkAction):
classes = ("ajax-modal", "btn-edit")
class ViewFlavorExtras(tables.LinkAction):
name = "extras"
verbose_name = _("View Extra Specs")
url = "horizon:admin:flavors:extras:index"
classes = ("btn-edit",)
def get_size(flavor):
return _("%sMB") % flavor.ram
@ -51,4 +58,4 @@ class FlavorsTable(tables.DataTable):
name = "flavors"
verbose_name = _("Flavors")
table_actions = (CreateFlavor, DeleteFlavor)
row_actions = (EditFlavor, DeleteFlavor)
row_actions = (EditFlavor, ViewFlavorExtras, DeleteFlavor)

View File

@ -0,0 +1,27 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block form_id %}extra_spec_create_form{% endblock %}
{% block form_action %}{% url horizon:admin:flavors:extras:create flavor.id %}{% endblock %}
{% block modal_id %}extra_spec_create_modal{% endblock %}
{% block modal-header %}{% trans "Create Flavor Extra Spec" %}{% 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 'Create a new "extra spec" key-value pair for a flavor.' %}</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Create" %}" />
<a href="{% url horizon:admin:flavors:extras:index flavor.id %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block form_id %}extra_spec_edit_form{% endblock %}
{% block form_action %}{% url horizon:admin:flavors:extras:create flavor.id %}{% endblock %}
{% block modal_id %}extra_spec_edit_modal{% endblock %}
{% block modal-header %}{% trans "Edit Flavor Extra Spec" %}{% 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 'Update an "extra spec" key-value pair for a flavor.' %}</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Save" %}" />
<a href="{% url horizon:admin:flavors:extras:index flavor.id %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "horizon/common/_modal.html" %}
{% load i18n %}
{% block modal_id %}extra_specs_modal{% endblock %}
{% block modal-header %}{% trans "Flavor Extra Specs" %}{% endblock %}
{% block modal-body %}
{{ table.render }}
{% endblock %}
{% block modal-footer %}
<a href="{% url horizon:admin:flavors:index %}" class="btn secondary cancel close">{% trans "Close" %}</a>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Create Flavor Extra Spec" %}{% endblock %}
{% block page_header %}
<h2>{% trans "Flavor" %}: {{flavor.name}} </h2>
{% endblock page_header %}
{% block main %}
{% include "admin/flavors/extras/_create.html" %}
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Edit Flavor Extra Spec" %}{% endblock %}
{% block page_header %}
<h2>{% trans "Flavor" %}: {{flavor.name}} </h2>
{% endblock page_header %}
{% block main %}
{% include "admin/flavors/extras/_edit.html" %}
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Flavor Extra Specs" %}{% endblock %}
{% block page_header %}
<h2>{% trans "Flavor" %}: {{flavor.name}} </h2>
{% endblock page_header %}
{% block main %}
{% include "admin/flavors/extras/_index.html" %}
{% endblock %}

View File

@ -42,7 +42,9 @@ class FlavorsTests(test.BaseAdminViewTests):
def test_edit_flavor(self):
flavor = self.flavors.first()
eph = getattr(flavor, 'OS-FLV-EXT-DATA:ephemeral')
extras = {}
self.mox.StubOutWithMock(api.nova, 'flavor_list')
self.mox.StubOutWithMock(api.nova, 'flavor_get_extras')
self.mox.StubOutWithMock(api.nova, 'flavor_get')
self.mox.StubOutWithMock(api.nova, 'flavor_delete')
self.mox.StubOutWithMock(api.nova, 'flavor_create')
@ -52,6 +54,8 @@ class FlavorsTests(test.BaseAdminViewTests):
# POST
api.nova.flavor_get(IsA(http.HttpRequest), flavor.id).AndReturn(flavor)
api.nova.flavor_get_extras(IsA(http.HttpRequest), int(flavor.id))\
.AndReturn(extras)
api.nova.flavor_delete(IsA(http.HttpRequest), int(flavor.id))
api.nova.flavor_create(IsA(http.HttpRequest),
flavor.name,
@ -62,11 +66,13 @@ class FlavorsTests(test.BaseAdminViewTests):
ephemeral=eph).AndReturn(flavor)
self.mox.ReplayAll()
#get_test
url = reverse('horizon:admin:flavors:edit', args=[flavor.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertTemplateUsed(resp, "admin/flavors/edit.html")
#post test
data = {'flavor_id': flavor.id,
'name': flavor.name,
'vcpus': flavor.vcpus + 1,
@ -75,4 +81,4 @@ class FlavorsTests(test.BaseAdminViewTests):
'eph_gb': eph}
resp = self.client.post(url, data)
self.assertRedirectsNoFollow(resp,
reverse("horizon:admin:flavors:index"))
reverse("horizon:admin:flavors:index"))

View File

@ -18,12 +18,13 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.conf.urls.defaults import patterns, url
from django.conf.urls.defaults import patterns, url, include
from .views import IndexView, CreateView, EditView
from .extras import urls as extras_urls
urlpatterns = patterns('openstack_dashboard.dashboards.admin.flavors.views',
url(r'^$', IndexView.as_view(), name='index'),
url(r'^create/$', CreateView.as_view(), name='create'),
url(r'^(?P<id>[^/]+)/edit/$', EditView.as_view(), name='edit')
url(r'^(?P<id>[^/]+)/edit/$', EditView.as_view(), name='edit'),
url(r'^(?P<id>[^/]+)/extras/', include(extras_urls, namespace='extras')),
)