blob: 083ba955564e8ca61d24e9d8d6347d8d94c88136 [file] [log] [blame]
# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import logging
import os
import re
from google.appengine.api import datastore_errors
from google.appengine.ext import db
from google.appengine.ext import webapp
from google.appengine.ext.webapp import template
from google.appengine.api import users
import model
# Could replace this with a function if there is ever any reason
# to spread entries over multiple datastores. Consistency is only
# gauranteed within a datastore, but access should be limited to
# about 1 per second. That should not be a problem for us.
DATASTORE_KEY = db.Key.from_path('Stats', 'default')
class MainPage(webapp.RequestHandler):
"""Provide interface for interacting with DB."""
# Regex to peel SQL-like SELECT off front, if present, grabbing SELECT args.
# Example: "SELECT foo,bar WHERE blah blah"
# ==> group(1)="foo,bar", group(2)="WHERE blah blah"
# Example: "SELECT foo , bar"
# ==> group(1)="foo , bar", group(2)=""
# Example: "WHERE blah blah"
# ==> No match
QUERY_SELECT_PREFIX_RE = re.compile(r'^\s*SELECT\s+'
r'([^\s,]+(?:\s*,\s*[^\s,]+)*)' # Group 1
r'(?:$|\s+)(.*)', # Group 2
re.IGNORECASE | re.VERBOSE)
# Regex to determine if WHERE is present, and capture everything after it.
# Example: "WHERE foo=bar ORDER BY whatever"
# ==> group(1)="foo=bar ORDER BY whatever"
# Example: "ORDER BY whatever"
# ==> No match
QUERY_WHERE_PREFIX_RE = re.compile(r'^WHERE\s+(.+)$',
re.IGNORECASE | re.VERBOSE)
# Regex to discover ORDER BY columns in order to highlight them in results.
QUERY_ORDER_RE = re.compile(r'ORDER\s+BY\s+(\S+)', re.IGNORECASE)
# Regex to discover LIMIT value in query.
QUERY_LIMIT_RE = re.compile(r'LIMIT\s+(\d+)', re.IGNORECASE)
# Regex for separating tokens by commas, allowing spaces on either side.
COMMA_RE = re.compile(r'\s*,\s*')
# Default columns to show in results table if no SELECT given.
DEFAULT_COLUMNS = ['end_date', 'cmd_line', 'run_time', 'board',
'package_count']
# All possible columns in Statistics model.
ALL_COLUMNS = sorted(model.Statistics.properties())
# Provide example queries in interface as a form of documentation.
EXAMPLE_QUERIES = [
("ORDER BY end_date,run_time"
" LIMIT 30"),
("WHERE username='mtennant'"
" ORDER BY end_date DESC"
" LIMIT 30"),
("SELECT end_datetime,cmd_base,cmd_args,run_time,package_count"
" WHERE board='amd64-generic'"
" ORDER BY end_datetime"
" LIMIT 30"),
("SELECT end_date,cmd_base,run_time,board,package_count"
" WHERE end_date=DATE('2012-03-28')"
" ORDER BY run_time"
" LIMIT 30"),
("SELECT end_date,cmd_base,cmd_args,run_time,username"
" WHERE run_time>20"
" LIMIT 30"),
]
def get(self):
"""Support GET to stats page."""
# Note that google.com authorization is required to access this page, which
# is controlled in app.yaml and on appspot admin page.
orig_query = self.request.get('query')
logging.debug('Received raw query %r', orig_query)
# If no LIMIT was provided, default to a LIMIT of 30 for sanity.
if not self.QUERY_LIMIT_RE.search(orig_query):
orig_query += ' LIMIT 30'
query = orig_query
# Peel off "SELECT" clause from front of query. GCL does not support SELECT
# filtering, but we will support it right here to select/filter columns.
query, columns = self._RemoveSelectFromQuery(query)
if query == orig_query and columns == self.DEFAULT_COLUMNS:
# This means there was no SELECT in query. That is equivalent to
# SELECT of default columns, so show that to user.
orig_query = 'SELECT %s %s' % (','.join(columns), orig_query)
# All queries should have the "ancestor" WHERE clause in them, but that
# need not be exposed to interface. Insert the clause intelligently.
query = self._AdjustWhereInQuery(query)
# If results are sorted by any column that column should be in bold.
bold_columns = None
match = self.QUERY_ORDER_RE.search(query)
if match:
bold_columns = set(self.COMMA_RE.split(match.group(1)))
stat_entries = []
error_msg = None
try:
stat_entries = model.Statistics.gql(query, DATASTORE_KEY)
except datastore_errors.BadQueryError as ex:
error_msg = '<p>%s.</p><p>Actual GCL query used: "%s"</p>' % (ex, query)
results_html_table = self._PrepareResultsTable(stat_entries, columns,
bold_columns=bold_columns)
column_html_list = self._PrepareColumnList(self.ALL_COLUMNS,
self.DEFAULT_COLUMNS)
template_values = {
'column_list': column_html_list,
'error_msg': error_msg,
'example_queries': self.EXAMPLE_QUERIES,
'gcl_query': query,
'results_table': results_html_table,
'user_email': users.get_current_user(),
'user_query': orig_query,
}
path = os.path.join(os.path.dirname(__file__), 'index.html')
self.response.out.write(template.render(path, template_values))
def _RemoveSelectFromQuery(self, query):
"""Remove SELECT clause from |query|, return tuple (new_query, columns)."""
match = self.QUERY_SELECT_PREFIX_RE.search(query)
if match:
# A SELECT clause is present. Remove it but save requested columns.
columns = self.COMMA_RE.split(match.group(1))
query = match.group(2)
if columns == ['*']:
columns = self.ALL_COLUMNS
logging.debug('Columns selected for viewing: %s', ', '.join(columns))
return query, columns
else:
logging.debug('Using default columns for viewing: %s',
', '.join(self.DEFAULT_COLUMNS))
return query, self.DEFAULT_COLUMNS
def _AdjustWhereInQuery(self, query):
"""Insert WHERE ANCESTOR into |query| and return."""
match = self.QUERY_WHERE_PREFIX_RE.search(query)
if match:
return 'WHERE ANCESTOR IS :1 AND %s' % match.group(1)
else:
return 'WHERE ANCESTOR IS :1 %s' % query
def _PrepareResultsTable(self, stat_entries, columns, bold_columns):
"""Prepare html table for |stat_entries| using only |columns|."""
# One header blank for row numbers, then each column name.
headers = ('<tr>\n<td></td>\n%s\n</tr>' %
'\n'.join(['<td>%s</td>' % c for c in columns]))
# Prepare list of table rows, one for each stat entry.
rows = []
for stat_ix, stat_entry in enumerate(stat_entries):
row_data = ['<td>%d</td>' % (stat_ix + 1)]
for col in columns:
datum = getattr(stat_entry, col)
if bold_columns and col in bold_columns:
datum = '<b>%s</b>' % datum
row_data.append('<td>%s</td>' % datum)
rows.append('<tr>%s</tr>' % '\n'.join(row_data))
return headers + '\n' + '\n'.join(rows)
def _PrepareColumnList(self, columns, default_columns):
"""Prepare html list for |columns| with each |default_columns| bolded."""
default_columns = set(default_columns)
items = []
for col in columns:
if col in default_columns:
col = '<b>%s</b>' % col
items.append('<li>%s</li>' % col)
return '\n'.join(items)
class PostPage(webapp.RequestHandler):
"""Provides interface for uploading command stats to database."""
NO_VALUE = '__NO_VALUE_AT_ALL__'
def post(self):
"""Support POST of command stats."""
logging.info('Stats POST received at %r', self.request.uri)
new_stat = model.Statistics(parent=DATASTORE_KEY)
# Check each supported DB property to see if it has a value set
# in the POST request.
for prop in model.Statistics.properties():
# Skip properties with auto_now or auto_now_add enabled.
model_prop = getattr(model.Statistics, prop)
if ((hasattr(model_prop, 'auto_now_add') and model_prop.auto_now_add) or
(hasattr(model_prop, 'auto_now') and model_prop.auto_now)):
continue
# Note that using hasattr with self.request does not work at all.
# It (almost) always says the attribute is not present, when getattr
# does actually return a value. Also note that self.request.get is
# not returning None as the default value if no explicit default value
# is provided, contrary to the spec for dict.get.
value = self.request.get(prop, self.NO_VALUE)
if value is not self.NO_VALUE:
# String properties must be 500 characters or less (GQL requirement).
if isinstance(model_prop, db.StringProperty) and len(value) > 500:
logging.debug(' String property %r too long. Cutting off at 500'
' characters.', prop)
value = value[:500]
# Integer properties require casting
if isinstance(model_prop, db.IntegerProperty):
value = int(value)
logging.debug(' Stats POST property %r ==> %r', prop, value)
setattr(new_stat, prop, value)
# Use automatically set end_datetime prop to set end_date and end_time.
new_stat.end_time = new_stat.end_datetime.time()
new_stat.end_date = new_stat.end_datetime.date()
# Save to model.
new_stat.put()