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:
Ke Wu 2012-07-30 11:57:14 -07:00
parent dfdb8375de
commit 89d3d11cb1
23 changed files with 587 additions and 185 deletions

View File

@ -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(' ')

View 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
View 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
View 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

View 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

View File

@ -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']

View File

@ -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"

View File

@ -20,5 +20,5 @@
{% block modal-footer %}
<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 %}

View File

@ -21,5 +21,5 @@
{% block modal-footer %}
<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 %}

View File

@ -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 %}

View File

@ -3,9 +3,25 @@
{% block title %}Containers{% endblock %}
{% 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 %}
{% block main %}
{{ table.render }}
{{ swift_browser.render }}
{% endblock %}

View File

@ -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)

View File

@ -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<container_name>.+?)/)?(?P<subfolder_path>(.+/)+)?$',
ContainerView.as_view(), name='index'),
url(r'^(?P<container_name>(.+/)+)?create$',
CreateView.as_view(),
name='create'),
url(r'^(?P<container_name>[^/]+)/(?P<subfolder_path>(.+/)+)?$',
ObjectIndexView.as_view(),
name='object_index'),
url(r'^(?P<container_name>[^/]+)/(?P<subfolder_path>(.+/)+)?upload$',
url(r'^(?P<container_name>.+?)/(?P<subfolder_path>(.+/)+)?upload$',
UploadView.as_view(),
name='object_upload'),

View File

@ -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):

View File

@ -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,
'<td class="sortable">test_tenant</td>'
'<td class="sortable">%s</td>'
'<td class="sortable">%s</td>'
'<td class="sortable">%s</td>'
'<td class="sortable">%.2f</td>'
'<td class="sortable">%.2f</td>' %
'<td class="sortable normal_column">test_tenant'
'</td>'
'<td class="sortable normal_column">%s</td>'
'<td class="sortable normal_column">%s</td>'
'<td class="sortable normal_column">%s</td>'
'<td class="sortable normal_column">%.2f</td>'
'<td class="sortable normal_column">%.2f</td>' %
(usage_obj.vcpus,
usage_obj.disk_gb_hours,
mbformat(usage_obj.memory_mb),

View File

@ -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:

View File

@ -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)

View File

@ -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 '

View File

@ -11,11 +11,13 @@
{{ table.render_table_actions }}
</th>
</tr>
{% if not table.is_browser_table %}
<tr>
{% for column in columns %}
<th {{ column.attr_string|safe }}>{{ column }}</th>
{% endfor %}
</tr>
{% endif %}
</thead>
<tbody>
{% for row in rows %}
@ -52,4 +54,4 @@
{% endwith %}
{% if needs_form_wrapper %}</form>{% endif %}
</div>
{% endwith %}
{% endwith %}

View 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>

View File

@ -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)

View File

@ -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;

View File

@ -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;
}
}
}