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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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