summaryrefslogtreecommitdiff
path: root/tuskar_ui/infrastructure/flavors
diff options
context:
space:
mode:
Diffstat (limited to 'tuskar_ui/infrastructure/flavors')
-rw-r--r--tuskar_ui/infrastructure/flavors/__init__.py0
-rw-r--r--tuskar_ui/infrastructure/flavors/panel.py27
-rw-r--r--tuskar_ui/infrastructure/flavors/tables.py114
-rw-r--r--tuskar_ui/infrastructure/flavors/tabs.py140
-rw-r--r--tuskar_ui/infrastructure/flavors/templates/flavors/create.html11
-rw-r--r--tuskar_ui/infrastructure/flavors/templates/flavors/details.html35
-rw-r--r--tuskar_ui/infrastructure/flavors/templates/flavors/index.html16
-rw-r--r--tuskar_ui/infrastructure/flavors/tests.py219
-rw-r--r--tuskar_ui/infrastructure/flavors/urls.py28
-rw-r--r--tuskar_ui/infrastructure/flavors/views.py87
-rw-r--r--tuskar_ui/infrastructure/flavors/workflows.py106
11 files changed, 783 insertions, 0 deletions
diff --git a/tuskar_ui/infrastructure/flavors/__init__.py b/tuskar_ui/infrastructure/flavors/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/tuskar_ui/infrastructure/flavors/__init__.py
diff --git a/tuskar_ui/infrastructure/flavors/panel.py b/tuskar_ui/infrastructure/flavors/panel.py
new file mode 100644
index 00000000..ffdfdc7f
--- /dev/null
+++ b/tuskar_ui/infrastructure/flavors/panel.py
@@ -0,0 +1,27 @@
+# -*- coding: utf8 -*-
+#
+# 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 _
+
+import horizon
+
+from tuskar_ui.infrastructure import dashboard
+
+
+class Flavors(horizon.Panel):
+ name = _("Flavors")
+ slug = "flavors"
+
+
+dashboard.Infrastructure.register(Flavors)
diff --git a/tuskar_ui/infrastructure/flavors/tables.py b/tuskar_ui/infrastructure/flavors/tables.py
new file mode 100644
index 00000000..29b9e709
--- /dev/null
+++ b/tuskar_ui/infrastructure/flavors/tables.py
@@ -0,0 +1,114 @@
+# -*- coding: utf8 -*-
+#
+# 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 horizon import tables
+
+from openstack_dashboard.dashboards.admin.flavors \
+ import tables as flavor_tables
+
+from tuskar_ui import api
+
+
+class CreateFlavor(flavor_tables.CreateFlavor):
+ verbose_name = _("New Flavor")
+ url = "horizon:infrastructure:flavors:create"
+
+
+class CreateSuggestedFlavor(CreateFlavor):
+ verbose_name = _("Create")
+
+
+class DeleteFlavor(flavor_tables.DeleteFlavor):
+
+ def __init__(self, **kwargs):
+ super(DeleteFlavor, self).__init__(**kwargs)
+ # NOTE(dtantsur): setting class attributes doesn't work
+ # probably due to metaclass magic in actions
+ self.data_type_singular = _("Flavor")
+ self.data_type_plural = _("Flavors")
+
+ def allowed(self, request, datum=None):
+ """Check that action is allowed on flavor
+
+ This is overridden method from horizon.tables.BaseAction.
+
+ :param datum: flavor we're operating on
+ :type datum: tuskar_ui.api.Flavor
+ """
+ if datum is not None:
+ deployed_flavors = api.Flavor.list_deployed_ids(
+ request, _error_default=None)
+ if deployed_flavors is None or datum.id in deployed_flavors:
+ return False
+ return super(DeleteFlavor, self).allowed(request, datum)
+
+
+class FlavorsTable(tables.DataTable):
+ name = tables.Column('name', verbose_name=_('Flavor'),
+ link="horizon:infrastructure:flavors:details")
+ arch = tables.Column('cpu_arch', verbose_name=_('Architecture'))
+ vcpus = tables.Column('vcpus', verbose_name=_('CPUs'))
+ ram = tables.Column(flavor_tables.get_size,
+ verbose_name=_('Memory'),
+ attrs={'data-type': 'size'})
+ disk = tables.Column(flavor_tables.get_disk_size,
+ verbose_name=_('Disk'),
+ attrs={'data-type': 'size'})
+ # FIXME(dtantsur): would be much better to have names here
+ kernel_image_id = tables.Column('kernel_image_id',
+ verbose_name=_('Deploy Kernel Image ID'))
+ ramdisk_image_id = tables.Column('ramdisk_image_id',
+ verbose_name=_('Deploy Ramdisk Image ID'))
+
+ class Meta:
+ name = "flavors"
+ verbose_name = _("Flavors")
+ table_actions = (CreateFlavor,
+ DeleteFlavor,
+ flavor_tables.FlavorFilterAction)
+ row_actions = (DeleteFlavor,)
+
+
+class FlavorRolesTable(tables.DataTable):
+ name = tables.Column('name', verbose_name=_('Role Name'))
+
+ def __init__(self, request, *args, **kwargs):
+ # TODO(dtantsur): support multiple overclouds
+ try:
+ overcloud = api.Overcloud.get_the_overcloud(request)
+ except Exception:
+ count_getter = lambda role: _("Not deployed")
+ else:
+ count_getter = overcloud.resources_count
+ self._columns['count'] = tables.Column(
+ count_getter,
+ verbose_name=_("Instances Count")
+ )
+ super(FlavorRolesTable, self).__init__(request, *args, **kwargs)
+
+
+class FlavorSuggestionsTable(tables.DataTable):
+ arch = tables.Column('cpu_arch', verbose_name=_('Architecture'))
+ vcpus = tables.Column('vcpus', verbose_name=_('CPUs'))
+ ram = tables.Column(flavor_tables.get_size, verbose_name=_('Memory'),
+ attrs={'data-type': 'size'})
+ disk = tables.Column(flavor_tables.get_disk_size,
+ verbose_name=_('Disk'), attrs={'data-type': 'size'})
+
+ class Meta:
+ name = "flavor_suggestions"
+ verbose_name = _("Flavor Suggestions")
+ table_actions = ()
+ row_actions = (CreateSuggestedFlavor,)
diff --git a/tuskar_ui/infrastructure/flavors/tabs.py b/tuskar_ui/infrastructure/flavors/tabs.py
new file mode 100644
index 00000000..772c40a3
--- /dev/null
+++ b/tuskar_ui/infrastructure/flavors/tabs.py
@@ -0,0 +1,140 @@
+# -*- coding: utf8 -*-
+#
+# 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 _
+import horizon.tabs
+
+from tuskar_ui import api
+from tuskar_ui.infrastructure.flavors import tables
+
+
+def _get_unmatched_suggestions(request):
+ unmatched_suggestions = []
+ flavor_suggestions = [FlavorSuggestion.from_flavor(flavor)
+ for flavor in api.Flavor.list(request)]
+ for node in api.Node.list(request):
+ node_suggestion = FlavorSuggestion.from_node(node)
+ for flavor_suggestion in flavor_suggestions:
+ if flavor_suggestion == node_suggestion:
+ break
+ else:
+ unmatched_suggestions.append(node_suggestion)
+ return unmatched_suggestions
+
+
+def get_flavor_suggestions(request):
+ return set(_get_unmatched_suggestions(request))
+
+
+class FlavorsTab(horizon.tabs.TableTab):
+ name = _("Flavors")
+ slug = 'flavors'
+ table_classes = (tables.FlavorsTable,)
+ template_name = ("horizon/common/_detail_table.html")
+ preload = False
+
+ def get_flavors_data(self):
+ flavors = api.Flavor.list(self.request)
+ flavors.sort(key=lambda np: (np.vcpus, np.ram, np.disk))
+ return flavors
+
+
+class FlavorSuggestion(object):
+ """Describe node parameters in a way that is easy to compare."""
+
+ def __init__(self, vcpus=None, ram=None, disk=None, cpu_arch=None,
+ ram_bytes=None, disk_bytes=None, node_id=None):
+ self.vcpus = vcpus
+ self.ram_bytes = ram_bytes or ram * 1024 * 1024 or 0
+ self.disk_bytes = disk_bytes or disk * 1024 * 1024 * 1024 or 0
+ self.cpu_arch = cpu_arch
+ self.id = node_id
+
+ @classmethod
+ def from_node(cls, node):
+ return cls(
+ node_id=node.id,
+ vcpus=int(node.properties['cpu']),
+ ram_bytes=int(node.properties['ram']),
+ disk_bytes=int(node.properties['local_disk']),
+ # TODO(rdopieralski) Add architecture when available.
+ )
+
+ @classmethod
+ def from_flavor(cls, flavor):
+ return cls(
+ vcpus=flavor.vcpus,
+ ram_bytes=flavor.ram_bytes,
+ disk_bytes=flavor.disk_bytes,
+ # TODO(rdopieralski) Add architecture when available.
+ )
+
+ @property
+ def name(self):
+ return '%s-%s-%s-%s' % (
+ self.vcpus or '0',
+ self.cpu_arch or '',
+ self.ram or '0',
+ self.disk or '0',
+ )
+
+ @property
+ def ram(self):
+ return self.ram_bytes / 1024 / 1024
+
+ @property
+ def disk(self):
+ return self.disk_bytes / 1024 / 1024 / 1024
+
+ def __hash__(self):
+ return self.name.__hash__()
+
+ def __eq__(self, other):
+ return self.name == other.name
+
+ def __ne__(self, other):
+ return not self == other
+
+ def __repr__(self):
+ return (
+ '%s(vcpus=%r, ram_bytes=%r, disk_bytes=%r, '
+ 'cpu_arch=%r, node_id=%r)' % (
+ self.__class__.__name__,
+ self.vcpus,
+ self.ram_bytes,
+ self.disk_bytes,
+ self.cpu_arch,
+ self.id,
+ )
+ )
+
+
+class FlavorSuggestionsTab(horizon.tabs.TableTab):
+ name = _("Flavor Suggestions")
+ slug = 'flavor_suggestions'
+ table_classes = (tables.FlavorSuggestionsTable,)
+ template_name = ("horizon/common/_detail_table.html")
+ preload = False
+
+ def get_flavor_suggestions_data(self):
+ return list(get_flavor_suggestions(self.request))
+
+
+class FlavorTabs(horizon.tabs.TabGroup):
+ slug = 'flavor_tabs'
+ tabs = (
+ FlavorsTab,
+ FlavorSuggestionsTab,
+ )
+ sticky = True
diff --git a/tuskar_ui/infrastructure/flavors/templates/flavors/create.html b/tuskar_ui/infrastructure/flavors/templates/flavors/create.html
new file mode 100644
index 00000000..4fedb05a
--- /dev/null
+++ b/tuskar_ui/infrastructure/flavors/templates/flavors/create.html
@@ -0,0 +1,11 @@
+{% extends 'infrastructure/base.html' %}
+{% load i18n %}
+{% block title %}{% trans "Create Flavor" %}{% endblock %}
+
+{% block page_header %}
+ {% include "horizon/common/_page_header.html" with title=_("Create Flavor") %}
+{% endblock page_header %}
+
+{% block main %}
+ {% include 'horizon/common/_workflow.html' %}
+{% endblock %}
diff --git a/tuskar_ui/infrastructure/flavors/templates/flavors/details.html b/tuskar_ui/infrastructure/flavors/templates/flavors/details.html
new file mode 100644
index 00000000..a6331c18
--- /dev/null
+++ b/tuskar_ui/infrastructure/flavors/templates/flavors/details.html
@@ -0,0 +1,35 @@
+{% extends 'infrastructure/base.html' %}
+{% load i18n %}
+{% block title %}{% trans 'Flavor Details' %}{% endblock %}
+
+{% block page_header %}
+ {% include 'horizon/common/_page_header.html' with title=_('Flavor Details') %}
+{% endblock page_header %}
+
+{% block main %}
+<div class="row-fluid">
+ <div class="span12">
+ <h4>{% trans "Hardware Info" %}</h4>
+ <dl class="clearfix">
+ <dt>{% trans "Architecture" %}</dt>
+ <dd>{{ flavor.cpu_arch|default:"&mdash;" }}</dd>
+ <dt>{% trans "CPUs" %}</dt>
+ <dd>{{ flavor.vcpus|default:"&mdash;" }}</dd>
+ <dt>{% trans "Memory" %}</dt>
+ <dd>{{ flavor.ram_bytes|filesizeformat|default:"&mdash;" }}</dd>
+ <dt>{% trans "Disk" %}</dt>
+ <dd>{{ flavor.disk_bytes|filesizeformat|default:"&mdash;" }}</dd>
+ </dl>
+ <h4>{% trans "Deploy Images" %}</h4>
+ <dl class="clearfix">
+ <dt>{% trans "Kernel" %}</dt>
+ <dd>{{ kernel_image.name|default:"&mdash;" }}</dd>
+ <dt>{% trans "Ramdisk" %}</dt>
+ <dd>{{ ramdisk_image.name|default:"&mdash;" }}</dd>
+ </dl>
+ <h4>{% trans "Overcloud Roles" %}</h4>
+ {{ table.render }}
+ </div>
+</div>
+
+{% endblock %}
diff --git a/tuskar_ui/infrastructure/flavors/templates/flavors/index.html b/tuskar_ui/infrastructure/flavors/templates/flavors/index.html
new file mode 100644
index 00000000..663a6237
--- /dev/null
+++ b/tuskar_ui/infrastructure/flavors/templates/flavors/index.html
@@ -0,0 +1,16 @@
+{% extends 'infrastructure/base.html' %}
+{% load i18n %}
+{% load url from future %}
+{% block title %}{% trans 'Flavors' %}{% endblock %}
+
+{% block page_header %}
+ {% include 'horizon/common/_domain_page_header.html' with title=_('Flavors') %}
+{% endblock page_header %}
+
+{% block main %}
+<div class="row-fluid">
+ <div class="span12">
+ {{ tab_group.render }}
+ </div>
+</div>
+{% endblock %}
diff --git a/tuskar_ui/infrastructure/flavors/tests.py b/tuskar_ui/infrastructure/flavors/tests.py
new file mode 100644
index 00000000..1b43d125
--- /dev/null
+++ b/tuskar_ui/infrastructure/flavors/tests.py
@@ -0,0 +1,219 @@
+# -*- coding: utf8 -*-
+#
+# 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 contextlib
+
+from django.core import urlresolvers
+
+from mock import patch, call # noqa
+
+from novaclient.v1_1 import servers
+
+from horizon import exceptions
+from openstack_dashboard.test.test_data import utils
+from tuskar_ui import api
+from tuskar_ui.test import helpers as test
+from tuskar_ui.test.test_data import tuskar_data
+
+
+TEST_DATA = utils.TestDataContainer()
+tuskar_data.data(TEST_DATA)
+INDEX_URL = urlresolvers.reverse(
+ 'horizon:infrastructure:flavors:index')
+CREATE_URL = urlresolvers.reverse(
+ 'horizon:infrastructure:flavors:create')
+DETAILS_VIEW = 'horizon:infrastructure:flavors:details'
+
+
+@contextlib.contextmanager
+def _prepare_create():
+ flavor = TEST_DATA.novaclient_flavors.first()
+ all_flavors = TEST_DATA.novaclient_flavors.list()
+ images = TEST_DATA.glanceclient_images.list()
+ data = {'name': 'foobar',
+ 'vcpus': 3,
+ 'memory_mb': 1024,
+ 'disk_gb': 40,
+ 'arch': 'amd64',
+ 'kernel_image_id': images[0].id,
+ 'ramdisk_image_id': images[1].id}
+ with contextlib.nested(
+ patch('tuskar_ui.api.Flavor.create',
+ return_value=flavor),
+ patch('openstack_dashboard.api.glance.image_list_detailed',
+ return_value=(TEST_DATA.glanceclient_images.list(), False)),
+ # Inherited code calls this directly
+ patch('openstack_dashboard.api.nova.flavor_list',
+ return_value=all_flavors),
+ ) as mocks:
+ yield mocks[0], data
+
+
+class FlavorsTest(test.BaseAdminViewTests):
+
+ def test_index(self):
+ with contextlib.nested(
+ patch('openstack_dashboard.api.nova.flavor_list',
+ return_value=TEST_DATA.novaclient_flavors.list()),
+ patch('openstack_dashboard.api.nova.server_list',
+ return_value=([], False)),
+ ) as (flavors_mock, servers_mock):
+ res = self.client.get(INDEX_URL)
+ self.assertEqual(flavors_mock.call_count, 1)
+ self.assertEqual(servers_mock.call_count, 1)
+
+ self.assertTemplateUsed(res,
+ 'infrastructure/flavors/index.html')
+
+ def test_index_recoverable_failure(self):
+ with patch('openstack_dashboard.api.nova.flavor_list',
+ side_effect=exceptions.Conflict):
+ self.client.get(INDEX_URL)
+ # FIXME(dtantsur): I expected the following to work:
+ # self.assertMessageCount(error=1, warning=0)
+
+ def test_create_get(self):
+ with patch('openstack_dashboard.api.glance.image_list_detailed',
+ return_value=([], False)) as mock:
+ res = self.client.get(CREATE_URL)
+ self.assertEqual(mock.call_count, 2)
+ self.assertTemplateUsed(res,
+ 'infrastructure/flavors/create.html')
+
+ def test_create_get_recoverable_failure(self):
+ with patch('openstack_dashboard.api.glance.image_list_detailed',
+ side_effect=exceptions.Conflict):
+ self.client.get(CREATE_URL)
+ self.assertMessageCount(error=1, warning=0)
+
+ def test_create_post_ok(self):
+ images = TEST_DATA.glanceclient_images.list()
+ with _prepare_create() as (create_mock, data):
+ res = self.client.post(CREATE_URL, data)
+ self.assertNoFormErrors(res)
+ self.assertRedirectsNoFollow(res, INDEX_URL)
+ request = create_mock.call_args_list[0][0][0]
+ self.assertListEqual(create_mock.call_args_list, [
+ call(request, name=u'foobar', memory=1024, vcpus=3, disk=40,
+ cpu_arch='amd64', kernel_image_id=images[0].id,
+ ramdisk_image_id=images[1].id)
+ ])
+
+ def test_create_post_name_exists(self):
+ flavor = TEST_DATA.novaclient_flavors.first()
+ with _prepare_create() as (create_mock, data):
+ data['name'] = flavor.name
+ res = self.client.post(CREATE_URL, data)
+ self.assertFormErrors(res)
+
+ def test_delete_ok(self):
+ flavors = TEST_DATA.novaclient_flavors.list()
+ data = {'action': 'flavors__delete',
+ 'object_ids': [flavors[0].id, flavors[1].id]}
+ with contextlib.nested(
+ patch('openstack_dashboard.api.nova.flavor_delete'),
+ patch('openstack_dashboard.api.nova.server_list',
+ return_value=([], False)),
+ patch('openstack_dashboard.api.glance.image_list_detailed',
+ return_value=([], False)),
+ patch('openstack_dashboard.api.nova.flavor_list',
+ return_value=TEST_DATA.novaclient_flavors.list())
+ ) as (delete_mock, server_list_mock, glance_mock, flavors_mock):
+ res = self.client.post(INDEX_URL, data)
+ self.assertNoFormErrors(res)
+ self.assertRedirectsNoFollow(res, INDEX_URL)
+ self.assertEqual(delete_mock.call_count, 2)
+ self.assertEqual(server_list_mock.call_count, 1)
+
+ def test_delete_deployed(self):
+ flavors = TEST_DATA.novaclient_flavors.list()
+ server = servers.Server(
+ servers.ServerManager(None),
+ {'id': 'aa',
+ 'name': 'Compute',
+ 'image': {'id': 1},
+ 'status': 'ACTIVE',
+ 'flavor': {'id': flavors[0].id}}
+ )
+ data = {'action': 'flavors__delete',
+ 'object_ids': [flavors[0].id, flavors[1].id]}
+ with contextlib.nested(
+ patch('openstack_dashboard.api.nova.flavor_delete'),
+ patch('openstack_dashboard.api.nova.server_list',
+ return_value=([server], False)),
+ patch('openstack_dashboard.api.glance.image_list_detailed',
+ return_value=([], False)),
+ patch('openstack_dashboard.api.nova.flavor_list',
+ return_value=TEST_DATA.novaclient_flavors.list())
+ ) as (delete_mock, server_list_mock, glance_mock, flavors_mock):
+ res = self.client.post(INDEX_URL, data)
+ self.assertMessageCount(error=1, warning=0)
+ self.assertNoFormErrors(res)
+ self.assertRedirectsNoFollow(res, INDEX_URL)
+ self.assertEqual(delete_mock.call_count, 1)
+ self.assertEqual(server_list_mock.call_count, 1)
+
+ def test_details_no_overcloud(self):
+ flavor = api.Flavor(TEST_DATA.novaclient_flavors.first())
+ images = TEST_DATA.glanceclient_images.list()[:2]
+ roles = TEST_DATA.tuskarclient_overcloud_roles.list()
+ roles[0].flavor_id = flavor.id
+ with contextlib.nested(
+ patch('openstack_dashboard.api.glance.image_get',
+ side_effect=images),
+ patch('tuskar_ui.api.Flavor.get',
+ return_value=flavor),
+ patch('tuskar_ui.api.OvercloudRole.list',
+ return_value=roles),
+ patch('tuskar_ui.api.Overcloud.get_the_overcloud',
+ side_effect=Exception)
+ ) as (image_mock, get_mock, roles_mock, overcloud_mock):
+ res = self.client.get(urlresolvers.reverse(DETAILS_VIEW,
+ args=(flavor.id,)))
+ self.assertEqual(image_mock.call_count, 1) # memoized
+ self.assertEqual(get_mock.call_count, 1)
+ self.assertEqual(roles_mock.call_count, 1)
+ self.assertEqual(overcloud_mock.call_count, 1)
+ self.assertTemplateUsed(res,
+ 'infrastructure/flavors/details.html')
+
+ def test_details(self):
+ flavor = api.Flavor(TEST_DATA.novaclient_flavors.first())
+ images = TEST_DATA.glanceclient_images.list()[:2]
+ roles = TEST_DATA.tuskarclient_overcloud_roles.list()
+ roles[0].flavor_id = flavor.id
+ overcloud = api.Overcloud(TEST_DATA.tuskarclient_overclouds.first())
+ with contextlib.nested(
+ patch('openstack_dashboard.api.glance.image_get',
+ side_effect=images),
+ patch('tuskar_ui.api.Flavor.get',
+ return_value=flavor),
+ patch('tuskar_ui.api.OvercloudRole.list',
+ return_value=roles),
+ patch('tuskar_ui.api.Overcloud.get_the_overcloud',
+ return_value=overcloud),
+ # __name__ is required for horizon.tables
+ patch('tuskar_ui.api.Overcloud.resources_count',
+ return_value=42, __name__='')
+ ) as (image_mock, get_mock, roles_mock, overcloud_mock, count_mock):
+ res = self.client.get(urlresolvers.reverse(DETAILS_VIEW,
+ args=(flavor.id,)))
+ self.assertEqual(image_mock.call_count, 1) # memoized
+ self.assertEqual(get_mock.call_count, 1)
+ self.assertEqual(roles_mock.call_count, 1)
+ self.assertEqual(overcloud_mock.call_count, 1)
+ self.assertEqual(count_mock.call_count, 1)
+ self.assertListEqual(count_mock.call_args_list, [call(roles[0])])
+ self.assertTemplateUsed(res,
+ 'infrastructure/flavors/details.html')
diff --git a/tuskar_ui/infrastructure/flavors/urls.py b/tuskar_ui/infrastructure/flavors/urls.py
new file mode 100644
index 00000000..7f78dc45
--- /dev/null
+++ b/tuskar_ui/infrastructure/flavors/urls.py
@@ -0,0 +1,28 @@
+# -*- coding: utf8 -*-
+#
+# 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 import urls
+
+from tuskar_ui.infrastructure.flavors import views
+
+
+urlpatterns = urls.patterns(
+ 'tuskar_ui.infrastructure.flavors.views',
+ urls.url(r'^$', views.IndexView.as_view(), name='index'),
+ urls.url(r'^create/(?P<suggestion_id>[^/]+)$', views.CreateView.as_view(),
+ name='create'),
+ urls.url(r'^create/$', views.CreateView.as_view(), name='create'),
+ urls.url(r'^(?P<flavor_id>[^/]+)/$', views.DetailView.as_view(),
+ name='details'),
+)
diff --git a/tuskar_ui/infrastructure/flavors/views.py b/tuskar_ui/infrastructure/flavors/views.py
new file mode 100644
index 00000000..ed8dc3d5
--- /dev/null
+++ b/tuskar_ui/infrastructure/flavors/views.py
@@ -0,0 +1,87 @@
+# -*- coding: utf8 -*-
+#
+# 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 django.utils.translation import ugettext_lazy as _
+
+import horizon.exceptions
+import horizon.tables
+import horizon.tabs
+import horizon.workflows
+
+import tuskar_ui.api
+from tuskar_ui.infrastructure.flavors import tables
+from tuskar_ui.infrastructure.flavors import tabs
+from tuskar_ui.infrastructure.flavors import workflows
+
+
+def image_get(request, image_id, error_message):
+ # TODO(dtantsur): there should be generic way to handle exceptions
+ try:
+ return tuskar_ui.api.image_get(request, image_id)
+ except Exception:
+ horizon.exceptions.handle(request, error_message)
+
+
+class IndexView(horizon.tabs.TabbedTableView):
+ tab_group_class = tabs.FlavorTabs
+ template_name = 'infrastructure/flavors/index.html'
+
+
+class CreateView(horizon.workflows.WorkflowView):
+ workflow_class = workflows.CreateFlavor
+ template_name = 'infrastructure/flavors/create.html'
+
+ def get_initial(self):
+ suggestion_id = self.kwargs.get('suggestion_id')
+ if not suggestion_id:
+ return super(CreateView, self).get_initial()
+ node = tuskar_ui.api.Node.get(self.request, suggestion_id)
+ suggestion = tabs.FlavorSuggestion.from_node(node)
+ return {
+ 'name': suggestion.name,
+ 'vcpus': suggestion.vcpus,
+ 'memory_mb': suggestion.ram,
+ 'disk_gb': suggestion.disk,
+ 'arch': suggestion.cpu_arch,
+ }
+
+
+class DetailView(horizon.tables.DataTableView):
+ table_class = tables.FlavorRolesTable
+ template_name = 'infrastructure/flavors/details.html'
+ error_redirect = reverse_lazy('horizon:infrastructure:flavors:index')
+
+ def get_context_data(self, **kwargs):
+ context = super(DetailView, self).get_context_data(**kwargs)
+ context['flavor'] = tuskar_ui.api.Flavor.get(
+ self.request,
+ kwargs.get('flavor_id'),
+ _error_redirect=self.error_redirect
+ )
+ context['kernel_image'] = image_get(
+ self.request,
+ context['flavor'].kernel_image_id,
+ error_message=_("Cannot get kernel image details")
+ )
+ context['ramdisk_image'] = image_get(
+ self.request,
+ context['flavor'].ramdisk_image_id,
+ error_message=_("Cannot get ramdisk image details")
+ )
+ return context
+
+ def get_data(self):
+ return [role for role in tuskar_ui.api.OvercloudRole.list(self.request)
+ if role.flavor_id == str(self.kwargs.get('flavor_id'))]
diff --git a/tuskar_ui/infrastructure/flavors/workflows.py b/tuskar_ui/infrastructure/flavors/workflows.py
new file mode 100644
index 00000000..0ea91c04
--- /dev/null
+++ b/tuskar_ui/infrastructure/flavors/workflows.py
@@ -0,0 +1,106 @@
+# -*- coding: utf8 -*-
+#
+# 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.forms import fields
+from django.utils.translation import ugettext_lazy as _
+
+from horizon import exceptions
+from horizon import workflows
+
+from openstack_dashboard.api import glance
+from openstack_dashboard.dashboards.admin.flavors \
+ import workflows as flavor_workflows
+from tuskar_ui import api
+
+
+class CreateFlavorAction(flavor_workflows.CreateFlavorInfoAction):
+ arch = fields.ChoiceField(choices=(('i386', 'i386'), ('amd64', 'amd64')),
+ label=_("Architecture"))
+ kernel_image_id = fields.ChoiceField(choices=(),
+ label=_("Deploy Kernel Image"))
+ ramdisk_image_id = fields.ChoiceField(choices=(),
+ label=_("Deploy Ramdisk Image"))
+
+ def __init__(self, *args, **kwrds):
+ super(CreateFlavorAction, self).__init__(*args, **kwrds)
+ try:
+ kernel_images = glance.image_list_detailed(
+ self.request,
+ filters={'disk_format': 'aki'}
+ )[0]
+ ramdisk_images = glance.image_list_detailed(
+ self.request,
+ filters={'disk_format': 'ari'}
+ )[0]
+ except Exception:
+ exceptions.handle(self.request,
+ _('Unable to retrieve images list.'))
+ kernel_images = []
+ ramdisk_images = []
+ self.fields['kernel_image_id'].choices = [(img.id, img.name)
+ for img in kernel_images]
+ self.fields['ramdisk_image_id'].choices = [(img.id, img.name)
+ for img in ramdisk_images]
+ # Delete what is not applicable to hardware
+ del self.fields['eph_gb']
+ del self.fields['swap_mb']
+ # Alter user-visible strings
+ self.fields['vcpus'].label = _("CPUs")
+ self.fields['disk_gb'].label = _("Disk GB")
+ # No idea why Horizon exposes this database detail
+ del self.fields['flavor_id']
+
+ class Meta:
+ name = _("Flavor")
+ # FIXME(dtantsur): maybe better help text?
+ help_text = _("From here you can create a new "
+ "flavor to organize instance resources.")
+
+
+class CreateFlavorStep(workflows.Step):
+ action_class = CreateFlavorAction
+ contributes = ("name",
+ "vcpus",
+ "memory_mb",
+ "disk_gb",
+ "arch",
+ "kernel_image_id",
+ "ramdisk_image_id")
+
+
+class CreateFlavor(flavor_workflows.CreateFlavor):
+ slug = "create_flavor"
+ name = _("Create Flavor")
+ finalize_button_name = _("Create Flavor")
+ success_message = _('Created new flavor "%s".')
+ failure_message = _('Unable to create flavor "%s".')
+ success_url = "horizon:infrastructure:flavors:index"
+ default_steps = (CreateFlavorStep,)
+
+ def handle(self, request, data):
+ try:
+ self.object = api.Flavor.create(
+ request,
+ name=data['name'],
+ memory=data['memory_mb'],
+ vcpus=data['vcpus'],
+ disk=data['disk_gb'],
+ cpu_arch=data['arch'],
+ kernel_image_id=data['kernel_image_id'],
+ ramdisk_image_id=data['ramdisk_image_id']
+ )
+ except Exception:
+ exceptions.handle(request, _("Unable to create flavor"))
+ return False
+ return True