Add images CRUD support

Adding images CRUD support, importing code from Horizon.

Change-Id: Ie054d33792881243201fdabed88aba2c72576e34
This commit is contained in:
Ladislav Smola 2014-09-11 14:53:03 +02:00
parent d1b731413e
commit c11d10ff8e
9 changed files with 372 additions and 9 deletions

View File

@ -0,0 +1,21 @@
# 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 openstack_dashboard.dashboards.project.images.images import forms
class CreateImageForm(forms.CreateImageForm):
pass
class UpdateImageForm(forms.UpdateImageForm):
pass

View File

@ -16,6 +16,46 @@ from django.utils.translation import ugettext_lazy as _
from horizon import tables
from openstack_dashboard import api
from openstack_dashboard.dashboards.project.images.images \
import tables as project_tables
class CreateImage(project_tables.CreateImage):
url = "horizon:infrastructure:images:create"
class DeleteImage(project_tables.DeleteImage):
def allowed(self, request, image=None):
if image and image.protected:
return False
else:
return True
class UpdateRow(tables.Row):
ajax = True
def get_data(self, request, image_id):
image = api.glance.image_get(request, image_id)
return image
class ImageFilterAction(tables.FilterAction):
filter_type = "server"
filter_choices = (('name', _("Image Name ="), True),
('status', _('Status ='), True),
('disk_format', _('Format ='), True),
('size_min', _('Min. Size (MB)'), True),
('size_max', _('Max. Size (MB)'), True))
class EditImage(project_tables.EditImage):
url = "horizon:infrastructure:images:update"
def allowed(self, request, image=None):
return True
class ImagesTable(tables.DataTable):
@ -29,7 +69,9 @@ class ImagesTable(tables.DataTable):
class Meta:
name = "images"
row_class = UpdateRow
verbose_name = _("Provisioning Images")
multi_select = False
table_actions = ()
row_actions = ()
table_actions = (CreateImage, DeleteImage,
ImageFilterAction)
row_actions = (EditImage, DeleteImage)

View File

@ -0,0 +1,35 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% load url from future %}
{% block form_id %}create_image_form{% endblock %}
{% block form_action %}{% url 'horizon:infrastructure:images:create' %}{% endblock %}
{% block form_attrs %}enctype="multipart/form-data"{% endblock %}
{% block modal-header %}{% trans "Create An Image" %}{% 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 "Specify an image to upload to the Image Service." %}
</p>
<p>
{% trans "Currently only images available via an HTTP URL are supported. The image location must be accessible to the Image Service. Compressed image binaries are supported (.zip and .tar.gz.)" %}
</p>
<p>
<strong>{% trans "Please note: " %}</strong>
{% trans "The Image Location field MUST be a valid and direct URL to the image binary. URLs that redirect or serve error pages will result in unusable images." %}
</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Create Image" %}" />
<a href="{% url 'horizon:infrastructure:images:index' %}" class="btn btn-default secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -0,0 +1,26 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% load url from future %}
{% block form_id %}update_image_form{% endblock %}
{% block form_action %}{% url 'horizon:infrastructure:images:update' image.id %}{% endblock %}
{% block modal_id %}update_image_modal{% endblock %}
{% block modal-header %}{% trans "Update Image" %}{% 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 "Modify different properties of an image." %}</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Update Image" %}" />
<a href="{% url 'horizon:infrastructure:images: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 "Create An Image" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Create An Image") %}
{% endblock page_header %}
{% block main %}
{% include 'infrastructure/images/_create.html' %}
{% endblock %}

View File

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

View File

@ -13,16 +13,21 @@
# under the License.
import contextlib
import mock
from mock import patch, call # noqa
from django.core import urlresolvers
from mock import patch, call # noqa
from openstack_dashboard.dashboards.project.images.images import forms
from tuskar_ui import api
from tuskar_ui.test import helpers as test
INDEX_URL = urlresolvers.reverse(
'horizon:infrastructure:images:index')
CREATE_URL = urlresolvers.reverse(
'horizon:infrastructure:images:create')
UPDATE_URL = 'horizon:infrastructure:images:update'
class ImagesTest(test.BaseAdminViewTests):
@ -40,6 +45,125 @@ class ImagesTest(test.BaseAdminViewTests):
return_value=plans),
patch('openstack_dashboard.api.glance.image_list_detailed',
return_value=[self.images.list(), False, False]),):
res = self.client.get(INDEX_URL)
self.assertTemplateUsed(res, 'infrastructure/images/index.html')
def test_create_get(self):
res = self.client.get(CREATE_URL)
self.assertTemplateUsed(res, 'infrastructure/images/create.html')
def test_update_get(self):
image = self.images.list()[0]
with contextlib.nested(
patch('openstack_dashboard.api.glance.image_get',
return_value=image),) as (mocked_get,):
res = self.client.get(
urlresolvers.reverse(UPDATE_URL, args=(image.id,)))
mocked_get.assert_called_once_with(mock.ANY, image.id)
self.assertTemplateUsed(res, 'infrastructure/images/update.html')
def test_create_post(self):
image = self.images.list()[0]
data = {
'name': 'Fedora',
'description': 'Login with admin/admin',
'source_type': 'url',
'copy_from': 'http://test_url.com',
'disk_format': 'qcow2',
'architecture': 'x86-64',
'minimum_disk': 15,
'minimum_ram': 512,
'is_public': True,
'protected': False}
forms.IMAGE_FORMAT_CHOICES = [('qcow2', 'qcow2')]
with contextlib.nested(
patch('openstack_dashboard.api.glance.image_create',
return_value=image),) as (mocked_create,):
res = self.client.post(CREATE_URL, data)
self.assertNoFormErrors(res)
self.assertEqual(res.status_code, 302)
self.assertRedirectsNoFollow(res, INDEX_URL)
mocked_create.assert_called_once_with(
mock.ANY, name=u'Fedora', container_format='bare', min_ram=512,
disk_format=u'qcow2', copy_from=u'http://test_url.com',
protected=False, min_disk=15, is_public=True,
properties={'description': u'Login with admin/admin',
'architecture': u'x86-64'})
def test_update_post(self):
image = self.images.list()[0]
data = {
'image_id': image.id,
'name': 'Fedora',
'description': 'Login with admin/admin',
'source_type': 'url',
'copy_from': 'http://test_url.com',
'disk_format': 'qcow2',
'architecture': 'x86-64',
'minimum_disk': 15,
'minimum_ram': 512,
'is_public': True,
'protected': False}
forms.IMAGE_FORMAT_CHOICES = [('qcow2', 'qcow2')]
with contextlib.nested(
patch('openstack_dashboard.api.glance.image_get',
return_value=image),
patch('openstack_dashboard.api.glance.image_update',
return_value=image),) as (mocked_get, mocked_update,):
res = self.client.post(
urlresolvers.reverse(UPDATE_URL, args=(image.id,)), data)
self.assertNoFormErrors(res)
self.assertEqual(res.status_code, 302)
self.assertRedirectsNoFollow(res, INDEX_URL)
mocked_get.assert_called_once_with(mock.ANY, image.id)
mocked_update.assert_called_once_with(
mock.ANY, image.id, name='Fedora', container_format='bare',
min_ram=512, disk_format='qcow2', protected=False,
is_public=False, min_disk=15, purge_props=False,
properties={'description': 'Login with admin/admin',
'architecture': 'x86-64'})
def test_delete_ok(self):
roles = [api.tuskar.Role(role) for role in
self.tuskarclient_roles.list()]
plans = [api.tuskar.Plan(plan) for plan in
self.tuskarclient_plans.list()]
images = self.images.list()
data = {'action': 'images__delete',
'object_ids': [images[0].id, images[1].id]}
with contextlib.nested(
patch('tuskar_ui.api.tuskar.Role.list',
return_value=roles),
patch('tuskar_ui.api.tuskar.Plan.list',
return_value=plans),
patch('openstack_dashboard.api.glance.image_list_detailed',
return_value=[images, False, False]),
patch('openstack_dashboard.api.glance.image_delete',
return_value=None),) as (
mock_role_list, plan_list, mock_image_lict, mock_image_delete):
res = self.client.post(INDEX_URL, data)
mock_image_delete.has_calls(
call(mock.ANY, images[0].id),
call(mock.ANY, images[1].id))
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)

View File

@ -16,8 +16,10 @@ from django.conf import urls
from tuskar_ui.infrastructure.images import views
urlpatterns = urls.patterns(
'',
urls.url(r'^$', views.IndexView.as_view(), name='index'),
urls.url(r'^create/$', views.CreateView.as_view(), name='create'),
urls.url(r'^(?P<image_id>[^/]+)/update/$',
views.UpdateView.as_view(), name='update'),
)

View File

@ -12,23 +12,113 @@
# License for the specific language governing permissions and limitations
# under the License.
import logging
from django.core.urlresolvers import reverse_lazy
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import tables as horizon_tables
from horizon.utils import memoized
from openstack_dashboard.api import glance
from openstack_dashboard import api
from openstack_dashboard.dashboards.project.images.images import views
from tuskar_ui import api
from tuskar_ui import api as tuskar_api
from tuskar_ui.infrastructure.images import forms
from tuskar_ui.infrastructure.images import tables
LOG = logging.getLogger(__name__)
class IndexView(horizon_tables.DataTableView):
table_class = tables.ImagesTable
template_name = "infrastructure/images/index.html"
def has_prev_data(self, table):
return self._prev
def has_more_data(self, table):
return self._more
def get_data(self):
plan = api.tuskar.Plan.get_the_plan(self.request)
images = glance.image_list_detailed(self.request)[0]
images = []
filters = self.get_filters()
prev_marker = self.request.GET.get(
tables.ImagesTable._meta.prev_pagination_param, None)
if prev_marker is not None:
sort_dir = 'asc'
marker = prev_marker
else:
sort_dir = 'desc'
marker = self.request.GET.get(
tables.ImagesTable._meta.pagination_param, None)
try:
images, self._more, self._prev = api.glance.image_list_detailed(
self.request,
marker=marker,
paginate=True,
filters=filters,
sort_dir=sort_dir)
if prev_marker is not None:
images = sorted(images, key=lambda image:
getattr(image, 'created_at'), reverse=True)
except Exception:
self._prev = False
self._more = False
msg = _('Unable to retrieve image list.')
exceptions.handle(self.request, msg)
# TODO(tzumainn): re-architect a bit to avoid inefficiency
plan = tuskar_api.tuskar.Plan.get_the_plan(self.request)
for image in images:
image.role = api.tuskar.Role.get_by_image(
image.role = tuskar_api.tuskar.Role.get_by_image(
self.request, plan, image)
return images
def get_filters(self):
filters = {'is_public': None}
filter_field = self.table.get_filter_field()
filter_string = self.table.get_filter_string()
filter_action = self.table._meta._filter_action
if filter_field and filter_string and (
filter_action.is_api_filter(filter_field)):
if filter_field in ['size_min', 'size_max']:
invalid_msg = ('API query is not valid and is ignored: %s=%s'
% (filter_field, filter_string))
try:
filter_string = long(float(filter_string) * (1024 ** 2))
if filter_string >= 0:
filters[filter_field] = filter_string
else:
LOG.warning(invalid_msg)
except ValueError:
LOG.warning(invalid_msg)
else:
filters[filter_field] = filter_string
return filters
class CreateView(views.CreateView):
template_name = 'infrastructure/images/create.html'
form_class = forms.CreateImageForm
success_url = reverse_lazy('horizon:infrastructure:images:index')
class UpdateView(views.UpdateView):
template_name = 'infrastructure/images/update.html'
form_class = forms.UpdateImageForm
success_url = reverse_lazy('horizon:infrastructure:images:index')
@memoized.memoized_method
def get_object(self):
try:
return api.glance.image_get(self.request, self.kwargs['image_id'])
except Exception:
msg = _('Unable to retrieve image.')
url = reverse_lazy('horizon:infrastructure:images:index')
exceptions.handle(self.request, msg, redirect=url)