widgets.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. #
  2. # BitBake Toaster Implementation
  3. #
  4. # Copyright (C) 2015 Intel Corporation
  5. #
  6. # SPDX-License-Identifier: GPL-2.0-only
  7. #
  8. from django.views.generic import View, TemplateView
  9. from django.utils.decorators import method_decorator
  10. from django.views.decorators.cache import cache_control
  11. from django.shortcuts import HttpResponse
  12. from django.core.cache import cache
  13. from django.core.paginator import Paginator, EmptyPage
  14. from django.db.models import Q
  15. from orm.models import Project, Build
  16. from django.template import Context, Template
  17. from django.template import VariableDoesNotExist
  18. from django.template import TemplateSyntaxError
  19. from django.core.serializers.json import DjangoJSONEncoder
  20. from django.core.exceptions import FieldError
  21. from django.utils import timezone
  22. from toastergui.templatetags.projecttags import sectohms, get_tasks
  23. from toastergui.templatetags.projecttags import json as template_json
  24. from django.http import JsonResponse
  25. from django.urls import reverse
  26. import types
  27. import json
  28. import collections
  29. import re
  30. import os
  31. from toastergui.tablefilter import TableFilterMap
  32. try:
  33. from urllib import unquote_plus
  34. except ImportError:
  35. from urllib.parse import unquote_plus
  36. import logging
  37. logger = logging.getLogger("toaster")
  38. class NoFieldOrDataName(Exception):
  39. pass
  40. class ToasterTable(TemplateView):
  41. def __init__(self, *args, **kwargs):
  42. super(ToasterTable, self).__init__()
  43. if 'template_name' in kwargs:
  44. self.template_name = kwargs['template_name']
  45. self.title = "Table"
  46. self.queryset = None
  47. self.columns = []
  48. # map from field names to Filter instances
  49. self.filter_map = TableFilterMap()
  50. self.total_count = 0
  51. self.static_context_extra = {}
  52. self.empty_state = "Sorry - no data found"
  53. self.default_orderby = ""
  54. # prevent HTTP caching of table data
  55. @method_decorator(cache_control(must_revalidate=True,
  56. max_age=0, no_store=True, no_cache=True))
  57. def dispatch(self, *args, **kwargs):
  58. return super(ToasterTable, self).dispatch(*args, **kwargs)
  59. def get_context_data(self, **kwargs):
  60. context = super(ToasterTable, self).get_context_data(**kwargs)
  61. context['title'] = self.title
  62. context['table_name'] = type(self).__name__.lower()
  63. context['empty_state'] = self.empty_state
  64. # global variables
  65. context['project_enable'] = ('1' == os.environ.get('TOASTER_BUILDSERVER'))
  66. try:
  67. context['project_specific'] = ('1' == os.environ.get('TOASTER_PROJECTSPECIFIC'))
  68. except:
  69. context['project_specific'] = ''
  70. return context
  71. def get(self, request, *args, **kwargs):
  72. if request.GET.get('format', None) == 'json':
  73. self.setup_queryset(*args, **kwargs)
  74. # Put the project id into the context for the static_data_template
  75. if 'pid' in kwargs:
  76. self.static_context_extra['pid'] = kwargs['pid']
  77. cmd = request.GET.get('cmd', None)
  78. if cmd and 'filterinfo' in cmd:
  79. data = self.get_filter_info(request, **kwargs)
  80. else:
  81. # If no cmd is specified we give you the table data
  82. data = self.get_data(request, **kwargs)
  83. return HttpResponse(data, content_type="application/json")
  84. return super(ToasterTable, self).get(request, *args, **kwargs)
  85. def get_filter_info(self, request, **kwargs):
  86. self.setup_filters(**kwargs)
  87. search = request.GET.get("search", None)
  88. if search:
  89. self.apply_search(search)
  90. name = request.GET.get("name", None)
  91. table_filter = self.filter_map.get_filter(name)
  92. return json.dumps(table_filter.to_json(self.queryset),
  93. indent=2,
  94. cls=DjangoJSONEncoder)
  95. def setup_columns(self, *args, **kwargs):
  96. """ function to implement in the subclass which sets up
  97. the columns """
  98. pass
  99. def setup_filters(self, *args, **kwargs):
  100. """ function to implement in the subclass which sets up the
  101. filters """
  102. pass
  103. def setup_queryset(self, *args, **kwargs):
  104. """ function to implement in the subclass which sets up the
  105. queryset"""
  106. pass
  107. def add_filter(self, table_filter):
  108. """Add a filter to the table.
  109. Args:
  110. table_filter: Filter instance
  111. """
  112. self.filter_map.add_filter(table_filter.name, table_filter)
  113. def add_column(self, title="", help_text="",
  114. orderable=False, hideable=True, hidden=False,
  115. field_name="", filter_name=None, static_data_name=None,
  116. static_data_template=None):
  117. """Add a column to the table.
  118. Args:
  119. title (str): Title for the table header
  120. help_text (str): Optional help text to describe the column
  121. orderable (bool): Whether the column can be ordered.
  122. We order on the field_name.
  123. hideable (bool): Whether the user can hide the column
  124. hidden (bool): Whether the column is default hidden
  125. field_name (str or list): field(s) required for this column's data
  126. static_data_name (str, optional): The column's main identifier
  127. which will replace the field_name.
  128. static_data_template(str, optional): The template to be rendered
  129. as data
  130. """
  131. self.columns.append({'title': title,
  132. 'help_text': help_text,
  133. 'orderable': orderable,
  134. 'hideable': hideable,
  135. 'hidden': hidden,
  136. 'field_name': field_name,
  137. 'filter_name': filter_name,
  138. 'static_data_name': static_data_name,
  139. 'static_data_template': static_data_template})
  140. def set_column_hidden(self, title, hidden):
  141. """
  142. Set the hidden state of the column to the value of hidden
  143. """
  144. for col in self.columns:
  145. if col['title'] == title:
  146. col['hidden'] = hidden
  147. break
  148. def set_column_hideable(self, title, hideable):
  149. """
  150. Set the hideable state of the column to the value of hideable
  151. """
  152. for col in self.columns:
  153. if col['title'] == title:
  154. col['hideable'] = hideable
  155. break
  156. def render_static_data(self, template, row):
  157. """Utility function to render the static data template"""
  158. context = {
  159. 'extra': self.static_context_extra,
  160. 'data': row,
  161. }
  162. context = Context(context)
  163. template = Template(template)
  164. return template.render(context)
  165. def apply_filter(self, filters, filter_value, **kwargs):
  166. """
  167. Apply a filter submitted in the querystring to the ToasterTable
  168. filters: (str) in the format:
  169. '<filter name>:<action name>'
  170. filter_value: (str) parameters to pass to the named filter
  171. <filter name> and <action name> are used to look up the correct filter
  172. in the ToasterTable's filter map; the <action params> are set on
  173. TableFilterAction* before its filter is applied and may modify the
  174. queryset returned by the filter
  175. """
  176. self.setup_filters(**kwargs)
  177. try:
  178. filter_name, action_name = filters.split(':')
  179. action_params = unquote_plus(filter_value)
  180. except ValueError:
  181. return
  182. if "all" in action_name:
  183. return
  184. try:
  185. table_filter = self.filter_map.get_filter(filter_name)
  186. action = table_filter.get_action(action_name)
  187. action.set_filter_params(action_params)
  188. self.queryset = action.filter(self.queryset)
  189. except KeyError:
  190. # pass it to the user - programming error here
  191. raise
  192. def apply_orderby(self, orderby):
  193. # Note that django will execute this when we try to retrieve the data
  194. self.queryset = self.queryset.order_by(orderby)
  195. def apply_search(self, search_term):
  196. """Creates a query based on the model's search_allowed_fields"""
  197. if not hasattr(self.queryset.model, 'search_allowed_fields'):
  198. raise Exception("Search fields aren't defined in the model %s"
  199. % self.queryset.model)
  200. search_queries = None
  201. for st in search_term.split(" "):
  202. queries = None
  203. for field in self.queryset.model.search_allowed_fields:
  204. query = Q(**{field + '__icontains': st})
  205. if queries:
  206. queries |= query
  207. else:
  208. queries = query
  209. if search_queries:
  210. search_queries &= queries
  211. else:
  212. search_queries = queries
  213. self.queryset = self.queryset.filter(search_queries)
  214. def get_data(self, request, **kwargs):
  215. """
  216. Returns the data for the page requested with the specified
  217. parameters applied
  218. filters: filter and action name, e.g. "outcome:build_succeeded"
  219. filter_value: value to pass to the named filter+action, e.g. "on"
  220. (for a toggle filter) or "2015-12-11,2015-12-12"
  221. (for a date range filter)
  222. """
  223. page_num = request.GET.get("page", 1)
  224. limit = request.GET.get("limit", 10)
  225. search = request.GET.get("search", None)
  226. filters = request.GET.get("filter", None)
  227. filter_value = request.GET.get("filter_value", "on")
  228. orderby = request.GET.get("orderby", None)
  229. nocache = request.GET.get("nocache", None)
  230. # Make a unique cache name
  231. cache_name = self.__class__.__name__
  232. for key, val in request.GET.items():
  233. if key == 'nocache':
  234. continue
  235. cache_name = cache_name + str(key) + str(val)
  236. for key, val in kwargs.items():
  237. cache_name = cache_name + str(key) + str(val)
  238. # No special chars allowed in the cache name apart from dash
  239. cache_name = re.sub(r'[^A-Za-z0-9-]', "", cache_name)
  240. if nocache:
  241. cache.delete(cache_name)
  242. data = cache.get(cache_name)
  243. if data:
  244. logger.debug("Got cache data for table '%s'" % self.title)
  245. return data
  246. self.setup_columns(**kwargs)
  247. if search:
  248. self.apply_search(search)
  249. if filters:
  250. self.apply_filter(filters, filter_value, **kwargs)
  251. if orderby:
  252. self.apply_orderby(orderby)
  253. paginator = Paginator(self.queryset, limit)
  254. try:
  255. page = paginator.page(page_num)
  256. except EmptyPage:
  257. page = paginator.page(1)
  258. data = {
  259. 'total': self.queryset.count(),
  260. 'default_orderby': self.default_orderby,
  261. 'columns': self.columns,
  262. 'rows': [],
  263. 'error': "ok",
  264. }
  265. try:
  266. for model_obj in page.object_list:
  267. # Use collection to maintain the order
  268. required_data = collections.OrderedDict()
  269. for col in self.columns:
  270. field = col['field_name']
  271. if not field:
  272. field = col['static_data_name']
  273. if not field:
  274. raise NoFieldOrDataName("Must supply a field_name or"
  275. "static_data_name for column"
  276. "%s.%s" %
  277. (self.__class__.__name__, col)
  278. )
  279. # Check if we need to process some static data
  280. if "static_data_name" in col and col['static_data_name']:
  281. # Overwrite the field_name with static_data_name
  282. # so that this can be used as the html class name
  283. col['field_name'] = col['static_data_name']
  284. try:
  285. # Render the template given
  286. required_data[col['static_data_name']] = \
  287. self.render_static_data(
  288. col['static_data_template'], model_obj)
  289. except (TemplateSyntaxError,
  290. VariableDoesNotExist) as e:
  291. logger.error("could not render template code"
  292. "%s %s %s",
  293. col['static_data_template'],
  294. e, self.__class__.__name__)
  295. required_data[col['static_data_name']] =\
  296. '<!--error-->'
  297. else:
  298. # Traverse to any foriegn key in the field
  299. # e.g. recipe__layer_version__name
  300. model_data = None
  301. if "__" in field:
  302. for subfield in field.split("__"):
  303. if not model_data:
  304. # The first iteration is always going to
  305. # be on the actual model object instance.
  306. # Subsequent ones are on the result of
  307. # that. e.g. forieng key objects
  308. model_data = getattr(model_obj,
  309. subfield)
  310. else:
  311. model_data = getattr(model_data,
  312. subfield)
  313. else:
  314. model_data = getattr(model_obj,
  315. col['field_name'])
  316. # We might have a model function as the field so
  317. # call it to return the data needed
  318. if isinstance(model_data, types.MethodType):
  319. model_data = model_data()
  320. required_data[col['field_name']] = model_data
  321. data['rows'].append(required_data)
  322. except FieldError:
  323. # pass it to the user - programming-error here
  324. raise
  325. data = json.dumps(data, indent=2, cls=DjangoJSONEncoder)
  326. cache.set(cache_name, data, 60*30)
  327. return data
  328. class ToasterTypeAhead(View):
  329. """ A typeahead mechanism to support the front end typeahead widgets """
  330. MAX_RESULTS = 6
  331. class MissingFieldsException(Exception):
  332. pass
  333. def __init__(self, *args, **kwargs):
  334. super(ToasterTypeAhead, self).__init__()
  335. def get(self, request, *args, **kwargs):
  336. def response(data):
  337. return HttpResponse(json.dumps(data,
  338. indent=2,
  339. cls=DjangoJSONEncoder),
  340. content_type="application/json")
  341. error = "ok"
  342. search_term = request.GET.get("search", None)
  343. if search_term is None:
  344. # We got no search value so return empty reponse
  345. return response({'error': error, 'results': []})
  346. try:
  347. prj = Project.objects.get(pk=kwargs['pid'])
  348. except KeyError:
  349. prj = None
  350. results = self.apply_search(search_term,
  351. prj,
  352. request)[:ToasterTypeAhead.MAX_RESULTS]
  353. if len(results) > 0:
  354. try:
  355. self.validate_fields(results[0])
  356. except self.MissingFieldsException as e:
  357. error = e
  358. data = {'results': results,
  359. 'error': error}
  360. return response(data)
  361. def validate_fields(self, result):
  362. if 'name' in result is False or 'detail' in result is False:
  363. raise self.MissingFieldsException(
  364. "name and detail are required fields")
  365. def apply_search(self, search_term, prj):
  366. """ Override this function to implement search. Return an array of
  367. dictionaries with a minium of a name and detail field"""
  368. pass
  369. class MostRecentBuildsView(View):
  370. def _was_yesterday_or_earlier(self, completed_on):
  371. now = timezone.now()
  372. delta = now - completed_on
  373. if delta.days >= 1:
  374. return True
  375. return False
  376. def get(self, request, *args, **kwargs):
  377. """
  378. Returns a list of builds in JSON format.
  379. """
  380. project = None
  381. project_id = request.GET.get('project_id', None)
  382. if project_id:
  383. try:
  384. project = Project.objects.get(pk=project_id)
  385. except:
  386. # if project lookup fails, assume no project
  387. pass
  388. recent_build_objs = Build.get_recent(project)
  389. recent_builds = []
  390. for build_obj in recent_build_objs:
  391. dashboard_url = reverse('builddashboard', args=(build_obj.pk,))
  392. buildtime_url = reverse('buildtime', args=(build_obj.pk,))
  393. rebuild_url = \
  394. reverse('xhr_buildrequest', args=(build_obj.project.pk,))
  395. cancel_url = \
  396. reverse('xhr_buildrequest', args=(build_obj.project.pk,))
  397. build = {}
  398. build['id'] = build_obj.pk
  399. build['dashboard_url'] = dashboard_url
  400. buildrequest_id = None
  401. if hasattr(build_obj, 'buildrequest'):
  402. buildrequest_id = build_obj.buildrequest.pk
  403. build['buildrequest_id'] = buildrequest_id
  404. if build_obj.recipes_to_parse > 0:
  405. build['recipes_parsed_percentage'] = \
  406. int((build_obj.recipes_parsed /
  407. build_obj.recipes_to_parse) * 100)
  408. else:
  409. build['recipes_parsed_percentage'] = 0
  410. if build_obj.repos_to_clone > 0:
  411. build['repos_cloned_percentage'] = \
  412. int((build_obj.repos_cloned /
  413. build_obj.repos_to_clone) * 100)
  414. else:
  415. build['repos_cloned_percentage'] = 0
  416. build['progress_item'] = build_obj.progress_item
  417. tasks_complete_percentage = 0
  418. if build_obj.outcome in (Build.SUCCEEDED, Build.FAILED):
  419. tasks_complete_percentage = 100
  420. elif build_obj.outcome == Build.IN_PROGRESS:
  421. tasks_complete_percentage = build_obj.completeper()
  422. build['tasks_complete_percentage'] = tasks_complete_percentage
  423. build['state'] = build_obj.get_state()
  424. build['errors'] = build_obj.errors.count()
  425. build['dashboard_errors_url'] = dashboard_url + '#errors'
  426. build['warnings'] = build_obj.warnings.count()
  427. build['dashboard_warnings_url'] = dashboard_url + '#warnings'
  428. build['buildtime'] = sectohms(build_obj.timespent_seconds)
  429. build['buildtime_url'] = buildtime_url
  430. build['rebuild_url'] = rebuild_url
  431. build['cancel_url'] = cancel_url
  432. build['is_default_project_build'] = build_obj.project.is_default
  433. build['build_targets_json'] = \
  434. template_json(get_tasks(build_obj.target_set.all()))
  435. # convert completed_on time to user's timezone
  436. completed_on = timezone.localtime(build_obj.completed_on)
  437. completed_on_template = '%H:%M'
  438. if self._was_yesterday_or_earlier(completed_on):
  439. completed_on_template = '%d/%m/%Y ' + completed_on_template
  440. build['completed_on'] = completed_on.strftime(
  441. completed_on_template)
  442. targets = []
  443. target_objs = build_obj.get_sorted_target_list()
  444. for target_obj in target_objs:
  445. if target_obj.task:
  446. targets.append(target_obj.target + ':' + target_obj.task)
  447. else:
  448. targets.append(target_obj.target)
  449. build['targets'] = ' '.join(targets)
  450. # abbreviated form of the full target list
  451. abbreviated_targets = ''
  452. num_targets = len(targets)
  453. if num_targets > 0:
  454. abbreviated_targets = targets[0]
  455. if num_targets > 1:
  456. abbreviated_targets += (' +%s' % (num_targets - 1))
  457. build['targets_abbreviated'] = abbreviated_targets
  458. recent_builds.append(build)
  459. return JsonResponse(recent_builds, safe=False)