Commits

evanlong committed f9a72c8

First commit for userbility. Hooking up the code so that visiting the /spy url allows watching the mouse cursor position in realtime as the user moves the mouse around

Comments (0)

Files changed (12)

+syntax: glob
+
+*.suo
+*.user
+*.pyc
+*~
+ Copyright (c) 2010 Evan Long
+
+ Permission is hereby granted, free of charge, to any person
+ obtaining a copy of this software and associated documentation
+ files (the "Software"), to deal in the Software without
+ restriction, including without limitation the rights to use,
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the
+ Software is furnished to do so, subject to the following
+ conditions:
+
+ The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ OTHER DEALINGS IN THE SOFTWARE.
+"""
+The design of this is based off of the tornadochat example which is distributed
+as part of the tornado web framework which has the following license:
+
+Tornado is an open source version of the scalable, non-blocking web server
+and and tools that power FriendFeed. Documentation and downloads are
+available at http://www.tornadoweb.org/
+
+Tornado is licensed under the Apache Licence, Version 2.0
+(http://www.apache.org/licenses/LICENSE-2.0.html).
+"""
+
+"""
+Implementation Copyright (c) 2010 Evan Long
+"""
+
+import tornado.web
+
+class ChannelMixin(object):
+    """
+    ChannelMixin class logic
+    """
+
+    """
+    _channel_data_store is a dict
+    key: name of the channel
+    value:     {'subscribers': [],
+                'messages': [],
+                'max_messages': int}
+
+    messages must be a dictionary and the key "cursor" is reserved.
+    An exception is raised if a message is broadcast which already contains this
+    key.
+    """
+    _channel_data_store = {}
+
+    def broadcast(self, message, channel_name):
+        chan_data = ChannelMixin._channel_data_store[channel_name]
+
+        for sub in chan_data["subscribers"]:
+            sub([message])
+
+        chan_data["subscribers"] = []
+
+        chan_data["messages"].append(message)
+        if len(chan_data["messages"]) > chan_data["max_messages"]:
+            chan_data["messages"] = chan_data["messages"][
+                -chan_data["max_messages"]:]
+
+        return message
+
+    def subscribe(self, cursor=None, callback=None, channel_name=None):
+        """
+        The purpose of the cursor is for the race condition which results
+        if new messages arrive while subscriber is receiving messages and
+        requests a new subscription. The subscriber sends back the cursor of the
+        last message it received.
+
+        callback must be wrapped in a tornado async_callback
+        """
+        if callback is None:
+            callback = self.async_callback(self._subscribe_callback)
+
+        chan_data = ChannelMixin._channel_data_store[channel_name]
+        #cursor None means it's the first time
+        #empty list means we have no data to send the user
+        #cursor at the end of the list means they have the most recent message
+        #all these cases just need to be added as a subscriber
+        if cursor is None or \
+                len(chan_data["messages"]) == 0 or \
+                chan_data["messages"][-1]["cursor"] == cursor:
+            chan_data["subscribers"].append(callback)
+            return
+
+        len_messages = len(chan_data["messages"])
+        index = -1
+        for i in xrange(len_messages-1, -1, -1):
+            if chan_data["messages"][i]["cursor"] == cursor:
+                index = i
+                break
+            
+        #cursor not in the list means send all messages back
+        #cursor anywhere else means send them every message from that index to 
+        #end
+        callback(chan_data["messages"][index+1:])
+
+    def _subscribe_callback(self, messages):
+        if self.request.connection.stream.closed():
+            return
+        self.finish({"messages":messages})
+
+    @classmethod
+    def get_messages(cls, channel_name=None):
+        if channel_name is None:
+            channel_name = cls.CHANNEL_NAME
+        chan_data = ChannelMixin._channel_data_store.get(channel_name)
+        if chan_data is not None:
+            return chan_data["messages"]
+        return []
+
+    @classmethod
+    def create_named_channel(cls, name, max_messages=200):
+        if not ChannelMixin._channel_data_store.has_key(name):
+            ChannelMixin._channel_data_store[name] = {
+                "subscribers": [],
+                "messages": [],
+                "max_messages": max_messages
+              }
+
+    @classmethod
+    def delete_named_channel(cls, name):
+        if ChannelMixin._channel_data_store.has_key(name):
+            del(ChannelMixin._channel_data_store[name])
+
+    @classmethod
+    def has_named_channel(cls, name):
+        return ChannelMixin._channel_data_store.has_key(name)
+#!/usr/bin/python
+
+import tornado.httpserver
+import tornado.ioloop
+from tornado.web import RequestHandler,asynchronous,addslash
+from tornado.options import define, options
+from httpchannel import ChannelMixin
+import uuid
+import logging
+import os.path
+import settings
+import model
+
+class RealtimeHandler(RequestHandler, ChannelMixin): pass
+
+class SpyHandler(RealtimeHandler):
+    @asynchronous
+    def post(self, spy_id):
+        cursor = self.get_argument(settings.CURSOR, None)
+        self.subscribe(cursor, channel_name=spy_id)
+
+    def get(self, spy_id=""):
+        if spy_id == "":
+            self.render("spy.html", data=[u.id for u in model._user_db])
+        else:
+            self.render("spy_view.html") #the polling view
+
+class RecordHandler(RealtimeHandler):
+    @model.dec_set_user
+    def post(self):
+        data = {
+            "mouseX": self.get_argument("mouseX", 0),
+            "mouseY": self.get_argument("mouseY", 0)
+            }
+        self.broadcast(model.generate_message("data", data),
+                       self.request.user.id)
+        self.write({"status":"ok"})
+
+class MainHandler(RealtimeHandler):
+    @model.dec_set_user
+    def get(self):
+        self.render("index.html")
+
+define("port", default=9988, help="run on the given port", type=int)
+
+if __name__ == "__main__":
+    ops = {
+        "template_path": os.path.join(os.path.dirname(__file__), "templates"),
+        "static_path": os.path.join(os.path.dirname(__file__), "static"),
+        "debug": True
+        }
+    application = tornado.web.Application(handlers=[
+            (r"/", MainHandler),
+            (r"/spy/(.*)", SpyHandler),
+            (r"/spy", SpyHandler),
+            (r"/record", RecordHandler),
+            ], **ops)
+    tornado.options.parse_command_line()
+    http_server = tornado.httpserver.HTTPServer(application)
+    http_server.listen(options.port)
+    tornado.ioloop.IOLoop.instance().start()
+"""
+In python memory DB models and decorators for tornado requests to ensure
+specific states of the program
+"""
+
+from collections import defaultdict
+import logging
+import uuid
+from httpchannel import ChannelMixin
+import settings
+import time
+
+#database
+_user_db = []
+
+#ActionType with comments of what should be the expected action value
+class ActionType:
+    LEFT_CLICK = 0 #None value
+    RIGHT_CLICK = 1 #None value
+    KEYPRESS = 2 #key name
+    SCROLLX = 3 #None value
+    SCROLLY = 4 #None value
+
+#model
+class User(object):
+    def __init__(self, id):
+        self.id = id
+        self.page_actions = defaultdict(list) #maps URL to list of actions
+        ChannelMixin.create_named_channel(id)
+
+class Action(object):
+    def __init__(self,
+                 timestamp,
+                 actionType,
+                 actionValue,
+                 mouseX,
+                 mouseY,
+                 docWidth,
+                 docHeight,
+                 scrollX,
+                 scrollY):
+        #client responsible for timestamp as they will probably be queuing
+        #and delivering actions in chunks instead of a delivery per action
+        self.timestamp = timestamp
+        self.actionType = actionType
+        self.actionValue = actionValue
+        self.mouseX = mouseX
+        self.mouseY = mouseY
+        self.docWidth = docWidth
+        self.docHeight = docHeight
+        self.scrollX = scrollX
+        self.scrollY = scrollY
+
+def create_user():
+    u = User(str(uuid.uuid4()))
+    _user_db.append(u)
+    return u
+
+def get_user_by_id(id):
+    for u in _user_db:
+        if u.id == id:
+            return u
+    return None
+
+def remove_user(u):
+    if u in _user_db:
+        _user_db.remove(u)
+
+#decs
+def dec_set_user(fun):
+    """
+    Sets the attribute 'user' on the request object for incoming requests. If
+    there is not a valid user for the session or the session was not set it
+    will create a new user object and session and write that to the client
+    """
+    def new_fun(self, *args, **kwargs):
+        session = self.get_cookie(settings.SESSION_KEY)
+        user = get_user_by_id(session)
+        if session is None or user is None:
+            user = create_user()
+            self.set_cookie(settings.SESSION_KEY, user.id)
+        self.request.user = user
+        return fun(self, *args, **kwargs)
+    return new_fun
+
+#private channel messages
+def generate_message(ty, data):
+    message = {
+        "cursor": str(uuid.uuid4()),
+        "type": ty,
+        "data": data,
+        }
+    return message
+SESSION_KEY="SESSION_ID"
+CURSOR="cursor"

static/channel.js

+/*
+  Channel
+  required args:
+  pollPath - url of the channel to long poll on
+  newMessagePath - url of the channel on which the server receives messages
+  optional args:
+  dictionary{
+  onopen - event called when connection is opened to the server
+  onmessage - event is called when message is received from the server
+  onerror - event is called when an error occurs while long polling
+
+  Depends on jQuery
+  }
+*/
+
+function Channel(pollPath, newMessagePath, optionalArgsObject) {
+    var self = this;
+    self.pollPath = pollPath;
+    self.newMessagePath = newMessagePath;
+    self.onopen = function() {};
+    self.onmessage = function(message) {};
+    self.onerror = function() {};
+    self.onbroadcastresponse = function(resp) {};
+    
+    if(optionalArgsObject.onopen != undefined)
+        self.onopen = optionalArgsObject.onopen;
+    if(optionalArgsObject.onmessage != undefined)
+        self.onmessage = optionalArgsObject.onmessage;
+    if(optionalArgsObject.onerror != undefined)
+        self.onerror = optionalArgsObject.onerror;
+    if(optionalArgsObject.onbroadcastresponse != undefined)
+        self.onbroadcastresponse = optionalArgsObject.onbroadcastresponse;
+
+    self.isOpen = false;
+    self.cursor = null;
+    var pollXHR = null;
+
+    /*
+      Opens a connection to the pollPath specified when the Channel object was 
+      created
+      return: true if connection was open false if it could not be opened or
+      if the connection was already opened
+    */
+    self.open = function() {
+        if(self.isOpen) return false;
+        self.isOpen = true;
+        self.onopen();
+        longPoll();
+    };
+
+    /*
+      Sends a message off to the newMessagePath.
+      args:
+      message - dictionary of key value pairs
+    */
+    self.broadcast = function(message) {
+        $.post(newMessagePath, message, function(resp){
+            self.onbroadcastresponse(resp);
+        }, "json");
+    };
+
+    /*
+      Stops the long polling of pollPath.
+    */
+    self.close = function() {
+        self.isOpen = false;
+        if(pollXHR != null) pollXHR.abort();
+    };
+
+    /*
+      
+     */
+    function longPoll() {
+        var postData = {};
+        if(self.cursor != null) {
+            postData["cursor"] = self.cursor;
+        }
+        pollXHR = $.ajax({
+            type: "POST",
+            url: pollPath,
+            dataType: "json",
+            data: postData,
+            success: function(data) {
+                self.onmessage(data);
+                self.cursor = data.messages[data.messages.length-1].cursor;
+                longPoll();
+            },
+            error: function(requestObj, textStatus, errorThrown) {
+                //timeout just means we need to keep long polling
+                if(textStatus == "timeout") {
+                    longPoll();
+                }
+                else {
+                    //http codes for timeout
+                    if(requestObj.status == 504 || requestObj.status == 408) {
+                        longPoll();
+                    }
+                    else {
+                        //this even happens when user navigates away from
+                        //the page
+                        self.close();
+                        self.onerror();
+                    }
+                }
+            }
+        });
+    }
+}

static/collect.js

+var oldX = 0;
+var oldY = 0;
+var THRESHOLD = 5;
+$(document).mousemove(function(event) {
+    var newX = event.pageX;
+    var newY = event.pageY;
+    if(Math.abs(newX-oldX) > THRESHOLD || Math.abs(newY-oldY) > THRESHOLD) {
+        console.info(event.pageX + "," + event.pageY);
+        oldX = newX;
+        oldY = newY;
+        var message = {
+            mouseX: newX,
+            mouseY: newY
+        };
+        $.post("/record", message, function(resp){
+            console.log(resp);
+        }, "json");        
+    }
+});
+var chan = new Channel(document.location.href,
+                       "",
+                       {
+                           onmessage: function(data) {
+                               for(var i in data.messages)
+                               {
+                                   var msg = data.messages[i];
+                                   $("#mouse_block")
+                                       .css("top", parseInt(msg.data.mouseY))
+                                       .css("left", parseInt(msg.data.mouseX));
+                               }
+                                      
+                           },
+                           onerror: function() {
+                               console.error("error");
+                           }
+                       });
+chan.open();

templates/index.html

+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" 
+          "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html>
+  <head>
+    <title>Userbility</title>
+  </head>
+  <body>
+    <div id="main">
+      This is the page contents
+    </div>
+    <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.3/jquery.min.js"
+            type="text/javascript"></script>
+    <script src="{{ static_url("channel.js") }}"
+            type="text/javascript"></script>
+    <script src="{{ static_url("collect.js") }}"
+            type="text/javascript"></script>
+  </body>
+</html>

templates/spy.html

+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" 
+          "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html>
+  <head>
+    <title>Userbility</title>
+  </head>
+  <body>
+    <div id="main">
+      <ul>
+      {% for i in data%}
+      <li><a href="/spy/{{ i }}">{{ i }}</a></li>
+      {% end %}
+      </ul>
+    </div>
+    <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.3/jquery.min.js"
+            type="text/javascript"></script>
+    <script src="{{ static_url("channel.js") }}"
+            type="text/javascript"></script>
+  </body>
+</html>

templates/spy_view.html

+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" 
+          "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html>
+  <head>
+    <title>Userbility</title>
+    <style type="text/css">
+      #mouse_block {
+      position: absolute;
+      width:5px;
+      height:5px;
+      background:red;
+      }
+    </style>
+  </head>
+  <body>
+    <div id="main">
+      <div id="mouse_block"></div>
+    </div>
+    <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.3/jquery.min.js"
+            type="text/javascript"></script>
+    <script src="{{ static_url("channel.js") }}"
+            type="text/javascript"></script>
+    <script src="{{ static_url("spy.js") }}"
+            type="text/javascript"></script>
+  </body>
+</html>