=== modified file 'dashboard_app/filters.py'
@@ -300,6 +300,22 @@
q = self.queryset.filter(bundle__uploaded_on__gt=since)
return self._wrap(q)
+ def with_tags(self, tag1, tag2):
+ if self.key == 'build_number':
+ q = self.queryset.extra(
+ where=['convert_to_integer("dashboard_app_namedattribute"."value") in (%s, %s)' % (tag1, tag2)]
+ )
+ else:
+ tag1 = datetime.datetime.strptime(tag1, "%Y-%m-%d %H:%M:%S.%f")
+ tag2 = datetime.datetime.strptime(tag2, "%Y-%m-%d %H:%M:%S.%f")
+ q = self.queryset.filter(bundle__uploaded_on__in=(tag1, tag2))
+ matches = list(self._wrap(q))
+ if matches[0].tag == tag1:
+ return matches
+ else:
+ matches.reverse()
+ return matches
+
def count(self):
return self.queryset.count()
=== added file 'dashboard_app/static/css/filter-detail.css'
@@ -0,0 +1,52 @@
+table.select-compare1 td { cursor: pointer; }
+table.select-compare1 tr.even td {
+ background-color: #ccf;
+}
+table.select-compare1 tr.even.hover td {
+ background-color: #77f;
+}
+table.select-compare1 tr.odd td {
+ background-color: #aaf;
+}
+table.select-compare1 tr.odd.hover td {
+ background-color: #77f;
+}
+
+table.select-compare2 td { cursor: pointer; }
+table.select-compare2 tr.even td {
+ background-color: #fcc;
+}
+table.select-compare2 tr.odd td {
+ background-color: #faa;
+}
+table.select-compare2 tr.selected-1 td {
+ background-color: #77f;
+}
+table.select-compare2 tr.selected-1.hover td {
+ background-color: #77f;
+}
+table.select-compare2 tr.hover td {
+ background-color: #f77;
+}
+table.select-compare3 tr.selected-1 td {
+ background-color: #77f;
+}
+table.select-compare3 tr.selected-1.hover td {
+ background-color: #77f;
+}
+table.select-compare3 tr.selected-2 td {
+ background-color: #f77;
+}
+table.select-compare3 tr.selected-2.hover td {
+ background-color: #f77;
+}
+table.select-compare3 tr.selected-1 {
+ cursor: pointer;
+}
+table.select-compare3 tr.selected-2 {
+ cursor: pointer;
+}
+#filter-table input {
+ margin-top: 0;
+ margin-bottom: 0;
+}
\ No newline at end of file
=== added file 'dashboard_app/static/js/filter-detail.js'
@@ -0,0 +1,135 @@
+var compareState = 0;
+var compare1 = null, compare2 = null;
+function cancelCompare () {
+ $("#filter-table").removeClass("select-compare1");
+ $("#filter-table").removeClass("select-compare2");
+ $("#filter-table").removeClass("select-compare3");
+ $("#filter-table tr").removeClass("selected-1");
+ $("#filter-table tr").removeClass("selected-2");
+ $("#filter-table tr").unbind("click");
+ $("#filter-table tr").unbind("hover");
+ $("#filter-table tr").each(removeCheckbox);
+ $("#first-prompt").hide();
+ $("#second-prompt").hide();
+ $("#third-prompt").hide();
+ $("#compare-button").button({label:"Compare builds"});
+ compareState = 0;
+}
+function startCompare () {
+ $("#compare-button").button({label:"Cancel"});
+ $("#filter-table").addClass("select-compare1");
+ $("#filter-table tr").click(rowClickHandler);
+ $("#filter-table tr").each(insertCheckbox);
+ $("#filter-table tr").hover(rowHoverHandlerIn, rowHoverHandlerOut);
+ $("#first-prompt").show();
+ compareState = 1;
+}
+function tagFromRow(tr) {
+ var firstCell = $(tr).find("td:eq(0)");
+ return {
+ machinetag: firstCell.find("span").data("machinetag"),
+ usertag: firstCell.text()
+ };
+}
+function rowClickHandler() {
+ if (compareState == 1) {
+ compare1 = tagFromRow($(this));
+ $(this).addClass("selected-1");
+ $(this).find("input").attr("checked", true);
+ $("#p2-build").text(compare1.usertag);
+ $("#first-prompt").hide();
+ $("#second-prompt").show();
+ $("#filter-table").removeClass("select-compare1");
+ $("#filter-table").addClass("select-compare2");
+ compareState = 2;
+ } else if (compareState == 2) {
+ var thistag = tagFromRow($(this));
+ if (compare1.machinetag == thistag.machinetag) {
+ cancelCompare();
+ startCompare();
+ } else {
+ compare2 = thistag;
+ $(this).find("input").attr("checked", true);
+ $(this).addClass("selected-2");
+ $("#second-prompt").hide();
+ $("#third-prompt").show();
+ $("#filter-table").removeClass("select-compare2");
+ $("#filter-table").addClass("select-compare3");
+ $("#filter-table input").attr("disabled", true);
+ $("#filter-table .selected-1 input").attr("disabled", false);
+ $("#filter-table .selected-2 input").attr("disabled", false);
+ $("#p3-build-1").text(compare1.usertag);
+ $("#p3-build-2").text(compare2.usertag);
+ $("#third-prompt a").attr("href", window.location + '/+compare/' + compare1.machinetag + '/' + compare2.machinetag);
+ compareState = 3;
+ }
+ } else if (compareState == 3) {
+ var thistag = tagFromRow($(this));
+ if (thistag.machinetag == compare1.machinetag || thistag.machinetag == compare2.machinetag) {
+ $("#second-prompt").show();
+ $("#third-prompt").hide();
+ $("#filter-table").addClass("select-compare2");
+ $("#filter-table").removeClass("select-compare3");
+ $("#filter-table input").attr("disabled", false);
+ compareState = 2;
+ $(this).find("input").attr("checked", false);
+ if (thistag.machinetag == compare1.machinetag) {
+ compare1 = compare2;
+ $("#filter-table .selected-1").removeClass("selected-1");
+ $("#filter-table .selected-2").addClass("selected-1");
+ $("#p2-build").text(compare1.usertag);
+ }
+ $("#filter-table .selected-2").removeClass("selected-2");
+ }
+ }
+ tagFromRow(this);
+}
+function rowHoverHandlerIn() {
+ $(this).addClass("hover");
+}
+function rowHoverHandlerOut() {
+ $(this).removeClass("hover");
+}
+function insertCheckbox() {
+ var row = $(this);
+ var checkbox = $('<input type="checkbox">');
+ row.find("td:first").prepend(checkbox);
+}
+function removeCheckbox() {
+ var row = $(this);
+ row.find('input').remove();
+}
+$(window).load(
+ function () {
+ $("#filter-table").dataTable().fnSettings().fnRowCallback = function(tr, data, index) {
+ if (compareState) {
+ insertCheckbox.call(tr);
+ $(tr).click(rowClickHandler);
+ $("#filter-table tr").hover(rowHoverHandlerIn, rowHoverHandlerOut);
+ if (compareState >= 2 && tagFromRow(tr).machinetag == compare1.machinetag) {
+ $(tr).addClass("selected-1");
+ $(tr).find("input").attr("checked", true);
+ }
+ if (compareState >= 3) {
+ if (tagFromRow(tr).machinetag == compare2.machinetag) {
+ $(tr).addClass("selected-2");
+ $(tr).find("input").attr("checked", true);
+ } else if (tagFromRow(tr).machinetag != compare1.machinetag) {
+ $(tr).find("input").attr("disabled", true);
+ }
+ }
+ }
+ return tr;
+ };
+ $("#compare-button").button();
+ $("#compare-button").click(
+ function (e) {
+ if (compareState == 0) {
+ startCompare();
+ } else {
+ cancelCompare();
+ }
+ }
+ );
+ }
+);
=== added file 'dashboard_app/templates/dashboard_app/filter_compare_matches.html'
@@ -0,0 +1,28 @@
+{% extends "dashboard_app/_content.html" %}
+
+{% load django_tables2 %}
+
+{% block extrahead %}
+{{ block.super }}
+<style type="text/css">
+ th.orderable.sortable a {
+ color: rgb(0, 136, 204);
+ text-decoration: underline;
+ }
+</style>
+{% endblock %}
+
+{% block content %}
+{% for trinfo in test_run_info %}
+<h3>{{ trinfo.key }} results</h3>
+{% if trinfo.only %}
+<p style="text-align: {{ trinfo.only }}">
+ Results were only present in <a href="{{ trinfo.tr.get_absolute_url }}">build {{ trinfo.tag }}</a>.
+</p>
+{% elif trinfo.table %}
+{% render_table trinfo.table %}
+{% else %}
+<p>No difference in {{ trinfo.key }} results{% if trinfo.cases %} for {{ trinfo.cases }}{% endif %}.</p>
+{% endif %}
+{% endfor %}
+{% endblock %}
=== modified file 'dashboard_app/templates/dashboard_app/filter_detail.html'
@@ -2,6 +2,12 @@
{% load i18n %}
{% load django_tables2 %}
+{% block extrahead %}
+{{ block.super }}
+<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}dashboard_app/css/filter-detail.css"/>
+<script type="text/javascript" src="{{ STATIC_URL }}dashboard_app/js/filter-detail.js"></script>
+{% endblock %}
+
{% block content %}
<h1>Filter {{ filter.name }}</h1>
@@ -27,4 +33,17 @@
{% render_table filter_table %}
+<p>
+ <button id="compare-button">Compare builds</button>
+ <span id="first-prompt" style="display:none">
+ Click a build to compare.
+ </span>
+ <span id="second-prompt" style="display:none">
+ Click build to compare with build <span id="p2-build">XXX</span>.
+ </span>
+ <span id="third-prompt" style="display:none">
+ Click <a href="#">here</a> to compare with build <span id="p3-build-1">XXX</span> with build <span id="p3-build-2">XXX</span>.
+ </span>
+</p>
+
{% endblock %}
=== modified file 'dashboard_app/urls.py'
@@ -45,7 +45,8 @@
url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/\+edit$', 'filters.views.filter_edit'),
url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/\+subscribe$', 'filters.views.filter_subscribe'),
url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/\+delete$', 'filters.views.filter_delete'),
- url(r'^xml-rpc/$', linaro_django_xmlrpc.views.handler,
+ url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/\+compare/(?P<tag1>[a-zA-Z0-9-_: .]+)/(?P<tag2>[a-zA-Z0-9-_: .]+)$', 'filters.views.compare_matches'),
+ url(r'^xml-rpc/$', linaro_django_xmlrpc.views.handler,
name='dashboard_app.views.dashboard_xml_rpc_handler',
kwargs={
'mapper': legacy_mapper,
=== modified file 'dashboard_app/views/__init__.py'
@@ -38,7 +38,6 @@
)
from django.shortcuts import render_to_response, redirect, get_object_or_404
from django.template import RequestContext, loader
-from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.views.generic.list_detail import object_list, object_detail
@@ -825,3 +824,4 @@
pk=effort.pk)
})
return HttpResponse(t.render(c))
+
=== modified file 'dashboard_app/views/filters/tables.py'
@@ -16,8 +16,11 @@
# You should have received a copy of the GNU Affero General Public License
# along with Launch Control. If not, see <http://www.gnu.org/licenses/>.
+import datetime
import operator
+from django.conf import settings
+from django.template import defaultfilters
from django.utils.html import escape
from django.utils.safestring import mark_safe
@@ -184,6 +187,12 @@
self.base_columns.insert(0, 'bundle_stream', bundle_stream_col)
self.base_columns.insert(0, 'tag', tag_col)
+ def render_tag(self, value):
+ if isinstance(value, datetime.datetime):
+ strvalue = defaultfilters.date(value, settings.DATETIME_FORMAT)
+ else:
+ strvalue = value
+ return mark_safe('<span data-machinetag="%s">%s</span>' % (escape(str(value)), strvalue))
tag = Column()
def render_bundle_stream(self, record):
@@ -225,3 +234,29 @@
datatable_opts.update({
"iDisplayLength": 10,
})
+
+
+class TestResultDifferenceTable(DataTablesTable):
+ test_case_id = Column(verbose_name=mark_safe('test_case_id'))
+ first_result = TemplateColumn('''
+ {% if record.first_result %}
+ <img src="{{ STATIC_URL }}dashboard_app/images/icon-{{ record.first_result }}.png"
+ alt="{{ record.first_result }}" width="16" height="16" border="0"/>{{ record.first_result }}
+ {% else %}
+ <i>missing</i>
+ {% endif %}
+ ''')
+ second_result = TemplateColumn('''
+ {% if record.second_result %}
+ <img src="{{ STATIC_URL }}dashboard_app/images/icon-{{ record.second_result }}.png"
+ alt="{{ record.second_result }}" width="16" height="16" border="0"/>{{ record.second_result }}
+ {% else %}
+ <i>missing</i>
+ {% endif %}
+ ''')
+
+ datatable_opts = {
+ 'iDisplayLength': 25,
+ 'sPaginationType': "full_numbers",
+ }
+
=== modified file 'dashboard_app/views/filters/views.py'
@@ -25,12 +25,17 @@
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render_to_response
from django.template import RequestContext
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
from lava_server.bread_crumbs import (
BreadCrumb,
BreadCrumbTrail,
)
+from dashboard_app.filters import (
+ evaluate_filter,
+ )
from dashboard_app.models import (
NamedAttribute,
Test,
@@ -39,7 +44,9 @@
TestRunFilter,
TestRunFilterSubscription,
)
-from dashboard_app.views import index
+from dashboard_app.views import (
+ index,
+ )
from dashboard_app.views.filters.forms import (
TestRunFilterForm,
TestRunFilterSubscriptionForm,
@@ -48,6 +55,7 @@
FilterTable,
FilterPreviewTable,
PublicFiltersTable,
+ TestResultDifferenceTable,
UserFiltersTable,
)
@@ -248,3 +256,145 @@
json.dumps(list(result)),
mimetype='application/json')
+
+def _iter_matching(seq1, seq2, key):
+ """Iterate over sequences in the order given by the key function, matching
+ elements with matching key values.
+
+ For example:
+
+ >>> seq1 = [(1, 2), (2, 3)]
+ >>> seq2 = [(1, 3), (3, 4)]
+ >>> def key(pair): return pair[0]
+ >>> list(_iter_matching(seq1, seq2, key))
+ [(1, (1, 2), (1, 3)), (2, (2, 3), None), (3, None, (3, 4))]
+ """
+ seq1.sort(key=key)
+ seq2.sort(key=key)
+ sentinel = object()
+ def next(it):
+ try:
+ o = it.next()
+ return (key(o), o)
+ except StopIteration:
+ return (sentinel, None)
+ iter1 = iter(seq1)
+ iter2 = iter(seq2)
+ k1, o1 = next(iter1)
+ k2, o2 = next(iter2)
+ while k1 is not sentinel or k2 is not sentinel:
+ if k1 is sentinel:
+ yield (k2, None, o2)
+ k2, o2 = next(iter2)
+ elif k2 is sentinel:
+ yield (k1, o1, None)
+ k1, o1 = next(iter1)
+ elif k1 == k2:
+ yield (k1, o1, o2)
+ k1, o1 = next(iter1)
+ k2, o2 = next(iter2)
+ elif k1 < k2:
+ yield (k1, o1, None)
+ k1, o1 = next(iter1)
+ else: # so k1 > k2...
+ yield (k2, None, o2)
+ k2, o2 = next(iter2)
+
+
+def _test_run_difference(test_run1, test_run2, cases=None):
+ test_results1 = list(test_run1.test_results.all().select_related('test_case'))
+ test_results2 = list(test_run2.test_results.all().select_related('test_case'))
+ def key(tr):
+ return tr.test_case.test_case_id
+ differences = []
+ for tc_id, tc1, tc2 in _iter_matching(test_results1, test_results2, key):
+ if cases is not None and tc_id not in cases:
+ continue
+ if tc1:
+ tc1 = tc1.result_code
+ if tc2:
+ tc2 = tc2.result_code
+ if tc1 != tc2:
+ differences.append({
+ 'test_case_id': tc_id,
+ 'first_result': tc1,
+ 'second_result': tc2,
+ })
+ return differences
+
+
+@BreadCrumb(
+ "Comparing builds {tag1} and {tag2}",
+ parent=filter_detail,
+ needs=['username', 'name', 'tag1', 'tag2'])
+def compare_matches(request, username, name, tag1, tag2):
+ filter = TestRunFilter.objects.get(owner__username=username, name=name)
+ if not filter.public and filter.owner != request.user:
+ raise PermissionDenied()
+ filter_data = filter.as_data()
+ matches = evaluate_filter(request.user, filter_data)
+ match1, match2 = matches.with_tags(tag1, tag2)
+ test_cases_for_test_id = {}
+ for test in filter_data['tests']:
+ test_cases = test['test_cases']
+ if test_cases:
+ test_cases = set([tc.test_case_id for tc in test_cases])
+ else:
+ test_cases = None
+ test_cases_for_test_id[test['test'].test_id] = test_cases
+ test_run_info = []
+ def key(tr):
+ return tr.test.test_id
+ for key, tr1, tr2 in _iter_matching(match1.test_runs, match2.test_runs, key):
+ if tr1 is None:
+ table = None
+ only = 'right'
+ tr = tr2
+ tag = tag2
+ cases = None
+ elif tr2 is None:
+ table = None
+ only = 'left'
+ tr = tr1
+ tag = tag1
+ cases = None
+ else:
+ only = None
+ tr = None
+ tag = None
+ cases = test_cases_for_test_id.get(key)
+ test_result_differences = _test_run_difference(tr1, tr2, cases)
+ if test_result_differences:
+ table = TestResultDifferenceTable(
+ "test-result-difference-" + escape(key), data=test_result_differences)
+ table.base_columns['first_result'].verbose_name = mark_safe(
+ '<a href="%s">build %s: %s</a>' % (
+ tr1.get_absolute_url(), escape(tag1), escape(key)))
+ table.base_columns['second_result'].verbose_name = mark_safe(
+ '<a href="%s">build %s: %s</a>' % (
+ tr2.get_absolute_url(), escape(tag2), escape(key)))
+ else:
+ table = None
+ if cases:
+ cases = sorted(cases)
+ if len(cases) > 1:
+ cases = ', '.join(cases[:-1]) + ' or ' + cases[-1]
+ else:
+ cases = cases[0]
+ test_run_info.append(dict(
+ only=only,
+ key=key,
+ table=table,
+ tr=tr,
+ tag=tag,
+ cases=cases))
+ return render_to_response(
+ "dashboard_app/filter_compare_matches.html", {
+ 'test_run_info': test_run_info,
+ 'bread_crumb_trail': BreadCrumbTrail.leading_to(
+ compare_matches,
+ name=name,
+ username=username,
+ tag1=tag1,
+ tag2=tag2),
+ }, RequestContext(request))