#!/usr/bin/env python
#
# Copyright 2008 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
#
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__author__ = 'cnygaard@google.com (Carl Nygaard)'
import cgi
import datetime
import logging
import random
import time
import wsgiref.handlers
from google.appengine.ext import db
from google.appengine.api import users
from google.appengine.ext import webapp
from google.appengine.ext.webapp import template
PRODUCTION_HOSTNAME = 'earthpad.appspot.com'
STAGING_HOSTNAME = 'earthpad.prom.corp.google.com'
DEBUG_HOSTNAME = 'gorgar.kir.corp.google.com'
API_KEYS = {PRODUCTION_HOSTNAME:
('ABQIAAAAwbkbZLyhsmTCWXbTcjbgbRQBG8cc0S7b5SA6aIkXMa1U6T-V0hQymUHyz'
'hIRKDHAyaKCzzlpNqRIOw'),
STAGING_HOSTNAME:
('ABQIAAAAVqIP_JQh0O0nvjijuZBcURTJwZ-NLfjZXzvx0gjxB6pbYFPWLBTcaONMF'
'p8DO9Ylu1R5T47YXLl9bA'),
DEBUG_HOSTNAME:
('ABQIAAAAVqIP_JQh0O0nvjijuZBcURQtuArS5cNtRkPopVjg4JQEPJ5LsxSTz9mbJ'
'UxvS0fDPd1Ut_F-AsVdZQ'),
}
XML_HEADER = '\n'
# TODO(cnygaard): Remove KML wrapper since it no longer has meaning.
KML_HEADER = (XML_HEADER +
'\n')
KML_FOOTER = '\n'
# The lifetime of a volitile revision. During an update, those volitile
# revisions older than this length of time will be deleted.
MAX_VOLITILE_REVISION_LIFE = 60 # 60 seconds
def InProduction(hostname):
"""Says whether the given hostname is the production hostname.
This is not foolproof and shouldn't be used in places where security matters.
"""
return hostname.startswith(PRODUCTION_HOSTNAME)
def GetMapsAPIKey(hostname):
"""The api key depends on the hostname of the server being run on."""
# Strip out the port if there is one:
if ':' in hostname:
hostname = hostname.split(':')[0]
if hostname in API_KEYS:
return API_KEYS[hostname]
else:
logging.warning('API Key not found for host %s.' % hostname)
return API_KEYS[DEBUG_HOSTNAME]
class EarthPadAbstractRequestHandler(webapp.RequestHandler):
"""Provide generic support to handle user authentication, etc."""
def GetNickName(self):
"""Returns the current user's id, or None if there isn't one."""
user = users.get_current_user()
if not user:
return None
return user.nickname()
def RedirectToLogin(self):
self.redirect(users.create_login_url(self.request.uri))
class InteractivePage(EarthPadAbstractRequestHandler):
"""The primary page through which users interact with earth pad."""
def get(self):
nick = self.GetNickName()
if not nick:
# Require all users to be logged in.
self.RedirectToLogin()
return
id = self.request.get('id')
if not id:
# If the id is is not provide, create a new page by default.
self.redirect('/')
return
# Extract whether we are in debug mode. The default depends on whether we
# are in production.
hostname = self.request.headers.get('host')
debug_mode = self.request.get('d', str(not InProduction(hostname)))
if debug_mode.upper() in ['1', 'TRUE']:
debug_mode = True
else:
debug_mode = False
# If the user tries to request a document that doesn't exist, send a
# friendly error message.
doc = Document.get_by_key_name(id)
# NOTE(cnygaard): I'm worried about race conditions where the document may
# not show up in all replicas before the redirection from / completes.
if not doc:
self.error(404)
self.response.out.write(
'Earth Pad not found. Try creating a new one!')
return
main_props = {
'id': id,
'debug_mode': debug_mode,
'maps_api_key': GetMapsAPIKey(hostname),
'nick': nick,
'docurl': 'http://%s/interact?id=%s' % (hostname, id),
'doctitle': doc.title or 'Untitled',
}
self.response.out.write(template.render('main.html', main_props))
class Document(db.Model):
"""Global properties for a single document."""
# The key name for the document is set to make a unique document.
# This value is used as a lock through transactional modifications to
# gaurantee that no two users can make a modification w/ the same rev number.
latest_revision_number = db.IntegerProperty(required=True)
# There is at least one lookat, and in order to retain it, we should remember
# which revision contains that lookat
latest_lookat_revision_number = db.IntegerProperty()
# The title of the document. Expected to be low traffic in terms of the
# number of changes, so we can afford to store it here and additionally give
# it a volatile revision.
title = db.StringProperty()
@staticmethod
def IncrementRevisionNumber(key_name, is_lookat):
"""Increment the given document's revision number.
Args:
key_name: The key to finding the document.
is_lookat: Says whether to also update the latest_lookat_revision_number.
Returns:
Tuple of acquired revision number and latest_lookat_revision_number.
"""
doc = Document.get_by_key_name(key_name)
doc.latest_revision_number += 1
if is_lookat:
doc.latest_lookat_revision_number = doc.latest_revision_number
doc.put()
return (doc.latest_revision_number, doc.latest_lookat_revision_number)
class Revision(db.Model):
"""A single revision to a document."""
# These two values act together as primary key.
document_id = db.StringProperty(required=True)
revision_number = db.IntegerProperty(required=True)
# The user that made this modification.
nickname = db.StringProperty(required=True)
timestamp = db.IntegerProperty(required=True)
# Each property below is optional. Typically in a single revision only one
# property is present.
# Used when the view changes
new_look_at = db.IntegerProperty()
# The message log contains plain text entries to be shown in the message box.
# This could include chat messages or sign in/out notifications.
message_log = db.StringProperty()
# Map contents authoring
added_placemark = db.IntegerProperty()
removed_placemark = db.IntegerProperty()
# The document also holds the current title, so this revision is volatile.
new_title = db.StringProperty()
def IsVolatile(self):
"""Returns true if the type of this revision is safe to delete."""
# Only added_placemarks are not volatile.
return self.added_placemark is None
class KmlLookAt(db.Model):
"""A data structure pertaining to the look at object."""
latitude = db.FloatProperty(required=True)
longitude = db.FloatProperty(required=True)
range = db.FloatProperty(required=True)
tilt = db.FloatProperty(required=True)
heading = db.FloatProperty(required=True)
altitude = db.FloatProperty(required=True)
altitude_mode = db.IntegerProperty(required=True)
_ALTITUDE_MODE_ENUM = {0: 'clampToGround',
1: 'relativeToGround',
2: 'absolute'}
def ToXML(self):
"""Returns an XML representation."""
return '\n'.join([
'',
' %f' % self.longitude,
' %f' % self.latitude,
' %f' % self.altitude,
' %f' % self.range,
' %f' % self.tilt,
' %f' % self.heading,
' %s' % (
self._ALTITUDE_MODE_ENUM[self.altitude_mode]),
''])
class KmlPlacemark(db.Model):
"""A simple placemark."""
latitude = db.FloatProperty(required=True)
longitude = db.FloatProperty(required=True)
note = db.StringProperty()
def ToXML(self):
"""Returns an XML representation."""
return '\n'.join([
'',
' %d' % self.key().id(),
' %f' % self.longitude,
' %f' % self.latitude,
' %s' % self.note,
'',
])
ID_LENGTH = 5
def GenerateRandomPageId():
"""Creates a likely-unique random page ID."""
# TODO(cnygaard): Improve this hashing to assure no collisions occur
return ''.join([chr(random.randint(65,65+25)) for _ in range(ID_LENGTH)])
class UpdateCheck(EarthPadAbstractRequestHandler):
"""Handler for requests for ajax update check requests."""
def post(self):
id = self.request.get('id')
last_known_revision = int(self.request.get('last_known_revision', -1))
logging.info('update check for %s' % id)
error_status = 0
if not id:
self.error(404)
return
# This is the ideal query. It does not seem to work in production.
# Instead, not only are all the appropriate values returned, so too are all
# other revisions whose document_id is alphabetically after the given id.
# q = db.GqlQuery('SELECT * FROM Revision '
# 'WHERE document_id = :1 '
# ' AND revision_number > :2 '
# 'ORDER BY revision_number',
# id, last_known_revision)
q = db.GqlQuery('SELECT * FROM Revision '
'WHERE document_id = :1 '
'ORDER BY revision_number',
id)
self.response.out.write(KML_HEADER)
# We should send only one lookat update per request to save effort and data.
latest_lookat_xml = None
num_invalid_revisions = 0
total_revs = 0
for revision in q:
total_revs += 1
if revision.document_id != id:
num_invalid_revisions += 1
continue
# We must do this manual filtering to get over the limitation above.
if revision.revision_number <= last_known_revision:
continue
last_known_revision = revision.revision_number
revision_tag = '' % (
revision.nickname, revision.revision_number)
def WrapInRevision(output):
"""Wraps the given output in the appropriate revision node."""
return '%s%s' % (revision_tag, output)
if revision.new_look_at:
look_at = KmlLookAt.get_by_id(revision.new_look_at)
if look_at:
latest_lookat_xml = WrapInRevision(look_at.ToXML())
if revision.message_log:
self.response.out.write(WrapInRevision(
'%s' % cgi.escape(revision.message_log)))
if revision.added_placemark:
placemark = KmlPlacemark.get_by_id(revision.added_placemark)
if placemark:
self.response.out.write(WrapInRevision(placemark.ToXML()))
if revision.removed_placemark:
placemark = KmlPlacemark.get_by_id(revision.removed_placemark)
self.response.out.write(WrapInRevision(
''
% revision.removed_placemark))
if revision.new_title:
self.response.out.write(WrapInRevision(
'%s' % cgi.escape(revision.new_title)))
if num_invalid_revisions:
logging.error('Request for document %s retrieved %s incorrect revisions.'
% (id, num_invalid_revisions))
if latest_lookat_xml:
self.response.out.write(latest_lookat_xml)
# Let the client know what the current revision number is.
logging.info('status: %s, revision: %s, total_revs: %d' % (
error_status, last_known_revision, total_revs))
self.response.out.write(''
% (error_status, last_known_revision))
self.response.out.write(KML_FOOTER)
def CleanUpOldRevisions(id, latest_revision_number,
latest_lookat_revision_number):
"""Clean up old volatile revision data.
This is done during update sends rather than the more frequently occuring
update check calls.
Args:
id: The doc id.
latest_revision_number: The revision number being created.
"""
q = db.GqlQuery('SELECT * FROM Revision '
'WHERE document_id = :1 '
'ORDER BY revision_number',
id)
old_revisions_by_placemark_id = {}
for revision in q:
if revision.document_id != id:
logging.error('Incorrect ID given!')
continue
# Added placemarks aren't volatile unless they are later deleted. Store all
# the old placemarks.
if revision.added_placemark:
old_revisions_by_placemark_id[revision.added_placemark] = revision
if not revision.IsVolatile():
continue
if revision.revision_number == latest_revision_number:
continue
if int(time.time()) - revision.timestamp < MAX_VOLITILE_REVISION_LIFE:
logging.info('Not deleting %d -- too young' % revision.revision_number)
continue
# We really want to make sure the revision of interest is deleted, no matter
# whether its constituent parts get deleted, so wrap in a try-finally.
really_delete = True
try:
if (revision.new_look_at and
revision.revision_number != latest_lookat_revision_number):
logging.info('Deleting look at %d' % revision.new_look_at)
look_at = KmlLookAt.get_by_id(revision.new_look_at)
if look_at:
look_at.delete()
elif revision.new_look_at:
# There may be a number of reasons the above if block throws an error.
# If there is a problem, we error on the side of destruction. If no
# problem, then here we prevent the deletion.
logging.info('Not deleting only look at %d' % revision.revision_number)
really_delete = False
if revision.removed_placemark:
logging.info('Deleting placemark %d' % revision.removed_placemark)
KmlPlacemark.get_by_id(revision.removed_placemark).delete()
# Get rid of the original placemark add.
if revision.removed_placemark in old_revisions_by_placemark_id:
old_rev = old_revisions_by_placemark_id[revision.removed_placemark]
logging.info('Del placemark - rev %d.' % old_rev.revision_number)
if old_rev:
old_rev.delete()
finally:
if really_delete:
logging.info('Deleting revision %d.' % revision.revision_number)
revision.delete()
class UpdateSend(EarthPadAbstractRequestHandler):
"""Handler for ajax requests to send updates."""
def post(self):
id = self.request.get('id')
type = self.request.get('type')
logging.info('update send for %s' % id)
error_status = 0
nick = self.GetNickName()
if not id or not type or not nick:
self.error(404)
return
revision = Revision(document_id=id,
revision_number=-1, # Set later.
nickname=nick,
timestamp=int(time.time()),
)
logging.info('Processing update of type %s.' % type)
if type == 'new_look_at':
revision.new_look_at = self.ProcessNewLookAt()
elif type == 'message_log':
# Arbitrarily clip messages larger than 4k.
revision.message_log = self.request.get('msg', '')[:4096]
logging.info('Recieved new message_log.');
elif type == 'add_placemark':
revision.added_placemark = self.ProcessAddPlacemark()
elif type == 'remove_placemark':
revision.removed_placemark = int(self.request.get('placemark_id'))
elif type == 'new_title':
revision.new_title = self.request.get('title')
doc = Document.get_by_key_name(id)
doc.title = revision.new_title
doc.put()
# Use transactions to assure unique revision numbers.
MAX_RETRIES = 10
rev_num = -1
for i in range(MAX_RETRIES):
try:
rev_num, lookat_rev_num = db.run_in_transaction(
Document.IncrementRevisionNumber, id, type == 'new_look_at')
break
except db.Rollback, e:
# When another process beat us out, try again.
pass
else:
logging.warning('Unable to acquire revision after %d tries. Error: %s'
% (MAX_RETRIES, str(e)))
error_status = 1
# Finalize the revision by pushing it to the server.
if not error_status:
revision.revision_number = rev_num
revision.put()
self.response.out.write(XML_HEADER +
''
% (error_status, rev_num))
CleanUpOldRevisions(id, rev_num, lookat_rev_num)
def ProcessNewLookAt(self):
"""An update request to change the lookat.
Returns:
The ID of the new look at object to be added to the revision.
"""
look_at = KmlLookAt(
latitude = float(self.request.get('latitude')),
longitude = float(self.request.get('longitude')),
range = float(self.request.get('range')),
tilt = float(self.request.get('tilt')),
heading = float(self.request.get('heading')),
altitude = float(self.request.get('altitude')),
altitude_mode = int(self.request.get('altitude_mode'))
)
look_at.put()
id = look_at.key().id()
logging.info('View %s: New latitude: %f new long: %f'
% (id, look_at.latitude, look_at.longitude))
return id
def ProcessAddPlacemark(self):
"""An update request to add a placemark.
Returns:
The ID of the new placemark object to be added to the revision.
"""
placemark = KmlPlacemark(
latitude = float(self.request.get('latitude')),
longitude = float(self.request.get('longitude')),
note = self.request.get('note'),
)
placemark.put()
id = placemark.key().id()
logging.info('Placemark %s: New latitude: %f new long: %f'
% (id, placemark.latitude, placemark.longitude))
return id
class MainPage(EarthPadAbstractRequestHandler):
"""Handler for / which creates a new document."""
def get(self):
nick = self.GetNickName()
if not nick:
self.RedirectToLogin()
return
# Make a new random page for the user.
id = GenerateRandomPageId()
new_doc = Document(key_name=id, latest_revision_number=-1)
new_doc.put()
self.redirect('/interact?id=%s' % id)
application = webapp.WSGIApplication([
('/', MainPage),
('/interact', InteractivePage),
('/updatecheck', UpdateCheck),
('/updatesend', UpdateSend),
], debug=True) # debug=True says to print a stack trace.
def main():
wsgiref.handlers.CGIHandler().run(application)
if __name__ == '__main__':
main()