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