diff --git a/horizon/api/swift.py b/horizon/api/swift.py index 00847e1c4..381851620 100644 --- a/horizon/api/swift.py +++ b/horizon/api/swift.py @@ -29,6 +29,7 @@ from horizon.api.base import url_for LOG = logging.getLogger(__name__) +FOLDER_DELIMITER = "/" class SwiftAuthentication(object): @@ -94,7 +95,7 @@ def swift_get_objects(request, container_name, prefix=None, path=None, objects = container.get_objects(prefix=prefix, marker=marker, limit=limit + 1, - delimiter="/", + delimiter=FOLDER_DELIMITER, path=path) if(len(objects) > limit): return (objects[0:-1], True) @@ -113,7 +114,7 @@ def swift_filter_objects(request, filter_string, container_name, prefix=None, objects = container.get_objects(prefix=prefix, marker=marker, limit=limit, - delimiter="/", + delimiter=FOLDER_DELIMITER, path=path) filter_string_list = filter_string.lower().strip().split(' ') diff --git a/horizon/browsers/__init__.py b/horizon/browsers/__init__.py new file mode 100644 index 000000000..c4fee9731 --- /dev/null +++ b/horizon/browsers/__init__.py @@ -0,0 +1,18 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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. + +from .base import ResourceBrowser +from .views import ResourceBrowserView diff --git a/horizon/browsers/base.py b/horizon/browsers/base.py new file mode 100644 index 000000000..635cdc3c4 --- /dev/null +++ b/horizon/browsers/base.py @@ -0,0 +1,105 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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. + +from django import template + +from horizon.tables import DataTable +from horizon.utils import html + + +class ResourceBrowser(html.HTMLElement): + """A class which defines a browser for displaying data. + + .. attribute:: name + + A short name or slug for the browser. + + .. attribute:: verbose_name + + A more verbose name for the browser meant for display purposes. + + .. attribute:: navigation_table_class + This table displays data on the left side of the browser. + Set the ``navigation_table_class`` attribute with + the desired :class:`~horizon.tables.DataTable` class. + This table class must set browser_table attribute in Meta to + ``"navigation"``. + + .. attribute:: content_table_class + This table displays data on the right side of the browser. + Set the ``content_table_class`` attribute with + the desired :class:`~horizon.tables.DataTable` class. + This table class must set browser_table attribute in Meta to + ``"content"``. + + .. attribute:: template + + String containing the template which should be used to render + the browser. Defaults to ``"horizon/common/_resource_browser.html"``. + + .. attribute:: context_var_name + + The name of the context variable which will contain the browser when + it is rendered. Defaults to ``"browser"``. + """ + name = None + verbose_name = None + navigation_table_class = None + content_table_class = None + template = "horizon/common/_resource_browser.html" + context_var_name = "browser" + + def __init__(self, request, tables=None, attrs=None, + **kwargs): + super(ResourceBrowser, self).__init__() + self.name = getattr(self, "name", self.__class__.__name__) + self.verbose_name = getattr(self, "verbose_name", self.name.title()) + self.request = request + self.attrs.update(attrs or {}) + + self.navigation_table_class = getattr(self, "navigation_table_class", + None) + self.check_table_class(self.navigation_table_class, + "navigation_table_class") + + self.content_table_class = getattr(self, "content_table_class", + None) + self.check_table_class(self.content_table_class, + "content_table_class") + + self.set_tables(tables) + + def check_table_class(self, cls, attr_name): + if not cls or not issubclass(cls, (DataTable, )): + raise ValueError("You must specify a DataTable class for " + "the %s attribute on %s " + % (attr_name, self.__class__.__name__)) + + def set_tables(self, tables): + if tables: + self.navigation_table = tables.get(self.navigation_table_class + ._meta.name, None) + self.content_table = tables.get(self.content_table_class + ._meta.name, None) + else: + raise ValueError("There are no tables passed to class %s." % + self.__class__.__name__) + + def render(self): + browser_template = template.loader.get_template(self.template) + extra_context = {self.context_var_name: self} + context = template.RequestContext(self.request, extra_context) + return browser_template.render(context) diff --git a/horizon/browsers/views.py b/horizon/browsers/views.py new file mode 100644 index 000000000..64e548a4b --- /dev/null +++ b/horizon/browsers/views.py @@ -0,0 +1,56 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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. + +from collections import defaultdict + +from horizon.tables import MultiTableView + + +class ResourceBrowserView(MultiTableView): + browser_class = None + data_method_pattern = "get_%s_data" + + def __init__(self, *args, **kwargs): + self.browser_class = getattr(self, "browser_class", None) + if not self.browser_class: + raise ValueError("You must specify a ResourceBrowser class " + " for the browser_class attribute on %s " + % self.__class__.__name__) + + self.navigation_table = self.browser_class.navigation_table_class + self.content_table = self.browser_class.content_table_class + + # Check and set up the method the view would use to collect data + self._data_methods = defaultdict(list) + self.table_classes = (self.navigation_table, self.content_table) + self.get_data_methods(self.table_classes, self._data_methods) + + self._tables = {} + self._data = {} + + def get_browser(self): + if not hasattr(self, "browser"): + tables = self.get_tables() + self.browser = self.browser_class(self.request, + tables, + **self.kwargs) + return self.browser + + def get_context_data(self, **kwargs): + context = super(ResourceBrowserView, self).get_context_data(**kwargs) + browser = self.get_browser() + context["%s_browser" % browser.name] = browser + return context diff --git a/horizon/dashboards/nova/containers/browsers.py b/horizon/dashboards/nova/containers/browsers.py new file mode 100644 index 000000000..0b986a8c0 --- /dev/null +++ b/horizon/dashboards/nova/containers/browsers.py @@ -0,0 +1,32 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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 horizon import browsers +from .tables import ContainersTable, ObjectsTable + + +LOG = logging.getLogger(__name__) + + +class ContainerBrowser(browsers.ResourceBrowser): + name = "swift" + verbose_name = _("Swift") + navigation_table_class = ContainersTable + content_table_class = ObjectsTable diff --git a/horizon/dashboards/nova/containers/forms.py b/horizon/dashboards/nova/containers/forms.py index 9be240b81..bde2f32c2 100644 --- a/horizon/dashboards/nova/containers/forms.py +++ b/horizon/dashboards/nova/containers/forms.py @@ -114,7 +114,7 @@ class CopyObject(forms.SelfHandlingForm): self.fields['new_container_name'].choices = containers def handle(self, request, data): - object_index = "horizon:nova:containers:object_index" + object_index = "horizon:nova:containers:index" orig_container = data['orig_container_name'] orig_object = data['orig_object_name'] new_container = data['new_container_name'] diff --git a/horizon/dashboards/nova/containers/tables.py b/horizon/dashboards/nova/containers/tables.py index 72ae557f7..65b2db351 100644 --- a/horizon/dashboards/nova/containers/tables.py +++ b/horizon/dashboards/nova/containers/tables.py @@ -25,14 +25,21 @@ from django.utils.translation import ugettext_lazy as _ from horizon import api from horizon import messages from horizon import tables +from horizon.api import FOLDER_DELIMITER +from horizon.tables import DataTable LOG = logging.getLogger(__name__) +def wrap_delimiter(name): + return name + FOLDER_DELIMITER + + class DeleteContainer(tables.DeleteAction): data_type_singular = _("Container") data_type_plural = _("Containers") + completion_url = "horizon:nova:containers:index" def delete(self, request, obj_id): try: @@ -42,6 +49,18 @@ class DeleteContainer(tables.DeleteAction): _('Containers must be empty before deletion.')) raise + def get_success_url(self, request=None): + """ + Returns the URL to redirect to after a successful action. + """ + current_container = self.table.kwargs.get("container_name", None) + + # If the current_container is deleted, then redirect to the default + # completion url + if current_container in self.success_ids: + return self.completion_url + return request.get_full_path() + class CreateContainer(tables.LinkAction): name = "create" @@ -53,9 +72,14 @@ class CreateContainer(tables.LinkAction): class ListObjects(tables.LinkAction): name = "list_objects" verbose_name = _("View Container") - url = "horizon:nova:containers:object_index" + url = "horizon:nova:containers:index" classes = ("btn-list",) + def get_link_url(self, datum=None): + container_name = http.urlquote(datum.name) + args = (wrap_delimiter(container_name),) + return reverse(self.url, args=args) + class UploadObject(tables.LinkAction): name = "upload" @@ -76,6 +100,11 @@ class UploadObject(tables.LinkAction): (container_name, subfolders) if bit) return reverse(self.url, args=args) + def allowed(self, request, datum=None): + if self.table.kwargs.get('container_name', None): + return True + return False + def update(self, request, obj): # This will only be called for the row, so we can remove the button # styles meant for the table action version. @@ -86,15 +115,14 @@ def get_size_used(container): return filesizeformat(container.size_used) +def get_container_link(container): + return reverse("horizon:nova:containers:index", + args=(http.urlquote(wrap_delimiter(container.name)),)) + + class ContainersTable(tables.DataTable): - name = tables.Column("name", link='horizon:nova:containers:object_index', + name = tables.Column("name", link=get_container_link, verbose_name=_("Container Name")) - objects = tables.Column("object_count", - verbose_name=_('Objects'), - empty_value="0") - size = tables.Column(get_size_used, - verbose_name=_('Size'), - attrs={'data-type': 'size'}) def get_object_id(self, container): return container.name @@ -102,8 +130,9 @@ class ContainersTable(tables.DataTable): class Meta: name = "containers" verbose_name = _("Containers") - table_actions = (CreateContainer, DeleteContainer) + table_actions = (CreateContainer,) row_actions = (ListObjects, UploadObject, DeleteContainer) + browser_table = "navigation" class DeleteObject(tables.DeleteAction): @@ -127,8 +156,8 @@ class DeleteSubfolder(DeleteObject): class DeleteMultipleObjects(DeleteObject): name = "delete_multiple_objects" - data_type_singular = _("Object/Folder") - data_type_plural = _("Objects/Folders") + data_type_singular = _("Object") + data_type_plural = _("Objects") allowed_data_types = ("subfolders", "objects",) @@ -161,7 +190,7 @@ class ObjectFilterAction(tables.FilterAction): request = table._meta.request container = self.table.kwargs['container_name'] subfolder = self.table.kwargs['subfolder_path'] - path = subfolder + '/' if subfolder else '' + path = subfolder + FOLDER_DELIMITER if subfolder else '' self.filtered_data = api.swift_filter_objects(request, filter_string, container, @@ -178,9 +207,14 @@ class ObjectFilterAction(tables.FilterAction): return [datum for datum in data if datum.content_type != "application/directory"] + def allowed(self, request, datum=None): + if self.table.kwargs.get('container_name', None): + return True + return False + def sanitize_name(name): - return name.split("/")[-1] + return name.split(FOLDER_DELIMITER)[-1] def get_size(obj): @@ -188,9 +222,10 @@ def get_size(obj): def get_link_subfolder(subfolder): - return reverse("horizon:nova:containers:object_index", - args=(http.urlquote(subfolder.container.name), - http.urlquote(subfolder.name + "/"))) + container_name = subfolder.container.name + return reverse("horizon:nova:containers:index", + args=(http.urlquote(wrap_delimiter(container_name)), + http.urlquote(wrap_delimiter(subfolder.name)))) class CreateSubfolder(CreateContainer): @@ -200,9 +235,15 @@ class CreateSubfolder(CreateContainer): def get_link_url(self): container = self.table.kwargs['container_name'] subfolders = self.table.kwargs['subfolder_path'] - parent = "/".join((bit for bit in [container, subfolders] if bit)) - parent = parent.rstrip("/") - return reverse(self.url, args=(http.urlquote(parent + "/"),)) + parent = FOLDER_DELIMITER.join((bit for bit in [container, + subfolders] if bit)) + parent = parent.rstrip(FOLDER_DELIMITER) + return reverse(self.url, args=[http.urlquote(wrap_delimiter(parent))]) + + def allowed(self, request, datum=None): + if self.table.kwargs.get('container_name', None): + return True + return False class ObjectsTable(tables.DataTable): @@ -211,6 +252,7 @@ class ObjectsTable(tables.DataTable): allowed_data_types=("subfolders",), verbose_name=_("Object Name"), filters=(sanitize_name,)) + size = tables.Column(get_size, verbose_name=_('Size')) def get_object_id(self, obj): @@ -218,9 +260,10 @@ class ObjectsTable(tables.DataTable): class Meta: name = "objects" - verbose_name = _("Subfolders and Objects") + verbose_name = _("Objects") table_actions = (ObjectFilterAction, CreateSubfolder, UploadObject, DeleteMultipleObjects) row_actions = (DownloadObject, CopyObject, DeleteObject, DeleteSubfolder) data_types = ("subfolders", "objects") + browser_table = "content" diff --git a/horizon/dashboards/nova/containers/templates/containers/_copy.html b/horizon/dashboards/nova/containers/templates/containers/_copy.html index 870c8689f..aef4431d0 100644 --- a/horizon/dashboards/nova/containers/templates/containers/_copy.html +++ b/horizon/dashboards/nova/containers/templates/containers/_copy.html @@ -20,5 +20,5 @@ {% block modal-footer %} - {% trans "Cancel" %} + {% trans "Cancel" %} {% endblock %} diff --git a/horizon/dashboards/nova/containers/templates/containers/_upload.html b/horizon/dashboards/nova/containers/templates/containers/_upload.html index 22d137ae2..fd8385714 100644 --- a/horizon/dashboards/nova/containers/templates/containers/_upload.html +++ b/horizon/dashboards/nova/containers/templates/containers/_upload.html @@ -21,5 +21,5 @@ {% block modal-footer %} - {% trans "Cancel" %} + {% trans "Cancel" %} {% endblock %} diff --git a/horizon/dashboards/nova/containers/templates/containers/detail.html b/horizon/dashboards/nova/containers/templates/containers/detail.html deleted file mode 100644 index a2e15f496..000000000 --- a/horizon/dashboards/nova/containers/templates/containers/detail.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends 'base.html' %} -{% load i18n %} -{% block title %}{% trans "Objects" %}{% endblock %} - -{% block page_header %} - -{% endblock page_header %} - -{% block main %} -
- {{ subfolders_table.render }} -
-
- {{ objects_table.render }} -
-{% endblock %} diff --git a/horizon/dashboards/nova/containers/templates/containers/index.html b/horizon/dashboards/nova/containers/templates/containers/index.html index 2060972ca..1dc45d98e 100644 --- a/horizon/dashboards/nova/containers/templates/containers/index.html +++ b/horizon/dashboards/nova/containers/templates/containers/index.html @@ -3,9 +3,25 @@ {% block title %}Containers{% endblock %} {% block page_header %} - {% include "horizon/common/_page_header.html" with title=_("Containers") %} + {% endblock page_header %} {% block main %} - {{ table.render }} + {{ swift_browser.render }} {% endblock %} diff --git a/horizon/dashboards/nova/containers/tests.py b/horizon/dashboards/nova/containers/tests.py index 7e7bbe8dc..a39ac2209 100644 --- a/horizon/dashboards/nova/containers/tests.py +++ b/horizon/dashboards/nova/containers/tests.py @@ -28,7 +28,7 @@ from mox import IsA from horizon import api from horizon import test -from .tables import ContainersTable, ObjectsTable +from .tables import ContainersTable, ObjectsTable, wrap_delimiter from . import forms @@ -93,50 +93,30 @@ class ContainerViewTests(test.TestCase): 'method': forms.CreateContainer.__name__} res = self.client.post(reverse('horizon:nova:containers:create'), formData) - url = reverse('horizon:nova:containers:object_index', - args=[self.containers.first().name]) + url = reverse('horizon:nova:containers:index', + args=[wrap_delimiter(self.containers.first().name)]) self.assertRedirectsNoFollow(res, url) -class ObjectViewTests(test.TestCase): - @test.create_stubs({api: ('swift_get_objects',)}) +class IndexViewTests(test.TestCase): def test_index(self): + self.mox.StubOutWithMock(api, 'swift_get_containers') + self.mox.StubOutWithMock(api, 'swift_get_objects') + containers = (self.containers.list(), False) ret = (self.objects.list(), False) + api.swift_get_containers(IsA(http.HttpRequest), + marker=None).AndReturn(containers) api.swift_get_objects(IsA(http.HttpRequest), self.containers.first().name, marker=None, path=None).AndReturn(ret) self.mox.ReplayAll() - res = self.client.get(reverse('horizon:nova:containers:object_index', - args=[self.containers.first().name])) - self.assertEquals(res.context['container_name'], - self.containers.first().name) - self.assertTemplateUsed(res, 'nova/containers/detail.html') - # UTF8 encoding here to ensure there aren't problems with Nose output. - expected = [obj.name.encode('utf8') for obj in self.objects.list()] - self.assertQuerysetEqual(res.context['objects_table'].data, - expected, - lambda obj: obj.name.encode('utf8')) - - @test.create_stubs({api: ('swift_get_objects',)}) - def test_index_subfolders(self): - ret = (self.objects.list(), False) - api.swift_get_objects(IsA(http.HttpRequest), - self.containers.first().name, - marker=None, - path='sub1/sub2').AndReturn(ret) - self.mox.ReplayAll() - - res = self.client.get(reverse('horizon:nova:containers:object_index', - args=[self.containers.first().name, - u'sub1/sub2/'])) - self.assertEquals(res.context['container_name'], - self.containers.first().name) - self.assertListEqual(res.context['subfolders'], - [('sub1', 'sub1/'), - ('sub2', 'sub1/sub2/'), ]) - self.assertTemplateUsed(res, 'nova/containers/detail.html') + res = self.client.get(reverse('horizon:nova:containers:index', + args=[wrap_delimiter(self.containers + .first() + .name)])) + self.assertTemplateUsed(res, 'nova/containers/index.html') # UTF8 encoding here to ensure there aren't problems with Nose output. expected = [obj.name.encode('utf8') for obj in self.objects.list()] self.assertQuerysetEqual(res.context['objects_table'].data, @@ -177,8 +157,8 @@ class ObjectViewTests(test.TestCase): 'object_file': temp_file} res = self.client.post(upload_url, formData) - index_url = reverse('horizon:nova:containers:object_index', - args=[container.name]) + index_url = reverse('horizon:nova:containers:index', + args=[wrap_delimiter(container.name)]) self.assertRedirectsNoFollow(res, index_url) # Test invalid filename @@ -197,8 +177,8 @@ class ObjectViewTests(test.TestCase): def test_delete(self): container = self.containers.first() obj = self.objects.first() - index_url = reverse('horizon:nova:containers:object_index', - args=[container.name]) + index_url = reverse('horizon:nova:containers:index', + args=[wrap_delimiter(container.name)]) self.mox.StubOutWithMock(api, 'swift_delete_object') api.swift_delete_object(IsA(http.HttpRequest), container.name, @@ -269,6 +249,6 @@ class ObjectViewTests(test.TestCase): copy_url = reverse('horizon:nova:containers:object_copy', args=[container_1.name, obj.name]) res = self.client.post(copy_url, formData) - index_url = reverse('horizon:nova:containers:object_index', - args=[container_2.name]) + index_url = reverse('horizon:nova:containers:index', + args=[wrap_delimiter(container_2.name)]) self.assertRedirectsNoFollow(res, index_url) diff --git a/horizon/dashboards/nova/containers/urls.py b/horizon/dashboards/nova/containers/urls.py index c72f1fc1d..363b19c72 100644 --- a/horizon/dashboards/nova/containers/urls.py +++ b/horizon/dashboards/nova/containers/urls.py @@ -20,22 +20,19 @@ from django.conf.urls.defaults import patterns, url -from .views import IndexView, CreateView, UploadView, ObjectIndexView, CopyView +from .views import CreateView, UploadView, CopyView, ContainerView # Swift containers and objects. urlpatterns = patterns('horizon.dashboards.nova.containers.views', - url(r'^$', IndexView.as_view(), name='index'), + url(r'^((?P.+?)/)?(?P(.+/)+)?$', + ContainerView.as_view(), name='index'), url(r'^(?P(.+/)+)?create$', CreateView.as_view(), name='create'), - url(r'^(?P[^/]+)/(?P(.+/)+)?$', - ObjectIndexView.as_view(), - name='object_index'), - - url(r'^(?P[^/]+)/(?P(.+/)+)?upload$', + url(r'^(?P.+?)/(?P(.+/)+)?upload$', UploadView.as_view(), name='object_upload'), diff --git a/horizon/dashboards/nova/containers/views.py b/horizon/dashboards/nova/containers/views.py index 91b9aaca7..e2d5d9a8d 100644 --- a/horizon/dashboards/nova/containers/views.py +++ b/horizon/dashboards/nova/containers/views.py @@ -21,7 +21,6 @@ """ Views for managing Swift containers. """ -import logging import os from django import http @@ -29,24 +28,20 @@ from django.core.urlresolvers import reverse from django.utils.translation import ugettext_lazy as _ from horizon import api +from horizon import browsers from horizon import exceptions from horizon import forms -from horizon import tables +from horizon.api import FOLDER_DELIMITER +from .browsers import ContainerBrowser from .forms import CreateContainer, UploadObject, CopyObject -from .tables import ContainersTable, ObjectsTable +from .tables import wrap_delimiter -LOG = logging.getLogger(__name__) +class ContainerView(browsers.ResourceBrowserView): + browser_class = ContainerBrowser + template_name = "nova/containers/index.html" - -class IndexView(tables.DataTableView): - table_class = ContainersTable - template_name = 'nova/containers/index.html' - - def has_more_data(self, table): - return self._more - - def get_data(self): + def get_containers_data(self): containers = [] self._more = None marker = self.request.GET.get('marker', None) @@ -58,35 +53,6 @@ class IndexView(tables.DataTableView): exceptions.handle(self.request, msg) return containers - -class CreateView(forms.ModalFormView): - form_class = CreateContainer - template_name = 'nova/containers/create.html' - success_url = "horizon:nova:containers:object_index" - - def get_success_url(self): - parent = self.request.POST.get('parent', None) - if parent: - container, slash, remainder = parent.partition("/") - if remainder and not remainder.endswith("/"): - remainder = "".join([remainder, "/"]) - return reverse(self.success_url, args=(container, remainder)) - else: - return reverse(self.success_url, args=[self.request.POST['name']]) - - def get_initial(self): - initial = super(CreateView, self).get_initial() - initial['parent'] = self.kwargs['container_name'] - return initial - - -class ObjectIndexView(tables.MixedDataTableView): - table_class = ObjectsTable - template_name = 'nova/containers/detail.html' - - def has_more_data(self, table): - return self._more - @property def objects(self): """ Returns a list of objects given the subfolder's path. @@ -99,20 +65,20 @@ class ObjectIndexView(tables.MixedDataTableView): marker = self.request.GET.get('marker', None) container_name = self.kwargs['container_name'] subfolders = self.kwargs['subfolder_path'] - if subfolders: - prefix = subfolders.rstrip("/") - else: - prefix = None - try: - objects, self._more = api.swift_get_objects(self.request, - container_name, - marker=marker, - path=prefix) - except: - self._more = None - objects = [] - msg = _('Unable to retrieve object list.') - exceptions.handle(self.request, msg) + prefix = None + if container_name: + if subfolders: + prefix = subfolders.rstrip(FOLDER_DELIMITER) + try: + objects, self._more = api.swift_get_objects(self.request, + container_name, + marker=marker, + path=prefix) + except: + self._more = None + objects = [] + msg = _('Unable to retrieve object list.') + exceptions.handle(self.request, msg) self._objects = objects return self._objects @@ -134,7 +100,7 @@ class ObjectIndexView(tables.MixedDataTableView): return filtered_objects def get_context_data(self, **kwargs): - context = super(ObjectIndexView, self).get_context_data(**kwargs) + context = super(ContainerView, self).get_context_data(**kwargs) context['container_name'] = self.kwargs["container_name"] context['subfolders'] = [] if self.kwargs["subfolder_path"]: @@ -147,14 +113,38 @@ class ObjectIndexView(tables.MixedDataTableView): return context +class CreateView(forms.ModalFormView): + form_class = CreateContainer + template_name = 'nova/containers/create.html' + success_url = "horizon:nova:containers:index" + + def get_success_url(self): + parent = self.request.POST.get('parent', None) + if parent: + container, slash, remainder = parent.partition(FOLDER_DELIMITER) + container += FOLDER_DELIMITER + if remainder and not remainder.endswith(FOLDER_DELIMITER): + remainder = "".join([remainder, FOLDER_DELIMITER]) + return reverse(self.success_url, args=(container, remainder)) + else: + return reverse(self.success_url, args=[self.request.POST['name'] + + FOLDER_DELIMITER]) + + def get_initial(self): + initial = super(CreateView, self).get_initial() + initial['parent'] = self.kwargs['container_name'] + return initial + + class UploadView(forms.ModalFormView): form_class = UploadObject template_name = 'nova/containers/upload.html' - success_url = "horizon:nova:containers:object_index" + success_url = "horizon:nova:containers:index" def get_success_url(self): + container_name = self.request.POST['container_name'] return reverse(self.success_url, - args=(self.request.POST['container_name'], + args=(wrap_delimiter(container_name), self.request.POST.get('path', ''))) def get_initial(self): @@ -171,7 +161,7 @@ def object_download(request, container_name, object_path): obj = api.swift.swift_get_object(request, container_name, object_path) # Add the original file extension back on if it wasn't preserved in the # name given to the object. - filename = object_path.rsplit("/")[-1] + filename = object_path.rsplit(FOLDER_DELIMITER)[-1] if not os.path.splitext(obj.name)[1]: name, ext = os.path.splitext(obj.metadata.get('orig-filename', '')) filename = "%s%s" % (filename, ext) @@ -196,11 +186,12 @@ def object_download(request, container_name, object_path): class CopyView(forms.ModalFormView): form_class = CopyObject template_name = 'nova/containers/copy.html' - success_url = "horizon:nova:containers:object_index" + success_url = "horizon:nova:containers:index" def get_success_url(self): + new_container_name = self.request.POST['new_container_name'] return reverse(self.success_url, - args=(self.request.POST['new_container_name'], + args=(wrap_delimiter(new_container_name), self.request.POST.get('path', ''))) def get_form_kwargs(self): diff --git a/horizon/dashboards/syspanel/overview/tests.py b/horizon/dashboards/syspanel/overview/tests.py index f9e81353c..612d838b7 100644 --- a/horizon/dashboards/syspanel/overview/tests.py +++ b/horizon/dashboards/syspanel/overview/tests.py @@ -51,12 +51,13 @@ class UsageViewTests(test.BaseAdminViewTests): self.assertTemplateUsed(res, 'syspanel/overview/usage.html') self.assertTrue(isinstance(res.context['usage'], usage.GlobalUsage)) self.assertContains(res, - 'test_tenant' - '%s' - '%s' - '%s' - '%.2f' - '%.2f' % + 'test_tenant' + '' + '%s' + '%s' + '%s' + '%.2f' + '%.2f' % (usage_obj.vcpus, usage_obj.disk_gb_hours, mbformat(usage_obj.memory_mb), diff --git a/horizon/tables/actions.py b/horizon/tables/actions.py index f5d7b9f8a..57b01beef 100644 --- a/horizon/tables/actions.py +++ b/horizon/tables/actions.py @@ -439,6 +439,8 @@ class BatchAction(Action): self._conjugate()) self.verbose_name_plural = getattr(self, "verbose_name_plural", self._conjugate('plural')) + # Keep record of successfully handled objects + self.success_ids = [] super(BatchAction, self).__init__() def _allowed(self, request, datum=None): @@ -508,6 +510,7 @@ class BatchAction(Action): #Call update to invoke changes if needed self.update(request, datum) action_success.append(datum_display) + self.success_ids.append(datum_id) LOG.info('%s: "%s"' % (self._conjugate(past=True), datum_display)) except: diff --git a/horizon/tables/base.py b/horizon/tables/base.py index 1a1ddea84..660868fb3 100644 --- a/horizon/tables/base.py +++ b/horizon/tables/base.py @@ -692,11 +692,13 @@ class DataTableOptions(object): Optional. Default: :``False`` .. attribute:: data_types + A list of data types that this table would accept. Default to be an empty list, but if the attibute ``mixed_data_type`` is set to ``True``, then this list must have at least one element. .. attribute:: data_type_name + The name of an attribute to assign to data passed to the table when it accepts mix data. Default: ``"_table_data_type"`` """ @@ -712,6 +714,7 @@ class DataTableOptions(object): self.row_class = getattr(options, 'row_class', Row) self.column_class = getattr(options, 'column_class', Column) self.pagination_param = getattr(options, 'pagination_param', 'marker') + self.browser_table = getattr(options, 'browser_table', None) # Set self.filter if we have any FilterActions filter_actions = [action for action in self.table_actions if @@ -767,6 +770,7 @@ class DataTableMetaclass(type): """ Metaclass to add options to DataTable class and collect columns. """ def __new__(mcs, name, bases, attrs): # Process options from Meta + class_name = name attrs["_meta"] = opts = DataTableOptions(attrs.get("Meta", None)) # Gather columns; this prevents the column from being an attribute @@ -776,6 +780,7 @@ class DataTableMetaclass(type): if issubclass(type(obj), (opts.column_class, Column)): column_instance = attrs.pop(name) column_instance.name = name + column_instance.classes.append('normal_column') columns.append((name, column_instance)) columns.sort(key=lambda x: x[1].creation_counter) @@ -785,6 +790,15 @@ class DataTableMetaclass(type): columns = base.base_columns.items() + columns attrs['base_columns'] = SortedDict(columns) + # If the table is in a ResourceBrowser, the column number must meet + # these limits because of the width of the browser. + if opts.browser_table == "navigation" and len(columns) > 1: + raise ValueError("You can only assign one column to %s." + % class_name) + if opts.browser_table == "content" and len(columns) > 2: + raise ValueError("You can only assign two columns to %s." + % class_name) + if opts.columns: # Remove any columns that weren't declared if we're being explicit # NOTE: we're iterating a COPY of the list here! @@ -794,7 +808,7 @@ class DataTableMetaclass(type): # Re-order based on declared columns columns.sort(key=lambda x: attrs['_meta'].columns.index(x[0])) # Add in our auto-generated columns - if opts.multi_select: + if opts.multi_select and opts.browser_table != "navigation": multi_select = opts.column_class("multi_select", verbose_name="", auto="multi_select") @@ -937,6 +951,11 @@ class DataTable(object): LOG.exception("Error while checking action permissions.") return None + def is_browser_table(self): + if self._meta.browser_table: + return True + return False + def render(self): """ Renders the table using the template from the table options. """ table_template = template.loader.get_template(self._meta.template) @@ -1036,7 +1055,8 @@ class DataTable(object): table_actions_template = template.loader.get_template(template_path) bound_actions = self.get_table_actions() extra_context = {"table_actions": bound_actions} - if self._meta.filter: + if self._meta.filter and \ + self._filter_action(self._meta._filter_action, self._meta.request): extra_context["filter"] = self._meta._filter_action context = template.RequestContext(self._meta.request, extra_context) return table_actions_template.render(context) diff --git a/horizon/tables/views.py b/horizon/tables/views.py index f50527ab6..835e51089 100644 --- a/horizon/tables/views.py +++ b/horizon/tables/views.py @@ -14,29 +14,74 @@ # License for the specific language governing permissions and limitations # under the License. +from collections import defaultdict + from django.views import generic class MultiTableMixin(object): """ A generic mixin which provides methods for handling DataTables. """ + data_method_pattern = "get_%s_data" + def __init__(self, *args, **kwargs): super(MultiTableMixin, self).__init__(*args, **kwargs) self.table_classes = getattr(self, "table_classes", []) self._data = {} self._tables = {} + self._data_methods = defaultdict(list) + self.get_data_methods(self.table_classes, self._data_methods) + def _get_data_dict(self): if not self._data: for table in self.table_classes: - func_name = "get_%s_data" % table._meta.name - data_func = getattr(self, func_name, None) - if data_func is None: - cls_name = self.__class__.__name__ - raise NotImplementedError("You must define a %s method " - "on %s." % (func_name, cls_name)) - self._data[table._meta.name] = data_func() + data = [] + name = table._meta.name + func_list = self._data_methods.get(name, []) + for func in func_list: + data.extend(func()) + self._data[name] = data return self._data + def get_data_methods(self, table_classes, methods): + for table in table_classes: + name = table._meta.name + if table._meta.mixed_data_type: + for data_type in table._meta.data_types: + func = self.check_method_exist(self.data_method_pattern, + data_type) + if func: + type_name = table._meta.data_type_name + methods[name].append(self.wrap_func(func, + type_name, + data_type)) + else: + func = self.check_method_exist(self.data_method_pattern, + name) + if func: + methods[name].append(func) + + def wrap_func(self, data_func, type_name, data_type): + def final_data(): + data = data_func() + self.assign_type_string(data, type_name, data_type) + return data + return final_data + + def check_method_exist(self, func_pattern="%s", *names): + func_name = func_pattern % names + func = getattr(self, func_name, None) + if not func or not callable(func): + cls_name = self.__class__.__name__ + raise NotImplementedError("You must define a %s method" + "in %s." % (func_name, cls_name)) + else: + return func + + def assign_type_string(self, data, type_name, data_type): + for datum in data: + setattr(datum, type_name, data_type) + def get_tables(self): if not self.table_classes: raise AttributeError('You must specify one or more DataTable ' diff --git a/horizon/templates/horizon/common/_data_table.html b/horizon/templates/horizon/common/_data_table.html index 89db10b46..9713752f5 100644 --- a/horizon/templates/horizon/common/_data_table.html +++ b/horizon/templates/horizon/common/_data_table.html @@ -11,11 +11,13 @@ {{ table.render_table_actions }} + {% if not table.is_browser_table %} {% for column in columns %} {{ column }} {% endfor %} + {% endif %} {% for row in rows %} @@ -52,4 +54,4 @@ {% endwith %} {% if needs_form_wrapper %}{% endif %} -{% endwith %} \ No newline at end of file +{% endwith %} diff --git a/horizon/templates/horizon/common/_resource_browser.html b/horizon/templates/horizon/common/_resource_browser.html new file mode 100644 index 000000000..6242f3eec --- /dev/null +++ b/horizon/templates/horizon/common/_resource_browser.html @@ -0,0 +1,9 @@ +{% load i18n %} +
+ +
+ {{ browser.content_table.render }} +
+
diff --git a/horizon/tests/table_tests.py b/horizon/tests/table_tests.py index 184a4b324..d34f349df 100644 --- a/horizon/tests/table_tests.py +++ b/horizon/tests/table_tests.py @@ -359,7 +359,7 @@ class DataTableTests(test.TestCase): self.assertEqual(row3.cells['optional'].value, "N/A") # classes self.assertEqual(value_col.get_final_attrs().get('class', ""), - "green blue sortable anchor") + "green blue sortable anchor normal_column") # status cell_status = row.cells['status'].status self.assertEqual(cell_status, True) diff --git a/openstack_dashboard/static/bootstrap/less/variables.less b/openstack_dashboard/static/bootstrap/less/variables.less index d7e813e28..afc463d88 100644 --- a/openstack_dashboard/static/bootstrap/less/variables.less +++ b/openstack_dashboard/static/bootstrap/less/variables.less @@ -105,3 +105,23 @@ // Fluid grid @fluidGridColumnWidth: 6.382978723%; @fluidGridGutterWidth: 2.127659574%; + +//ResourceBrowser +@dataTableBorderWidth: 1px; +@dataTableBorderColor: #DDD; + +@multiSelectionWidth: 25px; +@actionsColumnWidth: 150px; +@actionsColumnPadding: 10px; + +@navigationColWidth: 150px; +@contentColWidth: 240px; + +@smallButtonHeight: 28px; +@tbodyHeight: (@dataTableBorderWidth + @smallButtonHeight + @actionsColumnPadding) * 10; + +@tableCellPadding: 8px; + +@contentTableWidth: @multiSelectionWidth + @contentColWidth * 2 + @actionsColumnWidth + @actionsColumnPadding * 2 + @tableCellPadding * 6 + @dataTableBorderWidth * 3; +@navigationTableWidth: (@navigationColWidth + @actionsColumnPadding + @tableCellPadding) * 2 + @dataTableBorderWidth * 3; +@browserWrapperWidth: @contentTableWidth + @navigationTableWidth; diff --git a/openstack_dashboard/static/dashboard/less/horizon.less b/openstack_dashboard/static/dashboard/less/horizon.less index 4351fcf44..72d543d49 100644 --- a/openstack_dashboard/static/dashboard/less/horizon.less +++ b/openstack_dashboard/static/dashboard/less/horizon.less @@ -1389,3 +1389,98 @@ label.log-length { padding-right: 5px; float: left; } + +/* ResourceBrowser style +*/ +#browser_wrapper { + width: @browserWrapperWidth; + > div{ + position: relative; + padding: 55px 0 32px 0; + float: left; + background-color: @grayLighter; + } + div.table_wrapper { + height: @tbodyHeight; + border-left: @dataTableBorderWidth solid @dataTableBorderColor; + border-right: @dataTableBorderWidth solid @dataTableBorderColor; + overflow-y: scroll; + overflow-x: hidden; + } + div.navigation_wrapper { + width: @navigationTableWidth; + div.table_wrapper, + thead th.table_header { + width: @navigationTableWidth - 2px; + } + td { + background-color: whiteSmoke; + } + td.normal_column{ + width: @navigationColWidth; + min-width: @navigationColWidth; + > a { + width: @navigationColWidth; + min-width: @navigationColWidth; + } + } + tfoot td { + width: @navigationTableWidth - 2 * @dataTableBorderWidth - 2 * @tableCellPadding; + } + } + div.content_wrapper { + width: @contentTableWidth; + div.table_wrapper, + thead th.table_header { + width: @contentTableWidth - 2px; + } + td.normal_column { + width: @contentColWidth; + min-width: @contentColWidth; + > a { + width: @contentColWidth; + min-width: @contentColWidth; + } + } + tfoot td { + width: @contentTableWidth - 2 * @dataTableBorderWidth - 2 * @tableCellPadding; + } + } + table { + thead { + position: absolute; + top: 0; + left: 0; + tr th { + border: @dataTableBorderWidth solid @dataTableBorderColor; + border-bottom: none; + background-color: @grayLighter; + } + } + td.multi_select_column, + th.multi_select_column{ + width: @multiSelectionWidth; + } + td.actions_column, + th.actions_column{ + padding :@actionsColumnPadding; + width: @actionsColumnWidth; + } + tbody { + tr td:first-child{ + border-left: none; + } + tr td:last-child { + border-right: none; + } + tr:last-child td { + border-bottom: @dataTableBorderWidth solid @dataTableBorderColor; + } + } + tfoot td{ + position: absolute; + left: 0; + bottom: 0; + } + } +}