blob: d2ef5d3dbab43ab647d7b985e0e69b29ef1624ab [file] [log] [blame]
Patrick Williamsc124f4f2015-09-15 14:41:29 -05001#
2# ex:ts=4:sw=4:sts=4:et
3# -*- tab-width: 4; c-basic-offset: 4; indent-tabs-mode: nil -*-
4#
5# BitBake Toaster Implementation
6#
7# Copyright (C) 2015 Intel Corporation
8#
9# This program is free software; you can redistribute it and/or modify
10# it under the terms of the GNU General Public License version 2 as
11# published by the Free Software Foundation.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License along
19# with this program; if not, write to the Free Software Foundation, Inc.,
20# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
21
22from django.views.generic import View, TemplateView
Patrick Williamsf1e5d692016-03-30 15:21:19 -050023from django.views.decorators.cache import cache_control
Patrick Williamsc124f4f2015-09-15 14:41:29 -050024from django.shortcuts import HttpResponse
25from django.http import HttpResponseBadRequest
26from django.core import serializers
27from django.core.cache import cache
28from django.core.paginator import Paginator, EmptyPage
29from django.db.models import Q
30from orm.models import Project, ProjectLayer, Layer_Version
31from django.template import Context, Template
32from django.core.serializers.json import DjangoJSONEncoder
33from django.core.exceptions import FieldError
34from django.conf.urls import url, patterns
35
36import types
37import json
38import collections
39import operator
40import re
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050041import urllib
Patrick Williamsc124f4f2015-09-15 14:41:29 -050042
Patrick Williamsf1e5d692016-03-30 15:21:19 -050043import logging
44logger = logging.getLogger("toaster")
45
Patrick Williamsc124f4f2015-09-15 14:41:29 -050046from toastergui.views import objtojson
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050047from toastergui.tablefilter import TableFilterMap
Patrick Williamsc124f4f2015-09-15 14:41:29 -050048
49class ToasterTable(TemplateView):
50 def __init__(self, *args, **kwargs):
51 super(ToasterTable, self).__init__()
52 if 'template_name' in kwargs:
53 self.template_name = kwargs['template_name']
Patrick Williamsf1e5d692016-03-30 15:21:19 -050054 self.title = "Table"
Patrick Williamsc124f4f2015-09-15 14:41:29 -050055 self.queryset = None
56 self.columns = []
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050057
58 # map from field names to Filter instances
59 self.filter_map = TableFilterMap()
60
Patrick Williamsc124f4f2015-09-15 14:41:29 -050061 self.total_count = 0
62 self.static_context_extra = {}
Patrick Williamsc124f4f2015-09-15 14:41:29 -050063 self.empty_state = "Sorry - no data found"
64 self.default_orderby = ""
65
66 # add the "id" column, undisplayable, by default
67 self.add_column(title="Id",
68 displayable=False,
69 orderable=True,
70 field_name="id")
71
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050072 # prevent HTTP caching of table data
Patrick Williamsf1e5d692016-03-30 15:21:19 -050073 @cache_control(must_revalidate=True, max_age=0, no_store=True, no_cache=True)
74 def dispatch(self, *args, **kwargs):
75 return super(ToasterTable, self).dispatch(*args, **kwargs)
76
77 def get_context_data(self, **kwargs):
78 context = super(ToasterTable, self).get_context_data(**kwargs)
79 context['title'] = self.title
80 context['table_name'] = type(self).__name__.lower()
81
82 return context
83
Patrick Williamsc124f4f2015-09-15 14:41:29 -050084
85 def get(self, request, *args, **kwargs):
86 if request.GET.get('format', None) == 'json':
87
88 self.setup_queryset(*args, **kwargs)
89 # Put the project id into the context for the static_data_template
90 if 'pid' in kwargs:
91 self.static_context_extra['pid'] = kwargs['pid']
92
93 cmd = request.GET.get('cmd', None)
94 if cmd and 'filterinfo' in cmd:
95 data = self.get_filter_info(request, **kwargs)
96 else:
97 # If no cmd is specified we give you the table data
98 data = self.get_data(request, **kwargs)
99
100 return HttpResponse(data, content_type="application/json")
101
102 return super(ToasterTable, self).get(request, *args, **kwargs)
103
104 def get_filter_info(self, request, **kwargs):
105 data = None
106
107 self.setup_filters(**kwargs)
108
109 search = request.GET.get("search", None)
110 if search:
111 self.apply_search(search)
112
113 name = request.GET.get("name", None)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500114 table_filter = self.filter_map.get_filter(name)
115 return json.dumps(table_filter.to_json(self.queryset),
116 indent=2,
117 cls=DjangoJSONEncoder)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500118
119 def setup_columns(self, *args, **kwargs):
120 """ function to implement in the subclass which sets up the columns """
121 pass
122 def setup_filters(self, *args, **kwargs):
123 """ function to implement in the subclass which sets up the filters """
124 pass
125 def setup_queryset(self, *args, **kwargs):
126 """ function to implement in the subclass which sets up the queryset"""
127 pass
128
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500129 def add_filter(self, table_filter):
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500130 """Add a filter to the table.
131
132 Args:
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500133 table_filter: Filter instance
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500134 """
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500135 self.filter_map.add_filter(table_filter.name, table_filter)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500136
137 def add_column(self, title="", help_text="",
138 orderable=False, hideable=True, hidden=False,
139 field_name="", filter_name=None, static_data_name=None,
140 displayable=True, computation=None,
141 static_data_template=None):
142 """Add a column to the table.
143
144 Args:
145 title (str): Title for the table header
146 help_text (str): Optional help text to describe the column
147 orderable (bool): Whether the column can be ordered.
148 We order on the field_name.
149 hideable (bool): Whether the user can hide the column
150 hidden (bool): Whether the column is default hidden
151 field_name (str or list): field(s) required for this column's data
152 static_data_name (str, optional): The column's main identifier
153 which will replace the field_name.
154 static_data_template(str, optional): The template to be rendered
155 as data
156 """
157
158 self.columns.append({'title' : title,
159 'help_text' : help_text,
160 'orderable' : orderable,
161 'hideable' : hideable,
162 'hidden' : hidden,
163 'field_name' : field_name,
164 'filter_name' : filter_name,
165 'static_data_name': static_data_name,
166 'static_data_template': static_data_template,
167 'displayable': displayable,
168 'computation': computation,
169 })
170
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500171 def set_column_hidden(self, title, hidden):
172 """
173 Set the hidden state of the column to the value of hidden
174 """
175 for col in self.columns:
176 if col['title'] == title:
177 col['hidden'] = hidden
178 break
179
180 def set_column_hideable(self, title, hideable):
181 """
182 Set the hideable state of the column to the value of hideable
183 """
184 for col in self.columns:
185 if col['title'] == title:
186 col['hideable'] = hideable
187 break
188
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500189 def render_static_data(self, template, row):
190 """Utility function to render the static data template"""
191
192 context = {
193 'extra' : self.static_context_extra,
194 'data' : row,
195 }
196
197 context = Context(context)
198 template = Template(template)
199
200 return template.render(context)
201
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500202 def apply_filter(self, filters, filter_value, **kwargs):
203 """
204 Apply a filter submitted in the querystring to the ToasterTable
205
206 filters: (str) in the format:
207 '<filter name>:<action name>'
208 filter_value: (str) parameters to pass to the named filter
209
210 <filter name> and <action name> are used to look up the correct filter
211 in the ToasterTable's filter map; the <action params> are set on
212 TableFilterAction* before its filter is applied and may modify the
213 queryset returned by the filter
214 """
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500215 self.setup_filters(**kwargs)
216
217 try:
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500218 filter_name, action_name = filters.split(':')
219 action_params = urllib.unquote_plus(filter_value)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500220 except ValueError:
221 return
222
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500223 if "all" in action_name:
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500224 return
225
226 try:
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500227 table_filter = self.filter_map.get_filter(filter_name)
228 action = table_filter.get_action(action_name)
229 action.set_filter_params(action_params)
230 self.queryset = action.filter(self.queryset)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500231 except KeyError:
232 # pass it to the user - programming error here
233 raise
234
235 def apply_orderby(self, orderby):
236 # Note that django will execute this when we try to retrieve the data
237 self.queryset = self.queryset.order_by(orderby)
238
239 def apply_search(self, search_term):
240 """Creates a query based on the model's search_allowed_fields"""
241
242 if not hasattr(self.queryset.model, 'search_allowed_fields'):
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500243 raise Exception("Search fields aren't defined in the model %s"
244 % self.queryset.model)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500245
246 search_queries = []
247 for st in search_term.split(" "):
248 q_map = [Q(**{field + '__icontains': st})
249 for field in self.queryset.model.search_allowed_fields]
250
251 search_queries.append(reduce(operator.or_, q_map))
252
253 search_queries = reduce(operator.and_, search_queries)
254
255 self.queryset = self.queryset.filter(search_queries)
256
257
258 def get_data(self, request, **kwargs):
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500259 """
260 Returns the data for the page requested with the specified
261 parameters applied
262
263 filters: filter and action name, e.g. "outcome:build_succeeded"
264 filter_value: value to pass to the named filter+action, e.g. "on"
265 (for a toggle filter) or "2015-12-11,2015-12-12" (for a date range filter)
266 """
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500267
268 page_num = request.GET.get("page", 1)
269 limit = request.GET.get("limit", 10)
270 search = request.GET.get("search", None)
271 filters = request.GET.get("filter", None)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500272 filter_value = request.GET.get("filter_value", "on")
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500273 orderby = request.GET.get("orderby", None)
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500274 nocache = request.GET.get("nocache", None)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500275
276 # Make a unique cache name
277 cache_name = self.__class__.__name__
278
279 for key, val in request.GET.iteritems():
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500280 if key == 'nocache':
281 continue
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500282 cache_name = cache_name + str(key) + str(val)
283
284 for key, val in kwargs.iteritems():
285 cache_name = cache_name + str(key) + str(val)
286
287 # No special chars allowed in the cache name apart from dash
288 cache_name = re.sub(r'[^A-Za-z0-9-]', "", cache_name)
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500289
290 if nocache:
291 cache.delete(cache_name)
292
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500293 data = cache.get(cache_name)
294
295 if data:
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500296 logger.debug("Got cache data for table '%s'" % self.title)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500297 return data
298
299 self.setup_columns(**kwargs)
300
301 if search:
302 self.apply_search(search)
303 if filters:
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500304 self.apply_filter(filters, filter_value, **kwargs)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500305 if orderby:
306 self.apply_orderby(orderby)
307
308 paginator = Paginator(self.queryset, limit)
309
310 try:
311 page = paginator.page(page_num)
312 except EmptyPage:
313 page = paginator.page(1)
314
315 data = {
316 'total' : self.queryset.count(),
317 'default_orderby' : self.default_orderby,
318 'columns' : self.columns,
319 'rows' : [],
320 'error' : "ok",
321 }
322
323 try:
324 for row in page.object_list:
325 #Use collection to maintain the order
326 required_data = collections.OrderedDict()
327
328 for col in self.columns:
329 field = col['field_name']
330 if not field:
331 field = col['static_data_name']
332 if not field:
333 raise Exception("Must supply a field_name or static_data_name for column %s.%s" % (self.__class__.__name__,col))
334 # Check if we need to process some static data
335 if "static_data_name" in col and col['static_data_name']:
336 required_data["static:%s" % col['static_data_name']] = self.render_static_data(col['static_data_template'], row)
337
338 # Overwrite the field_name with static_data_name
339 # so that this can be used as the html class name
340
341 col['field_name'] = col['static_data_name']
342
343 # compute the computation on the raw data if needed
344 model_data = row
345 if col['computation']:
346 model_data = col['computation'](row)
347 else:
348 # Traverse to any foriegn key in the object hierachy
349 for subfield in field.split("__"):
350 if hasattr(model_data, subfield):
351 model_data = getattr(model_data, subfield)
352 # The field could be a function on the model so check
353 # If it is then call it
354 if isinstance(model_data, types.MethodType):
355 model_data = model_data()
356
357 required_data[col['field_name']] = model_data
358
359 data['rows'].append(required_data)
360
361 except FieldError:
362 # pass it to the user - programming-error here
363 raise
364 data = json.dumps(data, indent=2, default=objtojson)
365 cache.set(cache_name, data, 60*30)
366
367 return data
368
369
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500370
371class ToasterTypeAhead(View):
372 """ A typeahead mechanism to support the front end typeahead widgets """
373 MAX_RESULTS = 6
374
375 class MissingFieldsException(Exception):
376 pass
377
378 def __init__(self, *args, **kwargs):
379 super(ToasterTypeAhead, self).__init__()
380
381 def get(self, request, *args, **kwargs):
382 def response(data):
383 return HttpResponse(json.dumps(data,
384 indent=2,
385 cls=DjangoJSONEncoder),
386 content_type="application/json")
387
388 error = "ok"
389
390 search_term = request.GET.get("search", None)
391 if search_term == None:
392 # We got no search value so return empty reponse
393 return response({'error' : error , 'results': []})
394
395 try:
396 prj = Project.objects.get(pk=kwargs['pid'])
397 except KeyError:
398 prj = None
399
400 results = self.apply_search(search_term, prj, request)[:ToasterTypeAhead.MAX_RESULTS]
401
402 if len(results) > 0:
403 try:
404 self.validate_fields(results[0])
405 except MissingFieldsException as e:
406 error = e
407
408 data = { 'results' : results,
409 'error' : error,
410 }
411
412 return response(data)
413
414 def validate_fields(self, result):
415 if 'name' in result == False or 'detail' in result == False:
416 raise MissingFieldsException("name and detail are required fields")
417
418 def apply_search(self, search_term, prj):
419 """ Override this function to implement search. Return an array of
420 dictionaries with a minium of a name and detail field"""
421 pass