Adds ResourceBrowser and ResourceBrowserView class
related: blueprint swift-ui-improvements Provides new API for building browser for navigation, as well as new look and feel in CSS. Applies it to container panel of the nova dashboard. Change-Id: Iecd984263bae7c3774a0630639f645cee4cffca9
This commit is contained in:
parent
dfdb8375de
commit
89d3d11cb1
@ -29,6 +29,7 @@ from horizon.api.base import url_for
|
|||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
FOLDER_DELIMITER = "/"
|
||||||
|
|
||||||
|
|
||||||
class SwiftAuthentication(object):
|
class SwiftAuthentication(object):
|
||||||
@ -94,7 +95,7 @@ def swift_get_objects(request, container_name, prefix=None, path=None,
|
|||||||
objects = container.get_objects(prefix=prefix,
|
objects = container.get_objects(prefix=prefix,
|
||||||
marker=marker,
|
marker=marker,
|
||||||
limit=limit + 1,
|
limit=limit + 1,
|
||||||
delimiter="/",
|
delimiter=FOLDER_DELIMITER,
|
||||||
path=path)
|
path=path)
|
||||||
if(len(objects) > limit):
|
if(len(objects) > limit):
|
||||||
return (objects[0:-1], True)
|
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,
|
objects = container.get_objects(prefix=prefix,
|
||||||
marker=marker,
|
marker=marker,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
delimiter="/",
|
delimiter=FOLDER_DELIMITER,
|
||||||
path=path)
|
path=path)
|
||||||
filter_string_list = filter_string.lower().strip().split(' ')
|
filter_string_list = filter_string.lower().strip().split(' ')
|
||||||
|
|
||||||
|
18
horizon/browsers/__init__.py
Normal file
18
horizon/browsers/__init__.py
Normal file
@ -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
|
105
horizon/browsers/base.py
Normal file
105
horizon/browsers/base.py
Normal file
@ -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)
|
56
horizon/browsers/views.py
Normal file
56
horizon/browsers/views.py
Normal file
@ -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
|
32
horizon/dashboards/nova/containers/browsers.py
Normal file
32
horizon/dashboards/nova/containers/browsers.py
Normal file
@ -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
|
@ -114,7 +114,7 @@ class CopyObject(forms.SelfHandlingForm):
|
|||||||
self.fields['new_container_name'].choices = containers
|
self.fields['new_container_name'].choices = containers
|
||||||
|
|
||||||
def handle(self, request, data):
|
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_container = data['orig_container_name']
|
||||||
orig_object = data['orig_object_name']
|
orig_object = data['orig_object_name']
|
||||||
new_container = data['new_container_name']
|
new_container = data['new_container_name']
|
||||||
|
@ -25,14 +25,21 @@ from django.utils.translation import ugettext_lazy as _
|
|||||||
from horizon import api
|
from horizon import api
|
||||||
from horizon import messages
|
from horizon import messages
|
||||||
from horizon import tables
|
from horizon import tables
|
||||||
|
from horizon.api import FOLDER_DELIMITER
|
||||||
|
from horizon.tables import DataTable
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_delimiter(name):
|
||||||
|
return name + FOLDER_DELIMITER
|
||||||
|
|
||||||
|
|
||||||
class DeleteContainer(tables.DeleteAction):
|
class DeleteContainer(tables.DeleteAction):
|
||||||
data_type_singular = _("Container")
|
data_type_singular = _("Container")
|
||||||
data_type_plural = _("Containers")
|
data_type_plural = _("Containers")
|
||||||
|
completion_url = "horizon:nova:containers:index"
|
||||||
|
|
||||||
def delete(self, request, obj_id):
|
def delete(self, request, obj_id):
|
||||||
try:
|
try:
|
||||||
@ -42,6 +49,18 @@ class DeleteContainer(tables.DeleteAction):
|
|||||||
_('Containers must be empty before deletion.'))
|
_('Containers must be empty before deletion.'))
|
||||||
raise
|
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):
|
class CreateContainer(tables.LinkAction):
|
||||||
name = "create"
|
name = "create"
|
||||||
@ -53,9 +72,14 @@ class CreateContainer(tables.LinkAction):
|
|||||||
class ListObjects(tables.LinkAction):
|
class ListObjects(tables.LinkAction):
|
||||||
name = "list_objects"
|
name = "list_objects"
|
||||||
verbose_name = _("View Container")
|
verbose_name = _("View Container")
|
||||||
url = "horizon:nova:containers:object_index"
|
url = "horizon:nova:containers:index"
|
||||||
classes = ("btn-list",)
|
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):
|
class UploadObject(tables.LinkAction):
|
||||||
name = "upload"
|
name = "upload"
|
||||||
@ -76,6 +100,11 @@ class UploadObject(tables.LinkAction):
|
|||||||
(container_name, subfolders) if bit)
|
(container_name, subfolders) if bit)
|
||||||
return reverse(self.url, args=args)
|
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):
|
def update(self, request, obj):
|
||||||
# This will only be called for the row, so we can remove the button
|
# This will only be called for the row, so we can remove the button
|
||||||
# styles meant for the table action version.
|
# styles meant for the table action version.
|
||||||
@ -86,15 +115,14 @@ def get_size_used(container):
|
|||||||
return filesizeformat(container.size_used)
|
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):
|
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"))
|
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):
|
def get_object_id(self, container):
|
||||||
return container.name
|
return container.name
|
||||||
@ -102,8 +130,9 @@ class ContainersTable(tables.DataTable):
|
|||||||
class Meta:
|
class Meta:
|
||||||
name = "containers"
|
name = "containers"
|
||||||
verbose_name = _("Containers")
|
verbose_name = _("Containers")
|
||||||
table_actions = (CreateContainer, DeleteContainer)
|
table_actions = (CreateContainer,)
|
||||||
row_actions = (ListObjects, UploadObject, DeleteContainer)
|
row_actions = (ListObjects, UploadObject, DeleteContainer)
|
||||||
|
browser_table = "navigation"
|
||||||
|
|
||||||
|
|
||||||
class DeleteObject(tables.DeleteAction):
|
class DeleteObject(tables.DeleteAction):
|
||||||
@ -127,8 +156,8 @@ class DeleteSubfolder(DeleteObject):
|
|||||||
|
|
||||||
class DeleteMultipleObjects(DeleteObject):
|
class DeleteMultipleObjects(DeleteObject):
|
||||||
name = "delete_multiple_objects"
|
name = "delete_multiple_objects"
|
||||||
data_type_singular = _("Object/Folder")
|
data_type_singular = _("Object")
|
||||||
data_type_plural = _("Objects/Folders")
|
data_type_plural = _("Objects")
|
||||||
allowed_data_types = ("subfolders", "objects",)
|
allowed_data_types = ("subfolders", "objects",)
|
||||||
|
|
||||||
|
|
||||||
@ -161,7 +190,7 @@ class ObjectFilterAction(tables.FilterAction):
|
|||||||
request = table._meta.request
|
request = table._meta.request
|
||||||
container = self.table.kwargs['container_name']
|
container = self.table.kwargs['container_name']
|
||||||
subfolder = self.table.kwargs['subfolder_path']
|
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,
|
self.filtered_data = api.swift_filter_objects(request,
|
||||||
filter_string,
|
filter_string,
|
||||||
container,
|
container,
|
||||||
@ -178,9 +207,14 @@ class ObjectFilterAction(tables.FilterAction):
|
|||||||
return [datum for datum in data if
|
return [datum for datum in data if
|
||||||
datum.content_type != "application/directory"]
|
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):
|
def sanitize_name(name):
|
||||||
return name.split("/")[-1]
|
return name.split(FOLDER_DELIMITER)[-1]
|
||||||
|
|
||||||
|
|
||||||
def get_size(obj):
|
def get_size(obj):
|
||||||
@ -188,9 +222,10 @@ def get_size(obj):
|
|||||||
|
|
||||||
|
|
||||||
def get_link_subfolder(subfolder):
|
def get_link_subfolder(subfolder):
|
||||||
return reverse("horizon:nova:containers:object_index",
|
container_name = subfolder.container.name
|
||||||
args=(http.urlquote(subfolder.container.name),
|
return reverse("horizon:nova:containers:index",
|
||||||
http.urlquote(subfolder.name + "/")))
|
args=(http.urlquote(wrap_delimiter(container_name)),
|
||||||
|
http.urlquote(wrap_delimiter(subfolder.name))))
|
||||||
|
|
||||||
|
|
||||||
class CreateSubfolder(CreateContainer):
|
class CreateSubfolder(CreateContainer):
|
||||||
@ -200,9 +235,15 @@ class CreateSubfolder(CreateContainer):
|
|||||||
def get_link_url(self):
|
def get_link_url(self):
|
||||||
container = self.table.kwargs['container_name']
|
container = self.table.kwargs['container_name']
|
||||||
subfolders = self.table.kwargs['subfolder_path']
|
subfolders = self.table.kwargs['subfolder_path']
|
||||||
parent = "/".join((bit for bit in [container, subfolders] if bit))
|
parent = FOLDER_DELIMITER.join((bit for bit in [container,
|
||||||
parent = parent.rstrip("/")
|
subfolders] if bit))
|
||||||
return reverse(self.url, args=(http.urlquote(parent + "/"),))
|
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):
|
class ObjectsTable(tables.DataTable):
|
||||||
@ -211,6 +252,7 @@ class ObjectsTable(tables.DataTable):
|
|||||||
allowed_data_types=("subfolders",),
|
allowed_data_types=("subfolders",),
|
||||||
verbose_name=_("Object Name"),
|
verbose_name=_("Object Name"),
|
||||||
filters=(sanitize_name,))
|
filters=(sanitize_name,))
|
||||||
|
|
||||||
size = tables.Column(get_size, verbose_name=_('Size'))
|
size = tables.Column(get_size, verbose_name=_('Size'))
|
||||||
|
|
||||||
def get_object_id(self, obj):
|
def get_object_id(self, obj):
|
||||||
@ -218,9 +260,10 @@ class ObjectsTable(tables.DataTable):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
name = "objects"
|
name = "objects"
|
||||||
verbose_name = _("Subfolders and Objects")
|
verbose_name = _("Objects")
|
||||||
table_actions = (ObjectFilterAction, CreateSubfolder,
|
table_actions = (ObjectFilterAction, CreateSubfolder,
|
||||||
UploadObject, DeleteMultipleObjects)
|
UploadObject, DeleteMultipleObjects)
|
||||||
row_actions = (DownloadObject, CopyObject, DeleteObject,
|
row_actions = (DownloadObject, CopyObject, DeleteObject,
|
||||||
DeleteSubfolder)
|
DeleteSubfolder)
|
||||||
data_types = ("subfolders", "objects")
|
data_types = ("subfolders", "objects")
|
||||||
|
browser_table = "content"
|
||||||
|
@ -20,5 +20,5 @@
|
|||||||
|
|
||||||
{% block modal-footer %}
|
{% block modal-footer %}
|
||||||
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Copy Object" %}" />
|
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Copy Object" %}" />
|
||||||
<a href="{% url horizon:nova:containers:object_index container_name %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
|
<a href="{% url horizon:nova:containers:index container_name|add:'/' %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -21,5 +21,5 @@
|
|||||||
|
|
||||||
{% block modal-footer %}
|
{% block modal-footer %}
|
||||||
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Upload Object" %}" />
|
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Upload Object" %}" />
|
||||||
<a href="{% url horizon:nova:containers:object_index container_name %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
|
<a href="{% url horizon:nova:containers:index container_name|add:'/' %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% load i18n %}
|
|
||||||
{% block title %}{% trans "Objects" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block page_header %}
|
|
||||||
<div class='page-header'>
|
|
||||||
<h2>{% trans "Container" %}:
|
|
||||||
{% if subfolders %}
|
|
||||||
<a href="{% url horizon:nova:containers:object_index container_name=container_name %}">{{container_name}}</a>
|
|
||||||
<small>/</small>
|
|
||||||
{% else %}
|
|
||||||
{{container_name}}
|
|
||||||
{% endif %}
|
|
||||||
{% for subfolder, path in subfolders %}
|
|
||||||
<small>
|
|
||||||
{% if not forloop.last %}
|
|
||||||
<a href="{% url horizon:nova:containers:object_index container_name=container_name subfolder_path=path %}">
|
|
||||||
{% endif %}{{ subfolder }}{% if not forloop.last %}</a> /{% endif %}
|
|
||||||
</small>
|
|
||||||
{% endfor %}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
{% endblock page_header %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<div id="subfolders">
|
|
||||||
{{ subfolders_table.render }}
|
|
||||||
</div>
|
|
||||||
<div id="objects">
|
|
||||||
{{ objects_table.render }}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
@ -3,9 +3,25 @@
|
|||||||
{% block title %}Containers{% endblock %}
|
{% block title %}Containers{% endblock %}
|
||||||
|
|
||||||
{% block page_header %}
|
{% block page_header %}
|
||||||
{% include "horizon/common/_page_header.html" with title=_("Containers") %}
|
<div class='page-header'>
|
||||||
|
<h2>{% trans "Container" %}
|
||||||
|
{% if subfolders %}
|
||||||
|
: <a href="{% url horizon:nova:containers:index container_name|add:'/' %}">{{container_name}}</a>
|
||||||
|
<small>/</small>
|
||||||
|
{% elif container_name %}
|
||||||
|
: {{container_name}}
|
||||||
|
{% endif %}
|
||||||
|
{% for subfolder, path in subfolders %}
|
||||||
|
<small>
|
||||||
|
{% if not forloop.last %}
|
||||||
|
<a href="{% url horizon:nova:containers:index container_name|add:'/' path %}">
|
||||||
|
{% endif %}{{ subfolder }}{% if not forloop.last %}</a> /{% endif %}
|
||||||
|
</small>
|
||||||
|
{% endfor %}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
{% endblock page_header %}
|
{% endblock page_header %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
{{ table.render }}
|
{{ swift_browser.render }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -28,7 +28,7 @@ from mox import IsA
|
|||||||
|
|
||||||
from horizon import api
|
from horizon import api
|
||||||
from horizon import test
|
from horizon import test
|
||||||
from .tables import ContainersTable, ObjectsTable
|
from .tables import ContainersTable, ObjectsTable, wrap_delimiter
|
||||||
from . import forms
|
from . import forms
|
||||||
|
|
||||||
|
|
||||||
@ -93,50 +93,30 @@ class ContainerViewTests(test.TestCase):
|
|||||||
'method': forms.CreateContainer.__name__}
|
'method': forms.CreateContainer.__name__}
|
||||||
res = self.client.post(reverse('horizon:nova:containers:create'),
|
res = self.client.post(reverse('horizon:nova:containers:create'),
|
||||||
formData)
|
formData)
|
||||||
url = reverse('horizon:nova:containers:object_index',
|
url = reverse('horizon:nova:containers:index',
|
||||||
args=[self.containers.first().name])
|
args=[wrap_delimiter(self.containers.first().name)])
|
||||||
self.assertRedirectsNoFollow(res, url)
|
self.assertRedirectsNoFollow(res, url)
|
||||||
|
|
||||||
|
|
||||||
class ObjectViewTests(test.TestCase):
|
class IndexViewTests(test.TestCase):
|
||||||
@test.create_stubs({api: ('swift_get_objects',)})
|
|
||||||
def test_index(self):
|
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)
|
ret = (self.objects.list(), False)
|
||||||
|
api.swift_get_containers(IsA(http.HttpRequest),
|
||||||
|
marker=None).AndReturn(containers)
|
||||||
api.swift_get_objects(IsA(http.HttpRequest),
|
api.swift_get_objects(IsA(http.HttpRequest),
|
||||||
self.containers.first().name,
|
self.containers.first().name,
|
||||||
marker=None,
|
marker=None,
|
||||||
path=None).AndReturn(ret)
|
path=None).AndReturn(ret)
|
||||||
self.mox.ReplayAll()
|
self.mox.ReplayAll()
|
||||||
|
|
||||||
res = self.client.get(reverse('horizon:nova:containers:object_index',
|
res = self.client.get(reverse('horizon:nova:containers:index',
|
||||||
args=[self.containers.first().name]))
|
args=[wrap_delimiter(self.containers
|
||||||
self.assertEquals(res.context['container_name'],
|
.first()
|
||||||
self.containers.first().name)
|
.name)]))
|
||||||
self.assertTemplateUsed(res, 'nova/containers/detail.html')
|
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,
|
|
||||||
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')
|
|
||||||
# UTF8 encoding here to ensure there aren't problems with Nose output.
|
# UTF8 encoding here to ensure there aren't problems with Nose output.
|
||||||
expected = [obj.name.encode('utf8') for obj in self.objects.list()]
|
expected = [obj.name.encode('utf8') for obj in self.objects.list()]
|
||||||
self.assertQuerysetEqual(res.context['objects_table'].data,
|
self.assertQuerysetEqual(res.context['objects_table'].data,
|
||||||
@ -177,8 +157,8 @@ class ObjectViewTests(test.TestCase):
|
|||||||
'object_file': temp_file}
|
'object_file': temp_file}
|
||||||
res = self.client.post(upload_url, formData)
|
res = self.client.post(upload_url, formData)
|
||||||
|
|
||||||
index_url = reverse('horizon:nova:containers:object_index',
|
index_url = reverse('horizon:nova:containers:index',
|
||||||
args=[container.name])
|
args=[wrap_delimiter(container.name)])
|
||||||
self.assertRedirectsNoFollow(res, index_url)
|
self.assertRedirectsNoFollow(res, index_url)
|
||||||
|
|
||||||
# Test invalid filename
|
# Test invalid filename
|
||||||
@ -197,8 +177,8 @@ class ObjectViewTests(test.TestCase):
|
|||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
container = self.containers.first()
|
container = self.containers.first()
|
||||||
obj = self.objects.first()
|
obj = self.objects.first()
|
||||||
index_url = reverse('horizon:nova:containers:object_index',
|
index_url = reverse('horizon:nova:containers:index',
|
||||||
args=[container.name])
|
args=[wrap_delimiter(container.name)])
|
||||||
self.mox.StubOutWithMock(api, 'swift_delete_object')
|
self.mox.StubOutWithMock(api, 'swift_delete_object')
|
||||||
api.swift_delete_object(IsA(http.HttpRequest),
|
api.swift_delete_object(IsA(http.HttpRequest),
|
||||||
container.name,
|
container.name,
|
||||||
@ -269,6 +249,6 @@ class ObjectViewTests(test.TestCase):
|
|||||||
copy_url = reverse('horizon:nova:containers:object_copy',
|
copy_url = reverse('horizon:nova:containers:object_copy',
|
||||||
args=[container_1.name, obj.name])
|
args=[container_1.name, obj.name])
|
||||||
res = self.client.post(copy_url, formData)
|
res = self.client.post(copy_url, formData)
|
||||||
index_url = reverse('horizon:nova:containers:object_index',
|
index_url = reverse('horizon:nova:containers:index',
|
||||||
args=[container_2.name])
|
args=[wrap_delimiter(container_2.name)])
|
||||||
self.assertRedirectsNoFollow(res, index_url)
|
self.assertRedirectsNoFollow(res, index_url)
|
||||||
|
@ -20,22 +20,19 @@
|
|||||||
|
|
||||||
from django.conf.urls.defaults import patterns, url
|
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.
|
# Swift containers and objects.
|
||||||
urlpatterns = patterns('horizon.dashboards.nova.containers.views',
|
urlpatterns = patterns('horizon.dashboards.nova.containers.views',
|
||||||
url(r'^$', IndexView.as_view(), name='index'),
|
url(r'^((?P<container_name>.+?)/)?(?P<subfolder_path>(.+/)+)?$',
|
||||||
|
ContainerView.as_view(), name='index'),
|
||||||
|
|
||||||
url(r'^(?P<container_name>(.+/)+)?create$',
|
url(r'^(?P<container_name>(.+/)+)?create$',
|
||||||
CreateView.as_view(),
|
CreateView.as_view(),
|
||||||
name='create'),
|
name='create'),
|
||||||
|
|
||||||
url(r'^(?P<container_name>[^/]+)/(?P<subfolder_path>(.+/)+)?$',
|
url(r'^(?P<container_name>.+?)/(?P<subfolder_path>(.+/)+)?upload$',
|
||||||
ObjectIndexView.as_view(),
|
|
||||||
name='object_index'),
|
|
||||||
|
|
||||||
url(r'^(?P<container_name>[^/]+)/(?P<subfolder_path>(.+/)+)?upload$',
|
|
||||||
UploadView.as_view(),
|
UploadView.as_view(),
|
||||||
name='object_upload'),
|
name='object_upload'),
|
||||||
|
|
||||||
|
@ -21,7 +21,6 @@
|
|||||||
"""
|
"""
|
||||||
Views for managing Swift containers.
|
Views for managing Swift containers.
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django import http
|
from django import http
|
||||||
@ -29,24 +28,20 @@ from django.core.urlresolvers import reverse
|
|||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from horizon import api
|
from horizon import api
|
||||||
|
from horizon import browsers
|
||||||
from horizon import exceptions
|
from horizon import exceptions
|
||||||
from horizon import forms
|
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 .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"
|
||||||
|
|
||||||
|
def get_containers_data(self):
|
||||||
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):
|
|
||||||
containers = []
|
containers = []
|
||||||
self._more = None
|
self._more = None
|
||||||
marker = self.request.GET.get('marker', None)
|
marker = self.request.GET.get('marker', None)
|
||||||
@ -58,35 +53,6 @@ class IndexView(tables.DataTableView):
|
|||||||
exceptions.handle(self.request, msg)
|
exceptions.handle(self.request, msg)
|
||||||
return containers
|
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
|
@property
|
||||||
def objects(self):
|
def objects(self):
|
||||||
""" Returns a list of objects given the subfolder's path.
|
""" 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)
|
marker = self.request.GET.get('marker', None)
|
||||||
container_name = self.kwargs['container_name']
|
container_name = self.kwargs['container_name']
|
||||||
subfolders = self.kwargs['subfolder_path']
|
subfolders = self.kwargs['subfolder_path']
|
||||||
if subfolders:
|
prefix = None
|
||||||
prefix = subfolders.rstrip("/")
|
if container_name:
|
||||||
else:
|
if subfolders:
|
||||||
prefix = None
|
prefix = subfolders.rstrip(FOLDER_DELIMITER)
|
||||||
try:
|
try:
|
||||||
objects, self._more = api.swift_get_objects(self.request,
|
objects, self._more = api.swift_get_objects(self.request,
|
||||||
container_name,
|
container_name,
|
||||||
marker=marker,
|
marker=marker,
|
||||||
path=prefix)
|
path=prefix)
|
||||||
except:
|
except:
|
||||||
self._more = None
|
self._more = None
|
||||||
objects = []
|
objects = []
|
||||||
msg = _('Unable to retrieve object list.')
|
msg = _('Unable to retrieve object list.')
|
||||||
exceptions.handle(self.request, msg)
|
exceptions.handle(self.request, msg)
|
||||||
self._objects = objects
|
self._objects = objects
|
||||||
return self._objects
|
return self._objects
|
||||||
|
|
||||||
@ -134,7 +100,7 @@ class ObjectIndexView(tables.MixedDataTableView):
|
|||||||
return filtered_objects
|
return filtered_objects
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
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['container_name'] = self.kwargs["container_name"]
|
||||||
context['subfolders'] = []
|
context['subfolders'] = []
|
||||||
if self.kwargs["subfolder_path"]:
|
if self.kwargs["subfolder_path"]:
|
||||||
@ -147,14 +113,38 @@ class ObjectIndexView(tables.MixedDataTableView):
|
|||||||
return context
|
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):
|
class UploadView(forms.ModalFormView):
|
||||||
form_class = UploadObject
|
form_class = UploadObject
|
||||||
template_name = 'nova/containers/upload.html'
|
template_name = 'nova/containers/upload.html'
|
||||||
success_url = "horizon:nova:containers:object_index"
|
success_url = "horizon:nova:containers:index"
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
|
container_name = self.request.POST['container_name']
|
||||||
return reverse(self.success_url,
|
return reverse(self.success_url,
|
||||||
args=(self.request.POST['container_name'],
|
args=(wrap_delimiter(container_name),
|
||||||
self.request.POST.get('path', '')))
|
self.request.POST.get('path', '')))
|
||||||
|
|
||||||
def get_initial(self):
|
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)
|
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
|
# Add the original file extension back on if it wasn't preserved in the
|
||||||
# name given to the object.
|
# 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]:
|
if not os.path.splitext(obj.name)[1]:
|
||||||
name, ext = os.path.splitext(obj.metadata.get('orig-filename', ''))
|
name, ext = os.path.splitext(obj.metadata.get('orig-filename', ''))
|
||||||
filename = "%s%s" % (filename, ext)
|
filename = "%s%s" % (filename, ext)
|
||||||
@ -196,11 +186,12 @@ def object_download(request, container_name, object_path):
|
|||||||
class CopyView(forms.ModalFormView):
|
class CopyView(forms.ModalFormView):
|
||||||
form_class = CopyObject
|
form_class = CopyObject
|
||||||
template_name = 'nova/containers/copy.html'
|
template_name = 'nova/containers/copy.html'
|
||||||
success_url = "horizon:nova:containers:object_index"
|
success_url = "horizon:nova:containers:index"
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
|
new_container_name = self.request.POST['new_container_name']
|
||||||
return reverse(self.success_url,
|
return reverse(self.success_url,
|
||||||
args=(self.request.POST['new_container_name'],
|
args=(wrap_delimiter(new_container_name),
|
||||||
self.request.POST.get('path', '')))
|
self.request.POST.get('path', '')))
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
|
@ -51,12 +51,13 @@ class UsageViewTests(test.BaseAdminViewTests):
|
|||||||
self.assertTemplateUsed(res, 'syspanel/overview/usage.html')
|
self.assertTemplateUsed(res, 'syspanel/overview/usage.html')
|
||||||
self.assertTrue(isinstance(res.context['usage'], usage.GlobalUsage))
|
self.assertTrue(isinstance(res.context['usage'], usage.GlobalUsage))
|
||||||
self.assertContains(res,
|
self.assertContains(res,
|
||||||
'<td class="sortable">test_tenant</td>'
|
'<td class="sortable normal_column">test_tenant'
|
||||||
'<td class="sortable">%s</td>'
|
'</td>'
|
||||||
'<td class="sortable">%s</td>'
|
'<td class="sortable normal_column">%s</td>'
|
||||||
'<td class="sortable">%s</td>'
|
'<td class="sortable normal_column">%s</td>'
|
||||||
'<td class="sortable">%.2f</td>'
|
'<td class="sortable normal_column">%s</td>'
|
||||||
'<td class="sortable">%.2f</td>' %
|
'<td class="sortable normal_column">%.2f</td>'
|
||||||
|
'<td class="sortable normal_column">%.2f</td>' %
|
||||||
(usage_obj.vcpus,
|
(usage_obj.vcpus,
|
||||||
usage_obj.disk_gb_hours,
|
usage_obj.disk_gb_hours,
|
||||||
mbformat(usage_obj.memory_mb),
|
mbformat(usage_obj.memory_mb),
|
||||||
|
@ -439,6 +439,8 @@ class BatchAction(Action):
|
|||||||
self._conjugate())
|
self._conjugate())
|
||||||
self.verbose_name_plural = getattr(self, "verbose_name_plural",
|
self.verbose_name_plural = getattr(self, "verbose_name_plural",
|
||||||
self._conjugate('plural'))
|
self._conjugate('plural'))
|
||||||
|
# Keep record of successfully handled objects
|
||||||
|
self.success_ids = []
|
||||||
super(BatchAction, self).__init__()
|
super(BatchAction, self).__init__()
|
||||||
|
|
||||||
def _allowed(self, request, datum=None):
|
def _allowed(self, request, datum=None):
|
||||||
@ -508,6 +510,7 @@ class BatchAction(Action):
|
|||||||
#Call update to invoke changes if needed
|
#Call update to invoke changes if needed
|
||||||
self.update(request, datum)
|
self.update(request, datum)
|
||||||
action_success.append(datum_display)
|
action_success.append(datum_display)
|
||||||
|
self.success_ids.append(datum_id)
|
||||||
LOG.info('%s: "%s"' %
|
LOG.info('%s: "%s"' %
|
||||||
(self._conjugate(past=True), datum_display))
|
(self._conjugate(past=True), datum_display))
|
||||||
except:
|
except:
|
||||||
|
@ -692,11 +692,13 @@ class DataTableOptions(object):
|
|||||||
Optional. Default: :``False``
|
Optional. Default: :``False``
|
||||||
|
|
||||||
.. attribute:: data_types
|
.. attribute:: data_types
|
||||||
|
|
||||||
A list of data types that this table would accept. Default to be an
|
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``,
|
empty list, but if the attibute ``mixed_data_type`` is set to ``True``,
|
||||||
then this list must have at least one element.
|
then this list must have at least one element.
|
||||||
|
|
||||||
.. attribute:: data_type_name
|
.. attribute:: data_type_name
|
||||||
|
|
||||||
The name of an attribute to assign to data passed to the table when it
|
The name of an attribute to assign to data passed to the table when it
|
||||||
accepts mix data. Default: ``"_table_data_type"``
|
accepts mix data. Default: ``"_table_data_type"``
|
||||||
"""
|
"""
|
||||||
@ -712,6 +714,7 @@ class DataTableOptions(object):
|
|||||||
self.row_class = getattr(options, 'row_class', Row)
|
self.row_class = getattr(options, 'row_class', Row)
|
||||||
self.column_class = getattr(options, 'column_class', Column)
|
self.column_class = getattr(options, 'column_class', Column)
|
||||||
self.pagination_param = getattr(options, 'pagination_param', 'marker')
|
self.pagination_param = getattr(options, 'pagination_param', 'marker')
|
||||||
|
self.browser_table = getattr(options, 'browser_table', None)
|
||||||
|
|
||||||
# Set self.filter if we have any FilterActions
|
# Set self.filter if we have any FilterActions
|
||||||
filter_actions = [action for action in self.table_actions if
|
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. """
|
""" Metaclass to add options to DataTable class and collect columns. """
|
||||||
def __new__(mcs, name, bases, attrs):
|
def __new__(mcs, name, bases, attrs):
|
||||||
# Process options from Meta
|
# Process options from Meta
|
||||||
|
class_name = name
|
||||||
attrs["_meta"] = opts = DataTableOptions(attrs.get("Meta", None))
|
attrs["_meta"] = opts = DataTableOptions(attrs.get("Meta", None))
|
||||||
|
|
||||||
# Gather columns; this prevents the column from being an attribute
|
# 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)):
|
if issubclass(type(obj), (opts.column_class, Column)):
|
||||||
column_instance = attrs.pop(name)
|
column_instance = attrs.pop(name)
|
||||||
column_instance.name = name
|
column_instance.name = name
|
||||||
|
column_instance.classes.append('normal_column')
|
||||||
columns.append((name, column_instance))
|
columns.append((name, column_instance))
|
||||||
columns.sort(key=lambda x: x[1].creation_counter)
|
columns.sort(key=lambda x: x[1].creation_counter)
|
||||||
|
|
||||||
@ -785,6 +790,15 @@ class DataTableMetaclass(type):
|
|||||||
columns = base.base_columns.items() + columns
|
columns = base.base_columns.items() + columns
|
||||||
attrs['base_columns'] = SortedDict(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:
|
if opts.columns:
|
||||||
# Remove any columns that weren't declared if we're being explicit
|
# Remove any columns that weren't declared if we're being explicit
|
||||||
# NOTE: we're iterating a COPY of the list here!
|
# NOTE: we're iterating a COPY of the list here!
|
||||||
@ -794,7 +808,7 @@ class DataTableMetaclass(type):
|
|||||||
# Re-order based on declared columns
|
# Re-order based on declared columns
|
||||||
columns.sort(key=lambda x: attrs['_meta'].columns.index(x[0]))
|
columns.sort(key=lambda x: attrs['_meta'].columns.index(x[0]))
|
||||||
# Add in our auto-generated columns
|
# 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",
|
multi_select = opts.column_class("multi_select",
|
||||||
verbose_name="",
|
verbose_name="",
|
||||||
auto="multi_select")
|
auto="multi_select")
|
||||||
@ -937,6 +951,11 @@ class DataTable(object):
|
|||||||
LOG.exception("Error while checking action permissions.")
|
LOG.exception("Error while checking action permissions.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def is_browser_table(self):
|
||||||
|
if self._meta.browser_table:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
""" Renders the table using the template from the table options. """
|
""" Renders the table using the template from the table options. """
|
||||||
table_template = template.loader.get_template(self._meta.template)
|
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)
|
table_actions_template = template.loader.get_template(template_path)
|
||||||
bound_actions = self.get_table_actions()
|
bound_actions = self.get_table_actions()
|
||||||
extra_context = {"table_actions": bound_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
|
extra_context["filter"] = self._meta._filter_action
|
||||||
context = template.RequestContext(self._meta.request, extra_context)
|
context = template.RequestContext(self._meta.request, extra_context)
|
||||||
return table_actions_template.render(context)
|
return table_actions_template.render(context)
|
||||||
|
@ -14,29 +14,74 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
from django.views import generic
|
from django.views import generic
|
||||||
|
|
||||||
|
|
||||||
class MultiTableMixin(object):
|
class MultiTableMixin(object):
|
||||||
""" A generic mixin which provides methods for handling DataTables. """
|
""" A generic mixin which provides methods for handling DataTables. """
|
||||||
|
data_method_pattern = "get_%s_data"
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(MultiTableMixin, self).__init__(*args, **kwargs)
|
super(MultiTableMixin, self).__init__(*args, **kwargs)
|
||||||
self.table_classes = getattr(self, "table_classes", [])
|
self.table_classes = getattr(self, "table_classes", [])
|
||||||
self._data = {}
|
self._data = {}
|
||||||
self._tables = {}
|
self._tables = {}
|
||||||
|
|
||||||
|
self._data_methods = defaultdict(list)
|
||||||
|
self.get_data_methods(self.table_classes, self._data_methods)
|
||||||
|
|
||||||
def _get_data_dict(self):
|
def _get_data_dict(self):
|
||||||
if not self._data:
|
if not self._data:
|
||||||
for table in self.table_classes:
|
for table in self.table_classes:
|
||||||
func_name = "get_%s_data" % table._meta.name
|
data = []
|
||||||
data_func = getattr(self, func_name, None)
|
name = table._meta.name
|
||||||
if data_func is None:
|
func_list = self._data_methods.get(name, [])
|
||||||
cls_name = self.__class__.__name__
|
for func in func_list:
|
||||||
raise NotImplementedError("You must define a %s method "
|
data.extend(func())
|
||||||
"on %s." % (func_name, cls_name))
|
self._data[name] = data
|
||||||
self._data[table._meta.name] = data_func()
|
|
||||||
return self._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):
|
def get_tables(self):
|
||||||
if not self.table_classes:
|
if not self.table_classes:
|
||||||
raise AttributeError('You must specify one or more DataTable '
|
raise AttributeError('You must specify one or more DataTable '
|
||||||
|
@ -11,11 +11,13 @@
|
|||||||
{{ table.render_table_actions }}
|
{{ table.render_table_actions }}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if not table.is_browser_table %}
|
||||||
<tr>
|
<tr>
|
||||||
{% for column in columns %}
|
{% for column in columns %}
|
||||||
<th {{ column.attr_string|safe }}>{{ column }}</th>
|
<th {{ column.attr_string|safe }}>{{ column }}</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
|
{% endif %}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
@ -52,4 +54,4 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% if needs_form_wrapper %}</form>{% endif %}
|
{% if needs_form_wrapper %}</form>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
9
horizon/templates/horizon/common/_resource_browser.html
Normal file
9
horizon/templates/horizon/common/_resource_browser.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<div id="browser_wrapper">
|
||||||
|
<div class="navigation_wrapper">
|
||||||
|
{{ browser.navigation_table.render }}
|
||||||
|
</div>
|
||||||
|
<div class="content_wrapper">
|
||||||
|
{{ browser.content_table.render }}
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -359,7 +359,7 @@ class DataTableTests(test.TestCase):
|
|||||||
self.assertEqual(row3.cells['optional'].value, "N/A")
|
self.assertEqual(row3.cells['optional'].value, "N/A")
|
||||||
# classes
|
# classes
|
||||||
self.assertEqual(value_col.get_final_attrs().get('class', ""),
|
self.assertEqual(value_col.get_final_attrs().get('class', ""),
|
||||||
"green blue sortable anchor")
|
"green blue sortable anchor normal_column")
|
||||||
# status
|
# status
|
||||||
cell_status = row.cells['status'].status
|
cell_status = row.cells['status'].status
|
||||||
self.assertEqual(cell_status, True)
|
self.assertEqual(cell_status, True)
|
||||||
|
@ -105,3 +105,23 @@
|
|||||||
// Fluid grid
|
// Fluid grid
|
||||||
@fluidGridColumnWidth: 6.382978723%;
|
@fluidGridColumnWidth: 6.382978723%;
|
||||||
@fluidGridGutterWidth: 2.127659574%;
|
@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;
|
||||||
|
@ -1389,3 +1389,98 @@ label.log-length {
|
|||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
float: left;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user