From 949522f875c6e5abee1b57b95bf06f905cdf76a1 Mon Sep 17 00:00:00 2001 From: Matt Van Dijk Date: Thu, 14 Jan 2016 12:48:26 -0500 Subject: [PATCH] Add support for Trove configuration groups Added attach/detach configuration group actions to the instances table. Added tab to launch dialog to select a configuration group to associate with the instance. Added "Configuration Groups" panel which displays existing configuration groups and allows the user to create new or delete existing configuration groups. Added a details screen with three tabs (Values, Instances, Details). * The Values tab displays the list of all name/value parameters associated with a configuration group along with actions to add new parameters, delete existing parameters, discard changes and apply changes. Values can be modified in the table directly. * The Instances tab displays the instances that the configuration group is associated with along with actions to detach the configuration group from the instance. * The Details tab displays additional information on the configuration group. Added a configuration defaults tab to the database instance details. Use a cache for storing changes to the configuration group. Change-Id: Idbd6775342968c1659ca1e9b02dcb697750530b6 Co-Authored-By: Andrew Bramley Co-Authored-By: Duk Loi Implements: blueprint trove-configuration-group-support --- .../notes/conf-groups-7bc8115f8d0bcd14.yaml | 7 + trove_dashboard/api/trove.py | 56 +- .../database_configurations/__init__.py | 0 .../config_param_manager.py | 193 +++++++ .../content/database_configurations/forms.py | 190 ++++++ .../content/database_configurations/panel.py | 27 + .../content/database_configurations/tables.py | 256 +++++++++ .../content/database_configurations/tabs.py | 73 +++ .../_add_parameter.html | 6 + .../database_configurations/_create.html | 9 + .../_detail_overview.html | 24 + .../add_parameter.html | 5 + .../database_configurations/create.html | 5 + .../database_configurations/detail_param.html | 8 + .../database_configurations/details.html | 10 + .../database_configurations/index.html | 5 + .../content/database_configurations/tests.py | 540 ++++++++++++++++++ .../content/database_configurations/urls.py | 39 ++ .../content/database_configurations/views.py | 115 ++++ trove_dashboard/content/databases/forms.py | 38 ++ trove_dashboard/content/databases/tables.py | 56 +- trove_dashboard/content/databases/tabs.py | 24 +- .../templates/databases/_attach_config.html | 7 + .../templates/databases/_detail_overview.html | 8 + .../templates/databases/attach_config.html | 5 + trove_dashboard/content/databases/tests.py | 140 ++++- trove_dashboard/content/databases/urls.py | 2 + trove_dashboard/content/databases/views.py | 35 ++ .../databases/workflows/create_instance.py | 47 +- ...0_project_database_configurations_panel.py | 30 + trove_dashboard/test/settings.py | 1 + trove_dashboard/test/test_data/trove_data.py | 100 +++- 32 files changed, 2039 insertions(+), 22 deletions(-) create mode 100644 releasenotes/notes/conf-groups-7bc8115f8d0bcd14.yaml create mode 100644 trove_dashboard/content/database_configurations/__init__.py create mode 100644 trove_dashboard/content/database_configurations/config_param_manager.py create mode 100644 trove_dashboard/content/database_configurations/forms.py create mode 100644 trove_dashboard/content/database_configurations/panel.py create mode 100644 trove_dashboard/content/database_configurations/tables.py create mode 100644 trove_dashboard/content/database_configurations/tabs.py create mode 100644 trove_dashboard/content/database_configurations/templates/database_configurations/_add_parameter.html create mode 100644 trove_dashboard/content/database_configurations/templates/database_configurations/_create.html create mode 100644 trove_dashboard/content/database_configurations/templates/database_configurations/_detail_overview.html create mode 100644 trove_dashboard/content/database_configurations/templates/database_configurations/add_parameter.html create mode 100644 trove_dashboard/content/database_configurations/templates/database_configurations/create.html create mode 100644 trove_dashboard/content/database_configurations/templates/database_configurations/detail_param.html create mode 100644 trove_dashboard/content/database_configurations/templates/database_configurations/details.html create mode 100644 trove_dashboard/content/database_configurations/templates/database_configurations/index.html create mode 100644 trove_dashboard/content/database_configurations/tests.py create mode 100644 trove_dashboard/content/database_configurations/urls.py create mode 100644 trove_dashboard/content/database_configurations/views.py create mode 100644 trove_dashboard/content/databases/templates/databases/_attach_config.html create mode 100644 trove_dashboard/content/databases/templates/databases/attach_config.html create mode 100644 trove_dashboard/enabled/_1760_project_database_configurations_panel.py 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 %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+{% 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)