Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

- Players can now change their nick.

- Improved anonymous player code.
- Now using static file instead of template for HTML.
- Design improvements.
  • Loading branch information...
commit 538f33f7af0c91a6ab65235d09ef289977f4648b 1 parent 959c15f
Blixt authored June 11, 2008
4  app.yaml
@@ -4,6 +4,10 @@ runtime: python
4 4
 api_version: 1
5 5
 
6 6
 handlers:
  7
+- url: /
  8
+  static_files: misc/home.html
  9
+  upload: misc/home\.html
  10
+
7 11
 - url: /favicon\.ico
8 12
   mime_type: image/gif
9 13
   static_files: misc/favicon.gif
91  css/site.css
@@ -5,16 +5,10 @@
5 5
     padding: 15px;
6 6
 }
7 7
 
8  
-#user {
9  
-    font-size: medium;
10  
-    line-height: 40px;
11  
-    position: absolute;
12  
-    right: 55px;
13  
-    top: 30px;
14  
-}
15  
-
16  
-#user a {
17  
-    color: #8f0;
  8
+#source {
  9
+    border-top: #080 solid 1px;
  10
+    margin: 0;
  11
+    padding-top: .5em;
18 12
 }
19 13
 
20 14
 * {
@@ -66,28 +60,34 @@ div.monkey button {
66 60
 }
67 61
 
68 62
 div.monkey div.game ol {
  63
+    border-bottom: #080 solid 1px;
69 64
     font-size: small;
70 65
     line-height: 1em;
71 66
     list-style: none;
72  
-    margin-bottom: 1em;
  67
+    margin-bottom: .7em;
73 68
     overflow: auto;
74 69
 }
75 70
 
76 71
 div.monkey div.game ol li {
77 72
     background: #efe;
  73
+    border-color: #080;
  74
+    border-style: solid;
  75
+    border-width: 1px 1px 0;
78 76
     color: #080;
79 77
     float: left;
80 78
     margin-right: .5em;
81  
-    padding: .3em;
  79
+    padding: .5em;
82 80
 }
83 81
 
84 82
 div.monkey div.game ol li.current {
85 83
     background: #ffe;
86  
-    color: #760;
  84
+    border-color: #a80;
  85
+    color: #a80;
87 86
 }
88 87
 
89 88
 div.monkey div.game ol li.open {
90 89
     background: #eee;
  90
+    border-color: #666;
91 91
     color: #666;
92 92
 }
93 93
 
@@ -179,6 +179,11 @@ div.monkey div.lobby .rule-set {
179 179
     width: 60%;
180 180
 }
181 181
 
  182
+div.monkey div.lobby tbody tr:hover {
  183
+    outline-style: solid;
  184
+    outline-width: 2px;
  185
+}
  186
+
182 187
 div.monkey div.lobby td {
183 188
     border-color: #eee;
184 189
     border-style: solid;
@@ -197,37 +202,59 @@ div.monkey div.lobby td.rule-set small {
197 202
     font-size: x-small;
198 203
 }
199 204
 
200  
-div.monkey div.lobby td.slot, div.monkey div.lobby td.slot button {
  205
+div.monkey div.lobby td.slot {
201 206
     font-size: small;
202 207
     line-height: 1em;
203 208
 }
204 209
 
  210
+div.monkey div.lobby td.slot span {
  211
+    color: #aaa;
  212
+    font-style: italic;
  213
+}
  214
+
205 215
 div.monkey div.lobby th {
206 216
     font-family: Georgia, serif;
207 217
 }
208 218
 
  219
+div.monkey div.lobby tr {
  220
+    outline-color: #ccc;
  221
+}
  222
+
209 223
 div.monkey div.lobby tr.aborted td {
210 224
     color: #aaa;
211 225
 }
212 226
 
  227
+div.monkey div.lobby tr.loss {
  228
+    outline-color: #c00;
  229
+}
  230
+
213 231
 div.monkey div.lobby tr.loss td {
214 232
     background: #fee;
215 233
     border-color: #fdd;
216 234
     color: #c00;
217 235
 }
218 236
 
  237
+div.monkey div.lobby tr.win {
  238
+    outline-color: #080;
  239
+}
  240
+
219 241
 div.monkey div.lobby tr.win td {
220 242
     background: #efe;
221 243
     border-color: #dfd;
222 244
     color: #080;
223 245
 }
224 246
 
  247
+div.monkey div.lobby tr.playing {
  248
+    outline-color: #fc0;
  249
+}
  250
+
225 251
 div.monkey div.lobby tr.playing td {
226 252
     background: #ffc;
227 253
     border-color: #fea;
228 254
 }
229 255
 
230 256
 div.monkey div.lobby ul {
  257
+    border-bottom: #080 solid 1px;
231 258
     list-style: none;
232 259
     margin: 1em 0 .5em;
233 260
     overflow: auto;
@@ -235,21 +262,51 @@ div.monkey div.lobby ul {
235 262
 
236 263
 div.monkey div.lobby ul a {
237 264
     background: #eee;
238  
-    border: #ccc solid 1px;
239  
-    color: #333;
  265
+    border-color: #aaa;
  266
+    border-style: solid;
  267
+    border-width: 1px 1px 0;
  268
+    color: #888;
240 269
     display: block;
  270
+    outline: none;
241 271
     padding: .2em .5em;
242 272
     text-decoration: none;
243 273
 }
244 274
 
245 275
 div.monkey div.lobby ul li {
246 276
     float: left;
247  
-    margin-right: .5em;
  277
+    margin-left: .3em;
  278
+}
  279
+
  280
+div.monkey div.lobby.past li.past a,
  281
+div.monkey div.lobby.play li.play a,
  282
+div.monkey div.lobby.view li.view a {
  283
+    background-color: #efe;
  284
+    border-color: #080;
  285
+    color: #080;
  286
+}
  287
+
  288
+div.monkey p.player {
  289
+    color: #fff;
  290
+    font-size: medium;
  291
+    line-height: 40px;
  292
+    position: absolute;
  293
+    right: 55px;
  294
+    top: 30px;
  295
+}
  296
+
  297
+div.monkey p.player a {
  298
+    color: #8f0;
  299
+}
  300
+
  301
+div.monkey p.player a.alert {
  302
+    background: #8f0;
  303
+    color: #333;
248 304
 }
249 305
 
250 306
 div.monkey select {
251 307
     font-size: large;
252 308
     margin-right: .5em;
  309
+    width: 200px;
253 310
 }
254 311
 
255 312
 div.monkey table {
18  index.yaml
@@ -10,7 +10,7 @@ indexes:
10 10
 # automatically uploaded to the admin console when you next deploy
11 11
 # your application using appcfg.py.
12 12
 
13  
-# Used 1478 times in query history.
  13
+# Used 290 times in query history.
14 14
 - kind: Game
15 15
   properties:
16 16
   - name: players
@@ -18,23 +18,15 @@ indexes:
18 18
   - name: last_update
19 19
     direction: desc
20 20
 
21  
-# Used 1250 times in query history.
  21
+# Used 274 times in query history.
22 22
 - kind: Game
23 23
   properties:
24 24
   - name: state
25 25
   - name: last_update
26 26
     direction: desc
27 27
 
28  
-# Used 2 times in query history.
29  
-- kind: Game
30  
-  properties:
31  
-  - name: state
32  
-  - name: players
33  
-
34 28
 # Used 6 times in query history.
35  
-- kind: Game
  29
+- kind: Player
36 30
   properties:
37  
-  - name: state
38  
-  - name: players
39  
-  - name: last_update
40  
-    direction: desc
  31
+  - name: session
  32
+  - name: expires
92  js/monkey.js
@@ -88,6 +88,10 @@ var MonkeyService = new Class({
88 88
         this.call('add_cpu_player', { game_id: gameId }, onSuccess, onError);
89 89
     },
90 90
 
  91
+    changeNick: function (newNick, onSuccess, onError) {
  92
+        this.call('change_nickname', { nickname: newNick }, onSuccess, onError);
  93
+    },
  94
+
91 95
     createGame: function (ruleSetId, onSuccess, onError) {
92 96
         this.call('create', { rule_set_id: ruleSetId }, onSuccess, onError);
93 97
     },
@@ -99,6 +103,10 @@ var MonkeyService = new Class({
99 103
     getRuleSets: function (onSuccess, onError) {
100 104
         this.call('rule_sets', {}, onSuccess, onError);
101 105
     },
  106
+    
  107
+    getPlayerInfo: function (onSuccess, onError) {
  108
+        this.call('get_player_info', {}, onSuccess, onError);
  109
+    },
102 110
 
103 111
     joinGame: function(gameId, onSuccess, onError) {
104 112
         this.call('join', { 'game_id': gameId }, onSuccess, onError);
@@ -124,7 +132,6 @@ var MonkeyClient = new Class({
124 132
 
125 133
         mc.game = null;
126 134
         mc.gameId = null;
127  
-        mc.listMode = 'play';
128 135
         mc.service = new MonkeyService();
129 136
 
130 137
         var ruleSets;
@@ -155,7 +162,9 @@ var MonkeyClient = new Class({
155 162
                 )
156 163
             ),
157 164
 
158  
-            main: new Element('div', { 'class': 'monkey' }).inject('body'),
  165
+            main: new Element('div', { 'class': 'monkey' }).adopt(
  166
+                mc.html.player = new Element('p', { 'class': 'player', text: 'Please wait...' })
  167
+            ).inject('body'),
159 168
 
160 169
             lobby: new Element('div', {
161 170
                 'class': 'lobby'
@@ -170,21 +179,21 @@ var MonkeyClient = new Class({
170 179
                     })
171 180
                 ),
172 181
                 new Element('ul').adopt(
173  
-                    new Element('li').adopt(new Element('a', {
  182
+                    new Element('li', { 'class': 'play' }).adopt(new Element('a', {
174 183
                         events: {
175 184
                             click: this.setListMode.bind(this, 'play')
176 185
                         },
177 186
                         href: '#play',
178 187
                         text: 'Play'
179 188
                     })),
180  
-                    new Element('li').adopt(new Element('a', {
  189
+                    new Element('li', { 'class': 'view' }).adopt(new Element('a', {
181 190
                         events: {
182 191
                             click: this.setListMode.bind(this, 'view')
183 192
                         },
184 193
                         href: '#view',
185 194
                         text: 'View'
186 195
                     })),
187  
-                    new Element('li').adopt(new Element('a', {
  196
+                    new Element('li', { 'class': 'past' }).adopt(new Element('a', {
188 197
                         events: {
189 198
                             click: this.setListMode.bind(this, 'past')
190 199
                         },
@@ -229,7 +238,9 @@ var MonkeyClient = new Class({
229 238
             ruleSets.value = list[0].id;
230 239
         });
231 240
 
232  
-        mc.setMode(MonkeyClient.Mode.lobby);
  241
+        mc.setMode(MonkeyClient.Mode.lobby, true);
  242
+        mc.setListMode('play');
  243
+        mc.refreshPlayer();
233 244
     },
234 245
     
235 246
     addCpuPlayer: function () {
@@ -258,6 +269,10 @@ var MonkeyClient = new Class({
258 269
             return new Element('td', {
259 270
                 'class': 'open slot'
260 271
             }).adopt(
  272
+                game.playing_as?
  273
+                new Element('span', {
  274
+                    text: 'Waiting for player to join...'
  275
+                }):
261 276
                 new Element('a', {
262 277
                     events: { click: this.joinGame.bind(this, game.id) },
263 278
                     href: '#' + game.id,
@@ -275,7 +290,7 @@ var MonkeyClient = new Class({
275 290
                 new Element('td', {
276 291
                     'class': 'no-games',
277 292
                     colspan: 3,
278  
-                    text: 'There are currently no open games.'
  293
+                    text: 'There are no games to show here.'
279 294
                 })
280 295
             ));
281 296
         } else {
@@ -339,8 +354,9 @@ var MonkeyClient = new Class({
339 354
                     status = 'This game needs more players before it can start.';
340 355
                     break;
341 356
                 case 'playing':
  357
+                    var nick = game.players[cp - 1];
342 358
                     status = 'This game is currently being played. It\'s ' +
343  
-                             (pa == cp ? 'your' : game.players[cp - 1] + '\'s') +
  359
+                             (pa == cp ? 'your' : nick + '\'' + (nick.substring(nick.length - 1).toLowerCase() == 's' ? '' : 's')) +
344 360
                              ' turn.';
345 361
                     break;
346 362
                 case 'aborted':
@@ -445,6 +461,47 @@ var MonkeyClient = new Class({
445 461
         }
446 462
     },
447 463
     
  464
+    handlePlayer: function (player) {
  465
+        var mc = this, s = mc.service;
  466
+
  467
+        mc.player = player;
  468
+
  469
+        mc.html.player.empty();
  470
+
  471
+        var a = new Element('a', {
  472
+            events: {
  473
+                click: function () {
  474
+                    var nick = prompt('New nickname', mc.player.nickname);
  475
+                    if (nick) s.changeNick(nick, mc.handlePlayer.bind(mc));
  476
+                }
  477
+            },
  478
+            href: '#',
  479
+            text: player.nickname
  480
+        }).inject(mc.html.player);
  481
+
  482
+        if (player.nickname == 'Anonymous') {
  483
+            var flasher = function (i) {
  484
+                a.set('text', 'Click here to change nickname!');
  485
+                a.toggleClass('alert');
  486
+                
  487
+                if (i-- > 0) {
  488
+                    flasher.delay(500, null, i);
  489
+                } else {
  490
+                    a.set('text', mc.player.nickname);
  491
+                }
  492
+            };
  493
+
  494
+            flasher(7);
  495
+        }
  496
+
  497
+        mc.html.player.appendText(' — ');
  498
+
  499
+        new Element('a', {
  500
+            href: player.log_url,
  501
+            text: player.anonymous ? 'Log in' : 'Log out'
  502
+        }).inject(mc.html.player);
  503
+    },
  504
+    
448 505
     joinGame: function (gameId) {
449 506
         var mc = this;
450 507
         mc.service.joinGame(gameId, function (game) {
@@ -517,12 +574,27 @@ var MonkeyClient = new Class({
517 574
         }
518 575
     },
519 576
     
  577
+    refreshPlayer: function () {
  578
+        this.service.getPlayerInfo(this.handlePlayer.bind(this));
  579
+    },
  580
+    
520 581
     setListMode: function (newMode) {
  582
+        this.html.gameList.empty().adopt(
  583
+            new Element('tr').adopt(
  584
+                new Element('td', {
  585
+                    'class': 'no-games',
  586
+                    colspan: 3,
  587
+                    text: 'Loading...'
  588
+                })
  589
+            )
  590
+        );
  591
+
  592
+        this.html.lobby.set('class', 'lobby ' + newMode);
521 593
         this.listMode = newMode;
522 594
         this.refresh();
523 595
     },
524 596
     
525  
-    setMode: function (newMode) {
  597
+    setMode: function (newMode, skipRefresh) {
526 598
         switch (this.mode) {
527 599
             case MonkeyClient.Mode.lobby:
528 600
                 this.html.lobby.dispose();
@@ -559,7 +631,7 @@ var MonkeyClient = new Class({
559 631
                 break;
560 632
         }
561 633
         
562  
-        this.refresh();
  634
+        if (!skipRefresh) this.refresh();
563 635
     }
564 636
 });
565 637
 
24  main.py
@@ -22,6 +22,7 @@
22 22
 in this file.
23 23
 """
24 24
 
  25
+from google.appengine.api import users
25 26
 from google.appengine.ext import db, webapp
26 27
 
27 28
 import wsgiref.handlers
@@ -41,6 +42,11 @@ def add_cpu_player(self, game_id):
41 42
         cpu.player.join(game)
42 43
 
43 44
         return self.status(game_id)
  45
+
  46
+    def change_nickname(self, nickname):
  47
+        player = monkey.Player.get_current(self)
  48
+        player.rename(nickname)
  49
+        return self.get_player_info()
44 50
         
45 51
     def create(self, rule_set_id):
46 52
         """Creates a new game.
@@ -55,6 +61,23 @@ def create(self, rule_set_id):
55 61
         player.join(game)
56 62
 
57 63
         return game.key().id()
  64
+
  65
+    def get_player_info(self):
  66
+        """Gets information about the currently logged in player.
  67
+        """
  68
+        user = users.get_current_user()
  69
+        if user:
  70
+            log_url = users.create_logout_url('/')
  71
+        else:
  72
+            log_url = users.create_login_url('/')
  73
+        
  74
+        player = monkey.Player.get_current(self)
  75
+        return { 'nickname': player.nickname,
  76
+                 'anonymous': player.is_anonymous(),
  77
+                 'log_url': log_url,
  78
+                 'wins': player.wins,
  79
+                 'losses': player.losses,
  80
+                 'draws': player.draws }
58 81
     
59 82
     def join(self, game_id):
60 83
         """Joins an existing game.
@@ -199,7 +222,6 @@ def get(self):
199 222
 
200 223
 def main():
201 224
     application = webapp.WSGIApplication([
202  
-        ('/', HomePage),
203 225
         ('/game/.*', GameService)
204 226
     ])
205 227
     wsgiref.handlers.CGIHandler().run(application)
17  misc/home.html
... ...
@@ -0,0 +1,17 @@
  1
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
  2
+<html xmlns="http://www.w3.org/1999/xhtml">
  3
+<head>
  4
+<title>Play &laquo; MoNKey!</title>
  5
+<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  6
+<link href="/site.css" rel="stylesheet" type="text/css" />
  7
+<script src="/mootools.js" type="text/javascript"></script>
  8
+<script src="/monkey.js" type="text/javascript"></script>
  9
+</head>
  10
+<body>
  11
+<h1><a href="/">MoNKey!</a></h1>
  12
+<div id="body">
  13
+<script type="text/javascript">var monkey = new MonkeyClient()</script>
  14
+<p id="source">See full source at <a href="http://code.google.com/p/monkey-web/">http://code.google.com/p/monkey-web/</a></p>
  15
+</div>
  16
+</body>
  17
+</html>
62  monkey.py
@@ -35,7 +35,8 @@
35 35
 from google.appengine.api import users
36 36
 from google.appengine.ext import db
37 37
 
38  
-import datetime, hashlib, random, string, time, util, uuid
  38
+from datetime import datetime, timedelta
  39
+import hashlib, random, re, string, time, util, uuid
39 40
 
40 41
 class Error(Exception):
41 42
     """Base of all exceptions in the monkey module."""
@@ -256,6 +257,7 @@ class Player(db.Model):
256 257
     losses = db.IntegerProperty(default = 0)
257 258
     wins = db.IntegerProperty(default = 0)
258 259
     session = db.StringProperty()
  260
+    expires = db.DateTimeProperty()
259 261
 
260 262
     @staticmethod
261 263
     def from_user(user, nickname = None):
@@ -280,19 +282,26 @@ def get_current(handler = None):
280 282
             player = Player.from_user(curuser)
281 283
         else:
282 284
             try:
  285
+                # User has a session.
283 286
                 session = handler.request.cookies['session']
284  
-                player = Player.gql('WHERE session = :1', session).get()
  287
+                query = Player.all()
  288
+                query.filter('session =', session)
  289
+                query.filter('expires >', datetime.utcnow())
  290
+                player = query.get()
285 291
             except KeyError:
286 292
                 player = None
287 293
 
288 294
             if not player:
289  
-                anon_id = random.randint(10000, 99999)
290  
-                player = Player.from_user(users.User('anon%d@mnk' % (anon_id)),
291  
-                                          'Anonymous%d' % (anon_id))
  295
+                # Create a new anonymous player.
  296
+                player = Player(user = users.User('anonymous@mnk'),
  297
+                                nickname = 'Anonymous')
292 298
                 player.start_session(handler)
293 299
 
294 300
         return player
295 301
 
  302
+    def is_anonymous(self):
  303
+        return self.user == users.User('anonymous@mnk')
  304
+
296 305
     def join(self, game):
297 306
         """Convenience method for adding a player to a game.
298 307
         """
@@ -303,19 +312,41 @@ def leave(self, game):
303 312
         """
304 313
         game.remove_player(self)
305 314
 
  315
+    def rename(self, nickname):
  316
+        """Changes the nickname of the player.
  317
+        """
  318
+        if not re.match('^[A-Za-z]([\\-\\._ ]?[A-Z0-9a-z]+)*$', nickname):
  319
+            raise ValueError('Invalid nickname.')
  320
+        if len(nickname) < 3:
  321
+            raise ValueError('Nickname too short.')
  322
+        if len(nickname) > 20:
  323
+            raise ValueError('Nickname too long.')
  324
+
  325
+        self.nickname = nickname
  326
+        self.put()
  327
+
  328
+        # This results in very long query times and might have to be disabled.
  329
+        # Everything would still work, it's just that games created before the
  330
+        # player changed nickname will still show the old nickname.
  331
+        games = Game.all().filter('players =', self.key())
  332
+        for game in games:
  333
+            game.update_player_names()
  334
+            game.put()
  335
+
306 336
     def start_session(self, handler):
307 337
         """Gives the player a session id and stores it as a cookie in the user's
308 338
         browser.
309 339
         """
310 340
         self.session = uuid.uuid4().get_hex()
  341
+        self.expires = datetime.utcnow() + timedelta(days = 7)
311 342
         self.put()
312 343
 
313 344
         # Build and set cookie
314  
-        future = datetime.datetime.utcnow() + datetime.timedelta(days = 7)
315  
-        expires = time.strftime('%a, %d-%b-%Y %H:%M:%S GMT', future.timetuple())
316  
-        cookie = '%s=%s; expires=%s' % ('session', self.session, expires)
317  
-        handler.response.headers['Set-Cookie'] = cookie
  345
+        ts = time.strftime('%a, %d-%b-%Y %H:%M:%S GMT',
  346
+                           self.expires.timetuple())
  347
+        cookie = '%s=%s; expires=%s' % ('session', self.session, ts)
318 348
 
  349
+        handler.response.headers['Set-Cookie'] = cookie
319 350
         handler.request.cookies['session'] = self.session
320 351
 
321 352
 class RuleSet(db.Model):
@@ -398,7 +429,7 @@ class Game(db.Model):
398 429
                                     required = True,
399 430
                                     collection_name = 'games')
400 431
     added = db.DateTimeProperty(auto_now_add = True)
401  
-    last_update = db.DateTimeProperty(auto_now = True)
  432
+    last_update = db.DateTimeProperty(auto_now_add = True)
402 433
 
403 434
     def add_player(self, player):
404 435
         """Adds a player to the game and starts the game if it has enough
@@ -421,7 +452,7 @@ def add_player(self, player):
421 452
             self.current_player = 1
422 453
 
423 454
         self.update_player_names()
424  
-        self.put()
  455
+        self.put(True)
425 456
         self.handle_cpu()
426 457
 
427 458
     def handle_cpu(self):
@@ -488,7 +519,7 @@ def move(self, player, x, y):
488 519
         else:
489 520
             self.current_player = rs.whose_turn(self.turn)
490 521
 
491  
-        self.put()
  522
+        self.put(True)
492 523
         self.handle_cpu()
493 524
 
494 525
     def pack_board(self):
@@ -500,7 +531,7 @@ def pack_board(self):
500 531
                                   for y in xrange(self.rule_set.n)], '')
501 532
                      for x in xrange(self.rule_set.m)]
502 533
 
503  
-    def put(self):
  534
+    def put(self, update_time = False):
504 535
         """Does some additional processing before the entity is stored to the
505 536
         data store.
506 537
         """
@@ -511,6 +542,7 @@ def put(self):
511 542
             self.data = ['0' * self.rule_set.m
512 543
                          for i in xrange(self.rule_set.n)]
513 544
 
  545
+        if update_time: self.last_update = datetime.utcnow()
514 546
         db.Model.put(self)
515 547
 
516 548
     def remove_player(self, player):
@@ -524,13 +556,13 @@ def remove_player(self, player):
524 556
             if len(self.players) > 1:
525 557
                 self.players.remove(player.key())
526 558
                 self.update_player_names()
527  
-                self.put()
  559
+                self.put(True)
528 560
             else:
529 561
                 self.delete()
530 562
         elif self.state == 'playing':
531 563
             self.state = 'aborted'
532 564
             self.turn = -1
533  
-            self.put()
  565
+            self.put(True)
534 566
         else:
535 567
             raise LeaveError('Cannot leave game.')
536 568
 
2  templates/home.html
@@ -8,5 +8,5 @@
8 8
 
9 9
 {% block content %}
10 10
 <script type="text/javascript">var monkey = new MonkeyClient()</script>
11  
-<p>See full source at <a href="http://code.google.com/p/monkey-web/">http://code.google.com/p/monkey-web/</a>.</p>
  11
+<p id="source">See full source at <a href="http://code.google.com/p/monkey-web/">http://code.google.com/p/monkey-web/</a>.</p>
12 12
 {% endblock %}

0 notes on commit 538f33f

Please sign in to comment.
Something went wrong with that request. Please try again.