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 %}
-
' %
(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 %}
+