blob: 3297a9d65d59677d64ef78922a21d2fdd7179dfd [file] [log] [blame]
# Copyright (c) 2012 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Status management pages."""
import HTMLParser
import datetime
import json
import logging
import re
import urllib2
from google.appengine.api import memcache
from google.appengine.ext import db
from base_page import BasePage
from sheriff import SheriffSchedules
import codesite_utils
import utils
OPEN_STATE = 'open'
CAUTION_STATE = 'caution'
CLOSED_STATE = 'closed'
# The maximum chunk of statuses that are displayed.
MAX_STATUS_CHUNK = 1000
# The default chunk of statuses that are displayed.
DEFAULT_STATUS_CHUNK = 25
CHROMIUM_DEPS_FILE = 'http://src.chromium.org/viewvc/chrome/trunk/src/DEPS'
class Status(db.Model):
"""Description for the status table."""
# The username who added this status.
username = db.StringProperty(required=True)
# The date when the status got added.
date = db.DateTimeProperty(auto_now_add=True)
# The message. It can contain html code.
message = db.StringProperty(required=True)
# The first revision submitted after the status first changed.
first_rev = db.IntegerProperty(required=False)
# The last revision submitted before the status changed.
last_rev = db.IntegerProperty(required=False)
@property
def general_state(self):
"""Returns a string representing the state that the status message
describes.
"""
if re.search(CLOSED_STATE, self.message, re.IGNORECASE):
return CLOSED_STATE
elif re.search(CAUTION_STATE, self.message, re.IGNORECASE):
return CAUTION_STATE
else:
return OPEN_STATE
@property
def codesite_changes_url(self):
return codesite_utils.GetCodesiteUrlWithChangesRange(self.first_rev,
self.last_rev)
@staticmethod
def validate_state_message(message):
"""Throws an Error iff exactly one of closed, open or caution is missing."""
closed_state = re.search(CLOSED_STATE, message, re.IGNORECASE)
caution_state = re.search(CAUTION_STATE, message, re.IGNORECASE)
open_state = re.search(OPEN_STATE, message, re.IGNORECASE)
if (closed_state and open_state) or (
closed_state and caution_state) or (
caution_state and open_state):
raise ValueError(
'Cannot specify two keywords from (\'%s\', \'%s\', \'%s\') in a '
'status message!' % (CLOSED_STATE, CAUTION_STATE, OPEN_STATE))
elif not (closed_state or caution_state or open_state):
raise ValueError(
'Must specify either \'%s\' or \'%s\' or \'%s\' somewhere in the '
'status message!' % (CLOSED_STATE, CAUTION_STATE, OPEN_STATE))
@property
def can_commit_freely(self):
return (self.general_state == OPEN_STATE or
self.general_state == CAUTION_STATE)
def AsDict(self):
data = super(Status, self).AsDict()
data['general_state'] = self.general_state
data['can_commit_freely'] = self.can_commit_freely
return data
def get_status():
"""Returns the current Status, e.g. the most recent one."""
status = memcache.get('last_status')
if status is None:
status = Status.all().order('-date').get()
# Use add instead of set(); must not change it if it was already set.
memcache.add('last_status', status)
return status
def put_status(status):
"""Sets the current Status, e.g. append a new one."""
# Add the last_rev to the previous status.
prev_status = memcache.get('last_status')
if prev_status is None:
prev_status = Status.all().order('-date').get()
prev_status.last_rev = codesite_utils.GetCurrLatestRevNum()
prev_status.put()
# Now add the new status.
status.put()
# Flush the cache.
memcache.flush_all()
memcache.set('last_status', status)
memcache.delete('last_statuses')
def get_last_statuses(limit):
"""Returns the last |limit| statuses."""
statuses = memcache.get('last_statuses')
if not statuses or len(statuses) < limit:
statuses = Status.all().order('-date').fetch(limit)
memcache.add('last_statuses', statuses)
return statuses[:limit]
def parse_date(date):
"""Parses a date."""
match = re.match(r'^(\d\d\d\d)-(\d\d)-(\d\d)$', date)
if match:
return datetime.datetime(
int(match.group(1)), int(match.group(2)), int(match.group(3)))
if date.isdigit():
return datetime.datetime.utcfromtimestamp(int(date))
return None
class AllStatusPage(BasePage):
"""Displays a big chunk, equal to MAX_CHUNK of status values."""
def get(self):
query = db.Query(Status).order('-date')
start_date = self.request.get('startTime')
if start_date:
query.filter('date <', parse_date(start_date))
try:
limit = int(self.request.get('limit'))
except ValueError:
limit = MAX_STATUS_CHUNK
end_date = self.request.get('endTime')
beyond_end_of_range_status = None
if end_date:
query.filter('date >=', parse_date(end_date))
# We also need to get the very next status in the range, otherwise
# the caller can't tell what the effective tree status was at time
# |end_date|.
beyond_end_of_range_status = Status.all(
).filter('date <', end_date).order('-date').get()
out_format = self.request.get('format', 'csv')
if out_format == 'csv':
# It's not really an html page.
self.response.headers['Content-Type'] = 'text/plain'
template_values = self.InitializeTemplate(self.APP_NAME + ' Tree Status')
template_values['status'] = query.fetch(limit)
template_values['beyond_end_of_range_status'] = beyond_end_of_range_status
self.DisplayTemplate('allstatus.html', template_values)
elif out_format == 'json':
self.response.headers['Content-Type'] = 'application/json'
self.response.headers['Access-Control-Allow-Origin'] = '*'
statuses = [s.AsDict() for s in query.fetch(limit)]
if beyond_end_of_range_status:
statuses.append(beyond_end_of_range_status.AsDict())
data = json.dumps(statuses)
callback = self.request.get('callback')
if callback:
if re.match(r'^[a-zA-Z$_][a-zA-Z$0-9._]*$', callback):
data = '%s(%s);' % (callback, data)
self.response.out.write(data)
else:
self.response.headers['Content-Type'] = 'text/plain'
self.response.out.write('Invalid format')
class LkgrPage(BasePage):
"""Displays Skia's LKGR.
Parses Chromium's DEPS file to get this information. The justification for
this is that a Skia rev makes it into Chromium after passing all required
tests.
"""
def get(self):
"""Displays Skia's LKGR."""
try:
chromium_deps = urllib2.urlopen(CHROMIUM_DEPS_FILE)
html_parser = HTMLParser.HTMLParser()
for line in chromium_deps.readlines():
# Decode HTML entities.
line = html_parser.unescape(line)
if 'skia_revision' in line:
skia_lkgr = re.match(
r'.*"skia_revision": "(?P<revision>[0-9a-fA-F]{2,40})".*',
line).group('revision')
break
else:
raise Exception('Could not find skia_revision!')
except Exception, e:
skia_lkgr = -1
logging.error(e)
self.response.out.write(skia_lkgr)
class BannerStatusPage(BasePage):
"""Displays the /current page."""
def get(self):
"""Displays the current message and nothing else."""
out_format = self.request.get('format', 'html')
status = get_status()
if out_format == 'raw':
self.response.headers['Content-Type'] = 'text/plain'
self.response.out.write(status.message)
elif out_format == 'json':
self.response.headers['Content-Type'] = 'application/json'
if self.request.get('with_credentials'):
self.response.headers['Access-Control-Allow-Origin'] = (
'gerrit-int.chromium.org, gerrit.chromium.org')
self.response.headers['Access-Control-Allow-Credentials'] = 'true'
else:
self.response.headers['Access-Control-Allow-Origin'] = '*'
data = json.dumps(status.AsDict())
callback = self.request.get('callback')
if callback:
if re.match(r'^[a-zA-Z$_][a-zA-Z$0-9._]*$', callback):
data = '%s(%s);' % (callback, data)
self.response.out.write(data)
elif out_format == 'html':
template_values = self.InitializeTemplate(self.APP_NAME + ' Tree Status')
template_values['message'] = status.message
template_values['state'] = status.general_state
template_values['sheriff'] = SheriffSchedules.get_current_sheriff()
self.DisplayTemplate('current.html', template_values, use_cache=True)
else:
self.error(400)
class BinaryStatusPage(BasePage):
"""Displays the /binarystatus page."""
def get(self):
"""Displays 1 if the tree is open or in caution, and 0 if it is closed."""
status = get_status()
self.response.headers['Cache-Control'] = 'no-cache, private, max-age=0'
self.response.headers['Content-Type'] = 'text/plain'
self.response.out.write(str(int(status.can_commit_freely)))
@utils.admin_only
def post(self):
"""Adds a new message from a backdoor.
The main difference with MainPage.post() is that it doesn't look for
conflicts and doesn't redirect to /.
"""
message = self.request.get('message')
username = self.request.get('username')
if message and username:
put_status(Status(message=message, username=username,
first_rev=codesite_utils.GetNextRevNum()))
self.response.out.write('OK')
class MainPage(BasePage):
"""Displays the main page containing the last DEFAULT_STATUS_CHUNK msgs."""
@utils.require_user
def get(self):
return self._handle()
def _handle(self, error_message='', last_message=''):
"""Sets the information to be displayed on the main page."""
try:
limit = min(max(int(self.request.get('limit')), 1), MAX_STATUS_CHUNK)
except ValueError:
limit = DEFAULT_STATUS_CHUNK
status = get_last_statuses(limit)
current_status = get_status()
if not last_message:
last_message = current_status.message
template_values = self.InitializeTemplate(self.APP_NAME + ' Tree Status')
template_values['status'] = status
template_values['message'] = last_message
template_values['last_status_key'] = current_status.key()
template_values['error_message'] = error_message
template_values['default_status_chunk'] = DEFAULT_STATUS_CHUNK
self.DisplayTemplate('main.html', template_values)
@utils.require_user
@utils.admin_only
def post(self):
"""Adds a new message."""
# We pass these variables back into get(), prepare them.
last_message = ''
error_message = ''
# Get the posted information.
new_message = self.request.get('message')
last_status_key = self.request.get('last_status_key')
if not new_message:
# A submission contained no data. It's a better experience to redirect
# in this case.
self.redirect("/")
return
# Ensure the new status message contains exactly one of 'open' or 'closed'
# or 'caution'.
try:
Status.validate_state_message(new_message)
except ValueError, e:
error_message = e.message
return self._handle(e.message, new_message)
current_status = get_status()
if current_status and (last_status_key != str(current_status.key())):
error_message = ('Message not saved, mid-air collision detected, '
'please resolve any conflicts and try again!')
last_message = new_message
return self._handle(error_message, last_message)
else:
put_status(Status(message=new_message, username=self.user.email(),
first_rev=codesite_utils.GetNextRevNum()))
self.redirect("/")
def bootstrap():
# Guarantee that at least one instance exists.
if db.GqlQuery('SELECT __key__ FROM Status').get() is None:
Status(username='none', message='welcome to status',
first_rev=codesite_utils.GetNextRevNum()).put()