Merge "Add ability to manage image custom properties"

This commit is contained in:
Jenkins 2014-07-21 20:56:04 +00:00 committed by Gerrit Code Review
commit a0f7235278
16 changed files with 559 additions and 4 deletions

View File

@ -301,6 +301,20 @@ icon names are based on the default icon theme provided by Bootstrap.
Example: ``[{'text': 'Official', 'tenant': '27d0058849da47c896d205e2fc25a5e8', 'icon': 'icon-ok'}]``
``IMAGE_RESERVED_CUSTOM_PROPERTIES``
------------------------------------
.. versionadded:: 2014.2(Juno)
Default: ``[]``
A list of image custom property keys that should not be displayed in the
Image Custom Properties table.
This setting can be used in the case where a separate panel is used for
managing a custom property or if a certain custom property should never be
edited.
``OPENSTACK_ENABLE_PASSWORD_RETRIEVE``
--------------------------------------

View File

@ -34,13 +34,21 @@ from openstack_dashboard.api import base
LOG = logging.getLogger(__name__)
def glanceclient(request):
class ImageCustomProperty(object):
def __init__(self, image_id, key, val):
self.image_id = image_id
self.id = key
self.key = key
self.value = val
def glanceclient(request, version='1'):
url = base.url_for(request, 'image')
insecure = getattr(settings, 'OPENSTACK_SSL_NO_VERIFY', False)
cacert = getattr(settings, 'OPENSTACK_SSL_CACERT', None)
LOG.debug('glanceclient connection created using token "%s" and url "%s"'
% (request.user.token.id, url))
return glance_client.Client('1', url, token=request.user.token.id,
return glance_client.Client(version, url, token=request.user.token.id,
insecure=insecure, cacert=cacert)
@ -58,6 +66,26 @@ def image_get(request, image_id):
return image
def image_get_properties(request, image_id, reserved=True):
"""List all custom properties of an image."""
image = glanceclient(request, '2').images.get(image_id)
reserved_props = getattr(settings, 'IMAGE_RESERVED_CUSTOM_PROPERTIES', [])
properties_list = []
for key in image.keys():
if reserved or key not in reserved_props:
prop = ImageCustomProperty(image_id, key, image.get(key))
properties_list.append(prop)
return properties_list
def image_get_property(request, image_id, key, reserved=True):
"""Get a custom property of an image."""
for prop in image_get_properties(request, image_id, reserved):
if prop.key == key:
return prop
return None
def image_list_detailed(request, marker=None, sort_dir='desc',
sort_key='created_at', filters=None, paginate=False):
limit = getattr(settings, 'API_RESULT_LIMIT', 1000)
@ -121,3 +149,13 @@ def image_create(request, **kwargs):
'purge_props': False})
return image
def image_update_properties(request, image_id, **kwargs):
"""Add or update a custom property of an image."""
return glanceclient(request, '2').images.update(image_id, None, **kwargs)
def image_delete_properties(request, image_id, keys):
"""Delete custom properties for an image."""
return glanceclient(request, '2').images.update(image_id, keys)

View File

@ -0,0 +1,89 @@
# 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.utils.translation import ugettext_lazy as _
from glanceclient import exc
from horizon import exceptions
from horizon import forms
from horizon import messages
from openstack_dashboard import api
def str2bool(value):
"""Convert a string value to boolean
"""
return value.lower() in ("yes", "true", "1")
# Mapping of property names to type, used for converting input string value
# before submitting.
PROPERTY_TYPES = {'min_disk': long, 'min_ram': long, 'protected': str2bool}
def convert_value(key, value):
"""Convert the property value to the proper type if necessary.
"""
_type = PROPERTY_TYPES.get(key)
if _type:
return _type(value)
return value
class CreateProperty(forms.SelfHandlingForm):
key = forms.CharField(max_length="255", label=_("Key"))
value = forms.CharField(label=_("Value"))
def handle(self, request, data):
try:
api.glance.image_update_properties(request,
self.initial['image_id'],
**{data['key']: convert_value(data['key'], data['value'])})
msg = _('Created custom property "%s".') % data['key']
messages.success(request, msg)
return True
except exc.HTTPForbidden:
msg = _('Unable to create image custom property. Property "%s" '
'is read only.' % data['key'])
exceptions.handle(request, msg)
except exc.HTTPConflict:
msg = _('Unable to create image custom property. Property "%s" '
'already exists.' % data['key'])
exceptions.handle(request, msg)
except Exception:
msg = _('Unable to create image custom '
'property "%s".' % data['key'])
exceptions.handle(request, msg)
class EditProperty(forms.SelfHandlingForm):
key = forms.CharField(widget=forms.widgets.HiddenInput)
value = forms.CharField(label=_("Value"))
def handle(self, request, data):
try:
api.glance.image_update_properties(request,
self.initial['image_id'],
**{data['key']: convert_value(data['key'], data['value'])})
msg = _('Saved custom property "%s".') % data['key']
messages.success(request, msg)
return True
except exc.HTTPForbidden:
msg = _('Unable to edit image custom property. Property "%s" '
'is read only.' % data['key'])
exceptions.handle(request, msg)
except Exception:
msg = _('Unable to edit image custom '
'property "%s".' % data['key'])
exceptions.handle(request, msg)

View File

@ -0,0 +1,93 @@
# 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
from django.utils import http
from django.utils.translation import ugettext_lazy as _
from horizon import tables
from openstack_dashboard import api
# Most of the following image custom properties can be found in the glance
# project at glance.api.v2.images.RequestDeserializer.
# Properties that cannot be edited
READONLY_PROPERTIES = ['checksum', 'container_format', 'created_at', 'deleted',
'deleted_at', 'direct_url', 'disk_format', 'file', 'id', 'is_public',
'location', 'owner', 'schema', 'self', 'size',
'status', 'tags', 'updated_at', 'virtual_size']
# Properties that cannot be deleted
REQUIRED_PROPERTIES = ['checksum', 'container_format', 'created_at', 'deleted',
'deleted_at', 'direct_url', 'disk_format', 'file', 'id', 'is_public',
'location', 'min_disk', 'min_ram', 'name', 'owner', 'protected', 'schema',
'self', 'size', 'status', 'tags', 'updated_at', 'virtual_size',
'visibility']
class PropertyDelete(tables.DeleteAction):
data_type_singular = _("Property")
data_type_plural = _("Properties")
def allowed(self, request, prop=None):
if prop and prop.key in REQUIRED_PROPERTIES:
return False
return True
def delete(self, request, obj_ids):
api.glance.image_delete_properties(request, self.table.kwargs['id'],
[obj_ids])
class PropertyCreate(tables.LinkAction):
name = "create"
verbose_name = _("Create")
url = "horizon:admin:images:properties:create"
classes = ("btn-create", "ajax-modal")
def get_link_url(self, custom_property=None):
return reverse(self.url, args=[self.table.kwargs['id']])
class PropertyEdit(tables.LinkAction):
name = "edit"
verbose_name = _("Edit")
url = "horizon:admin:images:properties:edit"
classes = ("btn-edit", "ajax-modal")
def allowed(self, request, prop=None):
if prop and prop.key in READONLY_PROPERTIES:
return False
return True
def get_link_url(self, custom_property):
return reverse(self.url, args=[self.table.kwargs['id'],
http.urlquote(custom_property.key, '')])
class PropertiesTable(tables.DataTable):
key = tables.Column('key', verbose_name=_('Key'))
value = tables.Column('value', verbose_name=_('Value'))
class Meta:
name = "properties"
verbose_name = _("Custom Properties")
table_actions = (PropertyCreate, PropertyDelete)
row_actions = (PropertyEdit, PropertyDelete)
def get_object_id(self, datum):
return datum.key
def get_object_display(self, datum):
return datum.key

View File

@ -0,0 +1,102 @@
# 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
from django import http
from mox import IsA # noqa
from openstack_dashboard import api
from openstack_dashboard.test import helpers as test
class ImageCustomPropertiesTests(test.BaseAdminViewTests):
@test.create_stubs({api.glance: ('image_get',
'image_get_properties'), })
def test_list_properties(self):
image = self.images.first()
props = [api.glance.ImageCustomProperty(image.id, 'k1', 'v1')]
api.glance.image_get(IsA(http.HttpRequest), image.id).AndReturn(image)
api.glance.image_get_properties(IsA(http.HttpRequest),
image.id, False).AndReturn(props)
self.mox.ReplayAll()
url = reverse('horizon:admin:images:properties:index', args=[image.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertTemplateUsed(resp, "admin/images/properties/index.html")
@test.create_stubs({api.glance: ('image_update_properties',), })
def test_property_create_post(self):
image = self.images.first()
create_url = reverse('horizon:admin:images:properties:create',
args=[image.id])
index_url = reverse('horizon:admin:images:properties:index',
args=[image.id])
api.glance.image_update_properties(IsA(http.HttpRequest),
image.id, **{'k1': 'v1'})
self.mox.ReplayAll()
data = {'image_id': image.id,
'key': 'k1',
'value': 'v1'}
resp = self.client.post(create_url, data)
self.assertNoFormErrors(resp)
self.assertMessageCount(success=1)
self.assertRedirectsNoFollow(resp, index_url)
@test.create_stubs({api.glance: ('image_get',), })
def test_property_create_get(self):
image = self.images.first()
create_url = reverse('horizon:admin:images:properties:create',
args=[image.id])
api.glance.image_get(IsA(http.HttpRequest), image.id).AndReturn(image)
self.mox.ReplayAll()
resp = self.client.get(create_url)
self.assertEqual(resp.status_code, 200)
self.assertTemplateUsed(resp, 'admin/images/properties/create.html')
@test.create_stubs({api.glance: ('image_update_properties',
'image_get_property'), })
def test_property_update_post(self):
image = self.images.first()
prop = api.glance.ImageCustomProperty(image.id, 'k1', 'v1')
edit_url = reverse('horizon:admin:images:properties:edit',
args=[image.id, prop.id])
index_url = reverse('horizon:admin:images:properties:index',
args=[image.id])
api.glance.image_get_property(IsA(http.HttpRequest),
image.id, 'k1', False).AndReturn(prop)
api.glance.image_update_properties(IsA(http.HttpRequest),
image.id, **{'k1': 'v2'})
self.mox.ReplayAll()
data = {'image_id': image.id,
'key': 'k1',
'value': 'v2'}
resp = self.client.post(edit_url, data)
self.assertNoFormErrors(resp)
self.assertMessageCount(success=1)
self.assertRedirectsNoFollow(resp, index_url)
@test.create_stubs({api.glance: ('image_get',
'image_get_property'), })
def test_property_update_get(self):
image = self.images.first()
prop = api.glance.ImageCustomProperty(image.id, 'k1', 'v1')
edit_url = reverse('horizon:admin:images:properties:edit',
args=[image.id, prop.id])
api.glance.image_get(IsA(http.HttpRequest), image.id).AndReturn(image)
api.glance.image_get_property(IsA(http.HttpRequest),
image.id, 'k1', False).AndReturn(prop)
self.mox.ReplayAll()
resp = self.client.get(edit_url)
self.assertEqual(resp.status_code, 200)
self.assertTemplateUsed(resp, 'admin/images/properties/edit.html')

View File

@ -0,0 +1,22 @@
# 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 import patterns # noqa
from django.conf.urls import url # noqa
from openstack_dashboard.dashboards.admin.images.properties import views
urlpatterns = patterns('',
url(r'^$', views.IndexView.as_view(), name='index'),
url(r'^create/$', views.CreateView.as_view(), name='create'),
url(r'^(?P<key>[^/]+)/edit/$', views.EditView.as_view(), name='edit')
)

View File

@ -0,0 +1,89 @@
# 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
from django.utils import http
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 openstack_dashboard.dashboards.admin.images.properties \
import forms as project_forms
from openstack_dashboard.dashboards.admin.images.properties \
import tables as project_tables
class PropertyMixin(object):
def get_context_data(self, **kwargs):
context = super(PropertyMixin, self).get_context_data(**kwargs)
try:
context['image'] = api.glance.image_get(self.request,
self.kwargs['id'])
except Exception:
exceptions.handle(self.request,
_("Unable to retrieve image details."))
if 'key' in self.kwargs:
context['encoded_key'] = self.kwargs['key']
context['key'] = http.urlunquote(self.kwargs['key'])
return context
def get_success_url(self):
return reverse("horizon:admin:images:properties:index",
args=(self.kwargs["id"],))
class IndexView(PropertyMixin, tables.DataTableView):
table_class = project_tables.PropertiesTable
template_name = 'admin/images/properties/index.html'
def get_data(self):
try:
image_id = self.kwargs['id']
properties_list = api.glance.image_get_properties(self.request,
image_id,
False)
properties_list.sort(key=lambda prop: (prop.key,))
except Exception:
properties_list = []
exceptions.handle(self.request,
_('Unable to retrieve image custom properties list.'))
return properties_list
class CreateView(PropertyMixin, forms.ModalFormView):
form_class = project_forms.CreateProperty
template_name = 'admin/images/properties/create.html'
def get_initial(self):
return {'image_id': self.kwargs['id']}
class EditView(PropertyMixin, forms.ModalFormView):
form_class = project_forms.EditProperty
template_name = 'admin/images/properties/edit.html'
def get_initial(self):
image_id = self.kwargs['id']
key = http.urlunquote(self.kwargs['key'])
try:
prop = api.glance.image_get_property(self.request, image_id,
key, False)
except Exception:
prop = None
exceptions.handle(self.request,
_('Unable to retrieve image custom property.'))
return {'image_id': image_id,
'key': key,
'value': prop.value if prop else ''}

View File

@ -40,6 +40,13 @@ class AdminEditImage(project_tables.EditImage):
return True
class ViewCustomProperties(tables.LinkAction):
name = "properties"
verbose_name = _("View Custom Properties")
url = "horizon:admin:images:properties:index"
classes = ("btn-edit",)
class UpdateRow(tables.Row):
ajax = True
@ -59,4 +66,4 @@ class AdminImagesTable(project_tables.ImagesTable):
status_columns = ["status"]
verbose_name = _("Images")
table_actions = (AdminCreateImage, AdminDeleteImage)
row_actions = (AdminEditImage, AdminDeleteImage)
row_actions = (AdminEditImage, ViewCustomProperties, AdminDeleteImage)

View File

@ -0,0 +1,28 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% load url from future %}
{% block form_id %}image_custom_property_create_form{% endblock %}
{% block form_action %}{% url 'horizon:admin:images:properties:create' image.id %}{% endblock %}
{% block modal_id %}image_custom_property_create_modal{% endblock %}
{% block modal-header %}{% trans "Create Image Custom Property" %}{% 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 custom property for an image.' %}</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Create" %}" />
<a href="{% url 'horizon:admin:images:properties:index' image.id %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -0,0 +1,28 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% load url from future %}
{% block form_id %}custom_property_edit_form{% endblock %}
{% block form_action %}{% url 'horizon:admin:images:properties:edit' image.id encoded_key %}{% endblock %}
{% block modal_id %}custom_property_edit_modal{% endblock %}
{% block modal-header %}{% trans "Edit Custom Property Value" %}: {{ key }}{% 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 the custom property value for' %} &quot;{{ key }}&quot;</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Save" %}" />
<a href="{% url 'horizon:admin:images:properties:index' image.id %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Create Image Custom Property" %}{% endblock %}
{% block page_header %}
<h2>{% trans "Image" %}: {{image.name}} </h2>
{% endblock page_header %}
{% block main %}
{% include "admin/images/properties/_create.html" %}
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Edit Image Custom Property" %}{% endblock %}
{% block page_header %}
<h2>{% trans "Image" %}: {{image.name}} </h2>
{% endblock page_header %}
{% block main %}
{% include "admin/images/properties/_edit.html" %}
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Image Custom Properties" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Image Custom Properties: ")|add:image.name|default:_("Image Custom Properties:") %}
{% endblock page_header %}
{% block main %}
{{ table.render }}
{% endblock %}

View File

@ -16,9 +16,12 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.conf.urls import include # noqa
from django.conf.urls import patterns # noqa
from django.conf.urls import url # noqa
from openstack_dashboard.dashboards.admin.images.properties \
import urls as properties_urls
from openstack_dashboard.dashboards.admin.images import views
@ -28,5 +31,7 @@ urlpatterns = patterns('openstack_dashboard.dashboards.admin.images.views',
url(r'^(?P<image_id>[^/]+)/update/$',
views.UpdateView.as_view(), name='update'),
url(r'^(?P<image_id>[^/]+)/detail/$',
views.DetailView.as_view(), name='detail')
views.DetailView.as_view(), name='detail'),
url(r'^(?P<id>[^/]+)/properties/',
include(properties_urls, namespace='properties')),
)

View File

@ -218,6 +218,11 @@ IMAGE_CUSTOM_PROPERTY_TITLES = {
"image_type": _("Image Type")
}
# The IMAGE_RESERVED_CUSTOM_PROPERTIES setting is used to specify which image
# custom properties should not be displayed in the Image Custom Properties
# table.
IMAGE_RESERVED_CUSTOM_PROPERTIES = []
# OPENSTACK_ENDPOINT_TYPE specifies the endpoint type to use for the endpoints
# in the Keystone service catalog. Use this setting when Horizon is running
# external to the OpenStack environment. The default is 'publicURL'.