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] Signed-off-by: Elliot Smith <[email protected]> --- bitbake/lib/toaster/toastergui/querysetfilter.py | 7 +- bitbake/lib/toaster/toastergui/static/js/table.js | 80 +++++++++---- bitbake/lib/toaster/toastergui/tablefilter.py | 98 ++++++++++++++++ bitbake/lib/toaster/toastergui/tables.py | 132 ++++++++++++++-------- bitbake/lib/toaster/toastergui/widgets.py | 90 +++++++-------- 5 files changed, 289 insertions(+), 118 deletions(-) create mode 100644 bitbake/lib/toaster/toastergui/tablefilter.py diff --git a/bitbake/lib/toaster/toastergui/querysetfilter.py b/bitbake/lib/toaster/toastergui/querysetfilter.py index 62297e9..dbae239 100644 --- a/bitbake/lib/toaster/toastergui/querysetfilter.py +++ b/bitbake/lib/toaster/toastergui/querysetfilter.py @@ -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 """ diff --git a/bitbake/lib/toaster/toastergui/static/js/table.js b/bitbake/lib/toaster/toastergui/static/js/table.js index c69c205..fa01ddf 100644 --- a/bitbake/lib/toaster/toastergui/static/js/table.js +++ b/bitbake/lib/toaster/toastergui/static/js/table.js @@ -415,38 +415,76 @@ function tableInit(ctx){ data: params, headers: { 'X-CSRFToken' : $.cookie('csrftoken')}, success: function (filterData) { - var filterActionRadios = $('#filter-actions-'+ctx.tableName); + /* + 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> + } + ] + } - $('#filter-modal-title-'+ctx.tableName).text(filterData.title); + each filter_action gets a radio button; the value of this is + set to filterName + ':' + filter_action.name; e.g. - filterActionRadios.text(""); + in_current_project:in_project - for (var i in filterData.filter_actions){ - var filterAction = filterData.filter_actions[i]; + specifies the "in_project" action of the "in_current_project" + filter - var action = $('<label class="radio"><input type="radio" name="filter" value=""><span class="filter-title"></span></label>'); - var actionTitle = filterAction.title + ' (' + filterAction.count + ')'; + the filterName is set on the column filter icon, and corresponds + to a value in the table's filters property - var radioInput = action.children("input"); + 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 + */ - if (Number(filterAction.count) == 0){ - radioInput.attr("disabled", "disabled"); - } + var filterActionRadios = $('#filter-actions-'+ctx.tableName); - action.children(".filter-title").text(actionTitle); + $('#filter-modal-title-'+ctx.tableName).text(filterData.title); - radioInput.val(filterName + ':' + filterAction.name); + filterActionRadios.text(""); - /* 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"); + for (var i in filterData.filter_actions) { + var filterAction = filterData.filter_actions[i]; + var action = null; + + if (filterAction.type === 'toggle') { + var actionTitle = filterAction.title + ' (' + filterAction.count + ')'; + + action = $('<label class="radio">' + + '<input type="radio" name="filter" value="">' + + '<span class="filter-title">' + + actionTitle + + '</span>' + + '</label>'); + + 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"); + } } - filterActionRadios.append(action); + if (action) { + filterActionRadios.append(action); + } } $('#filter-modal-'+ctx.tableName).modal('show'); diff --git a/bitbake/lib/toaster/toastergui/tablefilter.py b/bitbake/lib/toaster/toastergui/tablefilter.py new file mode 100644 index 0000000..f1de94a --- /dev/null +++ b/bitbake/lib/toaster/toastergui/tablefilter.py @@ -0,0 +1,98 @@ +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 diff --git a/bitbake/lib/toaster/toastergui/tables.py b/bitbake/lib/toaster/toastergui/tables.py index a49e45c..61ea9cb 100644 --- a/bitbake/lib/toaster/toastergui/tables.py +++ b/bitbake/lib/toaster/toastergui/tables.py @@ -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) + in_current_project_filter = TableFilter( + "in_current_project", + "Filter by project layers" + ) + criteria = Q(projectlayer__in=self.project_layers) - in_project_filter = QuerysetFilter(criteria) - not_in_project_filter = QuerysetFilter(~criteria) - 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) - ]) + 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']) @@ -1072,47 +1114,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) diff --git a/bitbake/lib/toaster/toastergui/widgets.py b/bitbake/lib/toaster/toastergui/widgets.py index 71b29ea..8790340 100644 --- a/bitbake/lib/toaster/toastergui/widgets.py +++ b/bitbake/lib/toaster/toastergui/widgets.py @@ -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 -- Elliot Smith Software Engineer Intel OTC --------------------------------------------------------------------- Intel Corporation (UK) Limited Registered No. 1134945 (England) Registered Office: Pipers Way, Swindon SN3 1RJ VAT No: 860 2173 47 This e-mail and any attachments may contain confidential material for the sole use of the intended recipient(s). Any review or distribution by others is strictly prohibited. If you are not the intended recipient, please contact the sender and delete all copies. -- _______________________________________________ toaster mailing list [email protected] https://lists.yoctoproject.org/listinfo/toaster
