From 008769587b024eb9ff701b0d6e6a836bf4a15c14 Mon Sep 17 00:00:00 2001 From: Jonas Pfannschmidt Date: Fri, 6 Mar 2015 12:18:05 +0000 Subject: [PATCH] This version contains the following pages: - Overview displays charts/reports (Currently only placeholders) - Configurations allows to define new backup configurations and link them to instances - Backups shows a list of all succesful backups and allows to restore them - Restores shows a history of all restored backups It communicates with the api server via the client apis (freeezer.client.client). The "glue code" between client apis and the UI lives in freezer.api.api. Implements: Blueprint freezer-api-web-ui Change-Id: I48cd8cba2b0169c6e64f650233c1a31b91ced34f --- README.rst | 47 ++- _50_freezer.py | 10 +- __init__.py | 1 + freezer/dashboard.py | 19 -- freezer/freezerpanel/panel.py | 13 - freezer/freezerpanel/tables.py | 220 ------------ freezer/freezerpanel/tabs.py | 37 -- .../freezerpanel/_container_metadata.html | 12 - .../templates/freezerpanel/container.html | 15 - .../templates/freezerpanel/index.html | 15 - freezer/freezerpanel/tests.py | 7 - freezer/freezerpanel/urls.py | 15 - freezer/freezerpanel/views.py | 98 ------ .../static/mydashboard/css/mydashboard.css | 1 - freezer/static/mydashboard/js/mydashboard.js | 1 - {freezer => freezer_ui}/__init__.py | 0 .../actions}/__init__.py | 0 .../actions}/models.py | 0 freezer_ui/actions/panel.py | 25 ++ freezer_ui/actions/tables.py | 73 ++++ .../actions/templates/actions/index.html | 11 + freezer_ui/actions/urls.py | 22 ++ freezer_ui/actions/views.py | 34 ++ freezer_ui/api/__init__.py | 1 + freezer_ui/api/api.py | 293 ++++++++++++++++ freezer_ui/api/rest/__init__.py | 0 freezer_ui/api/rest/rest_api.py | 37 ++ freezer_ui/api/rest/urls.py | 31 ++ freezer_ui/backups/__init__.py | 0 {freezer => freezer_ui/backups}/models.py | 0 freezer_ui/backups/panel.py | 25 ++ freezer_ui/backups/restore_workflow.py | 107 ++++++ freezer_ui/backups/tables.py | 111 ++++++ .../backups/templates/backups/detail.html | 17 + .../backups/templates/backups/index.html | 17 + .../backups/templates/backups/restore.html | 46 +++ freezer_ui/backups/urls.py | 26 ++ freezer_ui/backups/views.py | 141 ++++++++ freezer_ui/configurations/__init__.py | 1 + .../configurations}/browsers.py | 17 +- freezer_ui/configurations/models.py | 3 + freezer_ui/configurations/panel.py | 24 ++ freezer_ui/configurations/tables.py | 161 +++++++++ .../_workflow_step_update_members.html | 61 ++++ .../templates/configurations/browser.html | 11 + freezer_ui/configurations/urls.py | 34 ++ freezer_ui/configurations/utils.py | 40 +++ freezer_ui/configurations/views.py | 94 ++++++ .../configurations/workflows/__init__.py | 0 .../configurations/workflows/configure.py | 315 ++++++++++++++++++ freezer_ui/dashboard.py | 31 ++ freezer_ui/django_utils.py | 8 + freezer_ui/models.py | 3 + freezer_ui/overview/__init__.py | 1 + freezer_ui/overview/models.py | 3 + freezer_ui/overview/panel.py | 24 ++ .../overview/templates/overview/overview.html | 112 +++++++ freezer_ui/overview/urls.py | 22 ++ freezer_ui/overview/views.py | 21 ++ freezer_ui/static/freezer/css/freezer.css | 43 +++ freezer_ui/static/freezer/js/dashboard.js | 196 +++++++++++ freezer_ui/static/freezer/js/freezer.js | 16 + .../templates/freezer/base.html | 0 freezer_ui/tests/__init__.py | 1 + freezer_ui/tests/api_tests.py | 80 +++++ freezer_ui/tests/rest_api_tests.py | 30 ++ freezer_ui/tests/settings.py | 18 + freezer_ui/urls.py | 32 ++ manage.py | 23 ++ test-requirements.txt | 8 + tox.ini | 19 ++ 71 files changed, 2508 insertions(+), 472 deletions(-) create mode 100644 __init__.py delete mode 100644 freezer/dashboard.py delete mode 100644 freezer/freezerpanel/panel.py delete mode 100644 freezer/freezerpanel/tables.py delete mode 100644 freezer/freezerpanel/tabs.py delete mode 100644 freezer/freezerpanel/templates/freezerpanel/_container_metadata.html delete mode 100644 freezer/freezerpanel/templates/freezerpanel/container.html delete mode 100644 freezer/freezerpanel/templates/freezerpanel/index.html delete mode 100644 freezer/freezerpanel/tests.py delete mode 100644 freezer/freezerpanel/urls.py delete mode 100644 freezer/freezerpanel/views.py delete mode 100644 freezer/static/mydashboard/css/mydashboard.css delete mode 100644 freezer/static/mydashboard/js/mydashboard.js rename {freezer => freezer_ui}/__init__.py (100%) rename {freezer/freezerpanel => freezer_ui/actions}/__init__.py (100%) rename {freezer/freezerpanel => freezer_ui/actions}/models.py (100%) create mode 100644 freezer_ui/actions/panel.py create mode 100644 freezer_ui/actions/tables.py create mode 100644 freezer_ui/actions/templates/actions/index.html create mode 100644 freezer_ui/actions/urls.py create mode 100644 freezer_ui/actions/views.py create mode 100644 freezer_ui/api/__init__.py create mode 100644 freezer_ui/api/api.py create mode 100644 freezer_ui/api/rest/__init__.py create mode 100644 freezer_ui/api/rest/rest_api.py create mode 100644 freezer_ui/api/rest/urls.py create mode 100644 freezer_ui/backups/__init__.py rename {freezer => freezer_ui/backups}/models.py (100%) create mode 100644 freezer_ui/backups/panel.py create mode 100644 freezer_ui/backups/restore_workflow.py create mode 100644 freezer_ui/backups/tables.py create mode 100644 freezer_ui/backups/templates/backups/detail.html create mode 100644 freezer_ui/backups/templates/backups/index.html create mode 100644 freezer_ui/backups/templates/backups/restore.html create mode 100644 freezer_ui/backups/urls.py create mode 100644 freezer_ui/backups/views.py create mode 100644 freezer_ui/configurations/__init__.py rename {freezer/freezerpanel => freezer_ui/configurations}/browsers.py (63%) create mode 100644 freezer_ui/configurations/models.py create mode 100644 freezer_ui/configurations/panel.py create mode 100644 freezer_ui/configurations/tables.py create mode 100644 freezer_ui/configurations/templates/configurations/_workflow_step_update_members.html create mode 100644 freezer_ui/configurations/templates/configurations/browser.html create mode 100644 freezer_ui/configurations/urls.py create mode 100644 freezer_ui/configurations/utils.py create mode 100644 freezer_ui/configurations/views.py create mode 100644 freezer_ui/configurations/workflows/__init__.py create mode 100644 freezer_ui/configurations/workflows/configure.py create mode 100644 freezer_ui/dashboard.py create mode 100644 freezer_ui/django_utils.py create mode 100644 freezer_ui/models.py create mode 100644 freezer_ui/overview/__init__.py create mode 100644 freezer_ui/overview/models.py create mode 100644 freezer_ui/overview/panel.py create mode 100644 freezer_ui/overview/templates/overview/overview.html create mode 100644 freezer_ui/overview/urls.py create mode 100644 freezer_ui/overview/views.py create mode 100644 freezer_ui/static/freezer/css/freezer.css create mode 100644 freezer_ui/static/freezer/js/dashboard.js create mode 100644 freezer_ui/static/freezer/js/freezer.js rename {freezer => freezer_ui}/templates/freezer/base.html (100%) create mode 100644 freezer_ui/tests/__init__.py create mode 100644 freezer_ui/tests/api_tests.py create mode 100644 freezer_ui/tests/rest_api_tests.py create mode 100644 freezer_ui/tests/settings.py create mode 100644 freezer_ui/urls.py create mode 100755 manage.py create mode 100644 test-requirements.txt create mode 100644 tox.ini diff --git a/README.rst b/README.rst index 0e46d58..179183a 100644 --- a/README.rst +++ b/README.rst @@ -1,26 +1,59 @@ ======================== Freezer - Horizon Web UI -======================= +======================== -Freezer now has basic support for a Web UI integrated with OpenStack Horizon. +Installation +============ In the installation procedure we'll assume your main Horizon dashboard directory is /opt/stack/horizon/openstack_dashboard/dashboards/. + To install the horizon web ui you need to do the following:: # git clone https://github.com/stackforge/freezer # cd freezer/horizon_web_ui - # cp -r freezer /opt/stack/horizon/openstack_dashboard/dashboards/ + # cp _50_freezer.py /opt/stack/horizon/openstack_dashboard/local/enabled/ + + # modify _50_freezer.py (line 9) and point the path to the freezer repo. + + # /opt/stack/horizon/tools/with_venv.sh pip install parsedatetime + + # In horizons local_settings.py add the variable FREEZER_API_URL and set it + to the url of the freezer api server. Example: + + FREEZER_API_URL = 'http://127.0.0.1:9090' - # cp _50_freezer.py /opt/stack/horizon/openstack_dashboard/enabled/ # cd /opt/stack/horizon/ - # ./run_tests.sh --runserver 0.0.0.0:8878 + # ./run_tests.sh --runserver 0.0.0.0:8000 -Now a new Tab is available in the dashboard lists on the left, -called "Backup as a Service" and a sub tab called "Freezer". +Now a new Tab is available in the dashboard lists on the left, called "Backup +Restore DR". +Running the unit tests +====================== + +1. Create a virtual environment: + virtualenv --no-site-packages -p /usr/bin/python2.7 .venv + +2. Activate the virtual environment: + . ./.venv/bin/activate + +3. Install the requirements: + pip install -r test-requirements.txt + +4. Run the tests: + python manage.py test . --settings=freezer_ui.tests.settings + +Test coverage +------------- + +1. Collect coverage information: + coverage run --source='.' --omit='.venv/*' manage.py test . --settings=freezer_ui.tests.settings + +2. View coverage report: + coverage report diff --git a/_50_freezer.py b/_50_freezer.py index 3db3fdd..de6d898 100644 --- a/_50_freezer.py +++ b/_50_freezer.py @@ -1,10 +1,16 @@ # The name of the dashboard to be added to HORIZON['dashboards']. Required. -DASHBOARD = 'freezer' +DASHBOARD = 'freezer_ui' # If set to True, this dashboard will not be added to the settings. DISABLED = False +# Until there is a more elegant SYSPATH var scheme... +import sys +sys.path.append('/opt/stack/freezer') + # A list of applications to be added to INSTALLED_APPS. ADD_INSTALLED_APPS = [ - 'openstack_dashboard.dashboards.freezer', + 'horizon_web_ui.freezer_ui', ] + +ADD_JS_FILES = ['freezer/js/freezer.js'] diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..d95ce28 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +__author__ = 'jonas' diff --git a/freezer/dashboard.py b/freezer/dashboard.py deleted file mode 100644 index f4c46fe..0000000 --- a/freezer/dashboard.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.utils.translation import ugettext_lazy as _ - -import horizon - - -class Mygroup(horizon.PanelGroup): - slug = "mygroup" - name = _("Freezer") - panels = ('freezerpanel',) - - -class Freezer(horizon.Dashboard): - name = _("Backup-aaS") - slug = "freezer" - panels = (Mygroup,) # Add your panels here. - default_panel = 'freezerpanel' # Specify the slug of the default panel. - - -horizon.register(Freezer) \ No newline at end of file diff --git a/freezer/freezerpanel/panel.py b/freezer/freezerpanel/panel.py deleted file mode 100644 index 26cf9d2..0000000 --- a/freezer/freezerpanel/panel.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.utils.translation import ugettext_lazy as _ - -import horizon - -from openstack_dashboard.dashboards.freezer import dashboard - - -class Freezerpanel(horizon.Panel): - name = _("Admin") - slug = "freezerpanel" - - -dashboard.Freezer.register(Freezerpanel) diff --git a/freezer/freezerpanel/tables.py b/freezer/freezerpanel/tables.py deleted file mode 100644 index b892da0..0000000 --- a/freezer/freezerpanel/tables.py +++ /dev/null @@ -1,220 +0,0 @@ -from django.utils.translation import ugettext_lazy as _ -from django.utils.translation import ungettext_lazy -from horizon.utils.urlresolvers import reverse # noqa -from openstack_dashboard import api -from django.template import defaultfilters as filters -from django.utils import http -from django.utils import safestring - -from django import template - -from horizon import tables - -LOADING_IMAGE = safestring.mark_safe('') - -def get_metadata(container): - # If the metadata has not been loading, display a loading image - if not get_metadata_loaded(container): - return LOADING_IMAGE - template_name = 'freezer/freezerpanel/_container_metadata.html' - context = {"container": container} - return template.loader.render_to_string(template_name, context) - -def wrap_delimiter(name): - if name and not name.endswith(api.swift.FOLDER_DELIMITER): - return name + api.swift.FOLDER_DELIMITER - return name - -def get_container_link(container): - return reverse("horizon:freezer:freezerpanel:index", - args=(wrap_delimiter(container.name),)) - -def get_metadata_loaded(container): - # Determine if metadata has been loaded if the attribute is already set. - return hasattr(container, 'is_public') and container.is_public is not None - -class ContainerAjaxUpdateRow(tables.Row): - ajax = True - - def get_data(self, request, container_name): - container = api.swift.swift_get_container(request, - container_name, - with_data=False) - return container - - -class ContainersTable(tables.DataTable): - METADATA_LOADED_CHOICES = ( - (False, None), - (True, True), - ) - name = tables.Column("name", link=get_container_link, - verbose_name=_("Name")) - bytes = tables.Column(lambda x: x.container_bytes_used if get_metadata_loaded(x) else LOADING_IMAGE - , verbose_name=_("Size")) - count = tables.Column(lambda x: x.container_object_count if get_metadata_loaded(x) else LOADING_IMAGE - , verbose_name=_("Object count")) - metadata = tables.Column(get_metadata, - verbose_name=_("Container Details"), - classes=('nowrap-col', ),) - - metadata_loaded = tables.Column(get_metadata_loaded, - status=True, - status_choices=METADATA_LOADED_CHOICES, - hidden=True) - def get_object_id(self, container): - return container.name - - def get_absolute_url(self): - url = super(ContainersTable, self).get_absolute_url() - return http.urlquote(url) - - def get_full_url(self): - """Returns the encoded absolute URL path with its query string.""" - url = super(ContainersTable, self).get_full_url() - return http.urlquote(url) - - class Meta: - name = "containers" - verbose_name = _("Backups") - row_class = ContainerAjaxUpdateRow - status_columns = ['metadata_loaded', ] - - -class ObjectFilterAction(tables.FilterAction): - def _filtered_data(self, table, filter_string): - request = table.request - container = self.table.kwargs['container_name'] - subfolder = self.table.kwargs['subfolder_path'] - prefix = wrap_delimiter(subfolder) if subfolder else '' - self.filtered_data = api.swift.swift_filter_objects(request, - filter_string, - container, - prefix=prefix) - return self.filtered_data - - def filter_subfolders_data(self, table, objects, filter_string): - data = self._filtered_data(table, filter_string) - return [datum for datum in data if - datum.content_type == "application/pseudo-folder"] - - def filter_objects_data(self, table, objects, filter_string): - data = self._filtered_data(table, filter_string) - return [datum for datum in data if - datum.content_type != "application/pseudo-folder"] - - def allowed(self, request, datum=None): - if self.table.kwargs.get('container_name', None): - return True - return False - -def get_link_subfolder(subfolder): - container_name = subfolder.container_name - return reverse("horizon:freezer:freezerpanel:index", - args=(wrap_delimiter(container_name), - wrap_delimiter(subfolder.name))) - -def sanitize_name(name): - return name.split(api.swift.FOLDER_DELIMITER)[-1] - - -def get_size(obj): - if obj.bytes is None: - return _("pseudo-folder") - return filters.filesizeformat(obj.bytes) - -class DeleteObject(tables.DeleteAction): - @staticmethod - def action_present(count): - return ungettext_lazy( - u"Delete Object", - u"Delete Objects", - count - ) - - @staticmethod - def action_past(count): - return ungettext_lazy( - u"Deleted Object", - u"Deleted Objects", - count - ) - - name = "delete_object" - allowed_data_types = ("objects", "subfolders",) - - def delete(self, request, obj_id): - obj = self.table.get_object_by_id(obj_id) - container_name = obj.container_name - datum_type = getattr(obj, self.table._meta.data_type_name, None) - if datum_type == 'subfolders': - obj_id = obj_id[(len(container_name) + 1):] + "/" - api.swift.swift_delete_object(request, container_name, obj_id) - - def get_success_url(self, request): - url = super(DeleteObject, self).get_success_url(request) - return http.urlquote(url) - - -class DeleteMultipleObjects(DeleteObject): - name = "delete_multiple_objects" - -class CreatePseudoFolder(tables.FilterAction): - def _filtered_data(self, table, filter_string): - request = table.request - container = self.table.kwargs['container_name'] - subfolder = self.table.kwargs['subfolder_path'] - prefix = wrap_delimiter(subfolder) if subfolder else '' - self.filtered_data = api.swift.swift_filter_objects(request, - filter_string, - container, - prefix=prefix) - return self.filtered_data - - def filter_subfolders_data(self, table, objects, filter_string): - data = self._filtered_data(table, filter_string) - return [datum for datum in data if - datum.content_type == "application/pseudo-folder"] - - def filter_objects_data(self, table, objects, filter_string): - data = self._filtered_data(table, filter_string) - return [datum for datum in data if - datum.content_type != "application/pseudo-folder"] - - def allowed(self, request, datum=None): - if self.table.kwargs.get('container_name', None): - return True - return False - -class ObjectsTable(tables.DataTable): - name = tables.Column("name", - link=get_link_subfolder, - allowed_data_types=("subfolders",), - verbose_name=_("Object Name"), - filters=(sanitize_name,)) - - size = tables.Column(get_size, verbose_name=_('Size')) - - class Meta: - name = "objects" - verbose_name = _("Objects") - table_actions = (ObjectFilterAction, - DeleteMultipleObjects) - data_types = ("subfolders", "objects") - browser_table = "content" - footer = False - - def get_absolute_url(self): - url = super(ObjectsTable, self).get_absolute_url() - return http.urlquote(url) - - def get_full_url(self): - """Returns the encoded absolute URL path with its query string. - - This is used for the POST action attribute on the form element - wrapping the table. We use this method to persist the - pagination marker. - - """ - url = super(ObjectsTable, self).get_full_url() - return http.urlquote(url) \ No newline at end of file diff --git a/freezer/freezerpanel/tabs.py b/freezer/freezerpanel/tabs.py deleted file mode 100644 index 36fb5e6..0000000 --- a/freezer/freezerpanel/tabs.py +++ /dev/null @@ -1,37 +0,0 @@ -from django.utils.translation import ugettext_lazy as _ - -from horizon import exceptions -from horizon import tabs - -from openstack_dashboard import api -from openstack_dashboard.dashboards.freezer.freezerpanel import tables - - -class ContainerTab(tabs.TableTab): - name = _("Backups Tab") - slug = "instances_tab" - table_classes = (tables.ContainersTable,) - template_name = ("horizon/common/_detail_table.html") - preload = False - - def has_more_data(self, table): - return self._has_more - - def get_containers_data(self): - try: - marker = self.request.GET.get( - tables.ContainersTable._meta.pagination_param, None) - containers, self._has_more = api.swift.swift_get_containers(self.request, marker) - print '{}'.format(containers) - return containers - except Exception: - self._has_more = False - error_message = _('Unable to get instances') - exceptions.handle(self.request, error_message) - - return [] - -class MypanelTabs(tabs.TabGroup): - slug = "mypanel_tabs" - tabs = (ContainerTab,) - sticky = True \ No newline at end of file diff --git a/freezer/freezerpanel/templates/freezerpanel/_container_metadata.html b/freezer/freezerpanel/templates/freezerpanel/_container_metadata.html deleted file mode 100644 index e268ff5..0000000 --- a/freezer/freezerpanel/templates/freezerpanel/_container_metadata.html +++ /dev/null @@ -1,12 +0,0 @@ -{% load i18n %} - diff --git a/freezer/freezerpanel/templates/freezerpanel/container.html b/freezer/freezerpanel/templates/freezerpanel/container.html deleted file mode 100644 index b02dfd6..0000000 --- a/freezer/freezerpanel/templates/freezerpanel/container.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends 'freezer/base.html' %} -{% load i18n %} -{% block title %}{% trans "Backups" %}{% endblock %} - -{% block page_header %} - {% include "horizon/common/_page_header.html" with title=_("Freezer") %} -{% endblock page_header %} - -{% block mydashboard_main %} -
-
- {{ swift_browser.render }} -
-
-{% endblock %} \ No newline at end of file diff --git a/freezer/freezerpanel/templates/freezerpanel/index.html b/freezer/freezerpanel/templates/freezerpanel/index.html deleted file mode 100644 index dd1327d..0000000 --- a/freezer/freezerpanel/templates/freezerpanel/index.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends 'freezer/base.html' %} -{% load i18n %} -{% block title %}{% trans "Backups" %}{% endblock %} - -{% block page_header %} - {% include "horizon/common/_page_header.html" with title=_("Freezer") %} -{% endblock page_header %} - -{% block mydashboard_main %} -
-
- {{ tab_group.render }} -
-
-{% endblock %} \ No newline at end of file diff --git a/freezer/freezerpanel/tests.py b/freezer/freezerpanel/tests.py deleted file mode 100644 index 6ee36f9..0000000 --- a/freezer/freezerpanel/tests.py +++ /dev/null @@ -1,7 +0,0 @@ -from horizon.test import helpers as test - - -class MypanelTests(test.TestCase): - # Unit tests for mypanel. - def test_me(self): - self.assertTrue(1 + 1 == 2) diff --git a/freezer/freezerpanel/urls.py b/freezer/freezerpanel/urls.py deleted file mode 100644 index 0dc775c..0000000 --- a/freezer/freezerpanel/urls.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.conf.urls import patterns -from django.conf.urls import url - -from openstack_dashboard.dashboards.freezer.freezerpanel import views - - -urlpatterns = patterns('', - url(r'^$', views.IndexView.as_view(), name='index'), - - url(r'^((?P.+?)/)?(?P(.+/)+)?$', - views.BackupView.as_view(), name='index'), - - url(r'^\?tab=mypanel_tabs_tab$', - views.IndexView.as_view(), name='mypanel_tabs'), -) \ No newline at end of file diff --git a/freezer/freezerpanel/views.py b/freezer/freezerpanel/views.py deleted file mode 100644 index 6baba09..0000000 --- a/freezer/freezerpanel/views.py +++ /dev/null @@ -1,98 +0,0 @@ -from horizon import browsers -from horizon import tabs -from horizon import exceptions - -from django.utils.translation import ugettext_lazy as _ -from django.utils.functional import cached_property # noqa -from openstack_dashboard import api -from openstack_dashboard.dashboards.freezer.freezerpanel \ - import tabs as freezer_tabs -from openstack_dashboard.dashboards.freezer.freezerpanel \ - import browsers as freezer_browsers - -class IndexView(tabs.TabbedTableView): - tab_group_class = freezer_tabs.MypanelTabs - template_name = 'freezer/freezerpanel/index.html' - - def get_data(self, request, context, *args, **kwargs): - # Add data to the context here... - return context - -class BackupView(browsers.ResourceBrowserView): - browser_class = freezer_browsers.ContainerBrowser - template_name = "freezer/freezerpanel/container.html" - - def get_containers_data(self): - containers = [] - self._more = None - marker = self.request.GET.get('marker', None) - try: - containers, self._more = api.swift.swift_get_containers( - self.request, marker=marker) - except Exception: - msg = _('Unable to retrieve container list.') - exceptions.handle(self.request, msg) - return containers - - @cached_property - def objects(self): - """Returns a list of objects given the subfolder's path. - - The path is from the kwargs of the request. - """ - objects = [] - self._more = None - marker = self.request.GET.get('marker', None) - container_name = self.kwargs['container_name'] - subfolder = self.kwargs['subfolder_path'] - prefix = None - if container_name: - self.navigation_selection = True - if subfolder: - prefix = subfolder - try: - objects, self._more = api.swift.swift_get_objects( - self.request, - container_name, - marker=marker, - prefix=prefix) - except Exception: - self._more = None - objects = [] - msg = _('Unable to retrieve object list.') - exceptions.handle(self.request, msg) - return objects - - def is_subdir(self, item): - content_type = "application/pseudo-folder" - return getattr(item, "content_type", None) == content_type - - def is_placeholder(self, item): - object_name = getattr(item, "name", "") - return object_name.endswith(api.swift.FOLDER_DELIMITER) - - def get_objects_data(self): - """Returns a list of objects within the current folder.""" - filtered_objects = [item for item in self.objects - if (not self.is_subdir(item) and - not self.is_placeholder(item))] - return filtered_objects - - def get_subfolders_data(self): - """Returns a list of subfolders within the current folder.""" - filtered_objects = [item for item in self.objects - if self.is_subdir(item)] - return filtered_objects - - def get_context_data(self, **kwargs): - context = super(BackupView, self).get_context_data(**kwargs) - context['container_name'] = self.kwargs["container_name"] - context['subfolders'] = [] - if self.kwargs["subfolder_path"]: - (parent, slash, folder) = self.kwargs["subfolder_path"] \ - .strip('/').rpartition('/') - while folder: - path = "%s%s%s/" % (parent, slash, folder) - context['subfolders'].insert(0, (folder, path)) - (parent, slash, folder) = parent.rpartition('/') - return context diff --git a/freezer/static/mydashboard/css/mydashboard.css b/freezer/static/mydashboard/css/mydashboard.css deleted file mode 100644 index a329e7d..0000000 --- a/freezer/static/mydashboard/css/mydashboard.css +++ /dev/null @@ -1 +0,0 @@ -/* Additional CSS for mydashboard. */ diff --git a/freezer/static/mydashboard/js/mydashboard.js b/freezer/static/mydashboard/js/mydashboard.js deleted file mode 100644 index d8a2176..0000000 --- a/freezer/static/mydashboard/js/mydashboard.js +++ /dev/null @@ -1 +0,0 @@ -/* Additional JavaScript for mydashboard. */ diff --git a/freezer/__init__.py b/freezer_ui/__init__.py similarity index 100% rename from freezer/__init__.py rename to freezer_ui/__init__.py diff --git a/freezer/freezerpanel/__init__.py b/freezer_ui/actions/__init__.py similarity index 100% rename from freezer/freezerpanel/__init__.py rename to freezer_ui/actions/__init__.py diff --git a/freezer/freezerpanel/models.py b/freezer_ui/actions/models.py similarity index 100% rename from freezer/freezerpanel/models.py rename to freezer_ui/actions/models.py diff --git a/freezer_ui/actions/panel.py b/freezer_ui/actions/panel.py new file mode 100644 index 0000000..7e35a68 --- /dev/null +++ b/freezer_ui/actions/panel.py @@ -0,0 +1,25 @@ +# 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 horizon_web_ui.freezer_ui import dashboard + + +class ActionsPanel(horizon.Panel): + name = _("Jobs") + slug = "actions" + + +dashboard.Freezer.register(ActionsPanel) diff --git a/freezer_ui/actions/tables.py b/freezer_ui/actions/tables.py new file mode 100644 index 0000000..6015ba7 --- /dev/null +++ b/freezer_ui/actions/tables.py @@ -0,0 +1,73 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.utils.translation import ugettext_lazy as _ + +from horizon import tables +from horizon.utils import functions as utils + +from horizon_web_ui.freezer_ui.django_utils import timestamp_to_string + + +class ActionsTable(tables.DataTable): + METADATA_LOADED_CHOICES = ( + (False, None), + (True, True), + ) + + STATUS_DISPLAY = ( + ('pending', 'Pending'), + ('started', 'Started'), + ('abort_req', 'Abort Requested'), + ('aborting', 'Aborting'), + ('aborted', 'Aborted'), + ('success', 'Success'), + ('fail', 'Failed') + ) + + TYPE_DISPLAY = ( + ('restore', 'Restore'), + ('backup', 'Backup (Unscheduled)') + ) + + client_id = tables.Column("client_id", verbose_name=_("Client Id")) + type = tables.Column('action', verbose_name=_("Type"), + display_choices=TYPE_DISPLAY) + description = tables.Column("description", verbose_name=_("Description")) + status = tables.Column('status', + verbose_name=_("Status"), + display_choices=STATUS_DISPLAY) + created = tables.Column('time_created', verbose_name=_("Created"), + filters=(timestamp_to_string,)) + started = tables.Column('time_started', verbose_name=_("Started"), + filters=(timestamp_to_string,)) + ended = tables.Column('time_ended', verbose_name=_("Ended"), + filters=(timestamp_to_string,)) + + def get_object_id(self, action): + return action.id + + def __init__(self, *args, **kwargs): + super(ActionsTable, self).__init__(*args, **kwargs) + + if 'offset' in self.request.GET: + self.offset = self.request.GET['offset'] + else: + self.offset = 0 + + def get_pagination_string(self): + page_size = utils.get_page_size(self.request) + return "=".join(['offset', str(self.offset + page_size)]) + + class Meta(object): + name = "jobs" + verbose_name = _("Jobs") diff --git a/freezer_ui/actions/templates/actions/index.html b/freezer_ui/actions/templates/actions/index.html new file mode 100644 index 0000000..46ac305 --- /dev/null +++ b/freezer_ui/actions/templates/actions/index.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "VMs" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Jobs") %} +{% endblock page_header %} + +{% block main %} + {{ table.render }} +{% endblock %} diff --git a/freezer_ui/actions/urls.py b/freezer_ui/actions/urls.py new file mode 100644 index 0000000..07913af --- /dev/null +++ b/freezer_ui/actions/urls.py @@ -0,0 +1,22 @@ +# 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 +from django.conf.urls import url + +from horizon_web_ui.freezer_ui.actions import views + + +urlpatterns = patterns( + '', + url(r'^$', views.IndexView.as_view(), name='index'), +) diff --git a/freezer_ui/actions/views.py b/freezer_ui/actions/views.py new file mode 100644 index 0000000..5158086 --- /dev/null +++ b/freezer_ui/actions/views.py @@ -0,0 +1,34 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.utils.translation import ugettext_lazy as _ + +from horizon import tables +from horizon_web_ui.freezer_ui.actions import tables as actions_tables +from horizon_web_ui.freezer_ui.api import api as freezer_api + + +class IndexView(tables.DataTableView): + name = _("Jobs") + slug = "actions" + table_class = actions_tables.ActionsTable + template_name = ("freezer_ui/actions/index.html") + + def has_more_data(self, table): + return self._has_more + + def get_data(self): + backups, self._has_more = freezer_api.actions_list( + self.request, + offset=self.table.offset) + + return backups diff --git a/freezer_ui/api/__init__.py b/freezer_ui/api/__init__.py new file mode 100644 index 0000000..d95ce28 --- /dev/null +++ b/freezer_ui/api/__init__.py @@ -0,0 +1 @@ +__author__ = 'jonas' diff --git a/freezer_ui/api/api.py b/freezer_ui/api/api.py new file mode 100644 index 0000000..008d528 --- /dev/null +++ b/freezer_ui/api/api.py @@ -0,0 +1,293 @@ +# 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. + +"""Some helper functions to use the freezer_ui client functionality + from horizon. +""" + +from django.conf import settings +import warnings + +import freezer.apiclient.client + +from horizon.utils import functions as utils +from horizon.utils.memoized import memoized # noqa + + +class Dict2Object(object): + """Makes dictionary fields accessible as if they are attributes. + + The dictionary keys become class attributes. It is possible to use one + nested dictionary by overwriting nested_dict with the key of that nested + dict. + + This class is needed because we mostly deal with objects in horizon (e.g. + for providing data to the tables) but the api only gives us json data. + """ + nested_dict = None + + def __init__(self, data_dict): + self.data_dict = data_dict + + def __getattr__(self, attr): + """Make data_dict fields available via class interface """ + if attr in self.data_dict: + return self.data_dict[attr] + elif attr in self.data_dict[self.nested_dict]: + return self.data_dict[self.nested_dict][attr] + else: + return object.__getattribute__(self, attr) + + def get_dict(self): + return self.data_dict + + +class Action(Dict2Object): + nested_dict = 'job' + + @property + def id(self): + return self.action_id + + +class Configuration(Dict2Object): + nested_dict = 'config_file' + + @property + def id(self): + return self.config_id + + +class Backup(Dict2Object): + nested_dict = 'backup_metadata' + + @property + def id(self): + return self.backup_id + + +class Client(Dict2Object): + nested_dict = 'client' + + @property + def id(self): + return self.client_id + + @property + def name(self): + return self.client_id + + +class ConfigClient(object): + + def __init__(self, name, last_backup): + self.id = name + self.name = name + self.last_backup = last_backup + + +@memoized +def _freezerclient(request): + warnings.warn('Endpoint discovery not implemented yet. Using hard-coded: {' + '}'.format(settings.FREEZER_API_URL)) + + return freezer.apiclient.client.Client( + token=request.user.token.id, + auth_url=getattr(settings, 'OPENSTACK_KEYSTONE_URL'), + endpoint=settings.FREEZER_API_URL) + + +def configuration_create(request, name=None, container_name=None, + src_file=None, levels=None, optimize=None, + compression=None, encryption_password=None, + clients=[], start_datetime=None, interval=None, + exclude=None, log_file=None, proxy=None, + max_priority=False): + """Create a new configuration file """ + + data = { + "name": name, + "container_name": container_name, + "src_file": src_file, + "levels": levels, + "optimize": optimize, + "compression": compression, + "encryption_password": encryption_password, + "clients": clients, + "start_datetime": start_datetime, + "interval": interval, + "exclude": exclude, + "log_file": log_file, + "proxy": proxy, + "max_priority": max_priority + } + return _freezerclient(request).configs.create(data) + + +def configuration_update(request, config_id=None, name=None, + src_file=None, levels=None, optimize=None, + compression=None, encryption_password=None, + clients=[], start_datetime=None, interval=None, + exclude=None, log_file=None, proxy=None, + max_priority=False, container_name=None,): + + """Update a new configuration file """ + data = { + "name": name, + "container_name": container_name, + "src_file": src_file, + "levels": levels, + "optimize": optimize, + "compression": compression, + "encryption_password": encryption_password, + "clients": clients, + "start_datetime": start_datetime, + "interval": interval, + "exclude": exclude, + "log_file": log_file, + "proxy": proxy, + "max_priority": max_priority + } + return _freezerclient(request).configs.update(config_id, data) + + +def configuration_delete(request, obj_id): + return _freezerclient(request).configs.delete(obj_id) + + +def configuration_clone(request, config_id): + config_file = _freezerclient(request).configs.get(config_id) + data = config_file[0]['config_file'] + data['name'] = '{0}_clone'.format(data['name']) + return _freezerclient(request).configs.create(data) + + +def configuration_get(request, config_id): + config_file = _freezerclient(request).configs.get(config_id) + if config_file: + return [Configuration(data) for data in config_file] + return [] + + +def configuration_list(request): + configurations = _freezerclient(request).configs.list() + configurations = [Configuration(data) for data in configurations] + return configurations + + +def clients_in_config(request, config_id): + configuration = configuration_get(request, config_id) + clients = [] + last_backup = None + clients_dict = [c.get_dict() for c in configuration] + for client in clients_dict: + for client_id in client['config_file']['clients']: + backups, has_more = backups_list(request, text_match=client_id) + backups = [Backup(data) for data in backups] + backups = [b.get_dict() for b in backups] + for backup in backups: + last_backup = backup.data_dict['backup_metadata']['timestamp'] + clients.append(ConfigClient(client_id, last_backup)) + return clients + + +def client_list(request, limit=20): + clients = _freezerclient(request).registration.list(limit=limit) + clients = [Client(client) for client in clients] + return clients + + +def backups_list(request, offset=0, time_after=None, time_before=None, + text_match=None): + page_size = utils.get_page_size(request) + + search = {} + + if time_after: + search['time_after'] = time_after + if time_before: + search['time_before'] = time_before + + if text_match: + search['match'] = [ + { + "_all": text_match, + } + ] + + backups = _freezerclient(request).backups.list( + limit=page_size + 1, + offset=offset, + search=search) + + if len(backups) > page_size: + backups.pop() + has_more = True + else: + has_more = False + + # Wrap data in object for easier handling + backups = [Backup(data) for data in backups] + + return backups, has_more + + +def backup_get(request, backup_id): + data = _freezerclient(request).backups.get(backup_id) + if data: + return Backup(data[0]) + + +def restore_action_create(request, + backup_id, + destination_client_id, + destination_path, + description=None, + dry_run=False, + max_prio=False): + c = _freezerclient(request) + backup = c.backups.get(backup_id)[0] + + action = { + "job": { + "action": "restore", + "container_name": backup['backup_metadata']['container'], + "restore-abs-path": destination_path, + "backup-name": backup['backup_metadata']['backup_name'], + "restore-from-host": backup['backup_metadata']['host_name'], + "max_cpu_priority": max_prio, + "dry_run": dry_run + }, + "description": description, + "client_id": destination_client_id + } + + c.actions.create(action) + + +def actions_list(request, offset=0): + page_size = utils.get_page_size(request) + + actions = _freezerclient(request).actions.list( + limit=page_size + 1, + offset=offset) + + if len(actions) > page_size: + actions.pop() + has_more = True + else: + has_more = False + + # Wrap data in object for easier handling + actions = [Action(data['action']) for data in actions] + + return actions, has_more diff --git a/freezer_ui/api/rest/__init__.py b/freezer_ui/api/rest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freezer_ui/api/rest/rest_api.py b/freezer_ui/api/rest/rest_api.py new file mode 100644 index 0000000..a7a84d6 --- /dev/null +++ b/freezer_ui/api/rest/rest_api.py @@ -0,0 +1,37 @@ +import functools + +from django.views import generic + +from openstack_dashboard.api.rest import utils as rest_utils +from openstack_dashboard.api.rest.utils import JSONResponse + +import horizon_web_ui.freezer_ui.api.api as freezer_api + + +# https://github.com/tornadoweb/tornado/issues/1009 +# http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx/ +def prevent_json_hijacking(function): + @functools.wraps(function) + def wrapper(*args, **kwargs): + response = function(*args, **kwargs) + if isinstance(response, JSONResponse) and response.content: + response.content = ")]}',\n" + response.content + return response + + return wrapper + + +class Clients(generic.View): + """API for nova limits.""" + + @prevent_json_hijacking + @rest_utils.ajax() + def get(self, request): + """Get all registered freezer clients""" + + # we don't have a "get all clients" api (probably for good reason) so + # we need to resort to getting a very high number. + clients = freezer_api.client_list(request, limit=9999) + clients = [c.get_dict() for c in clients] + + return clients diff --git a/freezer_ui/api/rest/urls.py b/freezer_ui/api/rest/urls.py new file mode 100644 index 0000000..0d4ee6c --- /dev/null +++ b/freezer_ui/api/rest/urls.py @@ -0,0 +1,31 @@ +# Copyright 2012 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Copyright 2012 Nebula, 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. + +""" +URL patterns for the OpenStack Dashboard. +""" + +from django.conf.urls import patterns +from django.conf.urls import url + +import rest_api + +urlpatterns = patterns( + '', + url(r'^api/clients$', rest_api.Clients.as_view(), name="api_clients"), +) diff --git a/freezer_ui/backups/__init__.py b/freezer_ui/backups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freezer/models.py b/freezer_ui/backups/models.py similarity index 100% rename from freezer/models.py rename to freezer_ui/backups/models.py diff --git a/freezer_ui/backups/panel.py b/freezer_ui/backups/panel.py new file mode 100644 index 0000000..919a73c --- /dev/null +++ b/freezer_ui/backups/panel.py @@ -0,0 +1,25 @@ +# 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 + +import horizon_web_ui.freezer_ui.dashboard as dashboard + + +class BackupsPanel(horizon.Panel): + name = _("Backups") + slug = "backups" + + +dashboard.Freezer.register(BackupsPanel) diff --git a/freezer_ui/backups/restore_workflow.py b/freezer_ui/backups/restore_workflow.py new file mode 100644 index 0000000..43f56ec --- /dev/null +++ b/freezer_ui/backups/restore_workflow.py @@ -0,0 +1,107 @@ +# Copyright 2012 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Copyright 2012 Nebula, 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.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + +from horizon import forms +from horizon import workflows + +import horizon_web_ui.freezer_ui.api.api as freezer_api + +LOG = logging.getLogger(__name__) + + +class DestinationAction(workflows.MembershipAction): + path = forms.CharField(label=_("Destination Path"), + initial='/home/', + help_text="The path in which the backup should be " + "restored", + required=True) + backup_id = forms.CharField(widget=forms.HiddenInput()) + + def clean(self): + if 'client' in self.request.POST: + self.cleaned_data['client'] = self.request.POST['client'] + else: + raise ValidationError('Client is required') + + return self.cleaned_data + + class Meta(object): + name = _("Destination") + slug = "destination" + + +class Destination(workflows.Step): + template_name = 'freezer_ui/backups/restore.html' + action_class = DestinationAction + contributes = ('client', 'path', 'backup_id') + + def has_required_fields(self): + return True + + +class OptionsAction(workflows.Action): + description = forms.CharField(widget=forms.Textarea, + label="Description", + required=False, + help_text="Free text description of this " + "restore.") + + dry_run = forms.BooleanField(label=_("Dry Run"), + required=False) + max_prio = forms.BooleanField(label=_("Max Process Priority"), + required=False) + + class Meta(object): + name = _("Options") + + +class Options(workflows.Step): + action_class = OptionsAction + contributes = ('description', 'dry_run', 'max_prio') + after = Destination + + +class ConfigureBackups(workflows.Workflow): + slug = "restore" + name = _("Restore") + success_url = "horizon:freezer_ui:backups:index" + success_message = "Restore job successfully queued. It will get " \ + "executed soon." + wizard = False + default_steps = (Destination, Options) + + def __init__(self, *args, **kwargs): + super(ConfigureBackups, self).__init__(*args, **kwargs) + pass + + def handle(self, request, data): + freezer_api.restore_action_create( + request, + backup_id=data['backup_id'], + destination_client_id=data['client'], + destination_path=data['path'], + description=data['description'], + dry_run=data['dry_run'], + max_prio=data['max_prio'] + ) + return True diff --git a/freezer_ui/backups/tables.py b/freezer_ui/backups/tables.py new file mode 100644 index 0000000..7dfc068 --- /dev/null +++ b/freezer_ui/backups/tables.py @@ -0,0 +1,111 @@ +# 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.utils import safestring +from django.utils.translation import ugettext_lazy as _ +from horizon import tables +from horizon.utils import functions as utils +from horizon_web_ui.freezer_ui.django_utils import timestamp_to_string + + +class Restore(tables.LinkAction): + name = "restore" + verbose_name = _("Restore") + classes = ("ajax-modal", "btn-launch") + ajax = True + + def get_link_url(self, datum=None): + return reverse("horizon:freezer_ui:backups:restore", + kwargs={'backup_id': datum.id}) + + def allowed(self, request, instance): + return True # is_loaded(instance) + + +class BackupFilter(tables.FilterAction): + filter_type = "server" + filter_choices = (("before", "Created before", True), + ("after", "Created after", True), + ("between", "Created between", True), + ("contains", "Contains text", True)) + + +def icons(backup): + result = [] + + placeholder = '' + + level_txt = "Level: {} ({} backup) out of {}".format( + backup.level, "Full" if backup.level == 0 else "Incremental", + backup.max_level) + result.append( + '{}'.format( + level_txt, backup.level)) + + if backup.encrypted: + result.append( + '') + else: + result.append(placeholder) + + if int(backup.total_broken_links) > 0: + result.append( + ''.format(backup.total_broken_links)) + else: + result.append(placeholder) + + if backup.excluded_files: + result.append( + ''.format(len(backup.excluded_files))) + else: + result.append(placeholder) + + return safestring.mark_safe("".join(result)) + + +def backup_detail_view(backup): + return reverse("horizon:freezer_ui:backups:detail", args=[backup.id]) + + +class BackupsTable(tables.DataTable): + backup_name = tables.Column('backup_name', verbose_name=_("Backup Name"), + link=backup_detail_view) + host_name = tables.Column('host_name', verbose_name=_("Host Name")) + created_by = tables.Column("user_name", verbose_name=_("Created By")) + created = tables.Column("timestamp", + verbose_name=_("Created At"), + filters=[timestamp_to_string]) + icons = tables.Column(icons, verbose_name='Info') + + def __init__(self, *args, **kwargs): + super(BackupsTable, self).__init__(*args, **kwargs) + + if 'offset' in self.request.GET: + self.offset = self.request.GET['offset'] + else: + self.offset = 0 + + def get_object_id(self, backup): + return backup.id + + def get_pagination_string(self): + page_size = utils.get_page_size(self.request) + return "=".join(['offset', str(self.offset + page_size)]) + + class Meta(object): + name = "vms" + verbose_name = _("Backups") + row_actions = (Restore,) + table_actions = (BackupFilter, ) + multi_select = False diff --git a/freezer_ui/backups/templates/backups/detail.html b/freezer_ui/backups/templates/backups/detail.html new file mode 100644 index 0000000..1a5e67a --- /dev/null +++ b/freezer_ui/backups/templates/backups/detail.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %} + +{% load i18n %} +{% block title %}{% trans "Backups" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Backups") %} +{% endblock page_header %} + +{% block main %} +
+
+
{{ data }}
+
+
+ +{% endblock %} diff --git a/freezer_ui/backups/templates/backups/index.html b/freezer_ui/backups/templates/backups/index.html new file mode 100644 index 0000000..4987dcd --- /dev/null +++ b/freezer_ui/backups/templates/backups/index.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %} + +{% block css %} + {% include "_stylesheets.html" %} + +{% endblock %} + +{% load i18n %} +{% block title %}{% trans "VMs" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Backups") %} +{% endblock page_header %} + +{% block main %} + {{ table.render }} +{% endblock %} \ No newline at end of file diff --git a/freezer_ui/backups/templates/backups/restore.html b/freezer_ui/backups/templates/backups/restore.html new file mode 100644 index 0000000..0e744f7 --- /dev/null +++ b/freezer_ui/backups/templates/backups/restore.html @@ -0,0 +1,46 @@ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
HostnameDescription
+ + {$ client['client']['client_id'] $}{$ client['client']['description'] $}
+ {$ filtered.length ? filtered.length : 0 $} out of {$ clients.length $} displayed. Use the filter field to limit the number of results. +
+
+
+ {% include "horizon/common/_form_fields.html" %} + {{ table.render }} +
+
+ {{ step.get_help_text }} +
+
diff --git a/freezer_ui/backups/urls.py b/freezer_ui/backups/urls.py new file mode 100644 index 0000000..971b4e1 --- /dev/null +++ b/freezer_ui/backups/urls.py @@ -0,0 +1,26 @@ +# 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 +from django.conf.urls import url + +from horizon_web_ui.freezer_ui.backups import views + + +urlpatterns = patterns( + '', + url(r'^$', views.IndexView.as_view(), name='index'), + url(r'^(?P[^/]*)$', views.DetailView.as_view(), name='detail'), + url(r'^restore/(?P.*)$', + views.RestoreView.as_view(), + name='restore'), +) diff --git a/freezer_ui/backups/views.py b/freezer_ui/backups/views.py new file mode 100644 index 0000000..a781eef --- /dev/null +++ b/freezer_ui/backups/views.py @@ -0,0 +1,141 @@ +# 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 datetime +import pprint +import time + +from django.core.urlresolvers import reverse +from django.template.defaultfilters import date as django_date +from django.utils.translation import ugettext_lazy as _ +from django.views import generic +import parsedatetime as pdt + +from horizon import exceptions +from horizon import messages +from horizon import tables +from horizon import workflows +from horizon_web_ui.freezer_ui.backups import tables as freezer_tables + +import horizon_web_ui.freezer_ui.api.api as freezer_api +import restore_workflow + + +class IndexView(tables.DataTableView): + name = _("Backups") + slug = "backups" + table_class = freezer_tables.BackupsTable + template_name = ("freezer_ui/backups/index.html") + + def has_more_data(self, table): + return self._has_more + + def get_data(self): + filter = self.get_filters(self.request, + self.table.get_filter_field(), + self.table.get_filter_string()) + + backups, self._has_more = freezer_api.backups_list( + self.request, + offset=self.table.offset, + time_after=filter['from'], + time_before=filter['to'], + text_match=filter['contains'] + ) + + return backups + + def get_filters(self, request, filter_field, filter_string): + cal = pdt.Calendar() + + filters = {} + filters['from'] = None + filters['to'] = None + filters['contains'] = None + + if filter_field == 'between': + result_range = cal.nlp(filter_string) + + if result_range and len(result_range) == 2: + filters['from'] = int( + time.mktime(result_range[0][0].timetuple())) + filters['to'] = int( + time.mktime(result_range[1][0].timetuple())) + else: + messages.warning( + request, + "Please enter two dates. E.g: '01/01/2014 - 05/09/2015'.") + elif filter_field in ['before', 'after']: + result, what = cal.parse(filter_string) + + if what == 0: + messages.warning( + self.table.request, + "Please enter a date/time. E.g: '01/01/2014 12pm' or '1 we" + "ek ago'.") + else: + field = 'to' if filter_field == 'before' else 'from' + + dt = datetime.datetime(*result[:6]) + + if what == 1: # a date without time + # use .date() to remove time part + filters[field] = int(time.mktime(dt.date().timetuple())) + elif what in [2, 3]: # date and time or time with current date + filters[field] = int(time.mktime(dt.timetuple())) + else: + raise Exception( + 'Unknown result when parsing date: {}'.format(what)) + elif filter_field == 'contains': + filters['contains'] = filter_string.lower() + + return filters + + +class DetailView(generic.TemplateView): + template_name = 'freezer_ui/backups/detail.html' + + def get_context_data(self, **kwargs): + + backup = freezer_api.get_backup(self.request, kwargs['backup_id']) + return {'data': pprint.pformat(backup.data_dict)} + + +class RestoreView(workflows.WorkflowView): + workflow_class = restore_workflow.ConfigureBackups + + def get_object(self, *args, **kwargs): + id = self.kwargs['backup_id'] + try: + return freezer_api.get_backup(self.request, id) + except Exception: + redirect = reverse("horizon:freezer_ui:backups:index") + msg = _('Unable to retrieve details.') + exceptions.handle(self.request, msg, redirect=redirect) + + def is_update(self): + return 'name' in self.kwargs and bool(self.kwargs['name']) + + def get_workflow_name(self): + backup = freezer_api.backup_get(self.request, self.kwargs['backup_id']) + backup_date = datetime.datetime.fromtimestamp(int(backup.timestamp)) + backup_date_str = django_date(backup_date, 'SHORT_DATETIME_FORMAT') + return "Restore '{}' from {}".format( + backup.backup_name, backup_date_str) + + def get_initial(self): + return {"backup_id": self.kwargs['backup_id']} + + def get_workflow(self, *args, **kwargs): + workflow = super(RestoreView, self).get_workflow(*args, **kwargs) + workflow.name = self.get_workflow_name() + + return workflow diff --git a/freezer_ui/configurations/__init__.py b/freezer_ui/configurations/__init__.py new file mode 100644 index 0000000..d95ce28 --- /dev/null +++ b/freezer_ui/configurations/__init__.py @@ -0,0 +1 @@ +__author__ = 'jonas' diff --git a/freezer/freezerpanel/browsers.py b/freezer_ui/configurations/browsers.py similarity index 63% rename from freezer/freezerpanel/browsers.py rename to freezer_ui/configurations/browsers.py index 1ae3368..5a0e4dd 100644 --- a/freezer/freezerpanel/browsers.py +++ b/freezer_ui/configurations/browsers.py @@ -16,16 +16,13 @@ from django.utils.translation import ugettext_lazy as _ from horizon import browsers -from openstack_dashboard.dashboards.freezer.freezerpanel import tables +from horizon_web_ui.freezer_ui.configurations import tables class ContainerBrowser(browsers.ResourceBrowser): - name = "swift" - verbose_name = _("Swift") - navigation_table_class = tables.ContainersTable - content_table_class = tables.ObjectsTable - navigable_item_name = _("Container") - navigation_kwarg_name = "container_name" - content_kwarg_name = "subfolder_path" - has_breadcrumb = True - breadcrumb_url = "horizon:freezer:freezerpanel:index" + name = "backup_configuration" + verbose_name = _("Backup Configuration") + navigation_table_class = tables.BackupConfigsTable + content_table_class = tables.InstancesTable + navigable_item_name = _("Backup Configuration") + navigation_kwarg_name = "name" diff --git a/freezer_ui/configurations/models.py b/freezer_ui/configurations/models.py new file mode 100644 index 0000000..1b3d5f9 --- /dev/null +++ b/freezer_ui/configurations/models.py @@ -0,0 +1,3 @@ +""" +Stub file to work around django bug: https://code.djangoproject.com/ticket/7198 +""" diff --git a/freezer_ui/configurations/panel.py b/freezer_ui/configurations/panel.py new file mode 100644 index 0000000..082e3fd --- /dev/null +++ b/freezer_ui/configurations/panel.py @@ -0,0 +1,24 @@ +# 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 horizon_web_ui.freezer_ui import dashboard + + +class BackupConfigsPanel(horizon.Panel): + name = _("Configurations") + slug = "configurations" + + +dashboard.Freezer.register(BackupConfigsPanel) diff --git a/freezer_ui/configurations/tables.py b/freezer_ui/configurations/tables.py new file mode 100644 index 0000000..3351ee4 --- /dev/null +++ b/freezer_ui/configurations/tables.py @@ -0,0 +1,161 @@ +# 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 datetime + +from django import shortcuts +from django.utils import safestring +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ungettext_lazy + +from horizon import messages +from horizon import tables +from horizon.utils.urlresolvers import reverse + +import horizon_web_ui.freezer_ui.api.api as freezer_api +from horizon_web_ui.freezer_ui.django_utils import timestamp_to_string + + +def format_last_backup(last_backup): + last_backup_ts = datetime.datetime.fromtimestamp(last_backup) + ten_days_later = last_backup_ts + datetime.timedelta(days=10) + today = datetime.datetime.today() + + if last_backup is None: + colour = 'red' + icon = 'fire' + text = 'Never' + elif ten_days_later < today: + colour = 'orange' + icon = 'thumbs-down' + text = timestamp_to_string(last_backup) + else: + colour = 'green' + icon = 'thumbs-up' + text = timestamp_to_string(last_backup) + + return safestring.mark_safe( + ' {}'.format(colour, icon, text)) + + +class Restore(tables.Action): + name = "restore" + verbose_name = _("Restore") + + def single(self, table, request, instance): + messages.info(request, "Needs to be implemented") + + def allowed(self, request, instance): + return True + + +class DeleteConfig(tables.DeleteAction): + name = "delete" + classes = ("btn-danger",) + icon = "remove" + help_text = _("Delete configurations are not recoverable.") + + @staticmethod + def action_present(count): + return ungettext_lazy( + u"Delete Configuration File", + u"Delete Configuration Files", + count + ) + + @staticmethod + def action_past(count): + return ungettext_lazy( + u"Deleted Configuration File", + u"Deleted Configuration Files", + count + ) + + def delete(self, request, obj_id): + return freezer_api.configuration_delete(request, obj_id) + + +class CloneConfig(tables.Action): + name = "clone" + verbose_name = _("Clone") + # classes = ("ajax-modal",) + help_text = _("Clone and edit a configuration file") + + def single(self, table, request, obj_id): + freezer_api.configuration_clone(request, obj_id) + return shortcuts.redirect('horizon:freezer_ui:configurations:index') + + +class EditConfig(tables.LinkAction): + name = "edit" + verbose_name = _("Edit") + classes = ("ajax-modal",) + + def get_link_url(self, datum=None): + return reverse("horizon:freezer_ui:configurations:configure", + kwargs={'name': datum.config_id}) + + +def get_backup_configs_link(backup_config): + return reverse('horizon:freezer_ui:configurations:index', + kwargs={'config_id': backup_config.config_id}) + + +class CreateConfig(tables.LinkAction): + name = "create" + verbose_name = _("Create Configuration") + url = "horizon:freezer_ui:configurations:create" + classes = ("ajax-modal",) + icon = "plus" + + +class BackupConfigsTable(tables.DataTable): + name = tables.Column("name", link=get_backup_configs_link, + verbose_name=_("Configuration Name")) + + def get_object_id(self, backup_config): + return backup_config.id + + class Meta(object): + name = "backup_configuration" + verbose_name = _("Backup Configurations") + table_actions = (CreateConfig,) + footer = False + multi_select = False + row_actions = (EditConfig, + CloneConfig, + DeleteConfig, ) + + +class ObjectFilterAction(tables.FilterAction): + def allowed(self, request, datum): + return bool(self.table.kwargs['config_id']) + + +class InstancesTable(tables.DataTable): + client = tables.Column('name', verbose_name=_("Client Name")) + + created = tables.Column('last_backup', + filters=(format_last_backup,), + verbose_name=_("Last backup")) + + def get_object_id(self, container): + return container.name + + class Meta(object): + name = "clients" + verbose_name = _("Clients") + table_actions = (ObjectFilterAction,) + row_actions = (Restore,) + footer = False + multi_select = False diff --git a/freezer_ui/configurations/templates/configurations/_workflow_step_update_members.html b/freezer_ui/configurations/templates/configurations/_workflow_step_update_members.html new file mode 100644 index 0000000..0507419 --- /dev/null +++ b/freezer_ui/configurations/templates/configurations/_workflow_step_update_members.html @@ -0,0 +1,61 @@ +{% load i18n %} + + + + + +
+ {% include "horizon/common/_form_fields.html" %} +
+ + + diff --git a/freezer_ui/configurations/templates/configurations/browser.html b/freezer_ui/configurations/templates/configurations/browser.html new file mode 100644 index 0000000..a5e354f --- /dev/null +++ b/freezer_ui/configurations/templates/configurations/browser.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Backup Configurations" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Backup Configurations") %} +{% endblock page_header %} + +{% block main %} + {{ backup_configuration_browser.render }} +{% endblock %} diff --git a/freezer_ui/configurations/urls.py b/freezer_ui/configurations/urls.py new file mode 100644 index 0000000..d73d025 --- /dev/null +++ b/freezer_ui/configurations/urls.py @@ -0,0 +1,34 @@ +# 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 +from django.conf.urls import url + +from horizon_web_ui.freezer_ui.configurations import views + + +urlpatterns = patterns( + '', + # url(r'^$', views.BackupConfigsView.as_view(), name='test'), + + url(r'^(?P[^/]+)?$', + views.BackupConfigsView.as_view(), + name='index'), + + url(r'^create/$', + views.ConfigureWorkflowView.as_view(), + name='create'), + + url(r'^configure/(?P[^/]+)?$', + views.ConfigureWorkflowView.as_view(), + name='configure'), +) diff --git a/freezer_ui/configurations/utils.py b/freezer_ui/configurations/utils.py new file mode 100644 index 0000000..f62b258 --- /dev/null +++ b/freezer_ui/configurations/utils.py @@ -0,0 +1,40 @@ +# Copyright 2014 Hewlett-Packard +# +# 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. + + +class Configuration(object): + def __init__(self, data_dict): + self.data_dict = data_dict + + @property + def id(self): + return self.config_id + + def __getattr__(self, attr): + """Make data_dict fields available via class interface """ + if attr in self.data_dict: + return self.data_dict[attr] + elif attr in self.data_dict['config_file']: + return self.data_dict['config_file'][attr] + else: + return object.__getattribute__(self, attr) + + +class Client(object): + """Aggregate clients and metadata """ + + def __init__(self, client): + self.name = client + self.clients = client + self.client_id = client diff --git a/freezer_ui/configurations/views.py b/freezer_ui/configurations/views.py new file mode 100644 index 0000000..1dd2026 --- /dev/null +++ b/freezer_ui/configurations/views.py @@ -0,0 +1,94 @@ +# 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 workflows + +from horizon import browsers +from horizon import exceptions +import horizon_web_ui.freezer_ui.api.api as freezer_api +import horizon_web_ui.freezer_ui.configurations.browsers as project_browsers +import workflows.configure as configure_workflow + + +LOG = logging.getLogger(__name__) + + +class ConfigureWorkflowView(workflows.WorkflowView): + workflow_class = configure_workflow.ConfigureBackups + + def get_object(self, *args, **kwargs): + config_id = self.kwargs['name'] + try: + return freezer_api.configuration_get(self.request, config_id)[0] + except Exception: + redirect = reverse("horizon:freezer_ui:configurations:index") + msg = _('Unable to retrieve details.') + exceptions.handle(self.request, msg, redirect=redirect) + + def is_update(self): + return 'name' in self.kwargs and bool(self.kwargs['name']) + + def get_initial(self): + initial = super(ConfigureWorkflowView, self).get_initial() + if self.is_update(): + initial.update({'original_name': None}) + config = self.get_object() + initial['name'] = config.name + initial['container_name'] = config.container_name + initial['config_id'] = config.config_id + initial['src_file'] = config.src_file + initial['levels'] = config.levels + initial['optimize'] = config.optimize + initial['compression'] = config.compression + initial['encryption_password'] = config.encryption_password + initial['start_datetime'] = config.start_datetime + initial['interval'] = config.interval + initial['exclude'] = config.exclude + initial['log_file'] = config.log_file + initial['encryption_password'] = config.encryption_password + initial['proxy'] = config.proxy + initial['max_priority'] = config.max_priority + initial['clients'] = config.clients + initial['original_name'] = config.config_id + initial.update({'original_name': config.config_id}) + return initial + + +class BackupConfigsView(browsers.ResourceBrowserView): + browser_class = project_browsers.ContainerBrowser + template_name = "freezer_ui/configurations/browser.html" + + def get_backup_configuration_data(self): + configurations = [] + try: + configurations = freezer_api.configuration_list(self.request) + except Exception: + msg = _('Unable to retrieve configuration file list.') + exceptions.handle(self.request, msg) + return configurations + + def get_clients_data(self): + configuration = [] + try: + if self.kwargs['config_id']: + configuration = freezer_api.clients_in_config( + self.request, self.kwargs['config_id']) + + except Exception: + msg = _('Unable to retrieve instances for this configuration.') + exceptions.handle(self.request, msg) + return configuration diff --git a/freezer_ui/configurations/workflows/__init__.py b/freezer_ui/configurations/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freezer_ui/configurations/workflows/configure.py b/freezer_ui/configurations/workflows/configure.py new file mode 100644 index 0000000..8fbc061 --- /dev/null +++ b/freezer_ui/configurations/workflows/configure.py @@ -0,0 +1,315 @@ +# Copyright 2012 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Copyright 2012 Nebula, 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.utils.translation import ugettext_lazy as _ +from django.views.decorators.debug import sensitive_variables # noqa +from horizon import exceptions +from horizon import forms +from horizon import workflows + +import horizon_web_ui.freezer_ui.api.api as freezer_api + +LOG = logging.getLogger(__name__) + + +class BackupConfigurationAction(workflows.Action): + original_name = forms.CharField( + widget=forms.HiddenInput(), + required=False) + + name = forms.CharField( + label=_("Configuration Name"), + required=True) + + container_name = forms.CharField( + label=_("Swift Container Name"), + required=True) + + mode = forms.ChoiceField( + help_text="Choose what you want to backup", + required=True) + + src_file = forms.CharField( + label=_("Source File/Directory"), + help_text="The file or directory you want to back up to Swift", + required=True) + + def populate_mode_choices(self, request, context): + return [ + ('fs', _("File system")), + ('snapshot', _("Snapshot")), + ('mongo', _("MongoDB")), + ('mysql', _("MySQL")), + ('mssql', _("Microsoft SQL Server")), + ('elastic', _("ElasticSearch")), + ('postgres', _("Postgres")), + ] + + class Meta(object): + name = _("Backup") + + +class BackupConfiguration(workflows.Step): + action_class = BackupConfigurationAction + contributes = ('mode', + 'name', + 'container_name', + 'src_file', + 'original_name') + + +class OptionsConfigurationAction(workflows.Action): + levels = forms.IntegerField( + label=_("Number of incremental backups"), + initial=0, + min_value=0, + required=False, + help_text="Set the backup level used with tar" + " to implement incremental backup. " + "If a level 1 is specified but no " + "level 0 is already available, a " + "level 0 will be done and " + "subsequently backs to level 1. " + "Default 0 (No Incremental)") + + optimize = forms.ChoiceField( + choices=[('speed', _("Speed (tar)")), + ('bandwith', "Bandwith/Space (rsync)")], + help_text="", + label='Optimize for...', + required=False) + + compression = forms.ChoiceField( + choices=[('gzip', _("Minimum Compression (GZip/Zip)")), + ('bzip', _("Medium Compression (BZip2")), + ('xz', _("Maximum Compression (XZ)"))], + help_text="", + label='Compression Level', + required=False) + + encryption_password = forms.CharField( + label=_("Encryption Password"), # encryption key + widget=forms.PasswordInput(), + help_text="", + required=False) + + class Meta(object): + name = _("Options") + + +class OptionsConfiguration(workflows.Step): + action_class = OptionsConfigurationAction + contributes = ('levels', + 'optimize', + 'compression', + 'encryption_password',) + + +class ClientsConfigurationAction(workflows.MembershipAction): + def __init__(self, request, *args, **kwargs): + super(ClientsConfigurationAction, self).__init__(request, + *args, + **kwargs) + + err_msg_configured = 'Unable to retrieve list of configured clients.' + err_msg_all = 'Unable to retrieve list of clients.' + + default_role_field_name = self.get_default_role_field_name() + self.fields[default_role_field_name] = forms.CharField(required=False) + self.fields[default_role_field_name].initial = 'member' + + field_name = self.get_member_field_name('member') + self.fields[field_name] = forms.MultipleChoiceField(required=False) + + all_clients = [] + try: + all_clients = freezer_api.client_list(request) + except Exception: + exceptions.handle(request, err_msg_all) + + clients = [(c.client_id, c.name) for c in all_clients] + + self.fields[field_name].choices = clients + + if request.method == 'POST': + return + + initial_clients = [] + try: + original_name = args[0].get('original_name', None) + if original_name: + configured_clients = \ + freezer_api.clients_in_config(request, original_name) + initial_clients = [client.id for client in configured_clients] + except Exception: + exceptions.handle(request, err_msg_configured) + + self.fields[field_name].initial = initial_clients + + class Meta(object): + name = _("Clients") + slug = "configure_clients" + + +class ClientsConfiguration(workflows.UpdateMembersStep): + action_class = ClientsConfigurationAction + help_text = _( + "Select the clients that will be backed up using this configuration.") + available_list_title = _("All Clients") + members_list_title = _("Selected Clients") + no_available_text = _("No clients found.") + no_members_text = _("No clients selected.") + show_roles = False + contributes = ("clients",) + + def contribute(self, data, context): + if data: + member_field_name = self.get_member_field_name('member') + context['clients'] = data.get(member_field_name, []) + return context + + +class SchedulingConfigurationAction(workflows.Action): + start_datetime = forms.CharField( + label=_("Start Date and Time"), + required=False, + help_text=_("Set a start date and time for backups")) + + interval = forms.CharField( + label=_("Interval"), + required=False, + help_text=_("Repeat this configuration in an interval. e.g. 24 hours")) + + class Meta(object): + name = _("Scheduling") + + +class SchedulingConfiguration(workflows.Step): + action_class = SchedulingConfigurationAction + contributes = ('start_datetime', + 'interval',) + + +class AdvancedConfigurationAction(workflows.Action): + exclude = forms.CharField( + label=_("Exclude Files"), + help_text="Exclude files, given as a PATTERN.Ex:" + " --exclude '*.log' will exclude any " + "file with name ending with .log. " + "Default no exclude", + required=False) + log_file = forms.CharField( + label=_("Log File Path"), + help_text="Set log file. By default logs to " + "/var/log/freezer.log If that file " + "is not writable, freezer tries to " + "log to ~/.freezer/freezer.log", + required=False) + + proxy = forms.CharField( + label=_("Proxy URL"), + help_text="Enforce proxy that alters system " + "HTTP_PROXY and HTTPS_PROXY", + widget=forms.URLInput(), + required=False) + + max_priority = forms.BooleanField( + label=_("Max Priority"), + help_text="Set the cpu process to the " + "highest priority (i.e. -20 " + "on Linux) and real-time for " + "I/O. The process priority " + "will be set only if nice and " + "ionice are installed Default " + "disabled. Use with caution.", + widget=forms.CheckboxInput(), + required=False) + + class Meta(object): + name = _("Advanced Configuration") + + +class AdvancedConfiguration(workflows.Step): + action_class = AdvancedConfigurationAction + contributes = ('exclude', + 'log_file', + 'proxy', + 'max_priority') + + +class ConfigureBackups(workflows.Workflow): + slug = "configuration" + name = _("Configuration") + finalize_button_name = _("Save") + success_message = _('Configuration file saved correctly.') + failure_message = _('Unable to save configuration file.') + success_url = "horizon:freezer_ui:configurations:index" + + default_steps = (BackupConfiguration, + OptionsConfiguration, + ClientsConfiguration, + SchedulingConfiguration, + AdvancedConfiguration) + + @sensitive_variables('encryption_password', + 'confirm_encryption_password') + def handle(self, request, context): + try: + if context['original_name'] == '': + freezer_api.configuration_create( + request, + name=context['name'], + container_name=context['container_name'], + src_file=context['src_file'], + levels=context['levels'], # if empty save 0 not null + optimize=context['optimize'], + compression=context['compression'], + encryption_password=context['encryption_password'], + clients=context['clients'], # save the name of the client + start_datetime=context['start_datetime'], + interval=context['interval'], + exclude=context['exclude'], + log_file=context['log_file'], + proxy=context['proxy'], + max_priority=context['max_priority'], + ) + else: + freezer_api.configuration_update( + request, + config_id=context['original_name'], + name=context['name'], + container_name=context['container_name'], + src_file=context['src_file'], + levels=context['levels'], # if empty save 0 not null + optimize=context['optimize'], + compression=context['compression'], + encryption_password=context['encryption_password'], + clients=context['clients'], # save the name of the client + start_datetime=context['start_datetime'], + interval=context['interval'], + exclude=context['exclude'], + log_file=context['log_file'], + proxy=context['proxy'], + max_priority=context['max_priority'], + ) + return True + except Exception: + exceptions.handle(request) + return False diff --git a/freezer_ui/dashboard.py b/freezer_ui/dashboard.py new file mode 100644 index 0000000..b90b42a --- /dev/null +++ b/freezer_ui/dashboard.py @@ -0,0 +1,31 @@ +# 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 + + +class Mygroup(horizon.PanelGroup): + slug = "mygroup" + name = _("Freezer") + panels = ('overview', 'configurations', 'backups', 'actions') + + +class Freezer(horizon.Dashboard): + name = _("Backup Restore DR") + slug = "freezer_ui" + panels = (Mygroup,) + default_panel = 'overview' + + +horizon.register(Freezer) diff --git a/freezer_ui/django_utils.py b/freezer_ui/django_utils.py new file mode 100644 index 0000000..76fcc53 --- /dev/null +++ b/freezer_ui/django_utils.py @@ -0,0 +1,8 @@ +import datetime +from django.template.defaultfilters import date as django_date + + +def timestamp_to_string(ts): + return django_date( + datetime.datetime.fromtimestamp(int(ts)), + 'SHORT_DATETIME_FORMAT') diff --git a/freezer_ui/models.py b/freezer_ui/models.py new file mode 100644 index 0000000..1b3d5f9 --- /dev/null +++ b/freezer_ui/models.py @@ -0,0 +1,3 @@ +""" +Stub file to work around django bug: https://code.djangoproject.com/ticket/7198 +""" diff --git a/freezer_ui/overview/__init__.py b/freezer_ui/overview/__init__.py new file mode 100644 index 0000000..d95ce28 --- /dev/null +++ b/freezer_ui/overview/__init__.py @@ -0,0 +1 @@ +__author__ = 'jonas' diff --git a/freezer_ui/overview/models.py b/freezer_ui/overview/models.py new file mode 100644 index 0000000..1b3d5f9 --- /dev/null +++ b/freezer_ui/overview/models.py @@ -0,0 +1,3 @@ +""" +Stub file to work around django bug: https://code.djangoproject.com/ticket/7198 +""" diff --git a/freezer_ui/overview/panel.py b/freezer_ui/overview/panel.py new file mode 100644 index 0000000..8139f85 --- /dev/null +++ b/freezer_ui/overview/panel.py @@ -0,0 +1,24 @@ +# 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 horizon_web_ui.freezer_ui import dashboard + + +class OverviewPanel(horizon.Panel): + name = _("Overview") + slug = "overview" + + +dashboard.Freezer.register(OverviewPanel) diff --git a/freezer_ui/overview/templates/overview/overview.html b/freezer_ui/overview/templates/overview/overview.html new file mode 100644 index 0000000..09fdfa7 --- /dev/null +++ b/freezer_ui/overview/templates/overview/overview.html @@ -0,0 +1,112 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Backup Restore DR Overview" %}{% endblock %} + +{% block css %} + {% include "_stylesheets.html" %} + +{% endblock %} + + + +{% block main %} + +
+

{% trans "Overview" %}

+ +
+
+ {% trans "Nodes without backup in the last week" %}
+ 18 of 50 +
+
+ +
+
+ {% trans "Total space used for backups" %}
+ 150gb of 200gb +
+
+ +
+
+ {% trans "Storage price per month" %}
+ 60 dls +
+
+
+ + +
+

{% trans "Backup Summary" %}

+ +
+
+ {% trans "Backups older than 1 week" %}
+ 60 of 120 +
+
+ +
+
+ {% trans "Average backup size" %}
+ 7gb for 18 backups +
+
+ +
+
+ {% trans "Average backup time" %}
+ 18 min +
+
+ +
+ +
+

{% trans "Restore Summary" %}

+ +
+
+ {% trans "Average restore time" %}
+ 15 min +
+
+ +
+
+ {% trans "Restore ratio success to failure" %}
+ 100% success +
+
+
+ + +
+

{% trans "OS Summary" %}

+ +
+ +
+
+ + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/freezer_ui/overview/urls.py b/freezer_ui/overview/urls.py new file mode 100644 index 0000000..5427446 --- /dev/null +++ b/freezer_ui/overview/urls.py @@ -0,0 +1,22 @@ +# 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 +from django.conf.urls import url + +from horizon_web_ui.freezer_ui.overview import views + + +urlpatterns = patterns( + '', + url(r'^$', views.IndexView.as_view(), name='index'), +) diff --git a/freezer_ui/overview/views.py b/freezer_ui/overview/views.py new file mode 100644 index 0000000..a974306 --- /dev/null +++ b/freezer_ui/overview/views.py @@ -0,0 +1,21 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.utils.translation import ugettext_lazy as _ + +from django.views import generic + + +class IndexView(generic.TemplateView): + name = _("Overview") + slug = "overview" + template_name = ("freezer_ui/overview/overview.html") diff --git a/freezer_ui/static/freezer/css/freezer.css b/freezer_ui/static/freezer/css/freezer.css new file mode 100644 index 0000000..5bc3825 --- /dev/null +++ b/freezer_ui/static/freezer/css/freezer.css @@ -0,0 +1,43 @@ + + +.fa-custom-number { + font-family: monospace; + line-height: 1; + padding: 0.1em; + vertical-align: baseline; + font-weight: bold; + border: 1px solid #999; + border-radius: 25%; +} + +/* d3 css */ + +path { stroke: #fff; } +path:hover { opacity:0.9; } +rect:hover { fill:#006CCF; } +.axis { font: 10px sans-serif; } +.legend tr{ border-bottom:1px solid grey; } +.legend tr:first-child{ border-top:1px solid grey; } + +.axis path, +.axis line { + fill: none; + stroke: #000; + shape-rendering: crispEdges; +} + +.x.axis path { display: none; } +.legend{ + margin-bottom:76px; + display:inline-block; + border-collapse: collapse; + border-spacing: 0px; +} +.legend td{ + padding:4px 5px; + vertical-align:bottom; +} +.legendFreq, .legendPerc{ + align:right; + width:50px; +} \ No newline at end of file diff --git a/freezer_ui/static/freezer/js/dashboard.js b/freezer_ui/static/freezer/js/dashboard.js new file mode 100644 index 0000000..95686a1 --- /dev/null +++ b/freezer_ui/static/freezer/js/dashboard.js @@ -0,0 +1,196 @@ +function dashboard(id, fData){ + var barColor = '#006CCF'; + function segColor(c){ return {binary:"#807dba", text:"#e08214",media:"#41ab5d"}[c]; } + + // compute total for each state. + fData.forEach(function(d){d.total=d.freq.binary+d.freq.text+d.freq.media;}); + + // function to handle histogram. + function histoGram(fD){ + var hG={}, hGDim = {t: 60, r: 0, b: 30, l: 0}; + hGDim.w = 500 - hGDim.l - hGDim.r, + hGDim.h = 300 - hGDim.t - hGDim.b; + + //create svg for histogram. + var hGsvg = d3.select(id).append("svg") + .attr("width", hGDim.w + hGDim.l + hGDim.r) + .attr("height", hGDim.h + hGDim.t + hGDim.b).append("g") + .attr("transform", "translate(" + hGDim.l + "," + hGDim.t + ")"); + + // create function for x-axis mapping. + var x = d3.scale.ordinal().rangeRoundBands([0, hGDim.w], 0.1) + .domain(fD.map(function(d) { return d[0]; })); + + // Add x-axis to the histogram svg. + hGsvg.append("g").attr("class", "x axis") + .attr("transform", "translate(0," + hGDim.h + ")") + .call(d3.svg.axis().scale(x).orient("bottom")); + + // Create function for y-axis map. + var y = d3.scale.linear().range([hGDim.h, 0]) + .domain([0, d3.max(fD, function(d) { return d[1]; })]); + + // Create bars for histogram to contain rectangles and freq labels. + var bars = hGsvg.selectAll(".bar").data(fD).enter() + .append("g").attr("class", "bar"); + + //create the rectangles. + bars.append("rect") + .attr("x", function(d) { return x(d[0]); }) + .attr("y", function(d) { return y(d[1]); }) + .attr("width", x.rangeBand()) + .attr("height", function(d) { return hGDim.h - y(d[1]); }) + .attr('fill',barColor) + .on("mouseover",mouseover)// mouseover is defined below. + .on("mouseout",mouseout);// mouseout is defined below. + + //Create the frequency labels above the rectangles. + bars.append("text").text(function(d){ return d3.format(",")(d[1])}) + .attr("x", function(d) { return x(d[0])+x.rangeBand()/2; }) + .attr("y", function(d) { return y(d[1])-5; }) + .attr("text-anchor", "middle"); + + function mouseover(d){ // utility function to be called on mouseover. + // filter for selected state. + var st = fData.filter(function(s){ return s.State == d[0];})[0], + nD = d3.keys(st.freq).map(function(s){ return {type:s, freq:st.freq[s]};}); + + // call update functions of pie-chart and legend. + pC.update(nD); + leg.update(nD); + } + + function mouseout(d){ // utility function to be called on mouseout. + // reset the pie-chart and legend. + pC.update(tF); + leg.update(tF); + } + + // create function to update the bars. This will be used by pie-chart. + hG.update = function(nD, color){ + // update the domain of the y-axis map to reflect change in frequencies. + y.domain([0, d3.max(nD, function(d) { return d[1]; })]); + + // Attach the new data to the bars. + var bars = hGsvg.selectAll(".bar").data(nD); + + // transition the height and color of rectangles. + bars.select("rect").transition().duration(500) + .attr("y", function(d) {return y(d[1]); }) + .attr("height", function(d) { return hGDim.h - y(d[1]); }) + .attr("fill", color); + + // transition the frequency labels location and change value. + bars.select("text").transition().duration(500) + .text(function(d){ return d3.format(",")(d[1])}) + .attr("y", function(d) {return y(d[1])-5; }); + } + return hG; + } + + // function to handle pieChart. + function pieChart(pD){ + var pC ={}, pieDim ={w:250, h: 250}; + pieDim.r = Math.min(pieDim.w, pieDim.h) / 2; + + // create svg for pie chart. + var piesvg = d3.select(id).append("svg") + .attr("width", pieDim.w).attr("height", pieDim.h).append("g") + .attr("transform", "translate("+pieDim.w/2+","+pieDim.h/2+")"); + + // create function to draw the arcs of the pie slices. + var arc = d3.svg.arc().outerRadius(pieDim.r - 10).innerRadius(0); + + // create a function to compute the pie slice angles. + var pie = d3.layout.pie().sort(null).value(function(d) { return d.freq; }); + + // Draw the pie slices. + piesvg.selectAll("path").data(pie(pD)).enter().append("path").attr("d", arc) + .each(function(d) { this._current = d; }) + .style("fill", function(d) { return segColor(d.data.type); }) + .on("mouseover",mouseover).on("mouseout",mouseout); + + // create function to update pie-chart. This will be used by histogram. + pC.update = function(nD){ + piesvg.selectAll("path").data(pie(nD)).transition().duration(500) + .attrTween("d", arcTween); + } + // Utility function to be called on mouseover a pie slice. + function mouseover(d){ + // call the update function of histogram with new data. + hG.update(fData.map(function(v){ + return [v.State,v.freq[d.data.type]];}),segColor(d.data.type)); + } + //Utility function to be called on mouseout a pie slice. + function mouseout(d){ + // call the update function of histogram with all data. + hG.update(fData.map(function(v){ + return [v.State,v.total];}), barColor); + } + // Animating the pie-slice requiring a custom function which specifies + // how the intermediate paths should be drawn. + function arcTween(a) { + var i = d3.interpolate(this._current, a); + this._current = i(0); + return function(t) { return arc(i(t)); }; + } + return pC; + } + + // function to handle legend. + function legend(lD){ + var leg = {}; + + // create table for legend. + var legend = d3.select(id).append("table").attr('class','legend'); + + // create one row per segment. + var tr = legend.append("tbody").selectAll("tr").data(lD).enter().append("tr"); + + // create the first column for each segment. + tr.append("td").append("svg").attr("width", '16').attr("height", '16').append("rect") + .attr("width", '16').attr("height", '16') + .attr("fill",function(d){ return segColor(d.type); }); + + // create the second column for each segment. + tr.append("td").text(function(d){ return d.type;}); + + // create the third column for each segment. + tr.append("td").attr("class",'legendFreq') + .text(function(d){ return d3.format(",")(d.freq);}); + + // create the fourth column for each segment. + tr.append("td").attr("class",'legendPerc') + .text(function(d){ return getLegend(d,lD);}); + + // Utility function to be used to update the legend. + leg.update = function(nD){ + // update the data attached to the row elements. + var l = legend.select("tbody").selectAll("tr").data(nD); + + // update the frequencies. + l.select(".legendFreq").text(function(d){ return d3.format(",")(d.freq);}); + + // update the percentage column. + l.select(".legendPerc").text(function(d){ return getLegend(d,nD);}); + } + + function getLegend(d,aD){ // Utility function to compute percentage. + return d3.format("%")(d.freq/d3.sum(aD.map(function(v){ return v.freq; }))); + } + + return leg; + } + + // calculate total frequency by segment for all state. + var tF = ['binary','text','media'].map(function(d){ + return {type:d, freq: d3.sum(fData.map(function(t){ return t.freq[d];}))}; + }); + + // calculate total frequency by state for all segment. + var sF = fData.map(function(d){return [d.State,d.total];}); + + var hG = histoGram(sF), // create the histogram. + pC = pieChart(tF), // create the pie-chart. + leg= legend(tF); // create the legend. +} \ No newline at end of file diff --git a/freezer_ui/static/freezer/js/freezer.js b/freezer_ui/static/freezer/js/freezer.js new file mode 100644 index 0000000..2006fa6 --- /dev/null +++ b/freezer_ui/static/freezer/js/freezer.js @@ -0,0 +1,16 @@ +(function () { + 'use strict'; + angular.module('hz').controller('DestinationCtrl', function ($scope, $http, $location) { + $scope.query = ''; + + $http.get($location.protocol() + "://" + $location.host() + ":" + $location.port() + "/freezer_ui/api/clients"). + success(function(data, status, headers, config) { + $scope.clients = data + }); + $scope.searchComparator = function (actual, expected) { + return actual.description.indexOf(expected) > 0 + }; + }); + + +}()); diff --git a/freezer/templates/freezer/base.html b/freezer_ui/templates/freezer/base.html similarity index 100% rename from freezer/templates/freezer/base.html rename to freezer_ui/templates/freezer/base.html diff --git a/freezer_ui/tests/__init__.py b/freezer_ui/tests/__init__.py new file mode 100644 index 0000000..d95ce28 --- /dev/null +++ b/freezer_ui/tests/__init__.py @@ -0,0 +1 @@ +__author__ = 'jonas' diff --git a/freezer_ui/tests/api_tests.py b/freezer_ui/tests/api_tests.py new file mode 100644 index 0000000..5892529 --- /dev/null +++ b/freezer_ui/tests/api_tests.py @@ -0,0 +1,80 @@ +from django.conf import settings +from horizon_web_ui.freezer_ui.api import api +from mock import patch +from openstack_auth import utils +import openstack_dashboard.test.helpers as helpers + + +@patch('freezer.apiclient.client') +class TestApi(helpers.TestCase): + CONFIG = {u'user_name': u'admin', + u'config_id': u'053a62e0-66a9-4a1c-ba58-6b7348d22166', + u'config_file': {u'start_datetime': u'1432736797', + u'repeat': u'1440', + u'max_priority': False, + u'encryption_password': u'secret', + u'src_file': u'fdsfsdds', + u'clients': [u'test-client'], + u'levels': 0, + u'proxy': u'', + u'container_name': u'dummy_container', + u'exclude': u'/tmp', + u'compression': u'gzip', + u'log_file': u'', + u'optimize': u'speed', + u'name': u'fdsfs'}, + u'user_id': u'13c2b15308c04cdf86989ee7335eb504'} + + def setUp(self): + super(TestApi, self).setUp() + + # Usually this monkey patching happens in urls.py. This doesn't work + # here because we never invoke urls.py in this test. So we have to do + # it manually. + utils.patch_middleware_get_user() + + def _setup_request(self): + super(helpers.TestCase, self)._setup_request() + # For some strange reason, Horizon sets the token to the token id + # rather than the token object. This fixes it. + self.request.session['token'] = self.token + + def assert_client_got_created(self, client_mock): + client_mock.Client.assert_called_with( + token=self.request.session['token'].id, + auth_url=settings.OPENSTACK_KEYSTONE_URL, + endpoint=settings.FREEZER_API_URL) + + def test_configuration_delete(self, client_mock): + api.configuration_delete( + self.request, u'053a62e0-66a9-4a1c-ba58-6b7348d22166') + + self.assert_client_got_created(client_mock) + client_mock.Client().configs.delete.\ + assert_called_once_with(u'053a62e0-66a9-4a1c-ba58-6b7348d22166') + + def test_configuration_clone(self, client_mock): + client_mock.Client().configs.get.return_value = [self.CONFIG] + client_mock.Client().configs.\ + create.return_value = u'28124cf0-6cd3-4b38-a0e9-b6f41568fa37' + + result = api.configuration_clone( + self.request, u'053a62e0-66a9-4a1c-ba58-6b7348d22166') + + self.assertEqual(result, u'28124cf0-6cd3-4b38-a0e9-b6f41568fa37') + self.assert_client_got_created(client_mock) + data = self.CONFIG[u'config_file'] + data['name'] = 'fdsfs_clone' + client_mock.Client().configs.create.assert_called_once_with(data) + + def test_configuration_get(self, client_mock): + client_mock.Client().configs.get.return_value = [self.CONFIG] + + result = api.configuration_get( + self.request, u'053a62e0-66a9-4a1c-ba58-6b7348d22166') + + self.assertEqual(1, len(result)) + # Test if properties are accessible via object properties + self.assertEqual(u'admin', result[0].user_name) + # Test if nested properties are accessible via object properties + self.assertEqual(u'1432736797', result[0].start_datetime) diff --git a/freezer_ui/tests/rest_api_tests.py b/freezer_ui/tests/rest_api_tests.py new file mode 100644 index 0000000..1dcd69e --- /dev/null +++ b/freezer_ui/tests/rest_api_tests.py @@ -0,0 +1,30 @@ +import json +from mock import patch + +from horizon.utils.urlresolvers import reverse +import openstack_dashboard.test.helpers as helpers + + +@patch('freezer.apiclient.client') +class TestRestApi(helpers.TestCase): + CLIENT_1 = {u'client': {u'hostname': u'jonas', + u'description': u'client description', + u'client_id': u'test-client', + u'config_ids': [u'fdaf2fwf2', u'fdsfdsfdsfs']}, + u'user_id': u'13c2b15308c04cdf86989ee7335eb504'} + + JSON_PREFIX = ')]}\',\n' + + def test_clients_get(self, client_mock): + client_mock.Client().registration.list.return_value = [self.CLIENT_1] + url = reverse("horizon:freezer_ui:api_clients") + + res = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + self.assertEqual(200, res.status_code) + self.assertEqual('application/json', res['content-type']) + self.assertEqual(self.JSON_PREFIX + json.dumps([self.CLIENT_1]), + res.content) + # there is no get ALL api at the moment, so we just fetch a big number + client_mock.Client().registration.list.assert_called_once_with( + limit=9999) diff --git a/freezer_ui/tests/settings.py b/freezer_ui/tests/settings.py new file mode 100644 index 0000000..d7adef7 --- /dev/null +++ b/freezer_ui/tests/settings.py @@ -0,0 +1,18 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from horizon.test.settings import * # noqa + +INSTALLED_APPS = () +OPENSTACK_KEYSTONE_URL = "http://localhost:5000/v2.0" +OPENSTACK_KEYSTONE_DEFAULT_ROLE = "_member_" +FREEZER_API_URL = "test" diff --git a/freezer_ui/urls.py b/freezer_ui/urls.py new file mode 100644 index 0000000..2424c4d --- /dev/null +++ b/freezer_ui/urls.py @@ -0,0 +1,32 @@ +# Copyright 2012 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Copyright 2012 Nebula, 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. + +""" +URL patterns for the OpenStack Dashboard. +""" + +from django.conf.urls import include +from django.conf.urls import patterns +from django.conf.urls import url + +import horizon_web_ui.freezer_ui.api.rest.urls as rest_urls + +urlpatterns = patterns( + '', + url(r'', include(rest_urls)), +) diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..afceb57 --- /dev/null +++ b/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python + +# 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 os +import sys + +from django.core.management import execute_from_command_line # noqa + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", + "freezer_ui.tests.settings") + execute_from_command_line(sys.argv) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..bebc1f6 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,8 @@ +-e git+https://github.com/openstack/horizon.git#egg=horizon +django-nose +nose-exclude +mock +python-openstackclient +mox +parsedatetime +coverage>=3.6 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..2ceb5d3 --- /dev/null +++ b/tox.ini @@ -0,0 +1,19 @@ +[tox] +envlist = flake8 +skipsdist = True + +[testenv] +deps = +install_command = pip install -U {opts} {packages} +setenv = VIRTUAL_ENV={envdir} +commands = + +[testenv:flake8] +deps = hacking +commands = flake8 + +[flake8] +max-line-length = 80 +show-source = True +exclude = .venv,.git,.tox,*egg,.ropeproject +max-complexity = 19 \ No newline at end of file