blob: 6bb388936cd68551ed47eaf86fdbc9e44cdedefb [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
41
Patrick Williamsf1e5d692016-03-30 15:21:19 -050042import logging
43logger = logging.getLogger("toaster")
44
Patrick Williamsc124f4f2015-09-15 14:41:29 -050045from toastergui.views import objtojson
46
47class ToasterTable(TemplateView):
48 def __init__(self, *args, **kwargs):
49 super(ToasterTable, self).__init__()
50 if 'template_name' in kwargs:
51 self.template_name = kwargs['template_name']
Patrick Williamsf1e5d692016-03-30 15:21:19 -050052 self.title = "Table"
Patrick Williamsc124f4f2015-09-15 14:41:29 -050053 self.queryset = None
54 self.columns = []
55 self.filters = {}
56 self.total_count = 0
57 self.static_context_extra = {}
58 self.filter_actions = {}
59 self.empty_state = "Sorry - no data found"
60 self.default_orderby = ""
61
62 # add the "id" column, undisplayable, by default
63 self.add_column(title="Id",
64 displayable=False,
65 orderable=True,
66 field_name="id")
67
Patrick Williamsf1e5d692016-03-30 15:21:19 -050068 # prevent HTTP caching of table data
69 @cache_control(must_revalidate=True, max_age=0, no_store=True, no_cache=True)
70 def dispatch(self, *args, **kwargs):
71 return super(ToasterTable, self).dispatch(*args, **kwargs)
72
73 def get_context_data(self, **kwargs):
74 context = super(ToasterTable, self).get_context_data(**kwargs)
75 context['title'] = self.title
76 context['table_name'] = type(self).__name__.lower()
77
78 return context
79
Patrick Williamsc124f4f2015-09-15 14:41:29 -050080
81 def get(self, request, *args, **kwargs):
82 if request.GET.get('format', None) == 'json':
83
84 self.setup_queryset(*args, **kwargs)
85 # Put the project id into the context for the static_data_template
86 if 'pid' in kwargs:
87 self.static_context_extra['pid'] = kwargs['pid']
88
89 cmd = request.GET.get('cmd', None)
90 if cmd and 'filterinfo' in cmd:
91 data = self.get_filter_info(request, **kwargs)
92 else:
93 # If no cmd is specified we give you the table data
94 data = self.get_data(request, **kwargs)
95
96 return HttpResponse(data, content_type="application/json")
97
98 return super(ToasterTable, self).get(request, *args, **kwargs)
99
100 def get_filter_info(self, request, **kwargs):
101 data = None
102
103 self.setup_filters(**kwargs)
104
105 search = request.GET.get("search", None)
106 if search:
107 self.apply_search(search)
108
109 name = request.GET.get("name", None)
110 if name is None:
111 data = json.dumps(self.filters,
112 indent=2,
113 cls=DjangoJSONEncoder)
114 else:
115 for actions in self.filters[name]['filter_actions']:
116 actions['count'] = self.filter_actions[actions['name']](count_only=True)
117
118 # Add the "All" items filter action
119 self.filters[name]['filter_actions'].insert(0, {
120 'name' : 'all',
121 'title' : 'All',
122 'count' : self.queryset.count(),
123 })
124
125 data = json.dumps(self.filters[name],
126 indent=2,
127 cls=DjangoJSONEncoder)
128
129 return data
130
131 def setup_columns(self, *args, **kwargs):
132 """ function to implement in the subclass which sets up the columns """
133 pass
134 def setup_filters(self, *args, **kwargs):
135 """ function to implement in the subclass which sets up the filters """
136 pass
137 def setup_queryset(self, *args, **kwargs):
138 """ function to implement in the subclass which sets up the queryset"""
139 pass
140
141 def add_filter(self, name, title, filter_actions):
142 """Add a filter to the table.
143
144 Args:
145 name (str): Unique identifier of the filter.
146 title (str): Title of the filter.
147 filter_actions: Actions for all the filters.
148 """
149 self.filters[name] = {
150 'title' : title,
151 'filter_actions' : filter_actions,
152 }
153
154 def make_filter_action(self, name, title, action_function):
155 """ Utility to make a filter_action """
156
157 action = {
158 'title' : title,
159 'name' : name,
160 }
161
162 self.filter_actions[name] = action_function
163
164 return action
165
166 def add_column(self, title="", help_text="",
167 orderable=False, hideable=True, hidden=False,
168 field_name="", filter_name=None, static_data_name=None,
169 displayable=True, computation=None,
170 static_data_template=None):
171 """Add a column to the table.
172
173 Args:
174 title (str): Title for the table header
175 help_text (str): Optional help text to describe the column
176 orderable (bool): Whether the column can be ordered.
177 We order on the field_name.
178 hideable (bool): Whether the user can hide the column
179 hidden (bool): Whether the column is default hidden
180 field_name (str or list): field(s) required for this column's data
181 static_data_name (str, optional): The column's main identifier
182 which will replace the field_name.
183 static_data_template(str, optional): The template to be rendered
184 as data
185 """
186
187 self.columns.append({'title' : title,
188 'help_text' : help_text,
189 'orderable' : orderable,
190 'hideable' : hideable,
191 'hidden' : hidden,
192 'field_name' : field_name,
193 'filter_name' : filter_name,
194 'static_data_name': static_data_name,
195 'static_data_template': static_data_template,
196 'displayable': displayable,
197 'computation': computation,
198 })
199
200 def render_static_data(self, template, row):
201 """Utility function to render the static data template"""
202
203 context = {
204 'extra' : self.static_context_extra,
205 'data' : row,
206 }
207
208 context = Context(context)
209 template = Template(template)
210
211 return template.render(context)
212
213 def apply_filter(self, filters, **kwargs):
214 self.setup_filters(**kwargs)
215
216 try:
217 filter_name, filter_action = filters.split(':')
218 except ValueError:
219 return
220
221 if "all" in filter_action:
222 return
223
224 try:
225 self.filter_actions[filter_action]()
226 except KeyError:
227 # pass it to the user - programming error here
228 raise
229
230 def apply_orderby(self, orderby):
231 # Note that django will execute this when we try to retrieve the data
232 self.queryset = self.queryset.order_by(orderby)
233
234 def apply_search(self, search_term):
235 """Creates a query based on the model's search_allowed_fields"""
236
237 if not hasattr(self.queryset.model, 'search_allowed_fields'):
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500238 raise Exception("Search fields aren't defined in the model %s"
239 % self.queryset.model)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500240
241 search_queries = []
242 for st in search_term.split(" "):
243 q_map = [Q(**{field + '__icontains': st})
244 for field in self.queryset.model.search_allowed_fields]
245
246 search_queries.append(reduce(operator.or_, q_map))
247
248 search_queries = reduce(operator.and_, search_queries)
249
250 self.queryset = self.queryset.filter(search_queries)
251
252
253 def get_data(self, request, **kwargs):
254 """Returns the data for the page requested with the specified
255 parameters applied"""
256
257 page_num = request.GET.get("page", 1)
258 limit = request.GET.get("limit", 10)
259 search = request.GET.get("search", None)
260 filters = request.GET.get("filter", None)
261 orderby = request.GET.get("orderby", None)
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500262 nocache = request.GET.get("nocache", None)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500263
264 # Make a unique cache name
265 cache_name = self.__class__.__name__
266
267 for key, val in request.GET.iteritems():
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500268 if key == 'nocache':
269 continue
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500270 cache_name = cache_name + str(key) + str(val)
271
272 for key, val in kwargs.iteritems():
273 cache_name = cache_name + str(key) + str(val)
274
275 # No special chars allowed in the cache name apart from dash
276 cache_name = re.sub(r'[^A-Za-z0-9-]', "", cache_name)
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500277
278 if nocache:
279 cache.delete(cache_name)
280
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500281 data = cache.get(cache_name)
282
283 if data:
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500284 logger.debug("Got cache data for table '%s'" % self.title)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500285 return data
286
287 self.setup_columns(**kwargs)
288
289 if search:
290 self.apply_search(search)
291 if filters:
292 self.apply_filter(filters, **kwargs)
293 if orderby:
294 self.apply_orderby(orderby)
295
296 paginator = Paginator(self.queryset, limit)
297
298 try:
299 page = paginator.page(page_num)
300 except EmptyPage:
301 page = paginator.page(1)
302
303 data = {
304 'total' : self.queryset.count(),
305 'default_orderby' : self.default_orderby,
306 'columns' : self.columns,
307 'rows' : [],
308 'error' : "ok",
309 }
310
311 try:
312 for row in page.object_list:
313 #Use collection to maintain the order
314 required_data = collections.OrderedDict()
315
316 for col in self.columns:
317 field = col['field_name']
318 if not field:
319 field = col['static_data_name']
320 if not field:
321 raise Exception("Must supply a field_name or static_data_name for column %s.%s" % (self.__class__.__name__,col))
322 # Check if we need to process some static data
323 if "static_data_name" in col and col['static_data_name']:
324 required_data["static:%s" % col['static_data_name']] = self.render_static_data(col['static_data_template'], row)
325
326 # Overwrite the field_name with static_data_name
327 # so that this can be used as the html class name
328
329 col['field_name'] = col['static_data_name']
330
331 # compute the computation on the raw data if needed
332 model_data = row
333 if col['computation']:
334 model_data = col['computation'](row)
335 else:
336 # Traverse to any foriegn key in the object hierachy
337 for subfield in field.split("__"):
338 if hasattr(model_data, subfield):
339 model_data = getattr(model_data, subfield)
340 # The field could be a function on the model so check
341 # If it is then call it
342 if isinstance(model_data, types.MethodType):
343 model_data = model_data()
344
345 required_data[col['field_name']] = model_data
346
347 data['rows'].append(required_data)
348
349 except FieldError:
350 # pass it to the user - programming-error here
351 raise
352 data = json.dumps(data, indent=2, default=objtojson)
353 cache.set(cache_name, data, 60*30)
354
355 return data
356
357
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500358
359class ToasterTypeAhead(View):
360 """ A typeahead mechanism to support the front end typeahead widgets """
361 MAX_RESULTS = 6
362
363 class MissingFieldsException(Exception):
364 pass
365
366 def __init__(self, *args, **kwargs):
367 super(ToasterTypeAhead, self).__init__()
368
369 def get(self, request, *args, **kwargs):
370 def response(data):
371 return HttpResponse(json.dumps(data,
372 indent=2,
373 cls=DjangoJSONEncoder),
374 content_type="application/json")
375
376 error = "ok"
377
378 search_term = request.GET.get("search", None)
379 if search_term == None:
380 # We got no search value so return empty reponse
381 return response({'error' : error , 'results': []})
382
383 try:
384 prj = Project.objects.get(pk=kwargs['pid'])
385 except KeyError:
386 prj = None
387
388 results = self.apply_search(search_term, prj, request)[:ToasterTypeAhead.MAX_RESULTS]
389
390 if len(results) > 0:
391 try:
392 self.validate_fields(results[0])
393 except MissingFieldsException as e:
394 error = e
395
396 data = { 'results' : results,
397 'error' : error,
398 }
399
400 return response(data)
401
402 def validate_fields(self, result):
403 if 'name' in result == False or 'detail' in result == False:
404 raise MissingFieldsException("name and detail are required fields")
405
406 def apply_search(self, search_term, prj):
407 """ Override this function to implement search. Return an array of
408 dictionaries with a minium of a name and detail field"""
409 pass