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 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): def novaclient(request):
insecure = getattr(settings, 'OPENSTACK_SSL_NO_VERIFY', False) insecure = getattr(settings, 'OPENSTACK_SSL_NO_VERIFY', False)
LOG.debug('novaclient connection created using token "%s" and url "%s"' % LOG.debug('novaclient connection created using token "%s" and url "%s"' %
@ -206,6 +214,28 @@ def flavor_list(request):
return novaclient(request).flavors.list() 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): def tenant_floating_ip_list(request):
"""Fetches a list of all floating ips.""" """Fetches a list of all floating ips."""
return novaclient(request).floating_ips.list() 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): def handle(self, request, data):
try: 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. # First mark the existing flavor as deleted.
api.nova.flavor_delete(request, data['flavor_id']) api.nova.flavor_delete(request, data['flavor_id'])
# Then create a new flavor with the same name but a new 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 # This is in the same try/except block as the delete call
# because if the delete fails the API will error out because # because if the delete fails the API will error out because
# active flavors can't have the same name. # active flavors can't have the same name.
new_flavor_id = uuid.uuid4()
flavor = api.nova.flavor_create(request, flavor = api.nova.flavor_create(request,
data['name'], data['name'],
data['memory_mb'], data['memory_mb'],
data['vcpus'], data['vcpus'],
data['disk_gb'], data['disk_gb'],
uuid.uuid4(), new_flavor_id,
ephemeral=data['eph_gb']) 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'] msg = _('Updated flavor "%s".') % data['name']
messages.success(request, msg) messages.success(request, msg)
return flavor return flavor

View File

@ -32,6 +32,13 @@ class EditFlavor(tables.LinkAction):
classes = ("ajax-modal", "btn-edit") 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): def get_size(flavor):
return _("%sMB") % flavor.ram return _("%sMB") % flavor.ram
@ -51,4 +58,4 @@ class FlavorsTable(tables.DataTable):
name = "flavors" name = "flavors"
verbose_name = _("Flavors") verbose_name = _("Flavors")
table_actions = (CreateFlavor, DeleteFlavor) 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): def test_edit_flavor(self):
flavor = self.flavors.first() flavor = self.flavors.first()
eph = getattr(flavor, 'OS-FLV-EXT-DATA:ephemeral') eph = getattr(flavor, 'OS-FLV-EXT-DATA:ephemeral')
extras = {}
self.mox.StubOutWithMock(api.nova, 'flavor_list') 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_get')
self.mox.StubOutWithMock(api.nova, 'flavor_delete') self.mox.StubOutWithMock(api.nova, 'flavor_delete')
self.mox.StubOutWithMock(api.nova, 'flavor_create') self.mox.StubOutWithMock(api.nova, 'flavor_create')
@ -52,6 +54,8 @@ class FlavorsTests(test.BaseAdminViewTests):
# POST # POST
api.nova.flavor_get(IsA(http.HttpRequest), flavor.id).AndReturn(flavor) 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_delete(IsA(http.HttpRequest), int(flavor.id))
api.nova.flavor_create(IsA(http.HttpRequest), api.nova.flavor_create(IsA(http.HttpRequest),
flavor.name, flavor.name,
@ -62,11 +66,13 @@ class FlavorsTests(test.BaseAdminViewTests):
ephemeral=eph).AndReturn(flavor) ephemeral=eph).AndReturn(flavor)
self.mox.ReplayAll() self.mox.ReplayAll()
#get_test
url = reverse('horizon:admin:flavors:edit', args=[flavor.id]) url = reverse('horizon:admin:flavors:edit', args=[flavor.id])
resp = self.client.get(url) resp = self.client.get(url)
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertTemplateUsed(resp, "admin/flavors/edit.html") self.assertTemplateUsed(resp, "admin/flavors/edit.html")
#post test
data = {'flavor_id': flavor.id, data = {'flavor_id': flavor.id,
'name': flavor.name, 'name': flavor.name,
'vcpus': flavor.vcpus + 1, 'vcpus': flavor.vcpus + 1,

View File

@ -18,12 +18,13 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # 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 .views import IndexView, CreateView, EditView
from .extras import urls as extras_urls
urlpatterns = patterns('openstack_dashboard.dashboards.admin.flavors.views', urlpatterns = patterns('openstack_dashboard.dashboards.admin.flavors.views',
url(r'^$', IndexView.as_view(), name='index'), url(r'^$', IndexView.as_view(), name='index'),
url(r'^create/$', CreateView.as_view(), name='create'), 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')),
) )