bitbake: toastergui: refactor ToasterTable filtering

The filter code for ToasterTable was difficult to follow
and inflexible (not allowing different types of filter, for example).

Refactor to a set of filter classes to make the structure cleaner
and provide the flexibility needed for other filter types
(e.g. date range filter).

[YOCTO #8738]

(Bitbake rev: 94031bb30bdaf665d0c8c68b591fcb7a17b6674d)

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
Signed-off-by: Ed Bartosh <ed.bartosh@linux.intel.com>
Signed-off-by: Richard Purdie <richard.purdie@linuxfoundation.org>
This commit is contained in:
Elliot Smith
2016-01-15 13:00:50 +02:00
committed by Richard Purdie
parent 294579b531
commit 809046c6fb
5 changed files with 309 additions and 117 deletions

View File

@@ -5,7 +5,7 @@ class QuerysetFilter(object):
if criteria:
self.set_criteria(criteria)
def set_criteria(self, criteria):
def set_criteria(self, criteria = None):
"""
criteria is an instance of django.db.models.Q;
see https://docs.djangoproject.com/en/1.9/ref/models/querysets/#q-objects
@@ -17,7 +17,10 @@ class QuerysetFilter(object):
Filter queryset according to the criteria for this filter,
returning the filtered queryset
"""
return queryset.filter(self.criteria)
if self.criteria:
return queryset.filter(self.criteria)
else:
return queryset
def count(self, queryset):
""" Returns a count of the elements in the filtered queryset """

View File

@@ -415,38 +415,76 @@ function tableInit(ctx){
data: params,
headers: { 'X-CSRFToken' : $.cookie('csrftoken')},
success: function (filterData) {
/*
filterData structure:
{
title: '<title for the filter popup>',
filter_actions: [
{
title: '<label for radio button inside the popup>',
name: '<name of the filter action>',
count: <number of items this filter will show>
}
]
}
each filter_action gets a radio button; the value of this is
set to filterName + ':' + filter_action.name; e.g.
in_current_project:in_project
specifies the "in_project" action of the "in_current_project"
filter
the filterName is set on the column filter icon, and corresponds
to a value in the table's filters property
when the filter popup's "Apply" button is clicked, the
value for the radio button which is checked is passed in the
querystring and applied to the queryset on the table
*/
var filterActionRadios = $('#filter-actions-'+ctx.tableName);
$('#filter-modal-title-'+ctx.tableName).text(filterData.title);
filterActionRadios.text("");
for (var i in filterData.filter_actions){
for (var i in filterData.filter_actions) {
var filterAction = filterData.filter_actions[i];
var action = null;
var action = $('<label class="radio"><input type="radio" name="filter" value=""><span class="filter-title"></span></label>');
var actionTitle = filterAction.title + ' (' + filterAction.count + ')';
if (filterAction.type === 'toggle') {
var actionTitle = filterAction.title + ' (' + filterAction.count + ')';
var radioInput = action.children("input");
action = $('<label class="radio">' +
'<input type="radio" name="filter" value="">' +
'<span class="filter-title">' +
actionTitle +
'</span>' +
'</label>');
if (Number(filterAction.count) == 0){
radioInput.attr("disabled", "disabled");
var radioInput = action.children("input");
if (Number(filterAction.count) == 0) {
radioInput.attr("disabled", "disabled");
}
radioInput.val(filterData.name + ':' + filterAction.action_name);
/* Setup the current selected filter, default to 'all' if
* no current filter selected.
*/
if ((tableParams.filter &&
tableParams.filter === radioInput.val()) ||
filterAction.action_name == 'all') {
radioInput.attr("checked", "checked");
}
}
action.children(".filter-title").text(actionTitle);
radioInput.val(filterName + ':' + filterAction.name);
/* Setup the current selected filter, default to 'all' if
* no current filter selected.
*/
if ((tableParams.filter &&
tableParams.filter === radioInput.val()) ||
filterAction.name == 'all') {
radioInput.attr("checked", "checked");
if (action) {
filterActionRadios.append(action);
}
filterActionRadios.append(action);
}
$('#filter-modal-'+ctx.tableName).modal('show');

View File

@@ -0,0 +1,119 @@
#
# ex:ts=4:sw=4:sts=4:et
# -*- tab-width: 4; c-basic-offset: 4; indent-tabs-mode: nil -*-
#
# BitBake Toaster Implementation
#
# Copyright (C) 2015 Intel Corporation
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
class TableFilter(object):
"""
Stores a filter for a named field, and can retrieve the action
requested for that filter
"""
def __init__(self, name, title):
self.name = name
self.title = title
self.__filter_action_map = {}
def add_action(self, action):
self.__filter_action_map[action.name] = action
def get_action(self, action_name):
return self.__filter_action_map[action_name]
def to_json(self, queryset):
"""
Dump all filter actions as an object which can be JSON serialised;
this is used to generate the JSON for processing in
table.js / filterOpenClicked()
"""
filter_actions = []
# add the "all" pseudo-filter action, which just selects the whole
# queryset
filter_actions.append({
'action_name' : 'all',
'title' : 'All',
'type': 'toggle',
'count' : queryset.count()
})
# add other filter actions
for action_name, filter_action in self.__filter_action_map.iteritems():
obj = filter_action.to_json(queryset)
obj['action_name'] = action_name
filter_actions.append(obj)
return {
'name': self.name,
'title': self.title,
'filter_actions': filter_actions
}
class TableFilterActionToggle(object):
"""
Stores a single filter action which will populate one radio button of
a ToasterTable filter popup; this filter can either be on or off and
has no other parameters
"""
def __init__(self, name, title, queryset_filter):
self.name = name
self.title = title
self.__queryset_filter = queryset_filter
self.type = 'toggle'
def set_params(self, params):
"""
params: (str) a string of extra parameters for the action;
the structure of this string depends on the type of action;
it's ignored for a toggle filter action, which is just on or off
"""
pass
def filter(self, queryset):
return self.__queryset_filter.filter(queryset)
def to_json(self, queryset):
""" Dump as a JSON object """
return {
'title': self.title,
'type': self.type,
'count': self.__queryset_filter.count(queryset)
}
class TableFilterMap(object):
"""
Map from field names to Filter objects for those fields
"""
def __init__(self):
self.__filters = {}
def add_filter(self, filter_name, table_filter):
""" table_filter is an instance of Filter """
self.__filters[filter_name] = table_filter
def get_filter(self, filter_name):
return self.__filters[filter_name]
def to_json(self, queryset):
data = {}
for filter_name, table_filter in self.__filters.iteritems():
data[filter_name] = table_filter.to_json()
return data

View File

@@ -28,6 +28,8 @@ from django.conf.urls import url
from django.core.urlresolvers import reverse
from django.views.generic import TemplateView
from toastergui.tablefilter import TableFilter, TableFilterActionToggle
class ProjectFilters(object):
def __init__(self, project_layers):
self.in_project = QuerysetFilter(Q(layer_version__in=project_layers))
@@ -53,16 +55,28 @@ class LayersTable(ToasterTable):
project = Project.objects.get(pk=kwargs['pid'])
self.project_layers = ProjectLayer.objects.filter(project=project)
criteria = Q(projectlayer__in=self.project_layers)
in_project_filter = QuerysetFilter(criteria)
not_in_project_filter = QuerysetFilter(~criteria)
in_current_project_filter = TableFilter(
"in_current_project",
"Filter by project layers"
)
self.add_filter(title="Filter by project layers",
name="in_current_project",
filter_actions=[
self.make_filter_action("in_project", "Layers added to this project", in_project_filter),
self.make_filter_action("not_in_project", "Layers not added to this project", not_in_project_filter)
])
criteria = Q(projectlayer__in=self.project_layers)
in_project_filter_action = TableFilterActionToggle(
"in_project",
"Layers added to this project",
QuerysetFilter(criteria)
)
not_in_project_filter_action = TableFilterActionToggle(
"not_in_project",
"Layers not added to this project",
QuerysetFilter(~criteria)
)
in_current_project_filter.add_action(in_project_filter_action)
in_current_project_filter.add_action(not_in_project_filter_action)
self.add_filter(in_current_project_filter)
def setup_queryset(self, *args, **kwargs):
prj = Project.objects.get(pk = kwargs['pid'])
@@ -199,12 +213,26 @@ class MachinesTable(ToasterTable):
project_filters = ProjectFilters(self.project_layers)
self.add_filter(title="Filter by project machines",
name="in_current_project",
filter_actions=[
self.make_filter_action("in_project", "Machines provided by layers added to this project", project_filters.in_project),
self.make_filter_action("not_in_project", "Machines provided by layers not added to this project", project_filters.not_in_project)
])
in_current_project_filter = TableFilter(
"in_current_project",
"Filter by project machines"
)
in_project_filter_action = TableFilterActionToggle(
"in_project",
"Machines provided by layers added to this project",
project_filters.in_project
)
not_in_project_filter_action = TableFilterActionToggle(
"not_in_project",
"Machines provided by layers not added to this project",
project_filters.not_in_project
)
in_current_project_filter.add_action(in_project_filter_action)
in_current_project_filter.add_action(not_in_project_filter_action)
self.add_filter(in_current_project_filter)
def setup_queryset(self, *args, **kwargs):
prj = Project.objects.get(pk = kwargs['pid'])
@@ -318,12 +346,26 @@ class RecipesTable(ToasterTable):
def setup_filters(self, *args, **kwargs):
project_filters = ProjectFilters(self.project_layers)
self.add_filter(title="Filter by project recipes",
name="in_current_project",
filter_actions=[
self.make_filter_action("in_project", "Recipes provided by layers added to this project", project_filters.in_project),
self.make_filter_action("not_in_project", "Recipes provided by layers not added to this project", project_filters.not_in_project)
])
table_filter = TableFilter(
'in_current_project',
'Filter by project recipes'
)
in_project_filter_action = TableFilterActionToggle(
'in_project',
'Recipes provided by layers added to this project',
project_filters.in_project
)
not_in_project_filter_action = TableFilterActionToggle(
'not_in_project',
'Recipes provided by layers not added to this project',
project_filters.not_in_project
)
table_filter.add_action(in_project_filter_action)
table_filter.add_action(not_in_project_filter_action)
self.add_filter(table_filter)
def setup_queryset(self, *args, **kwargs):
prj = Project.objects.get(pk = kwargs['pid'])
@@ -1070,47 +1112,47 @@ class BuildsTable(ToasterTable):
def setup_filters(self, *args, **kwargs):
# outcomes
filter_only_successful_builds = QuerysetFilter(Q(outcome=Build.SUCCEEDED))
successful_builds_filter = self.make_filter_action(
outcome_filter = TableFilter(
'outcome_filter',
'Filter builds by outcome'
)
successful_builds_filter_action = TableFilterActionToggle(
'successful_builds',
'Successful builds',
filter_only_successful_builds
QuerysetFilter(Q(outcome=Build.SUCCEEDED))
)
filter_only_failed_builds = QuerysetFilter(Q(outcome=Build.FAILED))
failed_builds_filter = self.make_filter_action(
failed_builds_filter_action = TableFilterActionToggle(
'failed_builds',
'Failed builds',
filter_only_failed_builds
QuerysetFilter(Q(outcome=Build.FAILED))
)
self.add_filter(title='Filter builds by outcome',
name='outcome_filter',
filter_actions = [
successful_builds_filter,
failed_builds_filter
])
outcome_filter.add_action(successful_builds_filter_action)
outcome_filter.add_action(failed_builds_filter_action)
self.add_filter(outcome_filter)
# failed tasks
failed_tasks_filter = TableFilter(
'failed_tasks_filter',
'Filter builds by failed tasks'
)
criteria = Q(task_build__outcome=Task.OUTCOME_FAILED)
filter_only_builds_with_failed_tasks = QuerysetFilter(criteria)
with_failed_tasks_filter = self.make_filter_action(
with_failed_tasks_filter_action = TableFilterActionToggle(
'with_failed_tasks',
'Builds with failed tasks',
filter_only_builds_with_failed_tasks
QuerysetFilter(criteria)
)
criteria = ~Q(task_build__outcome=Task.OUTCOME_FAILED)
filter_only_builds_without_failed_tasks = QuerysetFilter(criteria)
without_failed_tasks_filter = self.make_filter_action(
without_failed_tasks_filter_action = TableFilterActionToggle(
'without_failed_tasks',
'Builds without failed tasks',
filter_only_builds_without_failed_tasks
QuerysetFilter(~criteria)
)
self.add_filter(title='Filter builds by failed tasks',
name='failed_tasks_filter',
filter_actions = [
with_failed_tasks_filter,
without_failed_tasks_filter
])
failed_tasks_filter.add_action(with_failed_tasks_filter_action)
failed_tasks_filter.add_action(without_failed_tasks_filter_action)
self.add_filter(failed_tasks_filter)

View File

@@ -39,11 +39,13 @@ import json
import collections
import operator
import re
import urllib
import logging
logger = logging.getLogger("toaster")
from toastergui.views import objtojson
from toastergui.tablefilter import TableFilterMap
class ToasterTable(TemplateView):
def __init__(self, *args, **kwargs):
@@ -53,7 +55,10 @@ class ToasterTable(TemplateView):
self.title = "Table"
self.queryset = None
self.columns = []
self.filters = {}
# map from field names to Filter instances
self.filter_map = TableFilterMap()
self.total_count = 0
self.static_context_extra = {}
self.filter_actions = {}
@@ -66,7 +71,7 @@ class ToasterTable(TemplateView):
orderable=True,
field_name="id")
# prevent HTTP caching of table data
# prevent HTTP caching of table data
@cache_control(must_revalidate=True, max_age=0, no_store=True, no_cache=True)
def dispatch(self, *args, **kwargs):
return super(ToasterTable, self).dispatch(*args, **kwargs)
@@ -108,27 +113,10 @@ class ToasterTable(TemplateView):
self.apply_search(search)
name = request.GET.get("name", None)
if name is None:
data = json.dumps(self.filters,
indent=2,
cls=DjangoJSONEncoder)
else:
for actions in self.filters[name]['filter_actions']:
queryset_filter = self.filter_actions[actions['name']]
actions['count'] = queryset_filter.count(self.queryset)
# Add the "All" items filter action
self.filters[name]['filter_actions'].insert(0, {
'name' : 'all',
'title' : 'All',
'count' : self.queryset.count(),
})
data = json.dumps(self.filters[name],
indent=2,
cls=DjangoJSONEncoder)
return data
table_filter = self.filter_map.get_filter(name)
return json.dumps(table_filter.to_json(self.queryset),
indent=2,
cls=DjangoJSONEncoder)
def setup_columns(self, *args, **kwargs):
""" function to implement in the subclass which sets up the columns """
@@ -140,33 +128,13 @@ class ToasterTable(TemplateView):
""" function to implement in the subclass which sets up the queryset"""
pass
def add_filter(self, name, title, filter_actions):
def add_filter(self, table_filter):
"""Add a filter to the table.
Args:
name (str): Unique identifier of the filter.
title (str): Title of the filter.
filter_actions: Actions for all the filters.
table_filter: Filter instance
"""
self.filters[name] = {
'title' : title,
'filter_actions' : filter_actions,
}
def make_filter_action(self, name, title, queryset_filter):
"""
Utility to make a filter_action; queryset_filter is an instance
of QuerysetFilter or a function
"""
action = {
'title' : title,
'name' : name,
}
self.filter_actions[name] = queryset_filter
return action
self.filter_map.add_filter(table_filter.name, table_filter)
def add_column(self, title="", help_text="",
orderable=False, hideable=True, hidden=False,
@@ -216,19 +184,41 @@ class ToasterTable(TemplateView):
return template.render(context)
def apply_filter(self, filters, **kwargs):
"""
Apply a filter submitted in the querystring to the ToasterTable
filters: (str) in the format:
'<filter name>:<action name>!<action params>'
where <action params> is optional
<filter name> and <action name> are used to look up the correct filter
in the ToasterTable's filter map; the <action params> are set on
TableFilterAction* before its filter is applied and may modify the
queryset returned by the filter
"""
self.setup_filters(**kwargs)
try:
filter_name, filter_action = filters.split(':')
filter_name, action_name_and_params = filters.split(':')
action_name = None
action_params = None
if re.search('!', action_name_and_params):
action_name, action_params = action_name_and_params.split('!')
action_params = urllib.unquote_plus(action_params)
else:
action_name = action_name_and_params
except ValueError:
return
if "all" in filter_action:
if "all" in action_name:
return
try:
queryset_filter = self.filter_actions[filter_action]
self.queryset = queryset_filter.filter(self.queryset)
table_filter = self.filter_map.get_filter(filter_name)
action = table_filter.get_action(action_name)
action.set_params(action_params)
self.queryset = action.filter(self.queryset)
except KeyError:
# pass it to the user - programming error here
raise