574 lines
20 KiB
Python
574 lines
20 KiB
Python
#
|
|
# ex:ts=4:sw=4:sts=4:et
|
|
# -*- tab-width: 4; c-basic-offset: 4; indent-tabs-mode: nil -*-
|
|
#
|
|
# BitBake Toaster Implementation
|
|
#
|
|
# Copyright (C) 2015 Intel Corporation
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License version 2 as
|
|
# published by the Free Software Foundation.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License along
|
|
# with this program; if not, write to the Free Software Foundation, Inc.,
|
|
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
|
|
|
from django.views.generic import View, TemplateView
|
|
from django.views.decorators.cache import cache_control
|
|
from django.shortcuts import HttpResponse
|
|
from django.core.cache import cache
|
|
from django.core.paginator import Paginator, EmptyPage
|
|
from django.db.models import Q
|
|
from orm.models import Project, Build
|
|
from django.template import Context, Template
|
|
from django.template import VariableDoesNotExist
|
|
from django.template import TemplateSyntaxError
|
|
from django.core.serializers.json import DjangoJSONEncoder
|
|
from django.core.exceptions import FieldError
|
|
from django.utils import timezone
|
|
from toastergui.templatetags.projecttags import sectohms, get_tasks
|
|
from toastergui.templatetags.projecttags import json as template_json
|
|
from django.http import JsonResponse
|
|
from django.core.urlresolvers import reverse
|
|
|
|
import types
|
|
import json
|
|
import collections
|
|
import re
|
|
|
|
from toastergui.tablefilter import TableFilterMap
|
|
|
|
try:
|
|
from urllib import unquote_plus
|
|
except ImportError:
|
|
from urllib.parse import unquote_plus
|
|
|
|
import logging
|
|
logger = logging.getLogger("toaster")
|
|
|
|
|
|
class NoFieldOrDataName(Exception):
|
|
pass
|
|
|
|
|
|
class ToasterTable(TemplateView):
|
|
def __init__(self, *args, **kwargs):
|
|
super(ToasterTable, self).__init__()
|
|
if 'template_name' in kwargs:
|
|
self.template_name = kwargs['template_name']
|
|
self.title = "Table"
|
|
self.queryset = None
|
|
self.columns = []
|
|
|
|
# map from field names to Filter instances
|
|
self.filter_map = TableFilterMap()
|
|
|
|
self.total_count = 0
|
|
self.static_context_extra = {}
|
|
self.empty_state = "Sorry - no data found"
|
|
self.default_orderby = ""
|
|
|
|
# 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)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super(ToasterTable, self).get_context_data(**kwargs)
|
|
context['title'] = self.title
|
|
context['table_name'] = type(self).__name__.lower()
|
|
context['empty_state'] = self.empty_state
|
|
|
|
return context
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
if request.GET.get('format', None) == 'json':
|
|
|
|
self.setup_queryset(*args, **kwargs)
|
|
# Put the project id into the context for the static_data_template
|
|
if 'pid' in kwargs:
|
|
self.static_context_extra['pid'] = kwargs['pid']
|
|
|
|
cmd = request.GET.get('cmd', None)
|
|
if cmd and 'filterinfo' in cmd:
|
|
data = self.get_filter_info(request, **kwargs)
|
|
else:
|
|
# If no cmd is specified we give you the table data
|
|
data = self.get_data(request, **kwargs)
|
|
|
|
return HttpResponse(data, content_type="application/json")
|
|
|
|
return super(ToasterTable, self).get(request, *args, **kwargs)
|
|
|
|
def get_filter_info(self, request, **kwargs):
|
|
self.setup_filters(**kwargs)
|
|
|
|
search = request.GET.get("search", None)
|
|
if search:
|
|
self.apply_search(search)
|
|
|
|
name = request.GET.get("name", None)
|
|
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 """
|
|
pass
|
|
|
|
def setup_filters(self, *args, **kwargs):
|
|
""" function to implement in the subclass which sets up the
|
|
filters """
|
|
pass
|
|
|
|
def setup_queryset(self, *args, **kwargs):
|
|
""" function to implement in the subclass which sets up the
|
|
queryset"""
|
|
pass
|
|
|
|
def add_filter(self, table_filter):
|
|
"""Add a filter to the table.
|
|
|
|
Args:
|
|
table_filter: Filter instance
|
|
"""
|
|
self.filter_map.add_filter(table_filter.name, table_filter)
|
|
|
|
def add_column(self, title="", help_text="",
|
|
orderable=False, hideable=True, hidden=False,
|
|
field_name="", filter_name=None, static_data_name=None,
|
|
static_data_template=None):
|
|
"""Add a column to the table.
|
|
|
|
Args:
|
|
title (str): Title for the table header
|
|
help_text (str): Optional help text to describe the column
|
|
orderable (bool): Whether the column can be ordered.
|
|
We order on the field_name.
|
|
hideable (bool): Whether the user can hide the column
|
|
hidden (bool): Whether the column is default hidden
|
|
field_name (str or list): field(s) required for this column's data
|
|
static_data_name (str, optional): The column's main identifier
|
|
which will replace the field_name.
|
|
static_data_template(str, optional): The template to be rendered
|
|
as data
|
|
"""
|
|
|
|
self.columns.append({'title': title,
|
|
'help_text': help_text,
|
|
'orderable': orderable,
|
|
'hideable': hideable,
|
|
'hidden': hidden,
|
|
'field_name': field_name,
|
|
'filter_name': filter_name,
|
|
'static_data_name': static_data_name,
|
|
'static_data_template': static_data_template})
|
|
|
|
def set_column_hidden(self, title, hidden):
|
|
"""
|
|
Set the hidden state of the column to the value of hidden
|
|
"""
|
|
for col in self.columns:
|
|
if col['title'] == title:
|
|
col['hidden'] = hidden
|
|
break
|
|
|
|
def set_column_hideable(self, title, hideable):
|
|
"""
|
|
Set the hideable state of the column to the value of hideable
|
|
"""
|
|
for col in self.columns:
|
|
if col['title'] == title:
|
|
col['hideable'] = hideable
|
|
break
|
|
|
|
def render_static_data(self, template, row):
|
|
"""Utility function to render the static data template"""
|
|
|
|
context = {
|
|
'extra': self.static_context_extra,
|
|
'data': row,
|
|
}
|
|
|
|
context = Context(context)
|
|
template = Template(template)
|
|
|
|
return template.render(context)
|
|
|
|
def apply_filter(self, filters, filter_value, **kwargs):
|
|
"""
|
|
Apply a filter submitted in the querystring to the ToasterTable
|
|
|
|
filters: (str) in the format:
|
|
'<filter name>:<action name>'
|
|
filter_value: (str) parameters to pass to the named filter
|
|
|
|
<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, action_name = filters.split(':')
|
|
action_params = unquote_plus(filter_value)
|
|
except ValueError:
|
|
return
|
|
|
|
if "all" in action_name:
|
|
return
|
|
|
|
try:
|
|
table_filter = self.filter_map.get_filter(filter_name)
|
|
action = table_filter.get_action(action_name)
|
|
action.set_filter_params(action_params)
|
|
self.queryset = action.filter(self.queryset)
|
|
except KeyError:
|
|
# pass it to the user - programming error here
|
|
raise
|
|
|
|
def apply_orderby(self, orderby):
|
|
# Note that django will execute this when we try to retrieve the data
|
|
self.queryset = self.queryset.order_by(orderby)
|
|
|
|
def apply_search(self, search_term):
|
|
"""Creates a query based on the model's search_allowed_fields"""
|
|
|
|
if not hasattr(self.queryset.model, 'search_allowed_fields'):
|
|
raise Exception("Search fields aren't defined in the model %s"
|
|
% self.queryset.model)
|
|
|
|
search_queries = None
|
|
for st in search_term.split(" "):
|
|
queries = None
|
|
for field in self.queryset.model.search_allowed_fields:
|
|
query = Q(**{field + '__icontains': st})
|
|
if queries:
|
|
queries |= query
|
|
else:
|
|
queries = query
|
|
|
|
if search_queries:
|
|
search_queries &= queries
|
|
else:
|
|
search_queries = queries
|
|
|
|
self.queryset = self.queryset.filter(search_queries)
|
|
|
|
def get_data(self, request, **kwargs):
|
|
"""
|
|
Returns the data for the page requested with the specified
|
|
parameters applied
|
|
|
|
filters: filter and action name, e.g. "outcome:build_succeeded"
|
|
filter_value: value to pass to the named filter+action, e.g. "on"
|
|
(for a toggle filter) or "2015-12-11,2015-12-12"
|
|
(for a date range filter)
|
|
"""
|
|
|
|
page_num = request.GET.get("page", 1)
|
|
limit = request.GET.get("limit", 10)
|
|
search = request.GET.get("search", None)
|
|
filters = request.GET.get("filter", None)
|
|
filter_value = request.GET.get("filter_value", "on")
|
|
orderby = request.GET.get("orderby", None)
|
|
nocache = request.GET.get("nocache", None)
|
|
|
|
# Make a unique cache name
|
|
cache_name = self.__class__.__name__
|
|
|
|
for key, val in request.GET.items():
|
|
if key == 'nocache':
|
|
continue
|
|
cache_name = cache_name + str(key) + str(val)
|
|
|
|
for key, val in kwargs.items():
|
|
cache_name = cache_name + str(key) + str(val)
|
|
|
|
# No special chars allowed in the cache name apart from dash
|
|
cache_name = re.sub(r'[^A-Za-z0-9-]', "", cache_name)
|
|
|
|
if nocache:
|
|
cache.delete(cache_name)
|
|
|
|
data = cache.get(cache_name)
|
|
|
|
if data:
|
|
logger.debug("Got cache data for table '%s'" % self.title)
|
|
return data
|
|
|
|
self.setup_columns(**kwargs)
|
|
|
|
if search:
|
|
self.apply_search(search)
|
|
if filters:
|
|
self.apply_filter(filters, filter_value, **kwargs)
|
|
if orderby:
|
|
self.apply_orderby(orderby)
|
|
|
|
paginator = Paginator(self.queryset, limit)
|
|
|
|
try:
|
|
page = paginator.page(page_num)
|
|
except EmptyPage:
|
|
page = paginator.page(1)
|
|
|
|
data = {
|
|
'total': self.queryset.count(),
|
|
'default_orderby': self.default_orderby,
|
|
'columns': self.columns,
|
|
'rows': [],
|
|
'error': "ok",
|
|
}
|
|
|
|
try:
|
|
for model_obj in page.object_list:
|
|
# Use collection to maintain the order
|
|
required_data = collections.OrderedDict()
|
|
|
|
for col in self.columns:
|
|
field = col['field_name']
|
|
if not field:
|
|
field = col['static_data_name']
|
|
if not field:
|
|
raise NoFieldOrDataName("Must supply a field_name or"
|
|
"static_data_name for column"
|
|
"%s.%s" %
|
|
(self.__class__.__name__, col)
|
|
)
|
|
|
|
# Check if we need to process some static data
|
|
if "static_data_name" in col and col['static_data_name']:
|
|
# Overwrite the field_name with static_data_name
|
|
# so that this can be used as the html class name
|
|
col['field_name'] = col['static_data_name']
|
|
|
|
try:
|
|
# Render the template given
|
|
required_data[col['static_data_name']] = \
|
|
self.render_static_data(
|
|
col['static_data_template'], model_obj)
|
|
except (TemplateSyntaxError,
|
|
VariableDoesNotExist) as e:
|
|
logger.error("could not render template code"
|
|
"%s %s %s",
|
|
col['static_data_template'],
|
|
e, self.__class__.__name__)
|
|
required_data[col['static_data_name']] =\
|
|
'<!--error-->'
|
|
|
|
else:
|
|
# Traverse to any foriegn key in the field
|
|
# e.g. recipe__layer_version__name
|
|
model_data = None
|
|
|
|
if "__" in field:
|
|
for subfield in field.split("__"):
|
|
if not model_data:
|
|
# The first iteration is always going to
|
|
# be on the actual model object instance.
|
|
# Subsequent ones are on the result of
|
|
# that. e.g. forieng key objects
|
|
model_data = getattr(model_obj,
|
|
subfield)
|
|
else:
|
|
model_data = getattr(model_data,
|
|
subfield)
|
|
|
|
else:
|
|
model_data = getattr(model_obj,
|
|
col['field_name'])
|
|
|
|
# We might have a model function as the field so
|
|
# call it to return the data needed
|
|
if isinstance(model_data, types.MethodType):
|
|
model_data = model_data()
|
|
|
|
required_data[col['field_name']] = model_data
|
|
|
|
data['rows'].append(required_data)
|
|
|
|
except FieldError:
|
|
# pass it to the user - programming-error here
|
|
raise
|
|
|
|
data = json.dumps(data, indent=2, cls=DjangoJSONEncoder)
|
|
cache.set(cache_name, data, 60*30)
|
|
|
|
return data
|
|
|
|
|
|
class ToasterTypeAhead(View):
|
|
""" A typeahead mechanism to support the front end typeahead widgets """
|
|
MAX_RESULTS = 6
|
|
|
|
class MissingFieldsException(Exception):
|
|
pass
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(ToasterTypeAhead, self).__init__()
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
def response(data):
|
|
return HttpResponse(json.dumps(data,
|
|
indent=2,
|
|
cls=DjangoJSONEncoder),
|
|
content_type="application/json")
|
|
|
|
error = "ok"
|
|
|
|
search_term = request.GET.get("search", None)
|
|
if search_term is None:
|
|
# We got no search value so return empty reponse
|
|
return response({'error': error, 'results': []})
|
|
|
|
try:
|
|
prj = Project.objects.get(pk=kwargs['pid'])
|
|
except KeyError:
|
|
prj = None
|
|
|
|
results = self.apply_search(search_term,
|
|
prj,
|
|
request)[:ToasterTypeAhead.MAX_RESULTS]
|
|
|
|
if len(results) > 0:
|
|
try:
|
|
self.validate_fields(results[0])
|
|
except self.MissingFieldsException as e:
|
|
error = e
|
|
|
|
data = {'results': results,
|
|
'error': error}
|
|
|
|
return response(data)
|
|
|
|
def validate_fields(self, result):
|
|
if 'name' in result is False or 'detail' in result is False:
|
|
raise self.MissingFieldsException(
|
|
"name and detail are required fields")
|
|
|
|
def apply_search(self, search_term, prj):
|
|
""" Override this function to implement search. Return an array of
|
|
dictionaries with a minium of a name and detail field"""
|
|
pass
|
|
|
|
|
|
class MostRecentBuildsView(View):
|
|
def _was_yesterday_or_earlier(self, completed_on):
|
|
now = timezone.now()
|
|
delta = now - completed_on
|
|
|
|
if delta.days >= 1:
|
|
return True
|
|
|
|
return False
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
"""
|
|
Returns a list of builds in JSON format.
|
|
"""
|
|
project = None
|
|
|
|
project_id = request.GET.get('project_id', None)
|
|
if project_id:
|
|
try:
|
|
project = Project.objects.get(pk=project_id)
|
|
except:
|
|
# if project lookup fails, assume no project
|
|
pass
|
|
|
|
recent_build_objs = Build.get_recent(project)
|
|
recent_builds = []
|
|
|
|
for build_obj in recent_build_objs:
|
|
dashboard_url = reverse('builddashboard', args=(build_obj.pk,))
|
|
buildtime_url = reverse('buildtime', args=(build_obj.pk,))
|
|
rebuild_url = \
|
|
reverse('xhr_buildrequest', args=(build_obj.project.pk,))
|
|
cancel_url = \
|
|
reverse('xhr_buildrequest', args=(build_obj.project.pk,))
|
|
|
|
build = {}
|
|
build['id'] = build_obj.pk
|
|
build['dashboard_url'] = dashboard_url
|
|
|
|
buildrequest_id = None
|
|
if hasattr(build_obj, 'buildrequest'):
|
|
buildrequest_id = build_obj.buildrequest.pk
|
|
build['buildrequest_id'] = buildrequest_id
|
|
|
|
build['recipes_parsed_percentage'] = \
|
|
int((build_obj.recipes_parsed /
|
|
build_obj.recipes_to_parse) * 100)
|
|
|
|
build['repos_cloned_percentage'] = \
|
|
int((build_obj.repos_cloned /
|
|
build_obj.repos_to_clone) * 100)
|
|
|
|
tasks_complete_percentage = 0
|
|
if build_obj.outcome in (Build.SUCCEEDED, Build.FAILED):
|
|
tasks_complete_percentage = 100
|
|
elif build_obj.outcome == Build.IN_PROGRESS:
|
|
tasks_complete_percentage = build_obj.completeper()
|
|
build['tasks_complete_percentage'] = tasks_complete_percentage
|
|
|
|
build['state'] = build_obj.get_state()
|
|
|
|
build['errors'] = build_obj.errors.count()
|
|
build['dashboard_errors_url'] = dashboard_url + '#errors'
|
|
|
|
build['warnings'] = build_obj.warnings.count()
|
|
build['dashboard_warnings_url'] = dashboard_url + '#warnings'
|
|
|
|
build['buildtime'] = sectohms(build_obj.timespent_seconds)
|
|
build['buildtime_url'] = buildtime_url
|
|
|
|
build['rebuild_url'] = rebuild_url
|
|
build['cancel_url'] = cancel_url
|
|
|
|
build['is_default_project_build'] = build_obj.project.is_default
|
|
|
|
build['build_targets_json'] = \
|
|
template_json(get_tasks(build_obj.target_set.all()))
|
|
|
|
# convert completed_on time to user's timezone
|
|
completed_on = timezone.localtime(build_obj.completed_on)
|
|
|
|
completed_on_template = '%H:%M'
|
|
if self._was_yesterday_or_earlier(completed_on):
|
|
completed_on_template = '%d/%m/%Y ' + completed_on_template
|
|
build['completed_on'] = completed_on.strftime(
|
|
completed_on_template)
|
|
|
|
targets = []
|
|
target_objs = build_obj.get_sorted_target_list()
|
|
for target_obj in target_objs:
|
|
if target_obj.task:
|
|
targets.append(target_obj.target + ':' + target_obj.task)
|
|
else:
|
|
targets.append(target_obj.target)
|
|
build['targets'] = ' '.join(targets)
|
|
|
|
# abbreviated form of the full target list
|
|
abbreviated_targets = ''
|
|
num_targets = len(targets)
|
|
if num_targets > 0:
|
|
abbreviated_targets = targets[0]
|
|
if num_targets > 1:
|
|
abbreviated_targets += (' +%s' % (num_targets - 1))
|
|
build['targets_abbreviated'] = abbreviated_targets
|
|
|
|
recent_builds.append(build)
|
|
|
|
return JsonResponse(recent_builds, safe=False)
|