diff --git a/openstack_dashboard/dashboards/project/dashboard.py b/openstack_dashboard/dashboards/project/dashboard.py index ee50e5c382..ff7f522a83 100644 --- a/openstack_dashboard/dashboards/project/dashboard.py +++ b/openstack_dashboard/dashboards/project/dashboard.py @@ -61,7 +61,8 @@ class DataProcessingPanels(horizon.PanelGroup): name = _("Data Processing") slug = "data_processing" panels = ('data_processing.data_plugins', - 'data_processing.data_image_registry', ) + 'data_processing.data_image_registry', + 'data_processing.nodegroup_templates', ) class Project(horizon.Dashboard): diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/__init__.py b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/panel.py b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/panel.py new file mode 100644 index 0000000000..6f0e54414d --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/panel.py @@ -0,0 +1,27 @@ +# 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 openstack_dashboard.dashboards.project import dashboard + + +class NodegroupTemplatesPanel(horizon.Panel): + name = _("Node Group Templates") + slug = 'data_processing.nodegroup_templates' + permissions = ('openstack.services.data_processing',) + + +dashboard.Project.register(NodegroupTemplatesPanel) diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/tables.py b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/tables.py new file mode 100644 index 0000000000..d6a37aff10 --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/tables.py @@ -0,0 +1,88 @@ +# 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 import template + +import logging + +from django.utils.translation import ugettext_lazy as _ + +from horizon import tables +from openstack_dashboard.api import sahara as saharaclient + +LOG = logging.getLogger(__name__) + + +class CreateNodegroupTemplate(tables.LinkAction): + name = "create" + verbose_name = _("Create Template") + url = ("horizon:project:data_processing.nodegroup_templates:" + "create-nodegroup-template") + classes = ("ajax-modal", "btn-create", "create-nodegrouptemplate-btn") + + +class ConfigureNodegroupTemplate(tables.LinkAction): + name = "configure" + verbose_name = _("Configure Template") + url = ("horizon:project:data_processing.nodegroup_templates:" + "configure-nodegroup-template") + classes = ("ajax-modal", "btn-create", "configure-nodegrouptemplate-btn") + attrs = {"style": "display: none"} + + +class CopyTemplate(tables.LinkAction): + name = "copy" + verbose_name = _("Copy Template") + url = "horizon:project:data_processing.nodegroup_templates:copy" + classes = ("ajax-modal", ) + + +class DeleteTemplate(tables.BatchAction): + name = "delete_nodegroup_template" + verbose_name = _("Delete") + classes = ("btn-terminate", "btn-danger") + + action_present = _("Delete") + action_past = _("Deleted") + data_type_singular = _("Template") + data_type_plural = _("Templates") + + def action(self, request, template_id): + saharaclient.nodegroup_template_delete(request, template_id) + + +def render_processes(nodegroup_template): + template_name = ( + 'project/data_processing.nodegroup_templates/_processes_list.html') + context = {"processes": nodegroup_template.node_processes} + return template.loader.render_to_string(template_name, context) + + +class NodegroupTemplatesTable(tables.DataTable): + name = tables.Column("name", + verbose_name=_("Name"), + link=("horizon:project:data_processing.nodegroup_templates:details")) + plugin_name = tables.Column("plugin_name", + verbose_name=_("Plugin")) + hadoop_version = tables.Column("hadoop_version", + verbose_name=_("Hadoop Version")) + node_processes = tables.Column(render_processes, + verbose_name=_("Node Processes")) + + class Meta: + name = "nodegroup_templates" + verbose_name = _("Node Group Templates") + table_actions = (CreateNodegroupTemplate, + ConfigureNodegroupTemplate, + DeleteTemplate) + row_actions = (CopyTemplate, + DeleteTemplate,) diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/tabs.py b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/tabs.py new file mode 100644 index 0000000000..b670f5676c --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/tabs.py @@ -0,0 +1,71 @@ +# 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 tabs + +from openstack_dashboard.api import nova +from openstack_dashboard.api import sahara as saharaclient + + +LOG = logging.getLogger(__name__) + + +class GeneralTab(tabs.Tab): + name = _("General Info") + slug = "nodegroup_template_details_tab" + template_name = ( + "project/data_processing.nodegroup_templates/_details.html") + + def get_context_data(self, request): + template_id = self.tab_group.kwargs['template_id'] + try: + template = saharaclient.nodegroup_template_get(request, template_id) + except Exception: + template = {} + exceptions.handle(request, + _("Unable to fetch node group template.")) + try: + flavor = nova.flavor_get(request, template.flavor_id) + except Exception: + flavor = {} + exceptions.handle(request, + _("Unable to fetch flavor for template.")) + return {"template": template, "flavor": flavor} + + +class ConfigsTab(tabs.Tab): + name = _("Service Configurations") + slug = "nodegroup_template_service_configs_tab" + template_name = ( + "project/data_processing.nodegroup_templates/_service_confs.html") + + def get_context_data(self, request): + template_id = self.tab_group.kwargs['template_id'] + try: + template = saharaclient.nodegroup_template_get(request, template_id) + except Exception: + template = {} + exceptions.handle(request, + _("Unable to fetch node group template.")) + return {"template": template} + + +class NodegroupTemplateDetailsTabs(tabs.TabGroup): + slug = "nodegroup_template_details" + tabs = (GeneralTab, ConfigsTab, ) + sticky = True diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_configure_general_help.html b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_configure_general_help.html new file mode 100644 index 0000000000..10675b6162 --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_configure_general_help.html @@ -0,0 +1,20 @@ +{% load i18n horizon %} +
+

+ {% blocktrans %}This Node Group Template will be created for:{% endblocktrans %} +
+ {% blocktrans %}Plugin{% endblocktrans %}: {{ plugin_name }} +
+ {% blocktrans %}Hadoop version{% endblocktrans %}: {{ hadoop_version }} +
+

+

+ {% blocktrans %}The Node Group Template object should specify processes that will be launched on each instance. Also an OpenStack flavor is required to boot VMs.{% endblocktrans %} +

+

+ {% blocktrans %}Data Processing provides different storage location options. You may choose Ephemeral Drive or a Cinder Volume to be attached to instances.{% endblocktrans %} +

+

+ {% blocktrans %}When processes are selected, you may set node scoped Hadoop configurations on corresponding tabs.{% endblocktrans %} +

+
\ No newline at end of file diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_create_general_help.html b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_create_general_help.html new file mode 100644 index 0000000000..6b21b603f6 --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_create_general_help.html @@ -0,0 +1,4 @@ +{% load i18n horizon %} +

+ {% blocktrans %}Select a plugin and Hadoop version for a new Node group template.{% endblocktrans %} +

\ No newline at end of file diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_details.html b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_details.html new file mode 100644 index 0000000000..06621c338d --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_details.html @@ -0,0 +1,45 @@ +{% load i18n sizeformat %} +{% load url from future %} +

{% trans "Template Overview" %}

+
+
+
{% trans "Name" %}
+
{{ template.name }}
+
{% trans "ID" %}
+
{{ template.id }}
+
{% trans "Description" %}
+
{{ template.description|default:"None" }}
+
+
+
{% trans "Flavor" %}
+
{{ flavor.name }}
+
+
+
{% trans "Plugin" %}
+
{{ template.plugin_name }}
+
{% trans "Hadoop Version" %}
+
{{ template.hadoop_version }}
+
+
+
{% trans "Node Processes" %}
+
+
    + {% for process in template.node_processes %} +
  • {{ process }}
  • + {% endfor %} +
+
+
+
+

{% trans "HDFS placement" %}

+ {% if template.volumes_per_node %} +
{% trans "Cinder volumes" %}
+
{% trans "Volumes per node" %}
+
{{ template.volumes_per_node }}
+
{% trans "Volumes size" %}
+
{{ template.volumes_size }}
+ {% else %} +
{% trans "Ephemeral drive" %}
+ {% endif %} +
+
\ No newline at end of file diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_fields_help.html b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_fields_help.html new file mode 100644 index 0000000000..11b9c8af06 --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_fields_help.html @@ -0,0 +1,60 @@ +
+
+ Show full configuration + Hide full configuration +
+ + diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_processes_list.html b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_processes_list.html new file mode 100644 index 0000000000..52854fdbef --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_processes_list.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_service_confs.html b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_service_confs.html new file mode 100644 index 0000000000..74517b18ed --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/_service_confs.html @@ -0,0 +1,23 @@ +{% load i18n sizeformat %} +

{% trans "Service Configurations" %}

+
+
+ {% for service, config in template.node_configs.items %} +
{{ service }}
+
+ {% if config %} +
    + {% for conf_name, conf_val in config.items %} +
  • + {{ conf_name }}: {{ conf_val }} +
  • + {% endfor %} +
+ {% else %} +
No configurations
+ {% endif %} +
+ {% endfor %} +
+ +
\ No newline at end of file diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/configure.html b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/configure.html new file mode 100644 index 0000000000..1b03aa44dd --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/configure.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Create Node Group Template" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Create Node Group Template") %} +{% endblock page_header %} + +{% block main %} + {% include 'horizon/common/_workflow.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/create.html b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/create.html new file mode 100644 index 0000000000..1b03aa44dd --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/create.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Create Node Group Template" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Create Node Group Template") %} +{% endblock page_header %} + +{% block main %} + {% include 'horizon/common/_workflow.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/details.html b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/details.html new file mode 100644 index 0000000000..2877c30ce5 --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/details.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Nodegroup Template Details" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Node Group Template Details") %} +{% endblock page_header %} + +{% block main %} +
+
+ {{ tab_group.render }} +
+
+{% endblock %} \ No newline at end of file diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/nodegroup_templates.html b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/nodegroup_templates.html new file mode 100644 index 0000000000..da9214807f --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/templates/data_processing.nodegroup_templates/nodegroup_templates.html @@ -0,0 +1,97 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Data Processing" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Node Group Templates") %} +{% endblock page_header %} + +{% block main %} + +
+ {{ nodegroup_templates_table.render }} +
+ + + +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/tests.py b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/tests.py new file mode 100644 index 0000000000..b9c5420473 --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/tests.py @@ -0,0 +1,58 @@ +# 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 + + +INDEX_URL = reverse( + 'horizon:project:data_processing.nodegroup_templates:index') +DETAILS_URL = reverse( + 'horizon:project:data_processing.nodegroup_templates:details', + args=['id']) + + +class DataProcessingNodeGroupTests(test.TestCase): + @test.create_stubs({api.sahara: ('nodegroup_template_list',)}) + def test_index(self): + api.sahara.nodegroup_template_list(IsA(http.HttpRequest)) \ + .AndReturn(self.nodegroup_templates.list()) + self.mox.ReplayAll() + res = self.client.get(INDEX_URL) + self.assertTemplateUsed(res, + 'project/data_processing.nodegroup_templates/' + 'nodegroup_templates.html') + self.assertContains(res, 'Node Group Templates') + self.assertContains(res, 'Name') + self.assertContains(res, 'Plugin') + + @test.create_stubs({api.sahara: ('nodegroup_template_get',), + api.nova: ('flavor_get',)}) + def test_details(self): + flavor = self.flavors.first() + ngt = self.nodegroup_templates.first() + api.nova.flavor_get(IsA(http.HttpRequest), flavor.id).AndReturn(flavor) + api.sahara.nodegroup_template_get(IsA(http.HttpRequest), + IsA(unicode)) \ + .MultipleTimes().AndReturn(ngt) + self.mox.ReplayAll() + res = self.client.get(DETAILS_URL) + self.assertTemplateUsed(res, + 'project/data_processing.nodegroup_templates/' + 'details.html') + self.assertContains(res, 'sample-template') + self.assertContains(res, 'Template Overview') diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/urls.py b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/urls.py new file mode 100644 index 0000000000..2bcd21dd32 --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/urls.py @@ -0,0 +1,40 @@ +# 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 + +import openstack_dashboard.dashboards.project. \ + data_processing.nodegroup_templates.views as views + + +urlpatterns = patterns('sahara.nodegroup_templates.views', + url(r'^$', views.NodegroupTemplatesView.as_view(), + name='index'), + url(r'^nodegroup-templates$', + views.NodegroupTemplatesView.as_view(), + name='nodegroup-templates'), + url(r'^create-nodegroup-template$', + views.CreateNodegroupTemplateView.as_view(), + name='create-nodegroup-template'), + url(r'^configure-nodegroup-template$', + views.ConfigureNodegroupTemplateView.as_view(), + name='configure-nodegroup-template'), + url(r'^(?P[^/]+)$', + views.NodegroupTemplateDetailsView.as_view(), + name='details'), + url(r'^(?P[^/]+)/copy$', + views.CopyNodegroupTemplateView.as_view(), + name='copy') + ) diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/views.py b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/views.py new file mode 100644 index 0000000000..62fb88eeba --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/views.py @@ -0,0 +1,110 @@ +# 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 tables +from horizon import tabs +from horizon import workflows + +from openstack_dashboard.api import sahara as saharaclient + +import openstack_dashboard.dashboards.project.data_processing. \ + nodegroup_templates.tables as _tables +import openstack_dashboard.dashboards.project.data_processing. \ + nodegroup_templates.tabs as _tabs +import openstack_dashboard.dashboards.project.data_processing. \ + nodegroup_templates.workflows.copy as copy_flow +import openstack_dashboard.dashboards.project.data_processing. \ + nodegroup_templates.workflows.create as create_flow + +LOG = logging.getLogger(__name__) + + +class NodegroupTemplatesView(tables.DataTableView): + table_class = _tables.NodegroupTemplatesTable + template_name = ( + 'project/data_processing.nodegroup_templates/nodegroup_templates.html') + + def get_data(self): + try: + data = saharaclient.nodegroup_template_list(self.request) + except Exception: + data = [] + exceptions.handle(self.request, + _("Unable to fetch node group template list.")) + return data + + +class NodegroupTemplateDetailsView(tabs.TabView): + tab_group_class = _tabs.NodegroupTemplateDetailsTabs + template_name = 'project/data_processing.nodegroup_templates/details.html' + + def get_context_data(self, **kwargs): + context = super(NodegroupTemplateDetailsView, self)\ + .get_context_data(**kwargs) + return context + + def get_data(self): + pass + + +class CreateNodegroupTemplateView(workflows.WorkflowView): + workflow_class = create_flow.CreateNodegroupTemplate + success_url = ( + "horizon:project:data_processing.nodegroup_templates:" + "create-nodegroup-template") + classes = ("ajax-modal") + template_name = "project/data_processing.nodegroup_templates/create.html" + + +class ConfigureNodegroupTemplateView(workflows.WorkflowView): + workflow_class = create_flow.ConfigureNodegroupTemplate + success_url = "horizon:project:data_processing.nodegroup_templates" + template_name = ( + "project/data_processing.nodegroup_templates/configure.html") + + +class CopyNodegroupTemplateView(workflows.WorkflowView): + workflow_class = copy_flow.CopyNodegroupTemplate + success_url = "horizon:project:data_processing.nodegroup_templates" + template_name = ( + "project/data_processing.nodegroup_templates/configure.html") + + def get_context_data(self, **kwargs): + context = super(CopyNodegroupTemplateView, self)\ + .get_context_data(**kwargs) + + context["template_id"] = kwargs["template_id"] + return context + + def get_object(self, *args, **kwargs): + if not hasattr(self, "_object"): + template_id = self.kwargs['template_id'] + try: + template = saharaclient.nodegroup_template_get(self.request, + template_id) + except Exception: + template = None + exceptions.handle(self.request, + _("Unable to fetch template object.")) + self._object = template + return self._object + + def get_initial(self): + initial = super(CopyNodegroupTemplateView, self).get_initial() + initial['template_id'] = self.kwargs['template_id'] + return initial diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/workflows/__init__.py b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/workflows/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/workflows/copy.py b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/workflows/copy.py new file mode 100644 index 0000000000..f09a20959c --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/workflows/copy.py @@ -0,0 +1,86 @@ +# 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 openstack_dashboard.api import sahara as saharaclient + +import openstack_dashboard.dashboards.project.data_processing. \ + nodegroup_templates.workflows.create as create_flow + +LOG = logging.getLogger(__name__) + + +class CopyNodegroupTemplate(create_flow.ConfigureNodegroupTemplate): + success_message = _("Node Group Template copy %s created") + + def __init__(self, request, context_seed, entry_point, *args, **kwargs): + template_id = context_seed["template_id"] + template = saharaclient.nodegroup_template_get(request, template_id) + self._set_configs_to_copy(template.node_configs) + + plugin = template.plugin_name + hadoop_version = template.hadoop_version + + request.GET = request.GET.copy() + request.GET.update( + {"plugin_name": plugin, "hadoop_version": hadoop_version}) + + super(CopyNodegroupTemplate, self).__init__(request, context_seed, + entry_point, *args, + **kwargs) + + for step in self.steps: + if not isinstance(step, create_flow.GeneralConfig): + continue + fields = step.action.fields + + fields["nodegroup_name"].initial = template.name + "-copy" + fields["description"].initial = template.description + fields["flavor"].initial = template.flavor_id + + storage = "cinder_volume" if template.volumes_per_node > 0 \ + else "ephemeral_drive" + volumes_per_node = template.volumes_per_node + volumes_size = template.volumes_size + fields["storage"].initial = storage + fields["volumes_per_node"].initial = volumes_per_node + fields["volumes_size"].initial = volumes_size + + if template.floating_ip_pool: + fields['floating_ip_pool'].initial = template.floating_ip_pool + + processes_dict = dict() + try: + plugin_details = saharaclient.plugin_get_version_details( + request, + plugin, + hadoop_version) + plugin_node_processes = plugin_details.node_processes + except Exception: + plugin_node_processes = dict() + exceptions.handle(request, + _("Unable to fetch plugin details.")) + for process in template.node_processes: + #need to know the service + _service = None + for service, processes in plugin_node_processes.items(): + if process in processes: + _service = service + break + processes_dict["%s:%s" % (_service, process)] = process + fields["processes"].initial = processes_dict diff --git a/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/workflows/create.py b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/workflows/create.py new file mode 100644 index 0000000000..da7a0074e6 --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/nodegroup_templates/workflows/create.py @@ -0,0 +1,297 @@ +# 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 horizon import forms +import logging + +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import workflows + +from openstack_dashboard.api import network +from openstack_dashboard.api import nova +from openstack_dashboard.api import sahara as saharaclient + +import openstack_dashboard.dashboards.project.data_processing. \ + utils.helpers as helpers +import openstack_dashboard.dashboards.project.data_processing. \ + utils.workflow_helpers as whelpers + +from saharaclient.api import base as api_base + + +LOG = logging.getLogger(__name__) + + +class GeneralConfigAction(workflows.Action): + nodegroup_name = forms.CharField(label=_("Template Name")) + + description = forms.CharField(label=_("Description"), + required=False, + widget=forms.Textarea) + + flavor = forms.ChoiceField(label=_("OpenStack Flavor")) + + storage = forms.ChoiceField( + label=_("Storage location"), + help_text=_("Storage"), + choices=[("ephemeral_drive", "Ephemeral Drive"), + ("cinder_volume", "Cinder Volume")], + widget=forms.Select(attrs={"class": "storage_field"})) + + volumes_per_node = forms.IntegerField( + label=_("Volumes per node"), + required=False, + initial=1, + widget=forms.TextInput(attrs={"class": "volume_per_node_field"}) + ) + + volumes_size = forms.IntegerField( + label=_("Volumes size (GB)"), + required=False, + initial=10, + widget=forms.TextInput(attrs={"class": "volume_size_field"}) + ) + + hidden_configure_field = forms.CharField( + required=False, + widget=forms.HiddenInput(attrs={"class": "hidden_configure_field"})) + + def __init__(self, request, *args, **kwargs): + super(GeneralConfigAction, self).__init__(request, *args, **kwargs) + + sahara = saharaclient.client(request) + hlps = helpers.Helpers(sahara) + + plugin, hadoop_version = whelpers.\ + get_plugin_and_hadoop_version(request) + process_choices = [] + try: + version_details = saharaclient.plugin_get_version_details(request, + plugin, + hadoop_version) + for service, processes in version_details.node_processes.items(): + for process in processes: + process_choices.append( + (str(service) + ":" + str(process), process)) + except Exception: + exceptions.handle(request, + _("Unable to generate process choices.")) + + if not saharaclient.SAHARA_AUTO_IP_ALLOCATION_ENABLED: + pools = network.floating_ip_pools_list(request) + pool_choices = [(pool.id, pool.name) for pool in pools] + pool_choices.insert(0, (None, "Do not assign floating IPs")) + + self.fields['floating_ip_pool'] = forms.ChoiceField( + label=_("Floating IP pool"), + choices=pool_choices, + required=False) + + self.fields["processes"] = forms.MultipleChoiceField( + label=_("Processes"), + widget=forms.CheckboxSelectMultiple(), + help_text=_("Processes to be launched in node group"), + choices=process_choices) + + self.fields["plugin_name"] = forms.CharField( + widget=forms.HiddenInput(), + initial=plugin + ) + self.fields["hadoop_version"] = forms.CharField( + widget=forms.HiddenInput(), + initial=hadoop_version + ) + + node_parameters = hlps.get_general_node_group_configs(plugin, + hadoop_version) + for param in node_parameters: + self.fields[param.name] = whelpers.build_control(param) + + def populate_flavor_choices(self, request, context): + try: + flavors = nova.flavor_list(request) + flavor_list = [(flavor.id, "%s" % flavor.name) + for flavor in flavors] + except Exception: + flavor_list = [] + exceptions.handle(request, + _('Unable to retrieve instance flavors.')) + return sorted(flavor_list) + + def get_help_text(self): + extra = dict() + plugin, hadoop_version = whelpers.\ + get_plugin_and_hadoop_version(self.request) + extra["plugin_name"] = plugin + extra["hadoop_version"] = hadoop_version + return super(GeneralConfigAction, self).get_help_text(extra) + + class Meta: + name = _("Configure Node Group Template") + help_text_template = ( + "project/data_processing.nodegroup_templates" + "/_configure_general_help.html") + + +class GeneralConfig(workflows.Step): + action_class = GeneralConfigAction + contributes = ("general_nodegroup_name", ) + + def contribute(self, data, context): + for k, v in data.items(): + if "hidden" in k: + continue + context["general_" + k] = v if v != "None" else None + + post = self.workflow.request.POST + context['general_processes'] = post.getlist("processes") + return context + + +class ConfigureNodegroupTemplate(whelpers.ServiceParametersWorkflow, + whelpers.StatusFormatMixin): + slug = "configure_nodegroup_template" + name = _("Create Node Group Template") + finalize_button_name = _("Create") + success_message = _("Created Node Group Template %s") + name_property = "general_nodegroup_name" + success_url = "horizon:project:data_processing.nodegroup_templates:index" + default_steps = (GeneralConfig,) + + def __init__(self, request, context_seed, entry_point, *args, **kwargs): + sahara = saharaclient.client(request) + hlps = helpers.Helpers(sahara) + + plugin, hadoop_version = whelpers.\ + get_plugin_and_hadoop_version(request) + + general_parameters = hlps.get_general_node_group_configs( + plugin, + hadoop_version) + service_parameters = hlps.get_targeted_node_group_configs( + plugin, + hadoop_version) + + self._populate_tabs(general_parameters, service_parameters) + + super(ConfigureNodegroupTemplate, self).__init__(request, + context_seed, + entry_point, + *args, **kwargs) + + def is_valid(self): + missing = self.depends_on - set(self.context.keys()) + if missing: + raise exceptions.WorkflowValidationError( + "Unable to complete the workflow. The values %s are " + "required but not present." % ", ".join(missing)) + checked_steps = [] + + if "general_processes" in self.context: + checked_steps = self.context["general_processes"] + enabled_services = set([]) + for process_name in checked_steps: + enabled_services.add(str(process_name).split(":")[0]) + + steps_valid = True + for step in self.steps: + process_name = str(getattr(step, "process_name", None)) + if process_name not in enabled_services and \ + not isinstance(step, GeneralConfig): + continue + if not step.action.is_valid(): + steps_valid = False + step.has_errors = True + if not steps_valid: + return steps_valid + return self.validate(self.context) + + def handle(self, request, context): + try: + processes = [] + for service_process in context["general_processes"]: + processes.append(str(service_process).split(":")[1]) + + configs_dict = whelpers.parse_configs_from_context(context, + self.defaults) + + plugin, hadoop_version = whelpers.\ + get_plugin_and_hadoop_version(request) + + volumes_per_node = None + volumes_size = None + + if context["general_storage"] == "cinder_volume": + volumes_per_node = context["general_volumes_per_node"] + volumes_size = context["general_volumes_size"] + + saharaclient.nodegroup_template_create( + request, + name=context["general_nodegroup_name"], + plugin_name=plugin, + hadoop_version=hadoop_version, + description=context["general_description"], + flavor_id=context["general_flavor"], + volumes_per_node=volumes_per_node, + volumes_size=volumes_size, + node_processes=processes, + node_configs=configs_dict, + floating_ip_pool=context.get("general_floating_ip_pool", None)) + return True + except api_base.APIException as e: + self.error_description = str(e) + return False + except Exception: + exceptions.handle(request) + + +class SelectPluginAction(workflows.Action, + whelpers.PluginAndVersionMixin): + hidden_create_field = forms.CharField( + required=False, + widget=forms.HiddenInput(attrs={"class": "hidden_create_field"})) + + def __init__(self, request, *args, **kwargs): + super(SelectPluginAction, self).__init__(request, *args, **kwargs) + + sahara = saharaclient.client(request) + self._generate_plugin_version_fields(sahara) + + class Meta: + name = _("Select plugin and hadoop version") + help_text_template = ("project/data_processing.nodegroup_templates" + "/_create_general_help.html") + + +class SelectPlugin(workflows.Step): + action_class = SelectPluginAction + contributes = ("plugin_name", "hadoop_version") + + def contribute(self, data, context): + context = super(SelectPlugin, self).contribute(data, context) + context["plugin_name"] = data.get('plugin_name', None) + context["hadoop_version"] = \ + data.get(context["plugin_name"] + "_version", None) + return context + + +class CreateNodegroupTemplate(workflows.Workflow): + slug = "create_nodegroup_template" + name = _("Create Node Group Template") + finalize_button_name = _("Create") + success_message = _("Created") + failure_message = _("Could not create") + success_url = "horizon:project:data_processing.nodegroup_templates:index" + default_steps = (SelectPlugin,) diff --git a/openstack_dashboard/dashboards/project/data_processing/utils/__init__.py b/openstack_dashboard/dashboards/project/data_processing/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/project/data_processing/utils/helpers.py b/openstack_dashboard/dashboards/project/data_processing/utils/helpers.py new file mode 100644 index 0000000000..1a5f9d8e86 --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/utils/helpers.py @@ -0,0 +1,75 @@ +# 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 openstack_dashboard.dashboards.project.data_processing. \ + utils.workflow_helpers as work_helpers + + +class Helpers(object): + def __init__(self, sahara_client): + self.sahara = sahara_client + self.plugins = self.sahara.plugins + + def _get_node_processes(self, plugin): + processes = [] + for proc_lst in plugin.node_processes.values(): + processes += proc_lst + + return [(proc_name, proc_name) for proc_name in processes] + + def get_node_processes(self, plugin_name, hadoop_version): + plugin = self.plugins.get_version_details(plugin_name, hadoop_version) + + return self._get_node_processes(plugin) + + def _extract_parameters(self, configs, scope, applicable_target): + parameters = [] + for config in configs: + if (config['scope'] == scope and + config['applicable_target'] == applicable_target): + + parameters.append(work_helpers.Parameter(config)) + + return parameters + + def get_cluster_general_configs(self, plugin_name, hadoop_version): + plugin = self.plugins.get_version_details(plugin_name, hadoop_version) + + return self._extract_parameters(plugin.configs, 'cluster', "general") + + def get_general_node_group_configs(self, plugin_name, hadoop_version): + plugin = self.plugins.get_version_details(plugin_name, hadoop_version) + + return self._extract_parameters(plugin.configs, 'node', 'general') + + def get_targeted_node_group_configs(self, plugin_name, hadoop_version): + plugin = self.plugins.get_version_details(plugin_name, hadoop_version) + + parameters = {} + + for service in plugin.node_processes.keys(): + parameters[service] = self._extract_parameters(plugin.configs, + 'node', service) + + return parameters + + def get_targeted_cluster_configs(self, plugin_name, hadoop_version): + plugin = self.plugins.get_version_details(plugin_name, hadoop_version) + + parameters = {} + + for service in plugin.node_processes.keys(): + parameters[service] = self._extract_parameters(plugin.configs, + 'cluster', service) + + return parameters diff --git a/openstack_dashboard/dashboards/project/data_processing/utils/workflow_helpers.py b/openstack_dashboard/dashboards/project/data_processing/utils/workflow_helpers.py new file mode 100644 index 0000000000..e058eabdba --- /dev/null +++ b/openstack_dashboard/dashboards/project/data_processing/utils/workflow_helpers.py @@ -0,0 +1,259 @@ +# 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 forms +from horizon import workflows + + +class Parameter(object): + def __init__(self, config): + self.name = config['name'] + self.description = config.get('description', "No description") + self.required = not config['is_optional'] + self.default_value = config.get('default_value', None) + self.initial_value = self.default_value + self.param_type = config['config_type'] + self.priority = int(config.get('priority', 2)) + + +def build_control(parameter): + attrs = {"priority": parameter.priority, + "placeholder": parameter.default_value} + if parameter.param_type == "string": + return forms.CharField( + widget=forms.TextInput(attrs=attrs), + label=parameter.name, + required=(parameter.required and + parameter.default_value is None), + help_text=parameter.description, + initial=parameter.initial_value) + + if parameter.param_type == "int": + return forms.IntegerField( + widget=forms.TextInput(attrs=attrs), + label=parameter.name, + required=parameter.required, + help_text=parameter.description, + initial=parameter.initial_value) + + elif parameter.param_type == "bool": + return forms.BooleanField( + widget=forms.CheckboxInput(attrs=attrs), + label=parameter.name, + required=False, + initial=parameter.initial_value, + help_text=parameter.description) + + elif parameter.param_type == "dropdown": + return forms.ChoiceField( + widget=forms.CheckboxInput(attrs=attrs), + label=parameter.name, + required=parameter.required, + choices=parameter.choices, + help_text=parameter.description) + + +def _create_step_action(name, title, parameters, advanced_fields=None, + service=None): + class_fields = {} + contributes_field = () + for param in parameters: + field_name = "CONF:" + service + ":" + param.name + contributes_field += (field_name,) + class_fields[field_name] = build_control(param) + + if advanced_fields is not None: + for ad_field_name, ad_field_value in advanced_fields: + class_fields[ad_field_name] = ad_field_value + + action_meta = type('Meta', (object, ), + dict(help_text_template=("project" + "/data_processing." + "nodegroup_templates/" + "_fields_help.html"))) + + class_fields['Meta'] = action_meta + action = type(str(title), + (workflows.Action,), + class_fields) + + step_meta = type('Meta', (object,), dict(name=title)) + step = type(str(name), + (workflows.Step, ), + dict(name=name, + process_name=name, + action_class=action, + contributes=contributes_field, + Meta=step_meta)) + + return step + + +def build_node_group_fields(action, name, template, count): + action.fields[name] = forms.CharField( + label=_("Name"), + required=True, + widget=forms.TextInput()) + + action.fields[template] = forms.CharField( + label=_("Node group cluster"), + required=True, + widget=forms.HiddenInput()) + + action.fields[count] = forms.IntegerField( + label=_("Count"), + required=True, + min_value=0, + widget=forms.HiddenInput()) + + +def parse_configs_from_context(context, defaults): + configs_dict = dict() + for key, val in context.items(): + if str(key).startswith("CONF"): + key_split = str(key).split(":") + service = key_split[1] + config = key_split[2] + if service not in configs_dict: + configs_dict[service] = dict() + if (val is None or + unicode(defaults[service][config]) == unicode(val)): + continue + configs_dict[service][config] = val + return configs_dict + + +def safe_call(func, *args, **kwargs): + try: + return func(*args, **kwargs) + except Exception: + return None + + +def get_plugin_and_hadoop_version(request): + plugin_name = request.REQUEST["plugin_name"] + hadoop_version = request.REQUEST["hadoop_version"] + return (plugin_name, hadoop_version) + + +class PluginAndVersionMixin(object): + def _generate_plugin_version_fields(self, sahara): + plugins = sahara.plugins.list() + plugin_choices = [(plugin.name, plugin.title) for plugin in plugins] + + self.fields["plugin_name"] = forms.ChoiceField( + label=_("Plugin Name"), + required=True, + choices=plugin_choices, + widget=forms.Select(attrs={"class": "plugin_name_choice"})) + + for plugin in plugins: + field_name = plugin.name + "_version" + choice_field = forms.ChoiceField( + label=_("Hadoop Version"), + required=True, + choices=[(version, version) for version in plugin.versions], + widget=forms.Select( + attrs={"class": "plugin_version_choice " + + field_name + "_choice"}) + ) + self.fields[field_name] = choice_field + + +class PatchedDynamicWorkflow(workflows.Workflow): + """Overrides Workflow to fix its issues.""" + + def _ensure_dynamic_exist(self): + if not hasattr(self, 'dynamic_steps'): + self.dynamic_steps = list() + + def _register_step(self, step): + # Use that method instead of 'register' to register step. + # Note that a step could be registered in descendant class constructor + # only before this class constructor is invoked. + self._ensure_dynamic_exist() + self.dynamic_steps.append(step) + + def _order_steps(self): + # overrides method of Workflow + # crutch to fix https://bugs.launchpad.net/horizon/+bug/1196717 + # and another not filed issue that dynamic creation of tabs is + # not thread safe + self._ensure_dynamic_exist() + + self._registry = dict([(step, step(self)) + for step in self.dynamic_steps]) + + return list(self.default_steps) + self.dynamic_steps + + +class ServiceParametersWorkflow(PatchedDynamicWorkflow): + """Base class for Workflows having services tabs with parameters.""" + + def _populate_tabs(self, general_parameters, service_parameters): + # Populates tabs for 'general' and service parameters + # Also populates defaults and initial values + self.defaults = dict() + + self._init_step('general', 'General Parameters', general_parameters) + + for service, parameters in service_parameters.items(): + self._init_step(service, service + ' Parameters', parameters) + + def _init_step(self, service, title, parameters): + if not parameters: + return + + self._populate_initial_values(service, parameters) + + step = _create_step_action(service, title=title, parameters=parameters, + service=service) + + self.defaults[service] = dict() + for param in parameters: + self.defaults[service][param.name] = param.default_value + + self._register_step(step) + + def _set_configs_to_copy(self, configs): + self.configs_to_copy = configs + + def _populate_initial_values(self, service, parameters): + if not hasattr(self, 'configs_to_copy'): + return + + configs = self.configs_to_copy + + for param in parameters: + if (service in configs and + param.name in configs[service]): + param.initial_value = configs[service][param.name] + + +class StatusFormatMixin(workflows.Workflow): + def __init__(self, request, context_seed, entry_point, *args, **kwargs): + super(StatusFormatMixin, self).__init__(request, + context_seed, + entry_point, + *args, + **kwargs) + + def format_status_message(self, message): + error_description = getattr(self, 'error_description', None) + + if error_description: + return error_description + else: + return message % self.context[self.name_property] diff --git a/openstack_dashboard/test/test_data/sahara_data.py b/openstack_dashboard/test/test_data/sahara_data.py index 382937e6ab..a59d192331 100644 --- a/openstack_dashboard/test/test_data/sahara_data.py +++ b/openstack_dashboard/test/test_data/sahara_data.py @@ -12,11 +12,13 @@ from openstack_dashboard.test.test_data import utils +from saharaclient.api import node_group_templates from saharaclient.api import plugins def data(TEST): TEST.plugins = utils.TestDataContainer() + TEST.nodegroup_templates = utils.TestDataContainer() plugin1_dict = { "description": "vanilla plugin", @@ -28,3 +30,34 @@ def data(TEST): plugin1 = plugins.Plugin(plugins.PluginManager(None), plugin1_dict) TEST.plugins.add(plugin1) + + #Nodegroup_Templates + ngt1_dict = { + "created_at": "2014-06-04 14:01:03.701243", + "description": None, + "flavor_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "floating_ip_pool": None, + "hadoop_version": "1.2.1", + "id": "c166dfcc-9cc7-4b48-adc9-f0946169bb36", + "image_id": None, + "name": "sample-template", + "node_configs": {}, + "node_processes": [ + "namenode", + "jobtracker", + "secondarynamenode", + "hiveserver", + "oozie" + ], + "plugin_name": "vanilla", + "tenant_id": "429ad8447c2d47bc8e0382d244e1d1df", + "updated_at": None, + "volume_mount_prefix": "/volumes/disk", + "volumes_per_node": 0, + "volumes_size": 0 + } + + ngt1 = node_group_templates.NodeGroupTemplate( + node_group_templates.NodeGroupTemplateManager(None), ngt1_dict) + + TEST.nodegroup_templates.add(ngt1)