Add release report with image status indicators.

BUG=crosbug:14747
TEST=https://spreadsheets.google.com/a/google.com/ccc?key=0Ahn3gnzB-A8mdFVMMnNjNW04LWp0RmFWSzdmZU1xZUE&hl=en&pli=1#gid=0

Change-Id: I40e845cfad3d714c6a57a671eb1cfb88a646d5c1
Reviewed-on: http://gerrit.chromium.org/gerrit/842
Reviewed-by: Eric Li <ericli@chromium.org>
Tested-by: Mike Truty <truty@chromium.org>
diff --git a/apache/conf/tko-directives b/apache/conf/tko-directives
index 6964475..70da03c 100644
--- a/apache/conf/tko-directives
+++ b/apache/conf/tko-directives
@@ -1,3 +1,14 @@
+Alias /images "/usr/local/autotest/images/"
+<Directory /usr/local/autotest/images/>
+    Options Indexes FollowSymLinks MultiViews
+    AllowOverride None
+    Order allow,deny
+    Allow from all
+    <FilesMatch "\.log$">
+        ForceType "text/plain; authoritative=true"
+    </FilesMatch>
+</Directory>
+
 Alias /results "/usr/local/autotest/results/"
 <Directory /usr/local/autotest/results/>
     Options Indexes FollowSymLinks MultiViews
diff --git a/frontend/croschart/DEPLOYMENT_README b/frontend/croschart/DEPLOYMENT_README
index 36f87e6..2b0024e 100644
--- a/frontend/croschart/DEPLOYMENT_README
+++ b/frontend/croschart/DEPLOYMENT_README
@@ -5,9 +5,11 @@
 Dynamic Charts deployment notes:
 -When running under apache, the common apache user must have access to the
  classes and cache files of dynamic chart.
--Create a croschart directory:
-   mkdir ./croschart && chmod 755 ./croschart
--Create a writable croschart/.cache directory:
-   mkdir ./croschart/.cache && chmod 777 ./croschart/.cache
+-Create an autotest/frontend/croschart directory:
+   mkdir autotest/frontend/croschart && chmod 755 autotest/frontend/croschart
+-Create a writable autotest/frontend/croschart/.cache directory:
+   mkdir croschart/.cache && chmod 777 croschart/.cache
 -The files under croschart (including template files) must be world-readable.
    find ./croschart -type f -exec chmod 644 {} \;
+-Create an autotest/images directory:
+  mkdir autotest/images && chmod 755 autotest/images
diff --git a/frontend/croschart/chartmodels.py b/frontend/croschart/chartmodels.py
index 8dc1e50..39fc32f 100644
--- a/frontend/croschart/chartmodels.py
+++ b/frontend/croschart/chartmodels.py
@@ -4,26 +4,23 @@
 
 """Django chart model implementation.
 
-   This class produces the data behind a google visualisation data table
-   that can be rendered into a chart.
+   Produce the data behind a google visualisation data table that can
+   be rendered into a chart.
 
-   CLASSES:
+   This file is broken in 3 sections:
+   1. Data queries wrapped in stateless function wrappers.
+   2. Common helper functions to massage query results in to data tables.
+   3. Data retrieval entry points that are called from views.
 
-   BaseChartQuery: building block class.
-   |->BuildRangedChartQuery: adds from_build/to_build constraint.
-   |->DateRangedChartQuery: adds from_date/to_date constraint.
-   |->IntervalRangedChartQuery: adds mysql interval constraint.
-
-   OneKeyByBuildLinechart: base chart building model class.
-   |->OneKeyRangedChartModel: adds from_build/to_build constraint.
-
-   FUNCTIONS:
-
-   GetChartData(): The principal entry-point.
+   Data entry points at this time include:
+   -GetRangedOneKeyByBuildLinechartData(): produce a value by builds data table.
+   -GetMultiTestKeyReleaseTableData(): produce a values by 2builds data table.
 """
 
 import logging
+import os
 import re
+import simplejson
 
 from autotest_lib.frontend.afe import readonly_connection
 
@@ -35,9 +32,9 @@
 
 FIELD_SEPARATOR = ','
 BUILD_PATTERN = re.compile(
-    '[\w]+\-[\w]+\-r[\w]+\-'
-    '([\d]+\.[\d]+\.[\d]+\.[\d]+)-(r[\w]{8})-(b[\d]+)')
+    '([\w\-]+-r[c0-9]+)-([\d]+\.[\d]+\.[\d]+\.[\d]+)-(r[\w]{8})-(b[\d]+)')
 COMMON_REGEXP = "'(%s).*'"
+NO_DIFF = 'n/a'
 
 ###############################################################################
 # Queries: These are designed as stateless functions with static relationships.
@@ -55,19 +52,30 @@
   AND NOT ISNULL(test_finished_time)
   AND NOT ISNULL(job_finished_time)"""
 
+CHART_SELECT_KEYS = 'job_name, job_tag, iteration_value'
+RELEASE_SELECT_KEYS = """
+job_name, job_tag, test_name, iteration_key, iteration_value"""
+
 CHART_QUERY_KEYS = """
   AND test_name = '%(test_name)s'
   AND iteration_key = '%(test_key)s'"""
 
+RELEASEREPORT_QUERY_KEYS = """
+  AND test_name in ('%(test_names)s')
+  AND iteration_key in ('%(test_keys)s')"""
+
 # Use subqueries to find bracketing dates mapping version to job_names.
 RANGE_QUERY_TEMPLATE = """
 AND test_started_time >= (%(min_query)s)
 AND test_started_time <= (%(max_query)s)"""
 
+# Can only get date order from the db.
 DEFAULT_ORDER = 'ORDER BY test_started_time'
+# Release data sorted here.
+RELEASE_ORDER = ''
 
 
-def GetBaseQuery(request, raw=False):
+def GetBaseQueryParts(request):
   """Fully populates and returns a base query string."""
   query = COMMON_QUERY_TEMPLATE + CHART_QUERY_KEYS
 
@@ -76,16 +84,19 @@
   test_name, test_key = request.GET.get('testkey').split(FIELD_SEPARATOR)
 
   query_parameters = {}
-  query_parameters['select_keys'] = 'job_name, job_tag, iteration_value'
+  query_parameters['select_keys'] = CHART_SELECT_KEYS
   query_parameters['job_name'] = (COMMON_REGEXP % boards)
   query_parameters['platform'] = platform
   query_parameters['test_name'] = test_name
   query_parameters['test_key'] = test_key
 
-  if raw:
-    return query, query_parameters
-  else:
-    return query % query_parameters
+  return query, query_parameters
+
+
+def GetBaseQuery(request):
+  """Produce the assembled query."""
+  query, parameters = GetBaseQueryParts(request)
+  return query % parameters
 
 
 def GetBuildRangedChartQuery(request):
@@ -96,7 +107,7 @@
   from_build = request.GET.get('from_build')
   to_build = request.GET.get('to_build')
 
-  base_query, base_query_parameters = GetBaseQuery(request, raw=True)
+  base_query, base_query_parameters = GetBaseQueryParts(request)
   min_parameters = base_query_parameters.copy()
   min_parameters['select_keys'] = (
       'IFNULL(MIN(test_started_time), DATE_SUB(NOW(), INTERVAL 1 DAY))')
@@ -148,18 +159,108 @@
   return query % query_parameters
 
 
+def GetReleaseQueryParts(request):
+  """Fully populates and returns a base query string."""
+  query = COMMON_QUERY_TEMPLATE + RELEASEREPORT_QUERY_KEYS
+
+  boards = request.GET.getlist('board')
+  platform = 'netbook_%s' % request.GET.get('system').upper()
+  test_names = set()
+  test_keys = set()
+  test_key_tuples = {}
+  for t in request.GET.getlist('testkey'):
+    test_key_tuples[t] = ''
+  if not test_key_tuples:
+    test_key_tuples = simplejson.load(open(os.path.join(
+        os.path.abspath(os.path.dirname(__file__)),
+        'crosrelease_defaults.json')))
+  for t in test_key_tuples:
+    test_name, test_key = t.split(FIELD_SEPARATOR)
+    if not test_key:
+      raise ChartInputError('testkey must be a test,key pair.')
+    test_names.add(test_name)
+    test_keys.add(test_key)
+
+  from_build = request.GET.get('from_build')
+  to_build = request.GET.get('to_build')
+
+  query_parameters = {}
+  query_parameters['select_keys'] = RELEASE_SELECT_KEYS
+  query_parameters['job_name'] = "'(%s)-(%s|%s).*'" % (
+      '|'.join(boards), from_build, to_build)
+  query_parameters['platform'] = platform
+  query_parameters['test_names'] = "','".join(test_names)
+  query_parameters['test_keys'] = "','".join(test_keys)
+
+  # Use the query_parameters to communicate parsed data.
+  query_parameters['lowhigh'] = test_key_tuples
+
+  return query, query_parameters
+
+
+def GetReleaseQuery(request):
+  """Produce the assembled query."""
+  query, parameters = GetReleaseQueryParts(request)
+  return query % parameters
+
+
 ###############################################################################
 # Helpers
-def AbbreviateBuild(build):
+def AbbreviateBuild(build, with_board=False):
   """Condense full build string for x-axis representation."""
   m = re.match(BUILD_PATTERN, build)
-  if not m:
+  if not m or m.lastindex < 4:
     logging.warning('Skipping poorly formatted build: %s.', build)
     return build
-  new_build = '%s-%s' % (m.group(1), m.group(3))
+  if with_board:
+    new_build = '%s-%s-%s' % (m.group(1), m.group(2), m.group(4))
+  else:
+    new_build = '%s-%s' % (m.group(2), m.group(4))
   return new_build
 
 
+def BuildNumberCmp(build_number1, build_number2):
+  """Compare build numbers and return in ascending order."""
+  # 3 different build formats:
+  #1. xxx-yyy-r13-0.12.133.0-b1
+  #2. ttt_sss-rc-0.12.133.0-b1
+  #3. 0.12.133.0-b1
+  build1_split = build_number1.split('-')
+  build2_split = build_number2.split('-')
+  if len(build1_split) > 5:
+    return cmp(build_number1, build_number2)
+  if len(build1_split) > 3:
+    if len(build1_split) == 4:
+      board1, release1, build1, b1 = build1_split
+      board2, release2, build2, b2 = build2_split
+      platform1 = platform2 = ''
+    else:
+      platform1, board1, release1, build1, b1 = build1_split
+      platform2, board2, release2, build2, b2 = build2_split
+
+    if (platform1, board1, release1) != (platform2, board2, release2):
+      if platform1 != platform2:
+        return cmp(platform1, platform2)
+      if board1 != board2:
+        return cmp(board1, board2)
+      if release1 != release2:
+        return cmp(int(release1[1:]), int(release2[1:]))
+  else:
+    build1, b1 = build1_split
+    build2, b2 = build2_split
+
+  if build1 != build2:
+    major1 = build1.split('.')
+    major2 = build2.split('.')
+    major_len = min([len(major1), len(major2)])
+    for i in xrange(major_len):
+      if major1[i] != major2[i]:
+        return cmp(int(major1[i]), int(major2[i]))
+    return cmp(build1, build2)
+  else:
+    return cmp(int(b1[1:]), int(b2[1:]))
+
+
 ###############################################################################
 # Models
 def GetOneKeyByBuildLinechartData(test_key, query, query_order=DEFAULT_ORDER):
@@ -223,3 +324,129 @@
   # Added for chart labeling.
   data_dict.update({'test_name': test_name, 'test_key': test_key})
   return data_dict
+
+
+def GetMultiTestKeyReleaseTableData(query, query_order=RELEASE_ORDER,
+                                    extra=None):
+  """Prepare and run the db query and massage the results."""
+
+  def GetHighlights(test_name, test_key, lowhigh, diff):
+    """Select the background color based on a setting and the diff value."""
+    black_fg = '#000000'
+    green_fg = '#009900'
+    red_fg = '#cc0000'
+    highlights = {'test': test_name, 'metric': test_key, 'diff': diff}
+
+    if not lowhigh:
+      # Cannot decide which indicators to show.
+      return highlights
+
+    # Lookup if this key is driven up or down.
+    image_template = '<img src="/images/%s" />'
+    lowhigh_indicator = {'lowisgood': image_template % 'downisgoodmetric.png',
+                         'highisgood': image_template % 'upisgoodmetric.png'}
+    lookup = lowhigh.get('%s,%s' % (test_name, test_key), None)
+    if not lookup or not lookup in lowhigh_indicator:
+      # Cannot get a key indicator or diff indicator.
+      return highlights
+
+    highlights['metric'] = '%s%s' % (test_key, lowhigh_indicator[lookup])
+    if diff == NO_DIFF:
+      # Cannot pick a diff indicator.
+      return highlights
+
+    image_vector = [(red_fg, image_template % 'unhappymetric.png'),
+                    (black_fg, ''),
+                    (green_fg, image_template % 'happymetric.png')]
+    media_lookup = {'lowisgood': image_vector,
+                    'highisgood': image_vector[::-1]}
+    cmp_diff = float(diff.split(' ')[0])
+    fg_color, diff_indicator = media_lookup[lookup][cmp(cmp_diff, 0.0)+1]
+    diff_template = '<span style="color:%s">%s%s</span>'
+    highlights['diff'] = diff_template % (fg_color, diff, diff_indicator)
+    return highlights
+
+
+  def CalculateDiff(diff_list):
+    """Produce a diff string."""
+    if len(diff_list) < 2:
+      return NO_DIFF
+    return '%s (%s%%)' % (
+        diff_list[0] - diff_list[1],
+        round((diff_list[0] - diff_list[1]) / diff_list[0] * 100))
+
+  def AggregateBuilds(lowhigh, data_list):
+    """Groups and averages data by build and extracts job_tags."""
+    raw_dict = {}  # unsummarized data
+    builds = set()
+    # Aggregate all the data values by test_name, test_key, build.
+    for build, tag, test_name, test_key, test_value in data_list:
+      key_dict = raw_dict.setdefault(test_name, {})
+      build_dict = key_dict.setdefault(test_key, {})
+      build = AbbreviateBuild(build=build, with_board=True)
+      job_dict = build_dict.setdefault(build, {})
+      job_dict.setdefault('tag', tag)
+      value_list = job_dict.setdefault('values', [])
+      value_list.append(test_value)
+      builds.add(build)
+    if not raw_dict:
+      raise ChartDBError('No data returned')
+    if len(builds) < 2:
+      raise ChartDBError(
+          'Release report expected 2 builds and found %s builds.' % len(builds))
+    # Now append summary dict entries of the data for gviz.
+    builds = sorted(builds, cmp=BuildNumberCmp)
+    build_data = []
+    for test_name, key_dict in raw_dict.iteritems():
+      for test_key, build_dict in key_dict.iteritems():
+        data_dict = {}
+        diff_stats = []
+        for build in builds:
+          job_dict = build_dict.get(build, None)
+          # Need to make sure there is a value for every build.
+          if job_dict:
+            value_list = job_dict['values']
+            avg = round(sum(value_list, 0.0) / len(value_list), 2)
+            diff_stats.append(avg)
+            data_dict[build] = (
+                '<a href="http://cautotest/results/%s/%s/results/keyval" '
+                'target="_blank">%s</a>' % (job_dict['tag'], test_name, avg))
+          else:
+            data_dict[build] = 0.0
+        diff = CalculateDiff(diff_stats)
+        data_dict.update(GetHighlights(test_name, test_key, lowhigh, diff))
+        build_data.append(data_dict)
+    return builds, build_data
+
+  def ToGVizJsonTable(builds, table_data):
+    """Massage data into gviz data table in proper order."""
+    # Now format for gviz table.
+    description = {'test': ('string', 'Test'),
+                   'metric': ('string', 'Metric'),
+                   'diff': ('string', 'Diff')}
+    keys_in_order = ['test', 'metric']
+    for build in builds:
+      description[build] = ('string', build)
+      keys_in_order.append(build)
+    keys_in_order.append('diff')
+    gviz_data_table = gviz_api.DataTable(description)
+    gviz_data_table.LoadData(table_data)
+    gviz_data_table = gviz_data_table.ToJSon(keys_in_order)
+    return gviz_data_table
+
+  # Now massage the returned data into a gviz data table.
+  cursor = readonly_connection.connection().cursor()
+  cursor.execute('%s %s' % (query, query_order))
+  builds, build_data = AggregateBuilds(lowhigh=extra.get('lowhigh', None),
+                                       data_list=cursor.fetchall())
+  gviz_data_table = ToGVizJsonTable(builds, build_data)
+  return {'gviz_data_table': gviz_data_table}
+
+
+def GetReleaseReportData(request):
+  """Prepare and run the db query and massage the results."""
+
+  query, parameters = GetReleaseQueryParts(request)
+  data_dict = GetMultiTestKeyReleaseTableData(
+      query=query % parameters, extra=parameters)
+  return data_dict
diff --git a/frontend/croschart/crosrelease_defaults.json b/frontend/croschart/crosrelease_defaults.json
new file mode 100644
index 0000000..ba3f4f9
--- /dev/null
+++ b/frontend/croschart/crosrelease_defaults.json
@@ -0,0 +1,15 @@
+{
+  "platform_BootPerfServer,seconds_kernel_to_startup": "lowisgood",
+  "platform_BootPerfServer,seconds_power_on_to_login": "lowisgood",
+  "platform_BootPerfServer,seconds_reboot_time": "lowisgood",
+  "platform_BootPerfServer,seconds_shutdown_time": "lowisgood",
+  "platform_BootPerfServer,rdbytes_kernel_to_login": "lowisgood",
+  "platform_BootPerfServer,seconds_firmware_boot": "lowisgood",
+  "desktopui_ChromeFirstRender,seconds_chrome_first_tab": "lowisgood",
+  "build_RootFilesystemSize,bytes_rootfs_prod": "lowisgood",
+  "power_LoadTest.WIFI,minutes_battery_life": "highisgood",
+  "power_Resume,seconds_system_resume": "lowisgood",
+  "power_Resume,seconds_system_suspend": "lowisgood",
+  "desktopui_PageCyclerTests,PageCyclerTest.Alexa_usFile": "lowisgood",
+  "desktopui_V8Bench,score_total": "highisgood"
+}
diff --git a/frontend/croschart/templates/plot_releasereport.html b/frontend/croschart/templates/plot_releasereport.html
new file mode 100644
index 0000000..6863253
--- /dev/null
+++ b/frontend/croschart/templates/plot_releasereport.html
@@ -0,0 +1,48 @@
+{% extends "base.html" %}
+{% comment %}
+
+Copyright 2010 Google Inc. All Rights Reserved.
+
+This template builds a report comparing a new build to an older build.
+{% endcomment %}
+{% block title %}ChromeOS Release Report{% endblock %}
+{% block html_block %}
+<!--Load the AJAX API-->
+<script type="text/javascript" src="https://www.google.com/jsapi"></script>
+<script type="text/javascript">
+  // Load the Visualization API and the piechart package.
+  google.load('visualization', '1', {'packages':['table']});
+
+  // Set a callback to run when the Google Visualization API is loaded.
+  google.setOnLoadCallback(drawCharts);
+
+  // Callback that creates and populates a data table,
+  // instantiates the charts, passes in the data and
+  // draws them.
+  function drawCharts() {
+    var table = new google.visualization.Table(
+        document.getElementById('releasetable'));
+    var json_data = new google.visualization.DataTable(
+        {% autoescape off %}{{ tpl_chart.gviz_data_table }}{% endautoescape %}, 0.5);
+    diff_url = 'http://chromeos-images/diff/report' +
+        '?from=' + json_data.getColumnLabel(2).split('-')[3] +
+        '&to=' + json_data.getColumnLabel(3).split('-')[3];
+    document.getElementById('diff_iframe').src=diff_url;
+    table.draw(json_data,
+               {allowHtml: true, showRowNumber: true, sortColumn: 0});
+  }
+</script>
+<center>
+<h2>Chrome OS Release Report</h2>
+<span id="releasetable"></span>
+</center>
+<hr>
+Links:
+[<a href="http://goto/chromeos-dash" target="_blank">BVT Stats</a>]
+&nbsp;
+[<a href="http://chromeos-images/diff" target="_blank">Diff Stats</a>]
+&nbsp;
+[<a href="http://chromeos-images" target="_blank">Image Stats</a>]
+<hr>
+<iframe id="diff_iframe" width="100%" height="50%" frameborder="0"></iframe>
+{% endblock %}
diff --git a/frontend/croschart/urls.py b/frontend/croschart/urls.py
index 9e8c5f2..1bcb19c 100644
--- a/frontend/croschart/urls.py
+++ b/frontend/croschart/urls.py
@@ -13,4 +13,5 @@
     'frontend.croschart.views',
     (r'^chartdiff?%s$' % COMMON_URL, 'PlotChartDiff'),
     (r'^chartreport?%s$' % COMMON_URL, 'PlotChartReport'),
-    (r'^chart?%s$' % COMMON_URL, 'PlotChart'))
+    (r'^chart?%s$' % COMMON_URL, 'PlotChart'),
+    (r'^releasereport?%s$' % COMMON_URL, 'PlotReleaseReport'))
diff --git a/frontend/croschart/views.py b/frontend/croschart/views.py
index 26b44b6..ba4c64a 100644
--- a/frontend/croschart/views.py
+++ b/frontend/croschart/views.py
@@ -31,7 +31,10 @@
         'from_date': [validators.CrosReportValidator,
                       validators.DateRangeValidator],
         'interval': [validators.CrosReportValidator,
-                     validators.IntervalRangeValidator]}}
+                     validators.IntervalRangeValidator]},
+    'releasereport': {
+        'from_build': [validators.CrosReportValidator,
+                       validators.BuildRangeValidator]}}
 
 
 def ValidateParameters(request, vlist):
@@ -82,3 +85,15 @@
   except ChartInputError as e:
     tpl_hostname = request.get_host()
     return render_to_response('plot_syntax.html', locals())
+
+
+def PlotReleaseReport(request):
+  """Plot the requested report from /releasereport?..."""
+  try:
+    salt = ValidateParameters(request, VLISTS['releasereport'])
+    return chartviews.PlotChart(
+        request, 'plot_releasereport.html',
+        chartmodels.GetReleaseReportData, salt)
+  except ChartInputError as e:
+    tpl_hostname = request.get_host()
+    return render_to_response('plot_syntax.html', locals())
diff --git a/images/downisgoodmetric.png b/images/downisgoodmetric.png
new file mode 100644
index 0000000..d331226
--- /dev/null
+++ b/images/downisgoodmetric.png
Binary files differ
diff --git a/images/happymetric.png b/images/happymetric.png
new file mode 100644
index 0000000..4de7925
--- /dev/null
+++ b/images/happymetric.png
Binary files differ
diff --git a/images/unhappymetric.png b/images/unhappymetric.png
new file mode 100644
index 0000000..10bc938
--- /dev/null
+++ b/images/unhappymetric.png
Binary files differ
diff --git a/images/upisgoodmetric.png b/images/upisgoodmetric.png
new file mode 100644
index 0000000..00a6aae
--- /dev/null
+++ b/images/upisgoodmetric.png
Binary files differ