DerekSchaefer.NET I do stuff, you read about it!

8Feb/1110

Pyjamas + Django = Pure Win

In my last post, I focused on detailing a new way of developing web applications (using GWT/Pyjamas) and showing examples of compiled web front-ends. In this post I am going to bring everything together (so both front- and back-ends).

In the past I made a post about Django, which is my absolute favorite web application (back-end) framework. Coincidentally, Pyjamas (this is for creating the front-end, remember?!) ALSO uses Python. So in case you haven't had an aneurysm from the pure win yet, let me boil it down for you: you can write your entire web application in a single language, and an awesome one at that! For those of you who have created native GUI programs and web apps before, you can easily see the advantages that this type of approach has (hint: web development is... painful).

So enough of my fawning, let's roll!

My example is a modified version of the original GWT sample application used in my previous post. I will be adding persistence to the app. So when you add stocks to your list, they will show up again when you revisit the page, etc. Also, this will not be a global list that is viewable to all users, but is saved on a per-user basis. Also, it will NOT require an account (i.e. no login to tie the stocks lists to), it will be anonymous, based on cookies only. Not terribly complicated but it should make for a good example.

So here is the source as we left it, and here are the rest of the associated files. This is where we'll be starting from.

First off, here is the end product: http://stockwatcher2.derekschaefer.net/

It's the same as before, except it has persistence based on a Django session cookie set to expire in one year.

Try adding a few "stocks" and then refreshing, or even closing the browser and coming back. The same stocks should reappear. The only reason they won't is if you have configured your browser to be especially judicious when it comes to cookies, otherwise it should work fine (tested in Chrome, Firefox, Opera). The only exception is IE 8, as far as I know, which has rather severe problems with complex Javascript and I just can't be asked to make compatibility changes at the moment.

Let's first create the Django model for the database we're going to use to keep track of. It doesn't need to be complex, but we need to track everything based the session.

from django.db import models
from django.contrib.sessions.models import Session

class Stocks(models.Model):
    session = models.ForeignKey(Session)
    symbol = models.CharField(max_length=10)

    def __unicode__(self):
        return '%s | %s' % (self.session.pk, self.symbol)

To tie the stocks to a given session, it's as simple as importing the Session model and creating a foreign key field for it (you must have "django.contrib.sessions" listed under "INSTALLED_APPS" in your settings file, like so:

...
INSTALLED_APPS = (
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.sites',
    'django.contrib.messages',
    # Uncomment the next line to enable the admin:
    'django.contrib.admin',
    # Uncomment the next line to enable admin documentation:
    # 'django.contrib.admindocs',
    'stockwatcher2.stockwatcher',
)
...

Now let's look at the views. The first thing we need to consider is how Pyjamas and Django are going to communicate. As it turns out, the most convenient way to go about this to use JSON-RPC (a.k.a. JSON remote procedure calls). Pyjamas already has this functionality build it, so we only need to utilize it, but Django has a somewhat harder time. To cut right to it, I've found that the best option is libcommonDjango (which also contains lots of other useful things for Django). It will easily allow us to setup an RPC environment using standard Django views. First, you will have to download libcommonDjango and run "python setup.py install" which will place it in your "site-packages" directory so it will be on your $PYTHONPATH. And that's it! Now let's look at how to use it:

from django_pimentech.network import *
from django.shortcuts import render_to_response
from django.template import RequestContext
from django.contrib.sessions.models import Session

from stockwatcher.models import Stocks

import datetime

service = JSONRPCService()

def index(request):
    request.session.set_expiry(datetime.datetime.now() + datetime.timedelta(365))
    request.session.save()
    return render_to_response('StockWatcher.html')

def getStocks(request):
    try:
        session = Session.objects.get(pk=request.session._session_key)
        stocks = Stocks.objects.filter(session=session)
    except Exception as e:
        return []
    return [(stock.symbol) for stock in stocks]
service.add_method('getStocks', getStocks)

def addStock(request, symbol):
    session = Session.objects.get(pk=request.session._session_key)
    # Don't allow duplicates
    if len(Stocks.objects.filter(session=session, symbol=symbol)) > 0:
        return getStocks(request)
    stock = Stocks()
    stock.session = session
    stock.symbol = symbol
    stock.save()
    return getStocks(request)
service.add_method('addStock', addStock)

def deleteStock(request, symbol):
    try:
        session = Session.objects.get(pk=request.session._session_key)
        Stocks.objects.get(session=session, symbol=symbol).delete()
    except Stocks.DoesNotExist:
        pass
    return getStocks(request)
service.add_method('deleteStock', deleteStock)

As you can see, these are just normal Django view functions. The only difference is we import "from django_pimentech.network import *" and then setup the service variable to be a "JSONRPCService()" object. Finally then adding each view to the service by calling "service.add_method", which can also be done with a decorator but I've found this way to be more compatible.

The other things of note are "request.session.set_expiry(datetime.datetime.now() + datetime.timedelta(365))" and retrieving the session primary key via "request.session._session_key". With the former I am setting the session (and correspondingly the session cookie that will be stored) expiration date to one year (totally arbitrary) from the current time.

Also, you will need to add the following to your URLs file like so:

urlpatterns = patterns('',
    ...
    (r'^services/$', 'stockwatcher.views.service'),
    ...
)

Additionally, you will probably run into a CSRF verification error, in which case you need to disable CSRF verification in your settings file by removing "django.middleware.csrf.CsrfViewMiddleware" from "MIDDLEWARE_CLASSES".

Now the JSON-RPC environment is ready to go, which leaves the Pyjamas front-end.

In terms of organization, it's easiest if you place all the Pyjamas on your Django "MEDIA_PATH", as you can see here: http://downloads.derekschaefer.net/index.php?dir=pyjamas/stockwatcher2/

Once that is done, we just need to modify "StockWatcher.py".

import pyjd

from pyjamas.ui.RootPanel import RootPanel
from pyjamas.ui.Label import Label
from pyjamas.ui.Button import Button
from pyjamas.ui.FlexTable import FlexTable
from pyjamas.ui.HorizontalPanel import HorizontalPanel
from pyjamas.ui.TextBox import TextBox
from pyjamas.ui.VerticalPanel import VerticalPanel
from pyjamas.ui.KeyboardListener import KeyboardHandler, KEY_ENTER
from pyjamas.Timer import Timer
from pyjamas import Window

from pyjamas.JSONService import JSONProxy

from StockPrice import StockPrice

import re
import random
import datetime

class StockWatcher:
    def onModuleLoad(self):
        '''
        This is the main entry point method.
        '''

        # Setup JSON RPC
        self.remote = DataService()

        # Initialize member variables
        self.mainPanel = VerticalPanel()
        self.stocksFlexTable = FlexTable()
        self.addPanel = HorizontalPanel()
        self.newSymbolTextBox = TextBox()
        self.lastUpdatedLabel = Label()
        self.addStockButton = Button('Add', self.addStock)
        self.stocks = []
        self.stocksTableColumns = ['Symbol', 'Price', 'Change', 'Remove']

        # Add styles to elements in the stock list table
        self.stocksFlexTable.getRowFormatter().addStyleName(0, 'watchListHeader')
        self.stocksFlexTable.addStyleName('watchList')
        self.stocksFlexTable.getCellFormatter().addStyleName(0, 1, 'watchListNumericColumn')
        self.stocksFlexTable.getCellFormatter().addStyleName(0, 2, 'watchListNumericColumn')
        self.stocksFlexTable.getCellFormatter().addStyleName(0, 3, 'watchListRemoveColumn')

        # Create table for stock data
        for i in range(len(self.stocksTableColumns)):
            self.stocksFlexTable.setText(0, i, self.stocksTableColumns[i])

        # Assemble Add Stock panel
        self.addPanel.add(self.newSymbolTextBox)
        self.addPanel.add(self.addStockButton)
        self.addPanel.addStyleName('addPanel')

        # Assemble Main panel
        self.mainPanel.add(self.stocksFlexTable)
        self.mainPanel.add(self.addPanel)
        self.mainPanel.add(self.lastUpdatedLabel)

        # Associate the Main panel with the HTML host page
        RootPanel('stocksList').add(self.mainPanel)

        # Move cursor focus to the input box
        self.newSymbolTextBox.setFocus(True)

        # Setup timer to refresh list automatically
        refresh = self.refreshWatchlist
        class MyTimer(Timer):
            def run(self):
                refresh()
        refreshTimer = MyTimer()
        refreshTimer.scheduleRepeating(5000)

        # Listen for keyboard events in the input box
        self_addStock = self.addStock
        class StockTextBox_KeyboardHandler():
            def onKeyPress(self, sender, keycode, modifiers):
                if keycode == KEY_ENTER:
                    self_addStock()
            def onKeyDown(self, sender, keycode, modifiers): return
            def onKeyUp(self, sender, keycode, modifiers): return
        self.newSymbolTextBox.addKeyboardListener(StockTextBox_KeyboardHandler())

        # Load the stocks
        self.remote.getStocks(self)

    def addStock(self, sender, symbol=None):
        '''
        Add stock to FlexTable. Executed when the user clicks the addStockButton
        or presses enter in the newSymbolTextBox
        '''

        if symbol is None:
            # Get the symbol
            symbol = self.newSymbolTextBox.getText().upper().trim()
            self.newSymbolTextBox.setText('')
            # Don't add the stock if it's already in the table
            if symbol in self.stocks:
                return
            # Tell the server that we're adding this stock
            self.remote.addStock(symbol, self)
            self.newSymbolTextBox.setFocus(True)
            # Stocks code must be between 1 and 10 chars that are numbers/letters/dots
            p = re.compile('^[0-9A-Z\\.]{1,10}$')
            if p.match(symbol) == None:
                Window.alert('"%s" is not a valid symbol.' % symbol)
                self.newSymbolTextBox.selectAll()
                return    

        # Add the stock to the table
        row = self.stocksFlexTable.getRowCount()
        self.stocks.append(symbol)
        self.stocksFlexTable.setText(row, 0, symbol)
        self.stocksFlexTable.setWidget(row, 2, Label())
        self.stocksFlexTable.getCellFormatter().addStyleName(row, 1, 'watchListNumericColumn')
        self.stocksFlexTable.getCellFormatter().addStyleName(row, 2, 'watchListNumericColumn')
        self.stocksFlexTable.getCellFormatter().addStyleName(row, 3, 'watchListRemoveColumn')

        # Add a button to remove this stock from the table
        def _removeStockButton_Click(event):
            if symbol not in self.stocks:
                return
            removedIndex = self.stocks.index(symbol)
            self.remote.deleteStock(symbol, self)
            self.stocks.remove(symbol)
            self.stocksFlexTable.removeRow(removedIndex + 1)
        removeStockButton = Button('x', _removeStockButton_Click)
        removeStockButton.addStyleDependentName('remove')
        self.stocksFlexTable.setWidget(row, 3, removeStockButton)

        # Get the stock price
        self.refreshWatchlist()

    def refreshWatchlist(self):
        '''
        Update the price change for each stock
        '''

        MAX_PRICE = 100.0
        MAX_PRICE_CHANGE = 0.02

        prices = []
        for i in range(len(self.stocks)):
            price = random.random() * MAX_PRICE
            change = price * MAX_PRICE_CHANGE * (random.random() * 2.0 - 1.0)
            prices.append(StockPrice(self.stocks[i], price, change))

        self.updateTable(prices)

    def updateTable(self, prices):
        '''
        Update the price and change fields of all the rows in the stock table

        prices -- List of StockPrice objects for all rows
        '''

        # Type checking
        assert isinstance(prices, list)
        for price in prices:
            assert isinstance(price, StockPrice)

        # Nothing to do...
        if len(prices) == 0:
            return

        # Update each individual row
        for i in range(len(prices)):
            self.updateRow(prices[i])

        # Display timestamp showing last refresh
        self.lastUpdatedLabel.setText("Last update: %s" % datetime.datetime.now().strftime("%m/%d/%Y %I:%M:%S %p"))

    def updateRow(self, price):
        '''
        Update a single row in the stock table

        price -- StockPrice object for a single row
        '''

        # Type checking
        assert isinstance(price, StockPrice)

        # Make sure the stock is still in the stock table
        if price.symbol not in self.stocks:
            return

        # Find the index of
        row = self.stocks.index(price.symbol) + 1

        # Populate the price and change fields with new data
        self.stocksFlexTable.setText(row, 1, '%.2f' % price.price)
        changeWidget = self.stocksFlexTable.getWidget(row, 2)
        changeWidget.setText('%.2f (%.2f%%)' % (price.change, price.getChangePercent()))

        # Change the color of the text in the Change field based on its value
        changeStyleName = 'noChange'
        if price.getChangePercent() < -0.1:
            changeStyleName = 'negativeChange'
        else:
            changeStyleName = 'positiveChange'

        changeWidget.setStyleName(changeStyleName)

    def onRemoteResponse(self, response, request_info):
        '''
        Called when a response is received from a RPC.
        '''
        if request_info.method in DataService.methods:
            # Compare self.stocks and the stocks in response
            stocks_set = set(self.stocks)
            response_set = set(response)
            # Add the differences
            for symbol in list(response_set.difference(stocks_set)):
                self.addStock(None, symbol)
        else:
            Window.alert('Unrecognized JSONRPC method.')

class DataService(JSONProxy):
    methods = ['getStocks', 'addStock', 'deleteStock']

    def __init__(self):
        JSONProxy.__init__(self, 'services/', DataService.methods)

if __name__ == '__main__':
    pyjd.setup('./StockWatcher.html')
    app = StockWatcher()
    app.onModuleLoad()
    pyjd.run()

Essentially it's all the same as last time, except now we import "JSONProxy" from "pyjamas.JSONService" then subclass it, change the code in the "addStock" method, and add the "onRemoteResponse" method. Now the stocks (if any) will be loaded when the site is viewed, and stocks will be saved/deleted on the server when the user does. Not bad, right?

...and that how one writes an entire web application in Python!

Again, you can view all of the code here and use the actual application here.

Thanks for reading.

UPDATE (6/5/2011): A commenter has notified me that in the most recent version of Pyjamas, you have to change RootPanel('stocksList').add(self.mainPanel) to RootPanel().add(self.mainPanel) in order to get this demo to work. I'll look into this soon and post another update.

Comments (10) Trackbacks (0)
  1. excellent article. Thank you very much

  2. Kind of disappointed that pyjamas doesn’t let you add python lists together. Also kinda disappointed that there is IE breakage :(

    Node.js here we go!

  3. But overall, nice article and very informative. Thanks Derek!

  4. Jay,

    Please use the latest git or wait a week or so for the 0.8 release … 0.7 is incredibly old and missing many improvements.

    I’m not able to test ATM but I’m pretty sure we support list concatenation, and we have several users who depend on proper IE functionality.

  5. Which parts of the IE functionality is broken? I use a Linux machine for developing and testing pyjamas and Django, so I have not really tested my apps under IE much for obvious reasons…

  6. The tutorial is pure win too! Thanks! I ran it on the git version of pyjamas and it works fine, though there is one tricky change I had to make:

    From
    RootPanel(‘stocklist’).add(self.mainPanel)
    To
    RootPanel().add(self.mainPanel) #removed stocklist

    I haven’t really looked into this, but it is probably a glitch in the newer version of pyjamas.

    Again — great stuff!

  7. Thanks, Pyjamas is quite awesome!

    Hmmm, interesting. I’ll make a note of that in the post, and try to find the cause.

  8. How do you serve Pyajamas output pages as Django views?

  9. Ok, that is great except the stock watcher doesn’t work. The prices are not the correct ones and they are changing on Saturday.

  10. That’s correct! Pulling real data would involve back-end work which was beyond the scope of this tutorial.


Leave a comment

(required)

No trackbacks yet.