diff --git a/releasenotes/notes/conf-groups-7bc8115f8d0bcd14.yaml b/releasenotes/notes/conf-groups-7bc8115f8d0bcd14.yaml
new file mode 100644
index 0000000..181f33d
--- /dev/null
+++ b/releasenotes/notes/conf-groups-7bc8115f8d0bcd14.yaml
@@ -0,0 +1,7 @@
+---
+features:
+ - Support configuration groups in the dashboard. This
+ includes creating and deleting groups; adding,
+ editing and removing parameters; attaching and
+ detaching groups to running instances; and specifying
+ a group during instance creation.
diff --git a/trove_dashboard/api/trove.py b/trove_dashboard/api/trove.py
index 5ee69b8..0263fb8 100644
--- a/trove_dashboard/api/trove.py
+++ b/trove_dashboard/api/trove.py
@@ -135,7 +135,7 @@ def instance_create(request, name, volume, flavor, databases=None,
users=None, restore_point=None, nics=None,
datastore=None, datastore_version=None,
replica_of=None, replica_count=None,
- volume_type=None):
+ volume_type=None, configuration=None):
# TODO(dklyle): adding conditional to support trove without volume
# support for now until API supports checking for volume support
if volume > 0:
@@ -155,7 +155,8 @@ def instance_create(request, name, volume, flavor, databases=None,
datastore=datastore,
datastore_version=datastore_version,
replica_of=replica_of,
- replica_count=replica_count)
+ replica_count=replica_count,
+ configuration=configuration)
def instance_resize_volume(request, instance_id, size):
@@ -189,6 +190,15 @@ def eject_replica_source(request, instance_id):
return troveclient(request).instances.eject_replica_source(instance_id)
+def instance_attach_configuration(request, instance_id, configuration):
+ return troveclient(request).instances.modify(instance_id,
+ configuration=configuration)
+
+
+def instance_detach_configuration(request, instance_id):
+ return troveclient(request).instances.modify(instance_id)
+
+
def database_list(request, instance_id):
return troveclient(request).databases.list(instance_id)
@@ -338,3 +348,45 @@ def log_tail(request, instance_id, log_name, publish, lines, swift=None):
publish=publish,
lines=lines,
swift=swift)
+
+
+def configuration_list(request):
+ return troveclient(request).configurations.list()
+
+
+def configuration_get(request, group_id):
+ return troveclient(request).configurations.get(group_id)
+
+
+def configuration_parameters_list(request, datastore, datastore_version):
+ return troveclient(request).configuration_parameters.parameters(
+ datastore, datastore_version)
+
+
+def configuration_create(request,
+ name,
+ values,
+ description=None,
+ datastore=None,
+ datastore_version=None):
+ return troveclient(request).configurations.create(name,
+ values,
+ description,
+ datastore,
+ datastore_version)
+
+
+def configuration_delete(request, group_id):
+ return troveclient(request).configurations.delete(group_id)
+
+
+def configuration_instances(request, group_id):
+ return troveclient(request).configurations.instances(group_id)
+
+
+def configuration_update(request, group_id, values):
+ return troveclient(request).configurations.update(group_id, values)
+
+
+def configuration_default(request, instance_id):
+ return troveclient(request).instances.configuration(instance_id)
diff --git a/trove_dashboard/content/database_configurations/__init__.py b/trove_dashboard/content/database_configurations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/trove_dashboard/content/database_configurations/config_param_manager.py b/trove_dashboard/content/database_configurations/config_param_manager.py
new file mode 100644
index 0000000..fe2ec90
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/config_param_manager.py
@@ -0,0 +1,193 @@
+# Copyright 2015 Tesora Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+
+from django.core import cache
+from django.utils.translation import ugettext_lazy as _
+
+from trove_dashboard import api
+
+from oslo_serialization import jsonutils
+
+
+def get(request, configuration_group_id):
+ if not has_config(configuration_group_id):
+ manager = ConfigParamManager(configuration_group_id)
+ manager.configuration_get(request)
+ cache.cache.set(configuration_group_id, manager)
+
+ return cache.cache.get(configuration_group_id)
+
+
+def delete(configuration_group_id):
+ cache.cache.delete(configuration_group_id)
+
+
+def update(configuration_group_id, manager):
+ cache.cache.set(configuration_group_id, manager)
+
+
+def has_config(configuration_group_id):
+ if cache.cache.get(configuration_group_id):
+ return True
+ else:
+ return False
+
+
+def dict_has_changes(original, other):
+ if len(other) != len(original):
+ return True
+
+ diffs = (set(original.keys()) - set(other.keys()))
+ if len(diffs).__nonzero__():
+ return True
+
+ for key in original:
+ if original[key] != other[key]:
+ return True
+
+ return False
+
+
+class ConfigParamManager(object):
+
+ original_configuration_values = None
+ configuration = None
+
+ def __init__(self, configuration_id):
+ self.configuration_id = configuration_id
+
+ def configuration_get(self, request):
+ if self.configuration is None:
+ configuration = api.trove.configuration_get(
+ request, self.configuration_id)
+ # need to make one that can be cached
+ self.configuration = Configuration(
+ self.configuration_id,
+ configuration.name,
+ configuration.description,
+ configuration.datastore_name,
+ configuration.datastore_version_name,
+ configuration.created,
+ configuration.updated)
+ self.configuration.values = dict.copy(configuration.values)
+ self.original_configuration_values = dict.copy(
+ self.configuration.values)
+
+ return self.get_configuration()
+
+ def get_configuration(self):
+ return self.configuration
+
+ def create_config_value(self, name, value):
+ return ConfigParam(self.configuration_id, name, value)
+
+ def get_param(self, name):
+ for key_name in self.configuration.values:
+ if key_name == name:
+ return self.create_config_value(
+ key_name, self.configuration.values[key_name])
+ return None
+
+ def update_param(self, name, value):
+ self.configuration.values[name] = value
+ update(self.configuration_id, self)
+
+ def delete_param(self, name):
+ del self.configuration.values[name]
+ update(self.configuration_id, self)
+
+ def add_param(self, name, value):
+ self.update_param(name, value)
+
+ def to_json(self):
+ return jsonutils.dumps(self.configuration.values)
+
+ def has_changes(self):
+ return dict_has_changes(self.original_configuration_values,
+ self.configuration.values)
+
+
+class ConfigParam(object):
+ def __init__(self, configuration_id, name, value):
+ self.configuration_id = configuration_id
+ self.name = name
+ self.value = value
+
+
+class Configuration(object):
+ def __init__(self, id, name, description, datastore_name,
+ datastore_version_name, created, updated):
+ self.id = id
+ self.name = name
+ self.description = description
+ self.datastore_name = datastore_name
+ self.datastore_version_name = datastore_version_name
+ self.created = created
+ self.updated = updated
+
+
+def validate_config_param_value(config_param, value):
+ if (config_param.type in (u"boolean", u"float", u"integer", u"long")):
+ if config_param.type == u"boolean":
+ if (value.lower() not in ("true", "false")):
+ return _('Value must be "true" or "false".')
+ else:
+ try:
+ float(value)
+ except ValueError:
+ return _('Value must be a number.')
+
+ min = getattr(config_param, "min", None)
+ max = getattr(config_param, "max", None)
+ try:
+ val = adjust_type(config_param.type, value)
+ except ValueError:
+ return (_('Value must be of type %s.') % config_param.type)
+
+ if min is not None and max is not None:
+ if val < min or val > max:
+ return (_('Value must be a number '
+ 'between %(min)s and %(max)s.') %
+ {"min": min, "max": max})
+ elif min is not None:
+ if val < min:
+ return _('Value must be a number greater '
+ 'than or equal to %s.') % min
+ elif max is not None:
+ if val > max:
+ return _('Value must be a number '
+ 'less than or equal to %s.') % max
+ return None
+
+
+def find_parameter(name, config_params):
+ for param in config_params:
+ if param.name == name:
+ return param
+ return None
+
+
+def adjust_type(data_type, value):
+ if not value:
+ return value
+ if data_type == "float":
+ new_value = float(value)
+ elif data_type == "long":
+ new_value = long(value)
+ elif data_type == "integer":
+ new_value = int(value)
+ else:
+ new_value = value
+ return new_value
diff --git a/trove_dashboard/content/database_configurations/forms.py b/trove_dashboard/content/database_configurations/forms.py
new file mode 100644
index 0000000..f77cd0f
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/forms.py
@@ -0,0 +1,190 @@
+# Copyright 2015 Tesora Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import logging
+
+from django.core.urlresolvers import reverse
+from django.utils.translation import ugettext_lazy as _
+
+from horizon import exceptions
+from horizon import forms
+from horizon import messages
+from horizon.utils import memoized
+
+from trove_dashboard import api
+from trove_dashboard.content.database_configurations \
+ import config_param_manager
+
+LOG = logging.getLogger(__name__)
+
+
+class CreateConfigurationForm(forms.SelfHandlingForm):
+ name = forms.CharField(label=_("Name"))
+ description = forms.CharField(label=_("Description"), required=False)
+ datastore = forms.ChoiceField(
+ label=_("Datastore"),
+ help_text=_("Type and version of datastore."))
+
+ def __init__(self, request, *args, **kwargs):
+ super(CreateConfigurationForm, self).__init__(request, *args, **kwargs)
+
+ choices = self.get_datastore_choices(request)
+ self.fields['datastore'].choices = choices
+
+ @memoized.memoized_method
+ def datastores(self, request):
+ try:
+ return api.trove.datastore_list(request)
+ except Exception:
+ LOG.exception("Exception while obtaining datastores list")
+ redirect = reverse('horizon:project:database_configurations:index')
+ exceptions.handle(request,
+ _('Unable to obtain datastores.'),
+ redirect=redirect)
+
+ @memoized.memoized_method
+ def datastore_versions(self, request, datastore):
+ try:
+ return api.trove.datastore_version_list(request, datastore)
+ except Exception:
+ LOG.exception("Exception while obtaining datastore version list")
+ redirect = reverse('horizon:project:database_configurations:index')
+ exceptions.handle(request,
+ _('Unable to obtain datastore versions.'),
+ redirect=redirect)
+
+ def get_datastore_choices(self, request):
+ choices = ()
+ set_initial = False
+ datastores = self.datastores(request)
+ if datastores is not None:
+ num_datastores_with_one_version = 0
+ for ds in datastores:
+ versions = self.datastore_versions(request, ds.name)
+ if not set_initial:
+ if len(versions) >= 2:
+ set_initial = True
+ elif len(versions) == 1:
+ num_datastores_with_one_version += 1
+ if num_datastores_with_one_version > 1:
+ set_initial = True
+ if len(versions) > 0:
+ # only add to choices if datastore has at least one version
+ version_choices = ()
+ for v in versions:
+ version_choices = (version_choices +
+ ((ds.name + ',' + v.name, v.name),))
+ datastore_choices = (ds.name, version_choices)
+ choices = choices + (datastore_choices,)
+ if set_initial:
+ # prepend choice to force user to choose
+ initial = ('', _('Select datastore type and version'))
+ choices = (initial,) + choices
+ return choices
+
+ def handle(self, request, data):
+ try:
+ datastore = data['datastore'].split(',')[0]
+ datastore_version = data['datastore'].split(',')[1]
+
+ api.trove.configuration_create(request, data['name'], "{}",
+ description=data['description'],
+ datastore=datastore,
+ datastore_version=datastore_version)
+
+ messages.success(request, _('Created configuration group'))
+ except Exception as e:
+ redirect = reverse("horizon:project:database_configurations:index")
+ exceptions.handle(request, _('Unable to create configuration '
+ 'group. %s')
+ % e.message, redirect=redirect)
+ return True
+
+
+class AddParameterForm(forms.SelfHandlingForm):
+ name = forms.ChoiceField(label=_("Name"))
+ value = forms.CharField(label=_("Value"))
+
+ def __init__(self, request, *args, **kwargs):
+ super(AddParameterForm, self).__init__(request, *args, **kwargs)
+
+ configuration = (config_param_manager
+ .get(request, kwargs["initial"]["configuration_id"])
+ .get_configuration())
+
+ self.fields['name'].choices = self.get_parameters(
+ request, configuration.datastore_name,
+ configuration.datastore_version_name)
+
+ self.fields['value'].parameters = self.parameters
+
+ @memoized.memoized_method
+ def parameters(self, request, datastore, datastore_version):
+ try:
+ return api.trove.configuration_parameters_list(
+ request, datastore, datastore_version)
+ except Exception:
+ LOG.exception(
+ "Exception while obtaining configuration parameter list")
+ redirect = reverse('horizon:project:database_configurations:index')
+ exceptions.handle(request,
+ _('Unable to obtain list of parameters.'),
+ redirect=redirect)
+
+ def get_parameters(self, request, datastore, datastore_version):
+ try:
+ choices = []
+
+ self.parameters = self.parameters(
+ request, datastore, datastore_version)
+ for parameter in self.parameters:
+ choices.append((parameter.name, parameter.name))
+
+ return sorted(choices)
+ except Exception:
+ LOG.exception(
+ "Exception while obtaining configuration parameters list")
+ redirect = reverse('horizon:project:database_configurations:index')
+ exceptions.handle(request,
+ _('Unable to create list of parameters.'),
+ redirect=redirect)
+
+ def clean(self):
+ cleaned_data = super(AddParameterForm, self).clean()
+
+ if "value" in cleaned_data:
+ config_param = config_param_manager.find_parameter(
+ cleaned_data["name"], self.parameters)
+ if config_param:
+ error_msg = config_param_manager.validate_config_param_value(
+ config_param, cleaned_data["value"])
+ if error_msg:
+ self._errors['value'] = self.error_class([error_msg])
+ return cleaned_data
+
+ def handle(self, request, data):
+ try:
+ (config_param_manager
+ .get(request, self.initial["configuration_id"])
+ .add_param(data["name"],
+ config_param_manager.adjust_type(
+ config_param_manager.find_parameter(
+ data["name"], self.parameters).type,
+ data["value"])))
+ messages.success(request, _('Successfully added parameter'))
+ except Exception as e:
+ redirect = reverse("horizon:project:database_configurations:index")
+ exceptions.handle(request, _('Unable to add new parameter: %s')
+ % e.message, redirect=redirect)
+ return True
diff --git a/trove_dashboard/content/database_configurations/panel.py b/trove_dashboard/content/database_configurations/panel.py
new file mode 100644
index 0000000..37e5f8b
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/panel.py
@@ -0,0 +1,27 @@
+# Copyright 2015 Tesora Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from django.utils.translation import ugettext_lazy as _
+
+import horizon
+from openstack_dashboard.dashboards.project import dashboard
+
+
+class Configurations(horizon.Panel):
+ name = _("Configuration Groups")
+ slug = 'database_configurations'
+ permissions = ('openstack.services.database',)
+
+
+dashboard.Project.register(Configurations)
diff --git a/trove_dashboard/content/database_configurations/tables.py b/trove_dashboard/content/database_configurations/tables.py
new file mode 100644
index 0000000..5365337
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/tables.py
@@ -0,0 +1,256 @@
+# Copyright 2015 Tesora Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import logging
+import types
+
+from django.core import exceptions as core_exceptions
+from django.core import urlresolvers
+from django import shortcuts
+from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import ungettext_lazy
+
+from horizon import forms
+from horizon import messages
+from horizon import tables
+from horizon.utils import memoized
+
+from trove_dashboard import api
+from trove_dashboard.content.database_configurations \
+ import config_param_manager
+
+LOG = logging.getLogger(__name__)
+
+
+class CreateConfiguration(tables.LinkAction):
+ name = "create_configuration"
+ verbose_name = _("Create Configuration Group")
+ url = "horizon:project:database_configurations:create"
+ classes = ('ajax-modal', )
+ icon = "plus"
+
+
+class DeleteConfiguration(tables.DeleteAction):
+ data_type_singular = _("Configuration Group")
+ data_type_plural = _("Configuration Groups")
+
+ def delete(self, request, obj_id):
+ api.trove.configuration_delete(request, obj_id)
+
+
+class ConfigurationsTable(tables.DataTable):
+ name = tables.Column(
+ 'name',
+ verbose_name=_('Configuration Group Name'),
+ link="horizon:project:database_configurations:detail")
+ description = tables.Column(
+ lambda obj: getattr(obj, 'description', None),
+ verbose_name=_('Description'))
+ datastore = tables.Column(
+ 'datastore_name',
+ verbose_name=_('Datastore'))
+ datastore_version = tables.Column(
+ 'datastore_version_name',
+ verbose_name=_('Datastore Version'))
+
+ class Meta(object):
+ name = "configurations"
+ verbose_name = _("Configuration Groups")
+ table_actions = [CreateConfiguration, DeleteConfiguration]
+ row_actions = [DeleteConfiguration]
+
+
+class AddParameter(tables.LinkAction):
+ name = "add_parameter"
+ verbose_name = _("Add Parameter")
+ url = "horizon:project:database_configurations:add"
+ classes = ('ajax-modal', )
+ icon = "plus"
+
+ def get_link_url(self, datum=None):
+ configuration_id = self.table.kwargs['configuration_id']
+ return urlresolvers.reverse(self.url, args=[configuration_id])
+
+
+class ApplyChanges(tables.Action):
+ name = "apply_changes"
+ verbose_name = _("Apply Changes")
+ verbose_name_plural = _("Apply Changes")
+ icon = "pencil"
+
+ def __init__(self, **kwargs):
+ super(ApplyChanges, self).__init__(**kwargs)
+ self.requires_input = False
+
+ def handle(self, table, request, obj_ids):
+ configuration_id = table.kwargs['configuration_id']
+ if config_param_manager.get(request, configuration_id).has_changes():
+ try:
+ api.trove.configuration_update(
+ request, configuration_id,
+ config_param_manager.get(
+ request, configuration_id).to_json())
+ messages.success(request, _('Applied changes to server'))
+ except Exception:
+ messages.error(request, _('Error applying changes'))
+ finally:
+ config_param_manager.delete(configuration_id)
+
+ return shortcuts.redirect(request.build_absolute_uri())
+
+
+class DiscardChanges(tables.Action):
+ name = "discard_changes"
+ verbose_name = _("Discard Changes")
+ verbose_name_plural = _("Discard Changes")
+
+ def __init__(self, **kwargs):
+ super(DiscardChanges, self).__init__(**kwargs)
+ self.requires_input = False
+
+ def handle(self, table, request, obj_ids):
+ configuration_id = table.kwargs['configuration_id']
+ if config_param_manager.get(request, configuration_id).has_changes():
+ try:
+ config_param_manager.delete(configuration_id)
+ messages.success(request, _('Reset Parameters'))
+ except Exception as ex:
+ messages.error(
+ request,
+ _('Error resetting parameters: %s') % ex.message)
+
+ return shortcuts.redirect(request.build_absolute_uri())
+
+
+class DeleteParameter(tables.DeleteAction):
+ data_type_singular = _("Parameter")
+ data_type_plural = _("Parameters")
+
+ def delete(self, request, obj_ids):
+ configuration_id = self.table.kwargs['configuration_id']
+ (config_param_manager
+ .get(request, configuration_id)
+ .delete_param(obj_ids))
+
+
+class UpdateRow(tables.Row):
+ def get_data(self, request, name):
+ return config_param_manager.get(
+ request, self.table.kwargs["configuration_id"]).get_param(name)
+
+
+class UpdateCell(tables.UpdateAction):
+ def update_cell(self, request, datum, name,
+ cell_name, new_cell_value):
+ config_param = datum
+
+ config = config_param_manager.get(request,
+ config_param.configuration_id)
+ validation_param = config_param_manager.find_parameter(
+ name,
+ self.parameters(request,
+ config.configuration.datastore_name,
+ config.configuration.datastore_version_name))
+ if validation_param:
+ error_msg = config_param_manager.validate_config_param_value(
+ validation_param, new_cell_value)
+ if error_msg:
+ raise core_exceptions.ValidationError(error_msg)
+
+ if isinstance(config_param.value, types.IntType):
+ value = int(new_cell_value)
+ elif isinstance(config_param.value, types.LongType):
+ value = long(new_cell_value)
+ else:
+ value = new_cell_value
+
+ setattr(datum, cell_name, value)
+
+ (config_param_manager
+ .get(request, config_param.configuration_id)
+ .update_param(name, value))
+
+ return True
+
+ @memoized.memoized_method
+ def parameters(self, request, datastore, datastore_version):
+ return api.trove.configuration_parameters_list(
+ request, datastore, datastore_version)
+
+ def _adjust_type(self, data_type, value):
+ if not value:
+ return value
+ if data_type == "float":
+ new_value = float(value)
+ elif data_type == "long":
+ new_value = long(value)
+ elif data_type == "integer":
+ new_value = int(value)
+ else:
+ new_value = value
+ return new_value
+
+
+class ValuesTable(tables.DataTable):
+ name = tables.Column("name", verbose_name=_("Name"))
+ value = tables.Column("value", verbose_name=_("Value"),
+ form_field=forms.CharField(required=False),
+ update_action=UpdateCell)
+
+ class Meta(object):
+ name = "values"
+ verbose_name = _("Configuration Group Values")
+ table_actions = [ApplyChanges, DiscardChanges,
+ AddParameter, DeleteParameter]
+ row_class = UpdateRow
+ row_actions = [DeleteParameter]
+
+ def get_object_id(self, datum):
+ return datum.name
+
+
+class DetachConfiguration(tables.BatchAction):
+ @staticmethod
+ def action_present(count):
+ return ungettext_lazy(
+ u"Detach Configuration Group",
+ u"Detach Configuration Groups",
+ count
+ )
+
+ @staticmethod
+ def action_past(count):
+ return ungettext_lazy(
+ u"Detached Configuration Group",
+ u"Detached Configuration Groups",
+ count
+ )
+
+ name = "detach_configuration"
+ classes = ('btn-danger', 'btn-detach-config')
+
+ def action(self, request, obj_id):
+ api.trove.instance_detach_configuration(request, obj_id)
+
+
+class InstancesTable(tables.DataTable):
+ name = tables.Column("name",
+ link="horizon:project:databases:detail",
+ verbose_name=_("Name"))
+
+ class Meta(object):
+ name = "instances"
+ verbose_name = _("Configuration Group Instances")
+ multi_select = False
+ row_actions = [DetachConfiguration]
diff --git a/trove_dashboard/content/database_configurations/tabs.py b/trove_dashboard/content/database_configurations/tabs.py
new file mode 100644
index 0000000..7fb0e38
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/tabs.py
@@ -0,0 +1,73 @@
+# Copyright 2015 Tesora Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from django.utils.translation import ugettext_lazy as _
+
+from horizon import exceptions
+from horizon import tabs
+
+from trove_dashboard import api
+from trove_dashboard.content.database_configurations \
+ import config_param_manager
+from trove_dashboard.content.database_configurations \
+ import tables
+
+
+class DetailsTab(tabs.Tab):
+ name = _("Details")
+ slug = "details_tab"
+ template_name = "project/database_configurations/_detail_overview.html"
+
+ def get_context_data(self, request):
+ return {"configuration": self.tab_group.kwargs['configuration']}
+
+
+class ValuesTab(tabs.TableTab):
+ table_classes = [tables.ValuesTable]
+ name = _("Values")
+ slug = "values_tab"
+ template_name = "project/database_configurations/detail_param.html"
+
+ def get_values_data(self):
+ values_data = []
+ manager = config_param_manager.get(
+ self.request, self.tab_group.kwargs['configuration_id'])
+ for k, v in manager.get_configuration().values.items():
+ manager.add_param(k, v)
+ values_data.append(manager.create_config_value(k, v))
+ return values_data
+
+
+class InstancesTab(tabs.TableTab):
+ table_classes = [tables.InstancesTable]
+ name = _("Instances")
+ slug = "instances_tab"
+ template_name = "horizon/common/_detail_table.html"
+
+ def get_instances_data(self):
+ configuration = self.tab_group.kwargs['configuration']
+ try:
+ data = api.trove.configuration_instances(self.request,
+ configuration.id)
+ except Exception:
+ msg = _('Unable to get configuration data.')
+ exceptions.handle(self.request, msg)
+ data = []
+ return data
+
+
+class ConfigurationDetailTabs(tabs.TabGroup):
+ slug = "configuration_details"
+ tabs = (ValuesTab, InstancesTab, DetailsTab)
+ sticky = True
diff --git a/trove_dashboard/content/database_configurations/templates/database_configurations/_add_parameter.html b/trove_dashboard/content/database_configurations/templates/database_configurations/_add_parameter.html
new file mode 100644
index 0000000..e9d5aed
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/templates/database_configurations/_add_parameter.html
@@ -0,0 +1,6 @@
+{% extends "horizon/common/_modal_form.html" %}
+{% load i18n %}
+
+{% block modal-body-right %}
+
{% trans "Select a parameter and provide a value for the configuration parameter." %}
+{% endblock %}
diff --git a/trove_dashboard/content/database_configurations/templates/database_configurations/_create.html b/trove_dashboard/content/database_configurations/templates/database_configurations/_create.html
new file mode 100644
index 0000000..ff286cf
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/templates/database_configurations/_create.html
@@ -0,0 +1,9 @@
+{% extends "horizon/common/_modal_form.html" %}
+
+{% block modal-body %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/trove_dashboard/content/database_configurations/templates/database_configurations/_detail_overview.html b/trove_dashboard/content/database_configurations/templates/database_configurations/_detail_overview.html
new file mode 100644
index 0000000..3884b06
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/templates/database_configurations/_detail_overview.html
@@ -0,0 +1,24 @@
+{% load i18n sizeformat %}
+
+{% trans "Configuration Group Overview" %}
+
+
+
{% trans "Info" %}
+
+
+ - {% trans "Name" %}
+ - {{ configuration.name }}
+ - {% trans "Description" %}
+ - {{ configuration.description|linebreaksbr }}
+ - {% trans "ID" %}
+ - {{ configuration.id }}
+ - {% trans "Datastore" %}
+ - {{ configuration.datastore_name }}
+ - {% trans "Datastore Version" %}
+ - {{ configuration.datastore_version_name }}
+ - {% trans "Created" %}
+ - {{ configuration.created|parse_isotime }}
+ - {% trans "Updated" %}
+ - {{ configuration.updated|parse_isotime }}
+
+
diff --git a/trove_dashboard/content/database_configurations/templates/database_configurations/add_parameter.html b/trove_dashboard/content/database_configurations/templates/database_configurations/add_parameter.html
new file mode 100644
index 0000000..b8036bd
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/templates/database_configurations/add_parameter.html
@@ -0,0 +1,5 @@
+{% extends "base.html" %}
+
+{% block main %}
+ {% include "project/database_configurations/_add_parameter.html" %}
+{% endblock %}
diff --git a/trove_dashboard/content/database_configurations/templates/database_configurations/create.html b/trove_dashboard/content/database_configurations/templates/database_configurations/create.html
new file mode 100644
index 0000000..33a3683
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/templates/database_configurations/create.html
@@ -0,0 +1,5 @@
+{% extends "base.html" %}
+
+{% block main %}
+ {% include "project/database_configurations/_create.html" %}
+{% endblock %}
\ No newline at end of file
diff --git a/trove_dashboard/content/database_configurations/templates/database_configurations/detail_param.html b/trove_dashboard/content/database_configurations/templates/database_configurations/detail_param.html
new file mode 100644
index 0000000..5df8d50
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/templates/database_configurations/detail_param.html
@@ -0,0 +1,8 @@
+{% load i18n %}
+
+
+
+ {% trans "Add parameters to the configuration group. When all the parameters are added click 'Apply Changes' to persist changes." %}
+
+
+{{ table.render }}
diff --git a/trove_dashboard/content/database_configurations/templates/database_configurations/details.html b/trove_dashboard/content/database_configurations/templates/database_configurations/details.html
new file mode 100644
index 0000000..1090210
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/templates/database_configurations/details.html
@@ -0,0 +1,10 @@
+{% extends 'base.html' %}
+
+{% block main %}
+
+
+ {{ tab_group.render }}
+
+
+{% endblock %}
+
diff --git a/trove_dashboard/content/database_configurations/templates/database_configurations/index.html b/trove_dashboard/content/database_configurations/templates/database_configurations/index.html
new file mode 100644
index 0000000..fbc7378
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/templates/database_configurations/index.html
@@ -0,0 +1,5 @@
+{% extends 'base.html' %}
+
+{% block main %}
+ {{ table.render }}
+{% endblock %}
diff --git a/trove_dashboard/content/database_configurations/tests.py b/trove_dashboard/content/database_configurations/tests.py
new file mode 100644
index 0000000..b5630b2
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/tests.py
@@ -0,0 +1,540 @@
+# Copyright 2015 Tesora Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import copy
+import logging
+
+from django.core.urlresolvers import reverse
+from django import http
+from mox3.mox import IsA # noqa
+
+from trove_dashboard import api
+from trove_dashboard.content.database_configurations \
+ import config_param_manager
+from trove_dashboard.test import helpers as test
+
+
+INDEX_URL = reverse('horizon:project:database_configurations:index')
+CREATE_URL = reverse('horizon:project:database_configurations:create')
+DETAIL_URL = 'horizon:project:database_configurations:detail'
+ADD_URL = 'horizon:project:database_configurations:add'
+
+
+class DatabaseConfigurationsTests(test.TestCase):
+ @test.create_stubs({api.trove: ('configuration_list',)})
+ def test_index(self):
+ api.trove.configuration_list(IsA(http.HttpRequest)) \
+ .AndReturn(self.database_configurations.list())
+ self.mox.ReplayAll()
+ res = self.client.get(INDEX_URL)
+ self.assertTemplateUsed(res,
+ 'project/database_configurations/index.html')
+
+ @test.create_stubs({api.trove: ('configuration_list',)})
+ def test_index_exception(self):
+ api.trove.configuration_list(IsA(http.HttpRequest)) \
+ .AndRaise(self.exceptions.trove)
+ self.mox.ReplayAll()
+ res = self.client.get(INDEX_URL)
+ self.assertTemplateUsed(
+ res, 'project/database_configurations/index.html')
+ self.assertEqual(res.status_code, 200)
+ self.assertMessageCount(res, error=1)
+
+ @test.create_stubs({
+ api.trove: ('datastore_list', 'datastore_version_list')})
+ def test_create_configuration(self):
+ api.trove.datastore_list(IsA(http.HttpRequest)) \
+ .AndReturn(self.datastores.list())
+ api.trove.datastore_version_list(IsA(http.HttpRequest), IsA(str)) \
+ .MultipleTimes().AndReturn(self.datastore_versions.list())
+ self.mox.ReplayAll()
+ res = self.client.get(CREATE_URL)
+ self.assertTemplateUsed(res,
+ 'project/database_configurations/create.html')
+
+ @test.create_stubs({api.trove: ('datastore_list',)})
+ def test_create_configuration_exception_on_datastore(self):
+ api.trove.datastore_list(IsA(http.HttpRequest)) \
+ .AndRaise(self.exceptions.trove)
+ self.mox.ReplayAll()
+ toSuppress = ["trove_dashboard.content."
+ "database_configurations.forms", ]
+
+ # Suppress expected log messages in the test output
+ loggers = []
+ for cls in toSuppress:
+ logger = logging.getLogger(cls)
+ loggers.append((logger, logger.getEffectiveLevel()))
+ logger.setLevel(logging.CRITICAL)
+
+ try:
+ res = self.client.get(CREATE_URL)
+ self.assertEqual(res.status_code, 302)
+
+ finally:
+ # Restore the previous log levels
+ for (log, level) in loggers:
+ log.setLevel(level)
+
+ @test.create_stubs({
+ api.trove: ('datastore_list', 'datastore_version_list',
+ 'configuration_create')})
+ def _test_create_test_configuration(
+ self, config_description=u''):
+ api.trove.datastore_list(IsA(http.HttpRequest)) \
+ .AndReturn(self.datastores.list())
+ api.trove.datastore_version_list(IsA(http.HttpRequest), IsA(str)) \
+ .MultipleTimes().AndReturn(self.datastore_versions.list())
+
+ name = u'config1'
+ values = "{}"
+ ds = self._get_test_datastore('mysql')
+ dsv = self._get_test_datastore_version(ds.id, '5.5')
+ config_datastore = ds.name
+ config_datastore_version = dsv.name
+
+ api.trove.configuration_create(
+ IsA(http.HttpRequest),
+ name,
+ values,
+ description=config_description,
+ datastore=config_datastore,
+ datastore_version=config_datastore_version) \
+ .AndReturn(self.database_configurations.first())
+
+ self.mox.ReplayAll()
+ post = {
+ 'method': 'CreateConfigurationForm',
+ 'name': name,
+ 'description': config_description,
+ 'datastore': (config_datastore + ',' + config_datastore_version)}
+
+ res = self.client.post(CREATE_URL, post)
+ self.assertNoFormErrors(res)
+ self.assertMessageCount(success=1)
+
+ def test_create_test_configuration(self):
+ self._test_create_test_configuration(u'description of config1')
+
+ def test_create_test_configuration_with_no_description(self):
+ self._test_create_test_configuration()
+
+ @test.create_stubs({
+ api.trove: ('datastore_list', 'datastore_version_list',
+ 'configuration_create')})
+ def test_create_test_configuration_exception(self):
+ api.trove.datastore_list(IsA(http.HttpRequest)) \
+ .AndReturn(self.datastores.list())
+ api.trove.datastore_version_list(IsA(http.HttpRequest), IsA(str)) \
+ .MultipleTimes().AndReturn(self.datastore_versions.list())
+
+ name = u'config1'
+ values = "{}"
+ config_description = u'description of config1'
+ ds = self._get_test_datastore('mysql')
+ dsv = self._get_test_datastore_version(ds.id, '5.5')
+ config_datastore = ds.name
+ config_datastore_version = dsv.name
+
+ api.trove.configuration_create(
+ IsA(http.HttpRequest),
+ name,
+ values,
+ description=config_description,
+ datastore=config_datastore,
+ datastore_version=config_datastore_version) \
+ .AndRaise(self.exceptions.trove)
+
+ self.mox.ReplayAll()
+ post = {'method': 'CreateConfigurationForm',
+ 'name': name,
+ 'description': config_description,
+ 'datastore': config_datastore + ',' + config_datastore_version}
+
+ res = self.client.post(CREATE_URL, post)
+ self.assertRedirectsNoFollow(res, INDEX_URL)
+
+ @test.create_stubs({api.trove: ('configuration_get',
+ 'configuration_instances',)})
+ def test_details_tab(self):
+ config = self.database_configurations.first()
+ api.trove.configuration_get(IsA(http.HttpRequest),
+ config.id) \
+ .AndReturn(config)
+ self.mox.ReplayAll()
+ details_url = self._get_url_with_arg(DETAIL_URL, config.id)
+ url = details_url + '?tab=configuration_details__details'
+ res = self.client.get(url)
+ self.assertTemplateUsed(res,
+ 'project/database_configurations/details.html')
+
+ @test.create_stubs({api.trove: ('configuration_get',)})
+ def test_overview_tab_exception(self):
+ config = self.database_configurations.first()
+ api.trove.configuration_get(IsA(http.HttpRequest),
+ config.id) \
+ .AndRaise(self.exceptions.trove)
+ self.mox.ReplayAll()
+ details_url = self._get_url_with_arg(DETAIL_URL, config.id)
+ url = details_url + '?tab=configuration_details__overview'
+ res = self.client.get(url)
+ self.assertRedirectsNoFollow(res, INDEX_URL)
+
+ @test.create_stubs({
+ api.trove: ('configuration_get', 'configuration_parameters_list',),
+ config_param_manager.ConfigParamManager:
+ ('get_configuration', 'configuration_get',)})
+ def test_add_parameter(self):
+ config = config_param_manager.ConfigParamManager.get_configuration() \
+ .AndReturn(self.database_configurations.first())
+
+ config_param_manager.ConfigParamManager \
+ .configuration_get(IsA(http.HttpRequest)) \
+ .AndReturn(config)
+ ds = self._get_test_datastore('mysql')
+ dsv = self._get_test_datastore_version(ds.id, '5.5')
+ api.trove.configuration_parameters_list(
+ IsA(http.HttpRequest),
+ ds.name,
+ dsv.name) \
+ .AndReturn(self.configuration_parameters.list())
+ self.mox.ReplayAll()
+ res = self.client.get(self._get_url_with_arg(ADD_URL, 'id'))
+ self.assertTemplateUsed(
+ res, 'project/database_configurations/add_parameter.html')
+
+ @test.create_stubs({
+ api.trove: ('configuration_get', 'configuration_parameters_list',),
+ config_param_manager.ConfigParamManager:
+ ('get_configuration', 'configuration_get',)})
+ def test_add_parameter_exception_on_parameters(self):
+ try:
+ config = (config_param_manager.ConfigParamManager
+ .get_configuration()
+ .AndReturn(self.database_configurations.first()))
+
+ config_param_manager.ConfigParamManager \
+ .configuration_get(IsA(http.HttpRequest)) \
+ .AndReturn(config)
+
+ ds = self._get_test_datastore('mysql')
+ dsv = self._get_test_datastore_version(ds.id, '5.5')
+ api.trove.configuration_parameters_list(
+ IsA(http.HttpRequest),
+ ds.name,
+ dsv.name) \
+ .AndRaise(self.exceptions.trove)
+ self.mox.ReplayAll()
+ toSuppress = ["trove_dashboard.content."
+ "database_configurations.forms", ]
+
+ # Suppress expected log messages in the test output
+ loggers = []
+ for cls in toSuppress:
+ logger = logging.getLogger(cls)
+ loggers.append((logger, logger.getEffectiveLevel()))
+ logger.setLevel(logging.CRITICAL)
+
+ try:
+ res = self.client.get(
+ self._get_url_with_arg(ADD_URL, config.id))
+ self.assertEqual(res.status_code, 302)
+
+ finally:
+ # Restore the previous log levels
+ for (log, level) in loggers:
+ log.setLevel(level)
+ finally:
+ config_param_manager.delete(config.id)
+
+ @test.create_stubs({
+ api.trove: ('configuration_get', 'configuration_parameters_list',),
+ config_param_manager.ConfigParamManager:
+ ('get_configuration', 'add_param', 'configuration_get',)})
+ def test_add_new_parameter(self):
+ config = (config_param_manager.ConfigParamManager
+ .get_configuration()
+ .AndReturn(self.database_configurations.first()))
+ try:
+ config_param_manager.ConfigParamManager \
+ .configuration_get(IsA(http.HttpRequest)) \
+ .AndReturn(config)
+
+ ds = self._get_test_datastore('mysql')
+ dsv = self._get_test_datastore_version(ds.id, '5.5')
+ api.trove.configuration_parameters_list(
+ IsA(http.HttpRequest),
+ ds.name,
+ dsv.name) \
+ .AndReturn(self.configuration_parameters.list())
+
+ name = self.configuration_parameters.first().name
+ value = 1
+
+ config_param_manager.ConfigParamManager.add_param(name, value) \
+ .AndReturn(value)
+
+ self.mox.ReplayAll()
+ post = {
+ 'method': 'AddParameterForm',
+ 'name': name,
+ 'value': value}
+
+ res = self.client.post(self._get_url_with_arg(ADD_URL, config.id),
+ post)
+ self.assertNoFormErrors(res)
+ self.assertMessageCount(success=1)
+ finally:
+ config_param_manager.delete(config.id)
+
+ @test.create_stubs({
+ api.trove: ('configuration_get', 'configuration_parameters_list',),
+ config_param_manager: ('get',)})
+ def test_add_parameter_invalid_value(self):
+ try:
+ config = self.database_configurations.first()
+
+ # setup the configuration parameter manager
+ config_param_mgr = config_param_manager.ConfigParamManager(
+ config.id)
+ config_param_mgr.configuration = config
+ config_param_mgr.original_configuration_values = \
+ dict.copy(config.values)
+
+ config_param_manager.get(IsA(http.HttpRequest), config.id) \
+ .MultipleTimes().AndReturn(config_param_mgr)
+
+ ds = self._get_test_datastore('mysql')
+ dsv = self._get_test_datastore_version(ds.id, '5.5')
+ api.trove.configuration_parameters_list(
+ IsA(http.HttpRequest),
+ ds.name,
+ dsv.name) \
+ .AndReturn(self.configuration_parameters.list())
+
+ name = self.configuration_parameters.first().name
+ value = "non-numeric"
+
+ self.mox.ReplayAll()
+ post = {
+ 'method': 'AddParameterForm',
+ 'name': name,
+ 'value': value}
+
+ res = self.client.post(self._get_url_with_arg(ADD_URL, config.id),
+ post)
+ self.assertFormError(res, "form", 'value',
+ ['Value must be a number.'])
+ finally:
+ config_param_manager.delete(config.id)
+
+ @test.create_stubs({api.trove: ('configuration_get',
+ 'configuration_instances',)})
+ def test_values_tab_discard_action(self):
+ config = self.database_configurations.first()
+
+ api.trove.configuration_get(IsA(http.HttpRequest), config.id) \
+ .MultipleTimes().AndReturn(config)
+ self.mox.ReplayAll()
+
+ details_url = self._get_url_with_arg(DETAIL_URL, config.id)
+ url = details_url + '?tab=configuration_details__value'
+
+ self._test_create_altered_config_params(config, url)
+
+ # get the state of the configuration before discard action
+ changed_configuration_values = \
+ dict.copy(config_param_manager.get(self.request, config.id)
+ .get_configuration().values)
+
+ res = self.client.post(url, {'action': u"values__discard_changes"})
+ self.assertRedirectsNoFollow(res, url)
+
+ # get the state of the configuration after discard action
+ restored_configuration_values = \
+ dict.copy(config_param_manager.get(self.request, config.id)
+ .get_configuration().values)
+
+ self.assertTrue(config_param_manager.dict_has_changes(
+ changed_configuration_values, restored_configuration_values))
+
+ @test.create_stubs({api.trove: ('configuration_instances',
+ 'configuration_update',),
+ config_param_manager: ('get',)})
+ def test_values_tab_apply_action(self):
+ config = copy.deepcopy(self.database_configurations.first())
+
+ # setup the configuration parameter manager
+ config_param_mgr = config_param_manager.ConfigParamManager(
+ config.id)
+ config_param_mgr.configuration = config
+ config_param_mgr.original_configuration_values = \
+ dict.copy(config.values)
+
+ config_param_manager.get(IsA(http.HttpRequest), config.id) \
+ .MultipleTimes().AndReturn(config_param_mgr)
+
+ api.trove.configuration_update(
+ IsA(http.HttpRequest),
+ config.id,
+ config_param_mgr.to_json()) \
+ .AndReturn(None)
+ self.mox.ReplayAll()
+
+ details_url = self._get_url_with_arg(DETAIL_URL, config.id)
+ url = details_url + '?tab=configuration_details__value'
+
+ self._test_create_altered_config_params(config, url)
+
+ # apply changes
+ res = self.client.post(url, {'action': u"values__apply_changes"})
+ self.assertRedirectsNoFollow(res, url)
+
+ @test.create_stubs({api.trove: ('configuration_instances',
+ 'configuration_update',),
+ config_param_manager: ('get',)})
+ def test_values_tab_apply_action_exception(self):
+ config = copy.deepcopy(self.database_configurations.first())
+
+ # setup the configuration parameter manager
+ config_param_mgr = config_param_manager.ConfigParamManager(
+ config.id)
+ config_param_mgr.configuration = config
+ config_param_mgr.original_configuration_values = \
+ dict.copy(config.values)
+
+ config_param_manager.get(IsA(http.HttpRequest), config.id) \
+ .MultipleTimes().AndReturn(config_param_mgr)
+
+ api.trove.configuration_update(
+ IsA(http.HttpRequest),
+ config.id,
+ config_param_mgr.to_json())\
+ .AndRaise(self.exceptions.trove)
+ self.mox.ReplayAll()
+
+ details_url = self._get_url_with_arg(DETAIL_URL, config.id)
+ url = details_url + '?tab=configuration_details__value'
+
+ self._test_create_altered_config_params(config, url)
+
+ # apply changes
+ res = self.client.post(url, {'action': u"values__apply_changes"})
+ self.assertRedirectsNoFollow(res, url)
+ self.assertEqual(res.status_code, 302)
+
+ def _test_create_altered_config_params(self, config, url):
+ # determine the number of configuration group parameters in the list
+ res = self.client.get(url)
+
+ table_data = res.context['table'].data
+ number_params = len(table_data)
+ config_param = table_data[0]
+
+ # delete the first parameter
+ action_string = u"values__delete__%s" % config_param.name
+ form_data = {'action': action_string}
+ res = self.client.post(url, form_data)
+ self.assertRedirectsNoFollow(res, url)
+
+ # verify the test number of parameters is reduced by 1
+ res = self.client.get(url)
+ table_data = res.context['table'].data
+ new_number_params = len(table_data)
+
+ self.assertEqual((number_params - 1), new_number_params)
+
+ @test.create_stubs({api.trove: ('configuration_instances',),
+ config_param_manager: ('get',)})
+ def test_instances_tab(self):
+ try:
+ config = self.database_configurations.first()
+
+ # setup the configuration parameter manager
+ config_param_mgr = config_param_manager.ConfigParamManager(
+ config.id)
+ config_param_mgr.configuration = config
+ config_param_mgr.original_configuration_values = \
+ dict.copy(config.values)
+
+ config_param_manager.get(IsA(http.HttpRequest), config.id) \
+ .MultipleTimes().AndReturn(config_param_mgr)
+
+ api.trove.configuration_instances(IsA(http.HttpRequest),
+ config.id)\
+ .AndReturn(self.configuration_instances.list())
+ self.mox.ReplayAll()
+
+ details_url = self._get_url_with_arg(DETAIL_URL, config.id)
+ url = details_url + '?tab=configuration_details__instance'
+
+ res = self.client.get(url)
+ table_data = res.context['instances_table'].data
+ self.assertItemsEqual(
+ self.configuration_instances.list(), table_data)
+ self.assertTemplateUsed(
+ res, 'project/database_configurations/details.html')
+ finally:
+ config_param_manager.delete(config.id)
+
+ @test.create_stubs({api.trove: ('configuration_instances',),
+ config_param_manager: ('get',)})
+ def test_instances_tab_exception(self):
+ try:
+ config = self.database_configurations.first()
+
+ # setup the configuration parameter manager
+ config_param_mgr = config_param_manager.ConfigParamManager(
+ config.id)
+ config_param_mgr.configuration = config
+ config_param_mgr.original_configuration_values = \
+ dict.copy(config.values)
+
+ config_param_manager.get(IsA(http.HttpRequest), config.id) \
+ .MultipleTimes().AndReturn(config_param_mgr)
+
+ api.trove.configuration_instances(IsA(http.HttpRequest),
+ config.id) \
+ .AndRaise(self.exceptions.trove)
+ self.mox.ReplayAll()
+
+ details_url = self._get_url_with_arg(DETAIL_URL, config.id)
+ url = details_url + '?tab=configuration_details__instance'
+
+ res = self.client.get(url)
+ table_data = res.context['instances_table'].data
+ self.assertNotEqual(len(self.configuration_instances.list()),
+ len(table_data))
+ self.assertTemplateUsed(
+ res, 'project/database_configurations/details.html')
+ finally:
+ config_param_manager.delete(config.id)
+
+ def _get_url_with_arg(self, url, arg):
+ return reverse(url, args=[arg])
+
+ def _get_test_datastore(self, datastore_name):
+ for ds in self.datastores.list():
+ if ds.name == datastore_name:
+ return ds
+ return None
+
+ def _get_test_datastore_version(self, datastore_id,
+ datastore_version_name):
+ for dsv in self.datastore_versions.list():
+ if (dsv.datastore == datastore_id and
+ dsv.name == datastore_version_name):
+ return dsv
+ return None
diff --git a/trove_dashboard/content/database_configurations/urls.py b/trove_dashboard/content/database_configurations/urls.py
new file mode 100644
index 0000000..f6ec81e
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/urls.py
@@ -0,0 +1,39 @@
+# Copyright 2015 Tesora Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from django.conf.urls import patterns # noqa
+from django.conf.urls import url # noqa
+
+from trove_dashboard.content.database_configurations \
+ import views
+
+
+CONFIGS = r'^(?P[^/]+)/%s$'
+
+
+urlpatterns = patterns(
+ '',
+ url(r'^$',
+ views.IndexView.as_view(),
+ name='index'),
+ url(r'^create$',
+ views.CreateConfigurationView.as_view(),
+ name='create'),
+ url(CONFIGS % '',
+ views.DetailView.as_view(),
+ name='detail'),
+ url(CONFIGS % 'add',
+ views.AddParameterView.as_view(),
+ name='add')
+)
diff --git a/trove_dashboard/content/database_configurations/views.py b/trove_dashboard/content/database_configurations/views.py
new file mode 100644
index 0000000..04ec842
--- /dev/null
+++ b/trove_dashboard/content/database_configurations/views.py
@@ -0,0 +1,115 @@
+# Copyright 2015 Tesora Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from django.core.urlresolvers import reverse
+from django.core.urlresolvers import reverse_lazy
+from django.utils.translation import ugettext_lazy as _
+
+from horizon import exceptions
+from horizon import forms as horizon_forms
+from horizon import tables as horizon_tables
+from horizon import tabs as horizon_tabs
+from horizon.utils import memoized
+
+from trove_dashboard import api
+from trove_dashboard.content.database_configurations \
+ import config_param_manager
+from trove_dashboard.content.database_configurations \
+ import forms
+from trove_dashboard.content.database_configurations \
+ import tables
+from trove_dashboard.content.database_configurations \
+ import tabs
+
+
+class IndexView(horizon_tables.DataTableView):
+ table_class = tables.ConfigurationsTable
+ template_name = 'project/database_configurations/index.html'
+ page_title = _("Configuration Groups")
+
+ def get_data(self):
+ try:
+ configurations = api.trove.configuration_list(self.request)
+ except Exception:
+ configurations = []
+ msg = _('Error getting configuration group list.')
+ exceptions.handle(self.request, msg)
+ return configurations
+
+
+class DetailView(horizon_tabs.TabbedTableView):
+ tab_group_class = tabs.ConfigurationDetailTabs
+ template_name = "project/database_configurations/details.html"
+ page_title = _("Configuration Group Details: {{configuration.name}}")
+
+ def get_context_data(self, **kwargs):
+ context = super(DetailView, self).get_context_data(**kwargs)
+ context["configuration"] = self.get_data()
+ return context
+
+ @memoized.memoized_method
+ def get_data(self):
+ try:
+ configuration_id = self.kwargs['configuration_id']
+ return (config_param_manager
+ .get(self.request, configuration_id)
+ .configuration_get(self.request))
+ except Exception:
+ redirect = reverse('horizon:project:database_configurations:index')
+ msg = _('Unable to retrieve details for configuration '
+ 'group: %s') % configuration_id
+ exceptions.handle(self.request, msg, redirect=redirect)
+
+ def get_tabs(self, request, *args, **kwargs):
+ configuration = self.get_data()
+ return self.tab_group_class(request,
+ configuration=configuration,
+ **kwargs)
+
+
+class CreateConfigurationView(horizon_forms.ModalFormView):
+ form_class = forms.CreateConfigurationForm
+ form_id = "create_configuration_form"
+ modal_header = _("Create Configuration Group")
+ modal_id = "create_configuration_modal"
+ template_name = 'project/database_configurations/create.html'
+ submit_label = "Create Configuration Group"
+ submit_url = reverse_lazy('horizon:project:database_configurations:create')
+ success_url = reverse_lazy('horizon:project:database_configurations:index')
+
+
+class AddParameterView(horizon_forms.ModalFormView):
+ form_class = forms.AddParameterForm
+ form_id = "add_parameter_form"
+ modal_header = _("Add Parameter")
+ modal_id = "add_parameter_modal"
+ template_name = 'project/database_configurations/add_parameter.html'
+ submit_label = "Add Parameter"
+ submit_url = 'horizon:project:database_configurations:add'
+ success_url = 'horizon:project:database_configurations:detail'
+
+ def get_success_url(self):
+ return reverse(self.success_url,
+ args=(self.kwargs['configuration_id'],))
+
+ def get_context_data(self, **kwargs):
+ context = super(AddParameterView, self).get_context_data(**kwargs)
+ context["configuration_id"] = self.kwargs['configuration_id']
+ args = (self.kwargs['configuration_id'],)
+ context['submit_url'] = reverse(self.submit_url, args=args)
+ return context
+
+ def get_initial(self):
+ configuration_id = self.kwargs['configuration_id']
+ return {'configuration_id': configuration_id}
diff --git a/trove_dashboard/content/databases/forms.py b/trove_dashboard/content/databases/forms.py
index 478e1b9..9068d00 100644
--- a/trove_dashboard/content/databases/forms.py
+++ b/trove_dashboard/content/databases/forms.py
@@ -243,3 +243,41 @@ class EditUserForm(forms.SelfHandlingForm):
raise ValidationError(self.validation_error_message)
return cleaned_data
+
+
+class AttachConfigurationForm(forms.SelfHandlingForm):
+ instance_id = forms.CharField(widget=forms.HiddenInput())
+ configuration = forms.ChoiceField(label=_("Configuration Group"))
+
+ def __init__(self, request, *args, **kwargs):
+ super(AttachConfigurationForm, self).__init__(request, *args, **kwargs)
+ instance_id = kwargs.get('initial', {}).get('instance_id')
+ datastore = kwargs.get('initial', {}).get('datastore')
+ datastore_version = kwargs.get('initial', {}).get('datastore_version')
+ self.fields['instance_id'].initial = instance_id
+
+ configurations = api.trove.configuration_list(request)
+ choices = [(c.id, c.name) for c in configurations
+ if (c.datastore_name == datastore and
+ c.datastore_version_name == datastore_version)]
+ if choices:
+ choices.insert(0, ("", _("Select configuration group")))
+ else:
+ choices.insert(0, ("", _("No configuration groups available")))
+ self.fields['configuration'].choices = choices
+
+ def handle(self, request, data):
+ instance_id = data.get('instance_id')
+ try:
+ api.trove.instance_attach_configuration(request,
+ instance_id,
+ data['configuration'])
+
+ messages.success(request, _('Attaching Configuration group "%s"')
+ % instance_id)
+ except Exception as e:
+ redirect = reverse("horizon:project:databases:index")
+ exceptions.handle(request, _('Unable to attach configuration '
+ 'group. %s')
+ % e.message, redirect=redirect)
+ return True
diff --git a/trove_dashboard/content/databases/tables.py b/trove_dashboard/content/databases/tables.py
index 798bb40..34d4a6d 100644
--- a/trove_dashboard/content/databases/tables.py
+++ b/trove_dashboard/content/databases/tables.py
@@ -88,7 +88,8 @@ class RestartInstance(tables.BatchAction):
def allowed(self, request, instance=None):
return ((instance.status in ACTIVE_STATES
- or instance.status == 'SHUTDOWN'))
+ or instance.status == 'SHUTDOWN'
+ or instance.status == 'RESTART_REQUIRED'))
def action(self, request, obj_id):
api.trove.instance_restart(request, obj_id)
@@ -453,6 +454,45 @@ class ResizeInstance(tables.LinkAction):
return urlresolvers.reverse(self.url, args=[instance_id])
+class AttachConfiguration(tables.LinkAction):
+ name = "attach_configuration"
+ verbose_name = _("Attach Configuration Group")
+ url = "horizon:project:databases:attach_config"
+ classes = ("btn-attach-config", "ajax-modal")
+
+ def allowed(self, request, instance=None):
+ return (instance.status in ACTIVE_STATES
+ and not hasattr(instance, 'configuration'))
+
+
+class DetachConfiguration(tables.BatchAction):
+ @staticmethod
+ def action_present(count):
+ return ungettext_lazy(
+ u"Detach Configuration Group",
+ u"Detach Configuration Groups",
+ count
+ )
+
+ @staticmethod
+ def action_past(count):
+ return ungettext_lazy(
+ u"Detached Configuration Group",
+ u"Detached Configuration Groups",
+ count
+ )
+
+ name = "detach_configuration"
+ classes = ('btn-danger', 'btn-detach-config')
+
+ def allowed(self, request, instance=None):
+ return (instance.status in ACTIVE_STATES and
+ hasattr(instance, 'configuration'))
+
+ def action(self, request, obj_id):
+ api.trove.instance_detach_configuration(request, obj_id)
+
+
class EnableRootAction(tables.Action):
name = "enable_root_action"
verbose_name = _("Enable Root")
@@ -638,6 +678,8 @@ class InstancesTable(tables.DataTable):
ResizeVolume,
ResizeInstance,
PromoteToReplicaSource,
+ AttachConfiguration,
+ DetachConfiguration,
ManageRoot,
EjectReplicaSource,
DetachReplica,
@@ -704,3 +746,15 @@ class InstanceBackupsTable(tables.DataTable):
row_class = UpdateRow
table_actions = (backup_tables.LaunchLink, backup_tables.DeleteBackup)
row_actions = (backup_tables.RestoreLink, backup_tables.DeleteBackup)
+
+
+class ConfigDefaultsTable(tables.DataTable):
+ name = tables.Column('name', verbose_name=_('Property'))
+ value = tables.Column('value', verbose_name=_('Value'))
+
+ class Meta(object):
+ name = 'config_defaults'
+ verbose_name = _('Configuration Defaults')
+
+ def get_object_id(self, datum):
+ return datum.name
diff --git a/trove_dashboard/content/databases/tabs.py b/trove_dashboard/content/databases/tabs.py
index 95ad748..d640880 100644
--- a/trove_dashboard/content/databases/tabs.py
+++ b/trove_dashboard/content/databases/tabs.py
@@ -20,6 +20,8 @@ from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import tabs
from trove_dashboard import api
+from trove_dashboard.content.database_configurations import (
+ config_param_manager)
from trove_dashboard.content.databases import db_capability
from trove_dashboard.content.databases.logs import tables as log_tables
from trove_dashboard.content.databases import tables
@@ -120,6 +122,25 @@ class DatabaseTab(tabs.TableTab):
return tables.has_database_add_perm(request)
+class ConfigDefaultsTab(tabs.TableTab):
+ table_classes = [tables.ConfigDefaultsTable]
+ name = _("Defaults")
+ slug = "config_defaults"
+ instance = None
+ template_name = "horizon/common/_detail_table.html"
+ preload = False
+
+ def get_config_defaults_data(self):
+ instance = self.tab_group.kwargs['instance']
+ values_data = []
+ data = api.trove.configuration_default(self.request, instance.id)
+ if data is not None:
+ for k, v in data.configuration.items():
+ values_data.append(
+ config_param_manager.ConfigParam(None, k, v))
+ return sorted(values_data, key=lambda config: config.name)
+
+
class BackupsTab(tabs.TableTab):
table_classes = [tables.InstanceBackupsTable]
name = _("Backups")
@@ -163,5 +184,6 @@ class LogsTab(tabs.TableTab):
class InstanceDetailTabs(tabs.TabGroup):
slug = "instance_details"
- tabs = (OverviewTab, UserTab, DatabaseTab, BackupsTab, LogsTab)
+ tabs = (OverviewTab, UserTab, DatabaseTab, BackupsTab, LogsTab,
+ ConfigDefaultsTab)
sticky = True
diff --git a/trove_dashboard/content/databases/templates/databases/_attach_config.html b/trove_dashboard/content/databases/templates/databases/_attach_config.html
new file mode 100644
index 0000000..9b75dc7
--- /dev/null
+++ b/trove_dashboard/content/databases/templates/databases/_attach_config.html
@@ -0,0 +1,7 @@
+{% extends "horizon/common/_modal_form.html" %}
+{% load i18n %}
+
+{% block modal-body-right %}
+ {% trans "Select a configuration group to attach to the database instance." %}
+ {% trans "Please note: It may be necessary to reboot the database instance for this new configuration group to take effect." %}
+{% endblock %}
diff --git a/trove_dashboard/content/databases/templates/databases/_detail_overview.html b/trove_dashboard/content/databases/templates/databases/_detail_overview.html
index 1c887f4..6136dff 100644
--- a/trove_dashboard/content/databases/templates/databases/_detail_overview.html
+++ b/trove_dashboard/content/databases/templates/databases/_detail_overview.html
@@ -12,6 +12,14 @@
{{ instance.datastore.version }}
{% trans "Status" %}
{{ instance.status|title }}
+ {% if instance.configuration %}
+ {% trans "Configuration Group" %}
+
+
+ {{ instance.configuration.id }}
+
+
+ {% endif %}
{% trans "Root Enabled" %}
{{ root_enabled|capfirst }}
diff --git a/trove_dashboard/content/databases/templates/databases/attach_config.html b/trove_dashboard/content/databases/templates/databases/attach_config.html
new file mode 100644
index 0000000..c98416a
--- /dev/null
+++ b/trove_dashboard/content/databases/templates/databases/attach_config.html
@@ -0,0 +1,5 @@
+{% extends "base.html" %}
+
+{% block main %}
+ {% include "project/databases/_attach_config.html" %}
+{% endblock %}
\ No newline at end of file
diff --git a/trove_dashboard/content/databases/tests.py b/trove_dashboard/content/databases/tests.py
index 0fcbf26..4953c02 100644
--- a/trove_dashboard/content/databases/tests.py
+++ b/trove_dashboard/content/databases/tests.py
@@ -129,8 +129,8 @@ class DatabaseTests(test.TestCase):
self.assertMessageCount(res, error=1)
@test.create_stubs({
- api.trove: ('datastore_flavors', 'backup_list',
- 'datastore_list', 'datastore_version_list',
+ api.trove: ('backup_list', 'configuration_list', 'datastore_flavors',
+ 'datastore_list', 'datastore_version_list', 'flavor_list',
'instance_list'),
dash_api.cinder: ('volume_type_list',),
dash_api.neutron: ('network_list',),
@@ -144,6 +144,7 @@ class DatabaseTests(test.TestCase):
MultipleTimes().AndReturn(self.flavors.list())
api.trove.backup_list(IsA(http.HttpRequest)).AndReturn(
self.database_backups.list())
+ api.trove.configuration_list(IsA(http.HttpRequest)).AndReturn([])
api.trove.instance_list(IsA(http.HttpRequest)).AndReturn(
self.databases.list())
# Mock datastores
@@ -200,9 +201,9 @@ class DatabaseTests(test.TestCase):
log.setLevel(level)
@test.create_stubs({
- api.trove: ('datastore_flavors', 'backup_list', 'instance_create',
- 'datastore_list', 'datastore_version_list',
- 'instance_list'),
+ api.trove: ('backup_list', 'configuration_list', 'datastore_flavors',
+ 'datastore_list', 'datastore_version_list', 'flavor_list',
+ 'instance_create', 'instance_list'),
dash_api.cinder: ('volume_type_list',),
dash_api.neutron: ('network_list',),
policy: ('check',),
@@ -256,6 +257,7 @@ class DatabaseTests(test.TestCase):
datastore_version=datastore_version,
restore_point=None,
replica_of=None,
+ configuration=None,
users=None,
nics=nics,
replica_count=None,
@@ -276,9 +278,9 @@ class DatabaseTests(test.TestCase):
self.assertRedirectsNoFollow(res, INDEX_URL)
@test.create_stubs({
- api.trove: ('datastore_flavors', 'backup_list', 'instance_create',
- 'datastore_list', 'datastore_version_list',
- 'instance_list'),
+ api.trove: ('backup_list', 'configuration_list', 'datastore_flavors',
+ 'datastore_list', 'datastore_version_list', 'flavor_list',
+ 'instance_create', 'instance_list'),
dash_api.cinder: ('volume_type_list',),
dash_api.neutron: ('network_list',),
policy: ('check',),
@@ -333,6 +335,7 @@ class DatabaseTests(test.TestCase):
datastore_version=datastore_version,
restore_point=None,
replica_of=None,
+ configuration=None,
users=None,
nics=nics,
replica_count=None,
@@ -981,9 +984,9 @@ class DatabaseTests(test.TestCase):
self.assertRedirectsNoFollow(res, INDEX_URL)
@test.create_stubs({
- api.trove: ('datastore_flavors', 'backup_list', 'instance_create',
- 'datastore_list', 'datastore_version_list',
- 'instance_list_all', 'instance_get'),
+ api.trove: ('backup_list', 'configuration_list', 'datastore_flavors',
+ 'datastore_list', 'datastore_version_list', 'flavor_list',
+ 'instance_create', 'instance_get', 'instance_list_all'),
dash_api.cinder: ('volume_type_list',),
dash_api.neutron: ('network_list',),
policy: ('check',),
@@ -1039,6 +1042,7 @@ class DatabaseTests(test.TestCase):
datastore_version=datastore_version,
restore_point=None,
replica_of=self.databases.first().id,
+ configuration=None,
users=None,
nics=nics,
replica_count=2,
@@ -1190,3 +1194,117 @@ class DatabaseTests(test.TestCase):
def _build_flavor_widget_name(self, datastore, datastore_version):
return binascii.hexlify(self._build_datastore_display_text(
datastore, datastore_version))
+
+ @test.create_stubs({
+ api.trove: ('instance_get',
+ 'configuration_list',
+ 'instance_attach_configuration'),
+ })
+ def test_attach_configuration(self):
+ database = self.databases.first()
+ configuration = self.database_configurations.first()
+
+ api.trove.instance_get(IsA(http.HttpRequest), IsA(unicode))\
+ .AndReturn(database)
+
+ api.trove.configuration_list(IsA(http.HttpRequest))\
+ .AndReturn(self.database_configurations.list())
+
+ api.trove.instance_attach_configuration(
+ IsA(http.HttpRequest), database.id, configuration.id)\
+ .AndReturn(None)
+
+ self.mox.ReplayAll()
+ url = reverse('horizon:project:databases:attach_config',
+ args=[database.id])
+ form = {
+ 'instance_id': database.id,
+ 'configuration': configuration.id,
+ }
+ res = self.client.post(url, form)
+ self.assertNoFormErrors(res)
+ self.assertRedirectsNoFollow(res, INDEX_URL)
+
+ @test.create_stubs({
+ api.trove: ('instance_get',
+ 'configuration_list',
+ 'instance_attach_configuration'),
+ })
+ def test_attach_configuration_exception(self):
+ database = self.databases.first()
+ configuration = self.database_configurations.first()
+
+ api.trove.instance_get(IsA(http.HttpRequest), IsA(unicode))\
+ .AndReturn(database)
+
+ api.trove.configuration_list(IsA(http.HttpRequest))\
+ .AndReturn(self.database_configurations.list())
+
+ api.trove.instance_attach_configuration(
+ IsA(http.HttpRequest), database.id, configuration.id)\
+ .AndRaise(self.exceptions.trove)
+
+ self.mox.ReplayAll()
+ url = reverse('horizon:project:databases:attach_config',
+ args=[database.id])
+ form = {
+ 'instance_id': database.id,
+ 'configuration': configuration.id,
+ }
+ res = self.client.post(url, form)
+ self.assertEqual(res.status_code, 302)
+ self.assertRedirectsNoFollow(res, INDEX_URL)
+
+ @test.create_stubs({
+ api.trove: ('instance_list',
+ 'flavor_list',
+ 'instance_detach_configuration',),
+ })
+ def test_detach_configuration(self):
+ databases = common.Paginated(self.databases.list())
+ database = databases[2]
+
+ api.trove.instance_list(IsA(http.HttpRequest), marker=None)\
+ .AndReturn(databases)
+
+ api.trove.flavor_list(IsA(http.HttpRequest))\
+ .AndReturn(self.flavors.list())
+
+ api.trove.instance_detach_configuration(
+ IsA(http.HttpRequest), database.id)\
+ .AndReturn(None)
+
+ self.mox.ReplayAll()
+
+ res = self.client.post(
+ INDEX_URL,
+ {'action': 'databases__detach_configuration__%s' % database.id})
+
+ self.assertRedirectsNoFollow(res, INDEX_URL)
+
+ @test.create_stubs({
+ api.trove: ('instance_list',
+ 'flavor_list',
+ 'instance_detach_configuration',),
+ })
+ def test_detach_configuration_exception(self):
+ databases = common.Paginated(self.databases.list())
+ database = databases[2]
+
+ api.trove.instance_list(IsA(http.HttpRequest), marker=None)\
+ .AndReturn(databases)
+
+ api.trove.flavor_list(IsA(http.HttpRequest))\
+ .AndReturn(self.flavors.list())
+
+ api.trove.instance_detach_configuration(
+ IsA(http.HttpRequest), database.id)\
+ .AndRaise(self.exceptions.trove)
+
+ self.mox.ReplayAll()
+
+ res = self.client.post(
+ INDEX_URL,
+ {'action': 'databases__detach_configuration__%s' % database.id})
+
+ self.assertRedirectsNoFollow(res, INDEX_URL)
diff --git a/trove_dashboard/content/databases/urls.py b/trove_dashboard/content/databases/urls.py
index a1b54a3..1ae2a49 100644
--- a/trove_dashboard/content/databases/urls.py
+++ b/trove_dashboard/content/databases/urls.py
@@ -44,6 +44,8 @@ urlpatterns = patterns(
url(INSTANCES % 'promote_to_replica_source',
views.PromoteToReplicaSourceView.as_view(),
name='promote_to_replica_source'),
+ url(INSTANCES % 'attach_config', views.AttachConfigurationView.as_view(),
+ name='attach_config'),
url(INSTANCES % 'manage_root', views.ManageRootView.as_view(),
name='manage_root'),
url(BASEINSTANCES % 'logs/', include(logs_urls, namespace='logs')),
diff --git a/trove_dashboard/content/databases/views.py b/trove_dashboard/content/databases/views.py
index fe2c054..80f373d 100644
--- a/trove_dashboard/content/databases/views.py
+++ b/trove_dashboard/content/databases/views.py
@@ -205,6 +205,41 @@ class AccessDetailView(horizon_tables.DataTableView):
return context
+class AttachConfigurationView(horizon_forms.ModalFormView):
+ form_class = forms.AttachConfigurationForm
+ form_id = "attach_config_form"
+ modal_header = _("Attach Configuration Group")
+ modal_id = "attach_config_modal"
+ template_name = "project/databases/attach_config.html"
+ submit_label = "Attach Configuration"
+ submit_url = 'horizon:project:databases:attach_config'
+ success_url = reverse_lazy('horizon:project:databases:index')
+
+ @memoized.memoized_method
+ def get_object(self, *args, **kwargs):
+ instance_id = self.kwargs['instance_id']
+ try:
+ return api.trove.instance_get(self.request, instance_id)
+ except Exception:
+ msg = _('Unable to retrieve instance details.')
+ redirect = reverse('horizon:project:databases:index')
+ exceptions.handle(self.request, msg, redirect=redirect)
+
+ def get_context_data(self, **kwargs):
+ context = (super(AttachConfigurationView, self)
+ .get_context_data(**kwargs))
+ context['instance_id'] = self.kwargs['instance_id']
+ args = (self.kwargs['instance_id'],)
+ context['submit_url'] = reverse(self.submit_url, args=args)
+ return context
+
+ def get_initial(self):
+ instance = self.get_object()
+ return {'instance_id': self.kwargs['instance_id'],
+ 'datastore': instance.datastore.get('type', ''),
+ 'datastore_version': instance.datastore.get('version', '')}
+
+
class DetailView(horizon_tabs.TabbedTableView):
tab_group_class = tabs.InstanceDetailTabs
template_name = 'horizon/common/_detail.html'
diff --git a/trove_dashboard/content/databases/workflows/create_instance.py b/trove_dashboard/content/databases/workflows/create_instance.py
index 1508a1c..30075b7 100644
--- a/trove_dashboard/content/databases/workflows/create_instance.py
+++ b/trove_dashboard/content/databases/workflows/create_instance.py
@@ -252,6 +252,10 @@ class InitializeDatabase(workflows.Step):
class AdvancedAction(workflows.Action):
+ config = forms.ChoiceField(
+ label=_("Configuration Group"),
+ required=False,
+ help_text=_('Select a configuration group'))
initial_state = forms.ChoiceField(
label=_('Source for Initial State'),
required=False,
@@ -298,6 +302,24 @@ class AdvancedAction(workflows.Action):
name = _("Advanced")
help_text_template = "project/databases/_launch_advanced_help.html"
+ def populate_config_choices(self, request, context):
+ try:
+ configs = api.trove.configuration_list(request)
+ config_name = "%(name)s (%(datastore)s - %(version)s)"
+ choices = [(c.id,
+ config_name % {'name': c.name,
+ 'datastore': c.datastore_name,
+ 'version': c.datastore_version_name})
+ for c in configs]
+ except Exception:
+ choices = []
+
+ if choices:
+ choices.insert(0, ("", _("Select configuration")))
+ else:
+ choices.insert(0, ("", _("No configurations available")))
+ return choices
+
def populate_backup_choices(self, request, context):
try:
backups = api.trove.backup_list(request)
@@ -339,6 +361,19 @@ class AdvancedAction(workflows.Action):
def clean(self):
cleaned_data = super(AdvancedAction, self).clean()
+ config = self.cleaned_data['config']
+ if config:
+ try:
+ # Make sure the user is not "hacking" the form
+ # and that they have access to this configuration
+ cfg = api.trove.configuration_get(self.request, config)
+ self.cleaned_data['config'] = cfg.id
+ except Exception:
+ raise forms.ValidationError(_("Unable to find configuration "
+ "group!"))
+ else:
+ self.cleaned_data['config'] = None
+
initial_state = cleaned_data.get("initial_state")
if initial_state == 'backup':
@@ -377,7 +412,7 @@ class AdvancedAction(workflows.Action):
class Advanced(workflows.Step):
action_class = AdvancedAction
- contributes = ['backup', 'master', 'replica_count']
+ contributes = ['config', 'backup', 'master', 'replica_count']
class LaunchInstance(workflows.Workflow):
@@ -452,13 +487,16 @@ class LaunchInstance(workflows.Workflow):
"{name=%s, volume=%s, volume_type=%s, flavor=%s, "
"datastore=%s, datastore_version=%s, "
"dbs=%s, users=%s, "
- "backups=%s, nics=%s, replica_of=%s replica_count=%s}",
+ "backups=%s, nics=%s, "
+ "replica_of=%s, replica_count=%s, "
+ "configuration=%s}",
context['name'], context['volume'],
self._get_volume_type(context), context['flavor'],
datastore, datastore_version,
self._get_databases(context), self._get_users(context),
self._get_backup(context), self._get_nics(context),
- context.get('master'), context['replica_count'])
+ context.get('master'), context['replica_count'],
+ context.get('config'))
api.trove.instance_create(request,
context['name'],
context['volume'],
@@ -472,7 +510,8 @@ class LaunchInstance(workflows.Workflow):
replica_of=context.get('master'),
replica_count=context['replica_count'],
volume_type=self._get_volume_type(
- context))
+ context),
+ configuration=context.get('config'))
return True
except Exception:
exceptions.handle(request)
diff --git a/trove_dashboard/enabled/_1760_project_database_configurations_panel.py b/trove_dashboard/enabled/_1760_project_database_configurations_panel.py
new file mode 100644
index 0000000..9aa2cdf
--- /dev/null
+++ b/trove_dashboard/enabled/_1760_project_database_configurations_panel.py
@@ -0,0 +1,30 @@
+# 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 trove_dashboard import exceptions
+
+# The slug of the panel to be added to HORIZON_CONFIG. Required.
+PANEL = 'database_configurations'
+# The slug of the dashboard the PANEL associated with. Required.
+PANEL_DASHBOARD = 'project'
+# The slug of the panel group the PANEL is associated with.
+PANEL_GROUP = 'database'
+
+# Python panel class of the PANEL to be added.
+ADD_PANEL = ('trove_dashboard.content.database_configurations.panel.'
+ 'Configurations')
+
+ADD_EXCEPTIONS = {
+ 'not_found': exceptions.NOT_FOUND,
+ 'recoverable': exceptions.RECOVERABLE,
+ 'unauthorized': exceptions.UNAUTHORIZED,
+}
diff --git a/trove_dashboard/test/settings.py b/trove_dashboard/test/settings.py
index 9e6ccc2..d7a708b 100644
--- a/trove_dashboard/test/settings.py
+++ b/trove_dashboard/test/settings.py
@@ -17,4 +17,5 @@ from openstack_dashboard.test.settings import * # noqa
INSTALLED_APPS = list(INSTALLED_APPS)
INSTALLED_APPS.append('trove_dashboard.content.database_backups')
INSTALLED_APPS.append('trove_dashboard.content.database_clusters')
+INSTALLED_APPS.append('trove_dashboard.content.database_configurations')
INSTALLED_APPS.append('trove_dashboard.content.databases')
diff --git a/trove_dashboard/test/test_data/trove_data.py b/trove_dashboard/test/test_data/trove_data.py
index 2992342..bc77061 100644
--- a/trove_dashboard/test/test_data/trove_data.py
+++ b/trove_dashboard/test/test_data/trove_data.py
@@ -15,6 +15,7 @@
from troveclient.v1 import backups
from troveclient.v1 import clusters
+from troveclient.v1 import configurations
from troveclient.v1 import databases
from troveclient.v1 import datastores
from troveclient.v1 import flavors
@@ -225,7 +226,6 @@ BACKUP_ONE = {
"description": "Long description of backup",
}
-
BACKUP_TWO = {
"instance_id": "4d7b3f57-44f5-41d2-8e86-36b88cad572a",
"status": "COMPLETED",
@@ -238,7 +238,6 @@ BACKUP_TWO = {
"description": "Longer description of backup",
}
-
BACKUP_TWO_INC = {
"instance_id": "4d7b3f57-44f5-41d2-8e86-36b88cad572a",
"status": "COMPLETED",
@@ -252,6 +251,75 @@ BACKUP_TWO_INC = {
"parent_id": "e4602a3c-2bca-478f-b059-b6c215510fb4",
}
+CONFIG_ONE = {
+ "updated": "2014-07-11T14:33:35",
+ "name": "config1",
+ "created": "2014-07-11T14:33:35",
+ "instance_count": 1,
+ "values": {
+ "collation_server": "latin1_swedish_ci",
+ "max_connections": 6000
+ },
+ "id": "0ef978d3-7c83-4192-ab86-b7a0a5010fa0",
+ "description": "Long description of configuration one",
+ "datastore_name": "mysql",
+ "datastore_version_name": "5.5"
+}
+
+CONFIG_TWO = {
+ "updated": "2014-08-11T14:33:35",
+ "name": "config2",
+ "created": "2014-08-11T14:33:35",
+ "instance_count": 0,
+ "values": {
+ "collation_server": "latin1_swedish_ci",
+ "max_connections": 5000
+ },
+ "id": "87948232-10e7-4636-a3d3-a5e1593b7d16",
+ "description": "Long description of configuration two",
+ "datastore_name": "mysql",
+ "datastore_version_name": "5.6"
+}
+
+CONFIG_INSTANCE_ONE = {
+ "id": "c3369597-b53a-4bd4-bf54-41957c1291b8",
+ "name": "Test Database with Config",
+}
+
+CONFIG_PARAMS_ONE = [
+ {
+ "name": "autocommit",
+ "restart_required": False,
+ "max": 1,
+ "min": 0,
+ "type": "integer",
+ },
+ {
+ "name": "connect_timeout",
+ "restart_required": False,
+ "max": 65535,
+ "min": 1,
+ "type": "integer",
+ },
+ {
+ "name": "sort_buffer_size",
+ "restart_required": False,
+ "max": 18446744073709547520,
+ "min": 32768,
+ "type": "integer",
+ },
+ {
+ "name": "character_set_client",
+ "restart_required": False,
+ "type": "string",
+ },
+ {
+ "name": "character_set_connection",
+ "restart_required": False,
+ "type": "string",
+ },
+]
+
USER_ONE = {
"name": "Test_User",
"host": "%",
@@ -418,6 +486,12 @@ def data(TEST):
bkup1 = backups.Backup(backups.Backups(None), BACKUP_ONE)
bkup2 = backups.Backup(backups.Backups(None), BACKUP_TWO)
bkup3 = backups.Backup(backups.Backups(None), BACKUP_TWO_INC)
+
+ cfg1 = configurations.Configuration(configurations.Configurations(None),
+ CONFIG_ONE)
+ cfg2 = configurations.Configuration(configurations.Configurations(None),
+ CONFIG_TWO)
+
user1 = users.User(users.Users(None), USER_ONE)
user_db1 = databases.Database(databases.Databases(None),
USER_DB_ONE)
@@ -429,6 +503,9 @@ def data(TEST):
version1 = datastores.\
DatastoreVersion(datastores.DatastoreVersions(None),
VERSION_ONE)
+ version2 = datastores.\
+ DatastoreVersion(datastores.DatastoreVersions(None),
+ VERSION_TWO)
flavor1 = flavors.Flavor(flavors.Flavors(None), FLAVOR_ONE)
flavor2 = flavors.Flavor(flavors.Flavors(None), FLAVOR_TWO)
@@ -459,6 +536,7 @@ def data(TEST):
TEST.trove_clusters.add(cluster2)
TEST.databases = utils.TestDataContainer()
TEST.database_backups = utils.TestDataContainer()
+ TEST.database_configurations = utils.TestDataContainer()
TEST.database_users = utils.TestDataContainer()
TEST.database_user_dbs = utils.TestDataContainer()
TEST.database_user_roots = utils.TestDataContainer()
@@ -470,20 +548,36 @@ def data(TEST):
TEST.database_backups.add(bkup1)
TEST.database_backups.add(bkup2)
TEST.database_backups.add(bkup3)
+
+ TEST.database_configurations.add(cfg1)
+ TEST.database_configurations.add(cfg2)
+
+ TEST.configuration_parameters = utils.TestDataContainer()
+ for parameter in CONFIG_PARAMS_ONE:
+ TEST.configuration_parameters.add(
+ configurations.ConfigurationParameter(
+ configurations.ConfigurationParameters(None), parameter))
+
+ TEST.configuration_instances = utils.TestDataContainer()
+ TEST.configuration_instances.add(
+ configurations.Configuration(
+ configurations.Configurations(None), CONFIG_INSTANCE_ONE))
+
TEST.database_users.add(user1)
TEST.database_user_dbs.add(user_db1)
TEST.database_user_roots.add(user_root1)
TEST.datastores = utils.TestDataContainer()
- TEST.datastores.add(datastore1)
TEST.datastores.add(datastore_mongodb)
TEST.datastores.add(datastore_redis)
TEST.datastores.add(datastore_vertica)
+ TEST.datastores.add(datastore1)
TEST.database_flavors.add(flavor1, flavor2, flavor3)
TEST.datastore_versions = utils.TestDataContainer()
TEST.datastore_versions.add(version_vertica_7_1)
TEST.datastore_versions.add(version_redis_3_0)
TEST.datastore_versions.add(version_mongodb_2_6)
TEST.datastore_versions.add(version1)
+ TEST.datastore_versions.add(version2)
TEST.logs = utils.TestDataContainer()
TEST.logs.add(log1, log2, log3, log4)