blob: a1792d997fe63d90d09fa217be0fe22bd25cb0e3 [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
Patrick Williamsc124f4f2015-09-15 14:41:29 -050025from django.core.cache import cache
26from django.core.paginator import Paginator, EmptyPage
27from django.db.models import Q
Patrick Williamsc0f7c042017-02-23 20:41:17 -060028from orm.models import Project, Build
Patrick Williamsc124f4f2015-09-15 14:41:29 -050029from django.template import Context, Template
Patrick Williamsc0f7c042017-02-23 20:41:17 -060030from django.template import VariableDoesNotExist
31from django.template import TemplateSyntaxError
Patrick Williamsc124f4f2015-09-15 14:41:29 -050032from django.core.serializers.json import DjangoJSONEncoder
33from django.core.exceptions import FieldError
Patrick Williamsc0f7c042017-02-23 20:41:17 -060034from django.utils import timezone
35from toastergui.templatetags.projecttags import sectohms, get_tasks
36from toastergui.templatetags.projecttags import json as template_json
37from django.http import JsonResponse
38from django.core.urlresolvers import reverse
Patrick Williamsc124f4f2015-09-15 14:41:29 -050039
40import types
41import json
42import collections
Patrick Williamsc124f4f2015-09-15 14:41:29 -050043import re
Brad Bishopd7bf8c12018-02-25 22:55:05 -050044import os
Patrick Williamsc0f7c042017-02-23 20:41:17 -060045
Brad Bishop6e60e8b2018-02-01 10:27:11 -050046from toastergui.tablefilter import TableFilterMap
47
Patrick Williamsc0f7c042017-02-23 20:41:17 -060048try:
49 from urllib import unquote_plus
50except ImportError:
51 from urllib.parse import unquote_plus
Patrick Williamsc124f4f2015-09-15 14:41:29 -050052
Patrick Williamsf1e5d692016-03-30 15:21:19 -050053import logging
54logger = logging.getLogger("toaster")
55
Patrick Williamsc0f7c042017-02-23 20:41:17 -060056
57class NoFieldOrDataName(Exception):
58 pass
59
60
Patrick Williamsc124f4f2015-09-15 14:41:29 -050061class ToasterTable(TemplateView):
62 def __init__(self, *args, **kwargs):
63 super(ToasterTable, self).__init__()
64 if 'template_name' in kwargs:
65 self.template_name = kwargs['template_name']
Patrick Williamsf1e5d692016-03-30 15:21:19 -050066 self.title = "Table"
Patrick Williamsc124f4f2015-09-15 14:41:29 -050067 self.queryset = None
68 self.columns = []
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050069
70 # map from field names to Filter instances
71 self.filter_map = TableFilterMap()
72
Patrick Williamsc124f4f2015-09-15 14:41:29 -050073 self.total_count = 0
74 self.static_context_extra = {}
Patrick Williamsc124f4f2015-09-15 14:41:29 -050075 self.empty_state = "Sorry - no data found"
76 self.default_orderby = ""
77
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050078 # prevent HTTP caching of table data
Patrick Williamsc0f7c042017-02-23 20:41:17 -060079 @cache_control(must_revalidate=True,
80 max_age=0, no_store=True, no_cache=True)
Patrick Williamsf1e5d692016-03-30 15:21:19 -050081 def dispatch(self, *args, **kwargs):
82 return super(ToasterTable, self).dispatch(*args, **kwargs)
83
84 def get_context_data(self, **kwargs):
85 context = super(ToasterTable, self).get_context_data(**kwargs)
86 context['title'] = self.title
Patrick Williamsc0f7c042017-02-23 20:41:17 -060087 context['table_name'] = type(self).__name__.lower()
88 context['empty_state'] = self.empty_state
Patrick Williamsf1e5d692016-03-30 15:21:19 -050089
Brad Bishopd7bf8c12018-02-25 22:55:05 -050090 # global variables
91 context['project_enable'] = ('1' == os.environ.get('TOASTER_BUILDSERVER'))
92
Patrick Williamsf1e5d692016-03-30 15:21:19 -050093 return context
94
Patrick Williamsc124f4f2015-09-15 14:41:29 -050095 def get(self, request, *args, **kwargs):
96 if request.GET.get('format', None) == 'json':
97
98 self.setup_queryset(*args, **kwargs)
99 # Put the project id into the context for the static_data_template
100 if 'pid' in kwargs:
101 self.static_context_extra['pid'] = kwargs['pid']
102
103 cmd = request.GET.get('cmd', None)
104 if cmd and 'filterinfo' in cmd:
105 data = self.get_filter_info(request, **kwargs)
106 else:
107 # If no cmd is specified we give you the table data
108 data = self.get_data(request, **kwargs)
109
110 return HttpResponse(data, content_type="application/json")
111
112 return super(ToasterTable, self).get(request, *args, **kwargs)
113
114 def get_filter_info(self, request, **kwargs):
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500115 self.setup_filters(**kwargs)
116
117 search = request.GET.get("search", None)
118 if search:
119 self.apply_search(search)
120
121 name = request.GET.get("name", None)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500122 table_filter = self.filter_map.get_filter(name)
123 return json.dumps(table_filter.to_json(self.queryset),
124 indent=2,
125 cls=DjangoJSONEncoder)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500126
127 def setup_columns(self, *args, **kwargs):
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600128 """ function to implement in the subclass which sets up
129 the columns """
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500130 pass
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600131
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500132 def setup_filters(self, *args, **kwargs):
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600133 """ function to implement in the subclass which sets up the
134 filters """
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500135 pass
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600136
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500137 def setup_queryset(self, *args, **kwargs):
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600138 """ function to implement in the subclass which sets up the
139 queryset"""
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500140 pass
141
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500142 def add_filter(self, table_filter):
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500143 """Add a filter to the table.
144
145 Args:
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500146 table_filter: Filter instance
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500147 """
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500148 self.filter_map.add_filter(table_filter.name, table_filter)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500149
150 def add_column(self, title="", help_text="",
151 orderable=False, hideable=True, hidden=False,
152 field_name="", filter_name=None, static_data_name=None,
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500153 static_data_template=None):
154 """Add a column to the table.
155
156 Args:
157 title (str): Title for the table header
158 help_text (str): Optional help text to describe the column
159 orderable (bool): Whether the column can be ordered.
160 We order on the field_name.
161 hideable (bool): Whether the user can hide the column
162 hidden (bool): Whether the column is default hidden
163 field_name (str or list): field(s) required for this column's data
164 static_data_name (str, optional): The column's main identifier
165 which will replace the field_name.
166 static_data_template(str, optional): The template to be rendered
167 as data
168 """
169
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600170 self.columns.append({'title': title,
171 'help_text': help_text,
172 'orderable': orderable,
173 'hideable': hideable,
174 'hidden': hidden,
175 'field_name': field_name,
176 'filter_name': filter_name,
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500177 'static_data_name': static_data_name,
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600178 'static_data_template': static_data_template})
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500179
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500180 def set_column_hidden(self, title, hidden):
181 """
182 Set the hidden state of the column to the value of hidden
183 """
184 for col in self.columns:
185 if col['title'] == title:
186 col['hidden'] = hidden
187 break
188
189 def set_column_hideable(self, title, hideable):
190 """
191 Set the hideable state of the column to the value of hideable
192 """
193 for col in self.columns:
194 if col['title'] == title:
195 col['hideable'] = hideable
196 break
197
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500198 def render_static_data(self, template, row):
199 """Utility function to render the static data template"""
200
201 context = {
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600202 'extra': self.static_context_extra,
203 'data': row,
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500204 }
205
206 context = Context(context)
207 template = Template(template)
208
209 return template.render(context)
210
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500211 def apply_filter(self, filters, filter_value, **kwargs):
212 """
213 Apply a filter submitted in the querystring to the ToasterTable
214
215 filters: (str) in the format:
216 '<filter name>:<action name>'
217 filter_value: (str) parameters to pass to the named filter
218
219 <filter name> and <action name> are used to look up the correct filter
220 in the ToasterTable's filter map; the <action params> are set on
221 TableFilterAction* before its filter is applied and may modify the
222 queryset returned by the filter
223 """
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500224 self.setup_filters(**kwargs)
225
226 try:
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500227 filter_name, action_name = filters.split(':')
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600228 action_params = unquote_plus(filter_value)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500229 except ValueError:
230 return
231
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500232 if "all" in action_name:
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500233 return
234
235 try:
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500236 table_filter = self.filter_map.get_filter(filter_name)
237 action = table_filter.get_action(action_name)
238 action.set_filter_params(action_params)
239 self.queryset = action.filter(self.queryset)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500240 except KeyError:
241 # pass it to the user - programming error here
242 raise
243
244 def apply_orderby(self, orderby):
245 # Note that django will execute this when we try to retrieve the data
246 self.queryset = self.queryset.order_by(orderby)
247
248 def apply_search(self, search_term):
249 """Creates a query based on the model's search_allowed_fields"""
250
251 if not hasattr(self.queryset.model, 'search_allowed_fields'):
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500252 raise Exception("Search fields aren't defined in the model %s"
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600253 % self.queryset.model)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500254
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600255 search_queries = None
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500256 for st in search_term.split(" "):
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600257 queries = None
258 for field in self.queryset.model.search_allowed_fields:
259 query = Q(**{field + '__icontains': st})
260 if queries:
261 queries |= query
262 else:
263 queries = query
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500264
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600265 if search_queries:
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500266 search_queries &= queries
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600267 else:
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500268 search_queries = queries
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500269
270 self.queryset = self.queryset.filter(search_queries)
271
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500272 def get_data(self, request, **kwargs):
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500273 """
274 Returns the data for the page requested with the specified
275 parameters applied
276
277 filters: filter and action name, e.g. "outcome:build_succeeded"
278 filter_value: value to pass to the named filter+action, e.g. "on"
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600279 (for a toggle filter) or "2015-12-11,2015-12-12"
280 (for a date range filter)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500281 """
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500282
283 page_num = request.GET.get("page", 1)
284 limit = request.GET.get("limit", 10)
285 search = request.GET.get("search", None)
286 filters = request.GET.get("filter", None)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500287 filter_value = request.GET.get("filter_value", "on")
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500288 orderby = request.GET.get("orderby", None)
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500289 nocache = request.GET.get("nocache", None)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500290
291 # Make a unique cache name
292 cache_name = self.__class__.__name__
293
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600294 for key, val in request.GET.items():
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500295 if key == 'nocache':
296 continue
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500297 cache_name = cache_name + str(key) + str(val)
298
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600299 for key, val in kwargs.items():
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500300 cache_name = cache_name + str(key) + str(val)
301
302 # No special chars allowed in the cache name apart from dash
303 cache_name = re.sub(r'[^A-Za-z0-9-]', "", cache_name)
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500304
305 if nocache:
306 cache.delete(cache_name)
307
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500308 data = cache.get(cache_name)
309
310 if data:
Patrick Williamsf1e5d692016-03-30 15:21:19 -0500311 logger.debug("Got cache data for table '%s'" % self.title)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500312 return data
313
314 self.setup_columns(**kwargs)
315
316 if search:
317 self.apply_search(search)
318 if filters:
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500319 self.apply_filter(filters, filter_value, **kwargs)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500320 if orderby:
321 self.apply_orderby(orderby)
322
323 paginator = Paginator(self.queryset, limit)
324
325 try:
326 page = paginator.page(page_num)
327 except EmptyPage:
328 page = paginator.page(1)
329
330 data = {
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600331 'total': self.queryset.count(),
332 'default_orderby': self.default_orderby,
333 'columns': self.columns,
334 'rows': [],
335 'error': "ok",
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500336 }
337
338 try:
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600339 for model_obj in page.object_list:
340 # Use collection to maintain the order
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500341 required_data = collections.OrderedDict()
342
343 for col in self.columns:
344 field = col['field_name']
345 if not field:
346 field = col['static_data_name']
347 if not field:
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600348 raise NoFieldOrDataName("Must supply a field_name or"
349 "static_data_name for column"
350 "%s.%s" %
351 (self.__class__.__name__, col)
352 )
353
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500354 # Check if we need to process some static data
355 if "static_data_name" in col and col['static_data_name']:
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500356 # Overwrite the field_name with static_data_name
357 # so that this can be used as the html class name
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500358 col['field_name'] = col['static_data_name']
359
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600360 try:
361 # Render the template given
362 required_data[col['static_data_name']] = \
363 self.render_static_data(
364 col['static_data_template'], model_obj)
365 except (TemplateSyntaxError,
366 VariableDoesNotExist) as e:
367 logger.error("could not render template code"
368 "%s %s %s",
369 col['static_data_template'],
370 e, self.__class__.__name__)
371 required_data[col['static_data_name']] =\
372 '<!--error-->'
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500373
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600374 else:
375 # Traverse to any foriegn key in the field
376 # e.g. recipe__layer_version__name
377 model_data = None
378
379 if "__" in field:
380 for subfield in field.split("__"):
381 if not model_data:
382 # The first iteration is always going to
383 # be on the actual model object instance.
384 # Subsequent ones are on the result of
385 # that. e.g. forieng key objects
386 model_data = getattr(model_obj,
387 subfield)
388 else:
389 model_data = getattr(model_data,
390 subfield)
391
392 else:
393 model_data = getattr(model_obj,
394 col['field_name'])
395
396 # We might have a model function as the field so
397 # call it to return the data needed
398 if isinstance(model_data, types.MethodType):
399 model_data = model_data()
400
401 required_data[col['field_name']] = model_data
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500402
403 data['rows'].append(required_data)
404
405 except FieldError:
406 # pass it to the user - programming-error here
407 raise
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600408
409 data = json.dumps(data, indent=2, cls=DjangoJSONEncoder)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500410 cache.set(cache_name, data, 60*30)
411
412 return data
413
414
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500415class ToasterTypeAhead(View):
416 """ A typeahead mechanism to support the front end typeahead widgets """
417 MAX_RESULTS = 6
418
419 class MissingFieldsException(Exception):
420 pass
421
422 def __init__(self, *args, **kwargs):
423 super(ToasterTypeAhead, self).__init__()
424
425 def get(self, request, *args, **kwargs):
426 def response(data):
427 return HttpResponse(json.dumps(data,
428 indent=2,
429 cls=DjangoJSONEncoder),
430 content_type="application/json")
431
432 error = "ok"
433
434 search_term = request.GET.get("search", None)
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600435 if search_term is None:
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500436 # We got no search value so return empty reponse
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600437 return response({'error': error, 'results': []})
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500438
439 try:
440 prj = Project.objects.get(pk=kwargs['pid'])
441 except KeyError:
442 prj = None
443
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600444 results = self.apply_search(search_term,
445 prj,
446 request)[:ToasterTypeAhead.MAX_RESULTS]
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500447
448 if len(results) > 0:
449 try:
450 self.validate_fields(results[0])
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600451 except self.MissingFieldsException as e:
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500452 error = e
453
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600454 data = {'results': results,
455 'error': error}
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500456
457 return response(data)
458
459 def validate_fields(self, result):
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600460 if 'name' in result is False or 'detail' in result is False:
461 raise self.MissingFieldsException(
462 "name and detail are required fields")
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500463
464 def apply_search(self, search_term, prj):
465 """ Override this function to implement search. Return an array of
466 dictionaries with a minium of a name and detail field"""
467 pass
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600468
469
470class MostRecentBuildsView(View):
471 def _was_yesterday_or_earlier(self, completed_on):
472 now = timezone.now()
473 delta = now - completed_on
474
475 if delta.days >= 1:
476 return True
477
478 return False
479
480 def get(self, request, *args, **kwargs):
481 """
482 Returns a list of builds in JSON format.
483 """
484 project = None
485
486 project_id = request.GET.get('project_id', None)
487 if project_id:
488 try:
489 project = Project.objects.get(pk=project_id)
490 except:
491 # if project lookup fails, assume no project
492 pass
493
494 recent_build_objs = Build.get_recent(project)
495 recent_builds = []
496
497 for build_obj in recent_build_objs:
498 dashboard_url = reverse('builddashboard', args=(build_obj.pk,))
499 buildtime_url = reverse('buildtime', args=(build_obj.pk,))
500 rebuild_url = \
501 reverse('xhr_buildrequest', args=(build_obj.project.pk,))
502 cancel_url = \
503 reverse('xhr_buildrequest', args=(build_obj.project.pk,))
504
505 build = {}
506 build['id'] = build_obj.pk
507 build['dashboard_url'] = dashboard_url
508
509 buildrequest_id = None
510 if hasattr(build_obj, 'buildrequest'):
511 buildrequest_id = build_obj.buildrequest.pk
512 build['buildrequest_id'] = buildrequest_id
513
514 build['recipes_parsed_percentage'] = \
515 int((build_obj.recipes_parsed /
516 build_obj.recipes_to_parse) * 100)
517
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500518 build['repos_cloned_percentage'] = \
519 int((build_obj.repos_cloned /
520 build_obj.repos_to_clone) * 100)
521
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600522 tasks_complete_percentage = 0
523 if build_obj.outcome in (Build.SUCCEEDED, Build.FAILED):
524 tasks_complete_percentage = 100
525 elif build_obj.outcome == Build.IN_PROGRESS:
526 tasks_complete_percentage = build_obj.completeper()
527 build['tasks_complete_percentage'] = tasks_complete_percentage
528
529 build['state'] = build_obj.get_state()
530
531 build['errors'] = build_obj.errors.count()
532 build['dashboard_errors_url'] = dashboard_url + '#errors'
533
534 build['warnings'] = build_obj.warnings.count()
535 build['dashboard_warnings_url'] = dashboard_url + '#warnings'
536
537 build['buildtime'] = sectohms(build_obj.timespent_seconds)
538 build['buildtime_url'] = buildtime_url
539
540 build['rebuild_url'] = rebuild_url
541 build['cancel_url'] = cancel_url
542
543 build['is_default_project_build'] = build_obj.project.is_default
544
545 build['build_targets_json'] = \
546 template_json(get_tasks(build_obj.target_set.all()))
547
548 # convert completed_on time to user's timezone
549 completed_on = timezone.localtime(build_obj.completed_on)
550
551 completed_on_template = '%H:%M'
552 if self._was_yesterday_or_earlier(completed_on):
553 completed_on_template = '%d/%m/%Y ' + completed_on_template
554 build['completed_on'] = completed_on.strftime(
555 completed_on_template)
556
557 targets = []
558 target_objs = build_obj.get_sorted_target_list()
559 for target_obj in target_objs:
560 if target_obj.task:
561 targets.append(target_obj.target + ':' + target_obj.task)
562 else:
563 targets.append(target_obj.target)
564 build['targets'] = ' '.join(targets)
565
566 # abbreviated form of the full target list
567 abbreviated_targets = ''
568 num_targets = len(targets)
569 if num_targets > 0:
570 abbreviated_targets = targets[0]
571 if num_targets > 1:
572 abbreviated_targets += (' +%s' % (num_targets - 1))
573 build['targets_abbreviated'] = abbreviated_targets
574
575 recent_builds.append(build)
576
577 return JsonResponse(recent_builds, safe=False)