Allow image filtering based on image ownership

Uses buttons above the images list to allow filtering into categories:
project, "official", shared, public.

Adds a FixedFilterAction that creates a button group on top of the
datatable. Supports server-side and client-side filtering.

Implements blueprint organised-images-display.

Change-Id: I87f6cf4dd7d7397ad8c23ebadc8bf293d0bad998
This commit is contained in:
Kieran Spear 2013-01-23 15:25:33 +11:00
parent efff047b04
commit 62dd7b4e99
12 changed files with 341 additions and 53 deletions

View File

@ -218,6 +218,20 @@ horizon.datatables.update_footer_count = function (el, modifier) {
$footer.text(footer_text);
};
horizon.datatables.add_no_results_row = function (table) {
// Add a "no results" row if there are no results.
template = horizon.templates.compiled_templates["#empty_row_template"];
if (!table.find("tbody tr:visible").length && typeof(template) !== "undefined") {
colspan = table.find("th[colspan]").attr('colspan');
params = {"colspan": colspan};
table.find("tbody").append(template.render(params));
}
};
horizon.datatables.remove_no_results_row = function (table) {
table.find("tr.empty").remove();
};
horizon.datatables.set_table_sorting = function (parent) {
// Function to initialize the tablesorter plugin strictly on sortable columns.
$(parent).find("table.datatable").each(function () {
@ -252,7 +266,7 @@ horizon.datatables.add_table_checkboxes = function(parent) {
});
};
horizon.datatables.set_table_filter = function (parent) {
horizon.datatables.set_table_query_filter = function (parent) {
$(parent).find('table').each(function (index, elm) {
var input = $($(elm).find('div.table_search input')),
table_selector;
@ -280,23 +294,14 @@ horizon.datatables.set_table_filter = function (parent) {
'show': this.show,
'hide': this.hide,
onBefore: function () {
// Clear the "no results" row.
var table = $(table_selector);
table.find("tr.empty").remove();
horizon.datatables.remove_no_results_row(table);
},
onAfter: function () {
var template, table, colspan, params;
table = $(table_selector);
horizon.datatables.update_footer_count(table);
// Add a "no results" row if there are no results.
template = horizon.templates.compiled_templates["#empty_row_template"];
if (!$(table_selector + " tbody tr:visible").length && typeof(template) !== "undefined") {
colspan = table.find("th[colspan]").attr('colspan');
params = {"colspan": colspan};
table.find("tbody").append(template.render(params));
}
// Update footer count
horizon.datatables.add_no_results_row(table);
},
prepareQuery: function (val) {
return new RegExp(val, "i");
@ -309,6 +314,29 @@ horizon.datatables.set_table_filter = function (parent) {
});
};
horizon.datatables.set_table_fixed_filter = function (parent) {
$(parent).find('table.datatable').each(function (index, elm) {
$(elm).on('click', 'div.table_filter button', function(evt) {
var table = $(elm);
var category = $(this).val();
evt.preventDefault();
horizon.datatables.remove_no_results_row(table);
table.find('tbody tr').hide();
table.find('tbody tr.category-' + category).show();
horizon.datatables.update_footer_count(table);
horizon.datatables.add_no_results_row(table);
});
$(elm).find('div.table_filter button').each(function (i, button) {
// Select the first non-empty category
if ($(button).text().indexOf(' (0)') == -1) {
$(button).addClass('active');
$(button).trigger('click');
return false;
}
});
});
};
horizon.addInitFunction(function() {
horizon.datatables.validate_button();
horizon.datatables.update_footer_count($.find('table.datatable'),0);
@ -341,12 +369,14 @@ horizon.addInitFunction(function() {
// Trigger run-once setup scripts for tables.
horizon.datatables.add_table_checkboxes($('body'));
horizon.datatables.set_table_sorting($('body'));
horizon.datatables.set_table_filter($('body'));
horizon.datatables.set_table_query_filter($('body'));
horizon.datatables.set_table_fixed_filter($('body'));
// Also apply on tables in modal views.
horizon.modals.addModalInitFunction(horizon.datatables.add_table_checkboxes);
horizon.modals.addModalInitFunction(horizon.datatables.set_table_sorting);
horizon.modals.addModalInitFunction(horizon.datatables.set_table_filter);
horizon.modals.addModalInitFunction(horizon.datatables.set_table_query_filter);
horizon.modals.addModalInitFunction(horizon.datatables.set_table_fixed_filter);
horizon.datatables.update();
});

View File

@ -1,29 +1,49 @@
module("Tables (horizon.tables.js)");
test("Row filtering (fixed)", function () {
var fixture = $("#qunit-fixture");
var table = fixture.find("#table2");
ok(!table.find(".cat").is(":hidden"), "Filtering cats: cats visible by default");
ok(table.find(":not(.cat)").is(":hidden"), "Filtering cats: non-cats hidden by default");
$("#button_cats").trigger("click");
ok(!table.find(".cat").is(":hidden"), "Filtering cats: cats visible");
ok(table.find(":not(.cat)").is(":hidden"), "Filtering cats: non-cats hidden");
$("#button_dogs").trigger("click");
ok(!table.find(".dog").is(":hidden"), "Filtering dogs: dogs visible");
ok(table.find(":not(.dog)").is(":hidden"), "Filtering dogs: non-dogs hidden");
$("#button_big").trigger("click");
ok(!table.find(".big").is(":hidden"), "Filtering big animals: big visible");
ok(table.find(":not(.big)").is(":hidden"), "Filtering big animals: non-big hidden");
});
test("Footer count update", function () {
var fixture = $("#qunit-fixture");
var table = fixture.find("table.datatable");
var table = fixture.find("#table1");
var tbody = table.find('tbody');
var table_count = table.find("span.table_count");
var rows = tbody.find('tr');
horizon.datatables.update_footer_count(table);
notEqual(table_count.text().indexOf('4 items'), -1, "Initial count is correct");
// hide rows
$("table.datatable tbody tr#dog1").hide();
$("table.datatable tbody tr#cat2").hide();
rows.first().hide();
rows.first().next().hide();
horizon.datatables.update_footer_count(table);
notEqual(table_count.text().indexOf('2 items'), -1, "Count correct after hiding two rows");
// show a row
$("table.datatable tbody tr#cat2").show();
rows.first().next().show();
horizon.datatables.update_footer_count(table);
notEqual(table_count.text().indexOf('3 items'), -1, "Count correct after showing one row");
// add rows
$("table.datatable tbody tr#cat2").show();
$('<tr id="cat3"><td>cat3</td></tr>"').appendTo(tbody);
$('<tr id="cat4"><td>cat4</td></tr>"').appendTo(tbody);
$('<tr><td>cat3</td></tr>"').appendTo(tbody);
$('<tr><td>cat4</td></tr>"').appendTo(tbody);
horizon.datatables.update_footer_count(table);
notEqual(table_count.text().indexOf('5 items'), -1, "Count correct after adding two rows");
});

View File

@ -16,7 +16,7 @@
# Convenience imports for public API components.
from .actions import (Action, BatchAction, DeleteAction,
LinkAction, FilterAction)
LinkAction, FilterAction, FixedFilterAction)
from .base import DataTable, Column, Row
from .views import DataTableView, MultiTableView, MultiTableMixin, \
MixedDataTableView

View File

@ -16,6 +16,7 @@
import logging
import new
from collections import defaultdict
from django import shortcuts
from django.conf import settings
@ -328,6 +329,16 @@ class FilterAction(BaseAction):
A string representing the name of the request parameter used for the
search term. Default: ``"q"``.
.. attribute: filter_type
A string representing the type of this filter. Default: ``"query"``.
.. attribute: needs_preloading
If True, the filter function will be called for the initial
GET request with an empty ``filter_string``, regardless of the
value of ``method``.
"""
# TODO(gabriel): The method for a filter action should be a GET,
# but given the form structure of the table that's currently impossible.
@ -336,6 +347,8 @@ class FilterAction(BaseAction):
method = "POST"
name = "filter"
verbose_name = _("Filter")
filter_type = "query"
needs_preloading = False
def __init__(self, verbose_name=None, param_name=None):
super(FilterAction, self).__init__()
@ -387,6 +400,52 @@ class FilterAction(BaseAction):
"implemented by %s." % self.__class__)
class FixedFilterAction(FilterAction):
""" A filter action with fixed buttons.
"""
filter_type = 'fixed'
needs_preloading = True
def __init__(self, *args, **kwargs):
super(FixedFilterAction, self).__init__(args, kwargs)
self.fixed_buttons = self.get_fixed_buttons()
self.filter_string = ''
def filter(self, table, images, filter_string):
self.filter_string = filter_string
categories = self.categorize(table, images)
self.categories = defaultdict(list, categories)
for button in self.fixed_buttons:
button['count'] = len(self.categories[button['value']])
if not filter_string:
return images
return self.categories[filter_string]
def get_fixed_buttons(self):
"""Returns a list of dictionaries describing the fixed buttons
to use for filtering.
Each list item should be a dict with the keys:
text: Text to display on the button
icon: Icon class for icon element (inserted before text).
value: Value returned when the button is clicked.
This value is passed to ``filter()`` as
``filter_string``.
"""
raise NotImplementedError("The get_fixed_buttons method has "
"not been implemented by %s." %
self.__class__)
def categorize(self, table, images):
"""Override to separate images into categories.
Return a dict with a key for the value of each fixed button,
and a value that is a list of images in that category.
"""
raise NotImplementedError("The categorize method has not been "
"implemented by %s." % self.__class__)
class BatchAction(Action):
""" A table action which takes batch action on one or more
objects. This action should not require user input on a

View File

@ -950,7 +950,11 @@ class DataTable(object):
action = self._meta._filter_action
filter_string = self.get_filter_string()
request_method = self.request.method
if filter_string and request_method == action.method:
needs_preloading = (not filter_string
and request_method == 'GET'
and action.needs_preloading)
valid_method = (request_method == action.method)
if (filter_string and valid_method) or needs_preloading:
if self._meta.mixed_data_type:
self._filtered_data = action.data_type_filter(self,
self.data,

View File

@ -1,9 +1,15 @@
<div class="table_actions clearfix">
{% if filter %}
<div class="table_search">
<input class="span3 example" value="{{ filter.filter_string|default:'' }}" type="text" name="{{ filter.get_param_name }}" />
<button type="submit" {{ filter.attr_string|safe }}>Filter</button>
</div>
{% if filter.filter_type == 'fixed' %}
<div class="table_filter btn-group" data-toggle="buttons-radio">
{% for button in filter.fixed_buttons %}
<button name="{{ filter.get_param_name }}" type="submit" value="{{ button.value }}" class="btn btn-small{% ifequal button.value filter.filter_string %} active{% endifequal %}">{% if button.icon %}<i class="{{ button.icon }}"></i> {% endif %}{{ button.text }}{% if button.count >= 0 %} ({{ button.count }}){% endif %}</button>
{% endfor %}
</div>
{% elif filter.filter_type == 'query' %}
<div class="table_search">
<input class="span3 example" value="{{ filter.filter_string|default:'' }}" type="text" name="{{ filter.get_param_name }}" />
<button type="submit" {{ filter.attr_string|safe }}>Filter</button>
</div>
{% endif %}
{% for action in table_actions %}
{% if action != filter %}

View File

@ -33,10 +33,10 @@
<table id="table1" class="datatable">
<tbody>
<tr id="cat1"><td>cat1</td></tr>
<tr id="dog1"><td>dog1</td></tr>
<tr id="cat2"><td>cat2</td></tr>
<tr id="dog2"><td>dog2</td></tr>
<tr><td>cat1</td></tr>
<tr><td>dog1</td></tr>
<tr><td>cat2</td></tr>
<tr><td>dog2</td></tr>
</tbody>
<tfoot>
<tr>
@ -46,6 +46,28 @@
</tr>
</tfoot>
</table>
<table id="table2" class="datatable">
<thead><tr><th><div class="table_filter" data-toggle="buttons-radio">
<button name="cats" type="submit" value="cats" id="button_cats">Cats</button>
<button name="dogs" type="submit" value="dogs" id="button_dogs">Dogs</button>
<button name="big" type="submit" value="big" id="button_big">Big Animals</button>
</div></th></tr></thead>
<tbody>
<tr class="category-cat"><td>cat1</td></tr>
<tr class="category-big category-dog"><td>dog1</td></tr>
<tr class="category-big category-cat"><td>cat2</td></tr>
<tr class="category-dog"><td>dog2</td></tr>
</tbody>
<tfoot>
<tr>
<td colspan="1">
<span class="table_count">Displaying 4 items</span>
</td>
</tr>
</tfoot>
</table>
</div>
</body>
</html>

View File

@ -48,21 +48,23 @@ class ImagesViewTest(test.BaseAdminViewTests):
@test.create_stubs({api.glance: ('image_list_detailed',)})
def test_images_list_get_pagination(self):
images = self.images.list()[:5]
api.glance.image_list_detailed(IsA(http.HttpRequest),
marker=None) \
.AndReturn([self.images.list(),
.AndReturn([images,
True])
api.glance.image_list_detailed(IsA(http.HttpRequest),
marker=None) \
.AndReturn([self.images.list()[:2],
.AndReturn([images[:2],
True])
api.glance.image_list_detailed(IsA(http.HttpRequest),
marker=self.images.list()[2].id) \
.AndReturn([self.images.list()[2:4],
marker=images[2].id) \
.AndReturn([images[2:4],
True])
api.glance.image_list_detailed(IsA(http.HttpRequest),
marker=self.images.list()[4].id) \
.AndReturn([self.images.list()[4:],
marker=images[4].id) \
.AndReturn([images[4:],
True])
self.mox.ReplayAll()
@ -70,7 +72,7 @@ class ImagesViewTest(test.BaseAdminViewTests):
res = self.client.get(url)
# get all
self.assertEqual(len(res.context['images_table'].data),
len(self.images.list()))
len(images))
self.assertTemplateUsed(res, 'admin/images/index.html')
page_size = getattr(settings, "API_RESULT_PAGE_SIZE", None)
@ -83,7 +85,7 @@ class ImagesViewTest(test.BaseAdminViewTests):
url = "?".join([reverse('horizon:admin:images:index'),
"=".join([AdminImagesTable._meta.pagination_param,
self.images.list()[2].id])])
images[2].id])])
res = self.client.get(url)
# get second page (items 2-4)
self.assertEqual(len(res.context['images_table'].data),
@ -91,7 +93,7 @@ class ImagesViewTest(test.BaseAdminViewTests):
url = "?".join([reverse('horizon:admin:images:index'),
"=".join([AdminImagesTable._meta.pagination_param,
self.images.list()[4].id])])
images[4].id])])
res = self.client.get(url)
# get third page (item 5)
self.assertEqual(len(res.context['images_table'].data),

View File

@ -15,13 +15,16 @@
# under the License.
import logging
from collections import defaultdict
from django.conf import settings
from django.core.urlresolvers import reverse
from django.template import defaultfilters as filters
from django.utils.http import urlencode
from django.utils.translation import ugettext_lazy as _
from horizon import tables
from horizon.utils.memoized import memoized
from openstack_dashboard import api
@ -78,6 +81,52 @@ class EditImage(tables.LinkAction):
return False
def filter_tenants():
return getattr(settings, 'IMAGES_LIST_FILTER_TENANTS', [])
@memoized
def filter_tenant_ids():
return map(lambda ft: ft['tenant'], filter_tenants())
class OwnerFilter(tables.FixedFilterAction):
def get_fixed_buttons(self):
def make_dict(text, tenant, icon):
return dict(text=text, value=tenant, icon=icon)
buttons = [make_dict('Project', 'project', 'icon-home')]
for button_dict in filter_tenants():
new_dict = button_dict.copy()
new_dict['value'] = new_dict['tenant']
buttons.append(new_dict)
buttons.append(make_dict('Shared with Me', 'shared', 'icon-share'))
buttons.append(make_dict('Public', 'public', 'icon-fire'))
return buttons
def categorize(self, table, images):
user_tenant_id = table.request.user.tenant_id
tenants = defaultdict(list)
for im in images:
categories = get_image_categories(im, user_tenant_id)
for category in categories:
tenants[category].append(im)
return tenants
def get_image_categories(im, user_tenant_id):
categories = []
if im.is_public:
categories.append('public')
if im.owner == user_tenant_id:
categories.append('project')
elif im.owner in filter_tenant_ids():
categories.append(im.owner)
elif not im.is_public:
categories.append('shared')
return categories
def get_image_type(image):
return getattr(image, "properties", {}).get("image_type", _("Image"))
@ -97,6 +146,15 @@ class UpdateRow(tables.Row):
image = api.glance.image_get(request, image_id)
return image
def load_cells(self, image=None):
super(UpdateRow, self).load_cells(image)
# Tag the row with the image category for client-side filtering.
image = self.datum
my_tenant_id = self.table.request.user.tenant_id
image_categories = get_image_categories(image, my_tenant_id)
for category in image_categories:
self.classes.append('category-' + category)
class ImagesTable(tables.DataTable):
STATUS_CHOICES = (
@ -133,6 +191,6 @@ class ImagesTable(tables.DataTable):
# Hide the image_type column. Done this way so subclasses still get
# all the columns by default.
columns = ["name", "status", "public", "disk_format"]
table_actions = (CreateImage, DeleteImage,)
table_actions = (OwnerFilter, CreateImage, DeleteImage,)
row_actions = (LaunchImage, EditImage, DeleteImage,)
pagination_param = "image_marker"

View File

@ -19,13 +19,18 @@
# under the License.
from django import http
from django.conf import settings
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from mox import IsA
from horizon import tables as horizon_tables
from openstack_dashboard import api
from openstack_dashboard.test import helpers as test
from . import tables
IMAGES_INDEX_URL = reverse('horizon:project:images_and_snapshots:index')
@ -116,3 +121,48 @@ class ImageViewTests(test.TestCase):
" name='public' checked='checked'>",
html=True,
msg_prefix="The is_public checkbox is not checked")
class OwnerFilterTests(test.TestCase):
def setUp(self):
super(OwnerFilterTests, self).setUp()
self.table = self.mox.CreateMock(horizon_tables.DataTable)
self.table.request = self.request
@override_settings(IMAGES_LIST_FILTER_TENANTS=[{'name': 'Official',
'tenant': 'officialtenant',
'icon': 'icon-ok'}])
def test_filter(self):
self.mox.ReplayAll()
all_images = self.images.list()
table = self.table
self.filter_tenants = settings.IMAGES_LIST_FILTER_TENANTS
filter_ = tables.OwnerFilter()
images = filter_.filter(table, all_images, 'project')
self.assertEqual(images, self._expected('project'))
images = filter_.filter(table, all_images, 'public')
self.assertEqual(images, self._expected('public'))
images = filter_.filter(table, all_images, 'shared')
self.assertEqual(images, self._expected('shared'))
images = filter_.filter(table, all_images, 'officialtenant')
self.assertEqual(images, self._expected('officialtenant'))
def _expected(self, filter_string):
my_tenant_id = self.request.user.tenant_id
images = self.images.list()
special = map(lambda t: t['tenant'], self.filter_tenants)
if filter_string == 'public':
return filter(lambda im: im.is_public, images)
if filter_string == 'shared':
return filter(lambda im: not im.is_public and
im.owner != my_tenant_id and
im.owner not in special, images)
if filter_string == 'project':
filter_string = my_tenant_id
return filter(lambda im: im.owner == filter_string, images)

View File

@ -545,7 +545,7 @@ table form {
min-width: 400px;
}
.table_actions .table_search {
.table_actions .table_search, .table_actions .table_filter {
display: inline-block;
}
@ -568,11 +568,20 @@ table form {
min-width: 0;
}
.table_header .table_actions a, .table_header .table_actions button {
.table_header .table_actions a, .table_header .table_actions > button {
display: inline-block;
float: none;
}
.table_header .table_filter {
vertical-align: bottom;
margin-right: 20px;
}
.table_header .table_filter i {
vertical-align: middle;
}
.table_actions form {
float: right;
margin-left: 10px;

View File

@ -27,19 +27,22 @@ def data(TEST):
'id': 3,
'status': "active",
'owner': TEST.tenant.id,
'properties': {'image_type': u'snapshot'}}
'properties': {'image_type': u'snapshot'},
'is_public': False}
snapshot_dict_no_owner = {'name': u'snapshot 2',
'container_format': u'ami',
'id': 4,
'status': "active",
'owner': None,
'properties': {'image_type': u'snapshot'}}
'properties': {'image_type': u'snapshot'},
'is_public': False}
snapshot_dict_queued = {'name': u'snapshot 2',
'container_format': u'ami',
'id': 5,
'status': "queued",
'owner': TEST.tenant.id,
'properties': {'image_type': u'snapshot'}}
'properties': {'image_type': u'snapshot'},
'is_public': False}
snapshot = Image(ImageManager(None), snapshot_dict)
TEST.snapshots.add(snapshot)
snapshot = Image(ImageManager(None), snapshot_dict_no_owner)
@ -53,14 +56,16 @@ def data(TEST):
'status': "active",
'owner': TEST.tenant.id,
'container_format': 'novaImage',
'properties': {'image_type': u'image'}}
'properties': {'image_type': u'image'},
'is_public': True}
public_image = Image(ImageManager(None), image_dict)
image_dict = {'id': 'a001c047-22f8-47d0-80a1-8ec94a9524fe',
'name': 'private_image',
'status': "active",
'owner': TEST.tenant.id,
'container_format': 'aki'}
'container_format': 'aki',
'is_public': False}
private_image = Image(ImageManager(None), image_dict)
image_dict = {'id': '278905a6-4b52-4d1e-98f9-8c57bb25ba32',
@ -68,22 +73,45 @@ def data(TEST):
'status': "active",
'owner': TEST.tenant.id,
'container_format': 'novaImage',
'properties': {'image_type': u'image'}}
'properties': {'image_type': u'image'},
'is_public': True}
public_image2 = Image(ImageManager(None), image_dict)
image_dict = {'id': '710a1acf-a3e3-41dd-a32d-5d6b6c86ea10',
'name': 'private_image 2',
'status': "active",
'owner': TEST.tenant.id,
'container_format': 'aki'}
'container_format': 'aki',
'is_public': False}
private_image2 = Image(ImageManager(None), image_dict)
image_dict = {'id': '7cd892fd-5652-40f3-a450-547615680132',
'name': 'private_image 3',
'status': "active",
'owner': TEST.tenant.id,
'container_format': 'aki'}
'container_format': 'aki',
'is_public': False}
private_image3 = Image(ImageManager(None), image_dict)
# A shared image. Not public and not local tenant.
image_dict = {'id': 'c8756975-7a3b-4e43-b7f7-433576112849',
'name': 'shared_image 1',
'status': "active",
'owner': 'someothertenant',
'container_format': 'aki',
'is_public': False}
shared_image1 = Image(ImageManager(None), image_dict)
# "Official" image. Public and tenant matches an entry
# in IMAGES_LIST_FILTER_TENANTS.
image_dict = {'id': 'f448704f-0ce5-4d34-8441-11b6581c6619',
'name': 'official_image 1',
'status': "active",
'owner': 'officialtenant',
'container_format': 'aki',
'is_public': True}
official_image1 = Image(ImageManager(None), image_dict)
TEST.images.add(public_image, private_image,
public_image2, private_image2, private_image3)
public_image2, private_image2, private_image3,
shared_image1, official_image1)