aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Tom Willemse2013-06-30 22:38:05 +0200
committerGravatar Tom Willemse2013-07-01 00:01:48 +0200
commita9935738595c7fcbf78696a67d9e31b45830297f (patch)
treeb8c1a46d3ee8280af3dc46129975dcff8c628a5e
downloadscrumli-a9935738595c7fcbf78696a67d9e31b45830297f.tar.gz
scrumli-a9935738595c7fcbf78696a67d9e31b45830297f.zip
Initial commit
-rw-r--r--.gitignore1
-rw-r--r--defmodule.lisp46
-rw-r--r--pg-datastore.lisp65
-rw-r--r--scrumli.asd14
-rw-r--r--scrumli.lisp153
-rwxr-xr-xstart7
-rw-r--r--static/js/login.js12
-rw-r--r--static/js/main.js215
-rw-r--r--util.lisp9
9 files changed, 522 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ed4e90b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.emacs.desktop*
diff --git a/defmodule.lisp b/defmodule.lisp
new file mode 100644
index 0000000..d3756c9
--- /dev/null
+++ b/defmodule.lisp
@@ -0,0 +1,46 @@
+(restas:define-policy datastore
+ (:interface-package #:scrumli.policy.datastore)
+ (:interface-method-template "DATASTORE-~A")
+ (:internal-package #:scrumli.datastore)
+
+ (define-method init ()
+ "Initiate the datastore.")
+
+ (define-method get-all-stories ()
+ "Get all of the stories in the datastore.")
+
+ (define-method get-story (id)
+ "Get a story from the datastore.")
+
+ (define-method post-story (role necessity title content reporter)
+ "Post a new story.")
+
+ (define-method story-get-state (id)
+ "Get the state of a story.")
+
+ (define-method story-set-state (id state)
+ "Set the state of a story.")
+
+ (define-method story-change-priority (id dir)
+ "Change the priority of a story in direction DIR."))
+
+(restas:define-module #:scrumli
+ (:use #:cl #:restas #:json #:scrumli.datastore #:drakma)
+ (:export #:start-scrumli))
+
+(defpackage #:scrumli.pg-datastore
+ (:use #:cl #:postmodern #:scrumli.policy.datastore)
+ (:export #:pg-datastore))
+
+(in-package #:scrumli)
+
+(defparameter *static-directory*
+ (merge-pathnames #P"static/" scrumli-config:*base-directory*))
+
+(eval-when (:compile-toplevel :load-toplevel :execute)
+ (sexml:with-compiletime-active-layers
+ (sexml:standard-sexml sexml:xml-doctype)
+ (sexml:support-dtd
+ (merge-pathnames "html5.dtd" (asdf:system-source-directory "sexml"))
+ :<)))
+(<:augment-with-doctype "html" "" :auto-emit-p t)
diff --git a/pg-datastore.lisp b/pg-datastore.lisp
new file mode 100644
index 0000000..d9eb8b2
--- /dev/null
+++ b/pg-datastore.lisp
@@ -0,0 +1,65 @@
+(in-package #:scrumli.pg-datastore)
+
+(defclass pg-datastore ()
+ ((connection-spec :initarg :connection-spec
+ :accessor connection-spec)))
+
+(defclass story ()
+ ((id :col-type serial :reader story-id)
+ (state :col-type string :reader state :initform "TODO")
+ (role :col-type string :reader role :initarg :role)
+ (necessity :col-type string :reader necessity :initarg :necessity)
+ (title :col-type string :reader title :initarg :title)
+ (priority :col-type integer :reader priority :initarg :priority)
+ (content :col-type string :reader content :initarg :content)
+ (reporter :col-type string :reader reporter :initarg :reporter)
+ (assignee :col-type string :reader assignee :initarg :assignee))
+ (:metaclass dao-class)
+ (:keys id))
+
+(defmethod datastore-init ((datastore pg-datastore))
+ (with-connection (connection-spec datastore)
+ (unless (table-exists-p 'story)
+ (execute (dao-table-definition 'story)))))
+
+(defmethod datastore-get-all-stories ((datastore pg-datastore))
+ (with-connection (connection-spec datastore)
+ (query (:order-by (:select :* :from 'story) 'priority) :alists)))
+
+(defmethod datastore-get-story ((datastore pg-datastore) id)
+ (with-connection (connection-spec datastore)
+ (query (:select :* :from 'story :where (:= 'id id)) :alist)))
+
+(defmethod datastore-post-story
+ ((datastore pg-datastore) role necessity title content reporter)
+ (format t "~s:~s:~s:~s:~s~%" role necessity title content reporter)
+ (with-connection (connection-spec datastore)
+ (let ((obj (make-instance
+ 'story :role role :necessity necessity :title title
+ :priority (+ 1 (or (query (:select
+ (:coalesce (:max 'priority) 0)
+ :from 'story) :single)
+ 0))
+ :content content :assignee "" :reporter reporter)))
+ (save-dao obj))))
+
+(defmethod datastore-story-get-state ((datastore pg-datastore) id)
+ (with-connection (connection-spec datastore)
+ (query (:select 'state :from 'story :where (:= 'id id)) :single)))
+
+(defmethod datastore-story-set-state ((datastore pg-datastore) id state)
+ (with-connection (connection-spec datastore)
+ (execute (:update 'story :set 'state state :where (:= 'id id)))))
+
+(defmethod datastore-story-change-priority
+ ((datastore pg-datastore) id dir)
+ (with-connection (connection-spec datastore)
+ (let* ((current-priority (query (:select 'priority :from 'story
+ :where (:= 'id id))
+ :single))
+ (next-priority (funcall (ecase dir (:up #'-) (:down #'+))
+ current-priority 1)))
+ (execute (:update 'story :set 'priority current-priority
+ :where (:= 'priority next-priority)))
+ (execute (:update 'story :set 'priority next-priority
+ :where (:= 'id id))))))
diff --git a/scrumli.asd b/scrumli.asd
new file mode 100644
index 0000000..083f882
--- /dev/null
+++ b/scrumli.asd
@@ -0,0 +1,14 @@
+(defpackage #:scrumli-config (:export #:*base-directory*))
+(defparameter scrumli-config:*base-directory*
+ (make-pathname :name nil :type nil :defaults *load-truename*))
+
+(asdf:defsystem #:scrumli
+ :serial t
+ :description "Scrum with Lisp"
+ :author "Tom Willemse"
+ :license "AGPLv3"
+ :depends-on (:restas :sexml :postmodern :cl-json :drakma)
+ :components ((:file "defmodule")
+ (:file "pg-datastore")
+ (:file "util")
+ (:file "scrumli")))
diff --git a/scrumli.lisp b/scrumli.lisp
new file mode 100644
index 0000000..39a7039
--- /dev/null
+++ b/scrumli.lisp
@@ -0,0 +1,153 @@
+(in-package #:scrumli)
+
+(defvar *scrumli-host* "http://localhost:8080"
+ "The host currently running Scrumli. Used by Mozilla Persona.")
+
+(defvar *scrumelo-bootstrap-css-location*
+ "http://netdna.bootstrapcdn.com/twitter-bootstrap/2.3.2/css/bootstrap-combined.no-icons.min.css"
+ "The location of the twitter bootstrap CSS file.")
+
+(defvar *scrumelo-bootstrap-js-location*
+ "http://netdna.bootstrapcdn.com/twitter-bootstrap/2.3.2/js/bootstrap.min.js"
+ "The location of the twitter bootstrap JS file.")
+
+(defvar *scrumelo-font-awesome-css-location*
+ "http://netdna.bootstrapcdn.com/font-awesome/3.1.1/css/font-awesome.min.css"
+ "The location of the font awesome CSS file.")
+
+(defvar *scrumelo-jquery-js-location*
+ "http://code.jquery.com/jquery-2.0.0.min.js"
+ "The location of the jQuery JS file.")
+
+(defvar *scrumelo-react-js-location*
+ "http://cdnjs.cloudflare.com/ajax/libs/react/0.3.2/react.min.js"
+ "The location of the React JS file.")
+
+(defvar *scrumelo-jsxtransformer-js-location*
+ "http://cdnjs.cloudflare.com/ajax/libs/react/0.3.2/JSXTransformer.js"
+ "The location of the JSX Transformer JS file.")
+
+(defun logged-in-p ()
+ (hunchentoot:session-value :username))
+
+(define-route main ("")
+ (if (logged-in-p)
+ (<:html
+ (<:head (<:title "Hello!")
+ (<:link :href *scrumelo-bootstrap-css-location*
+ :rel "stylesheet" :type "text/css")
+ (<:link :href *scrumelo-font-awesome-css-location*
+ :rel "stylesheet" :type "text/css")
+ (<:script :type "text/javascript"
+ :src *scrumelo-bootstrap-js-location*)
+ (<:script :type "text/javascript"
+ :src *scrumelo-jquery-js-location*)
+ (<:script :type "text/javascript"
+ :src *scrumelo-react-js-location*)
+ (<:script :type "text/javascript"
+ :src *scrumelo-jsxtransformer-js-location*))
+ (<:body
+ (<:a :href "/logout" "Logout") " "
+ (hunchentoot:session-value :username)
+ (<:div :class "container"
+ (<:h1 "Backlog")
+ (<:div :id "content")
+ (<:script :type "text/jsx" :src "js/main.js"))))
+ (redirect 'login-page)))
+
+(define-route react-ui ("js/main.js"
+ :content-type "application/ecmascript")
+ (merge-pathnames "js/main.js" *static-directory*))
+
+(define-route login-js ("js/login.js"
+ :content-type "application/ecmascript")
+ (merge-pathnames "js/login.js" *static-directory*))
+
+(define-route stories-json ("stories" :content-type "text/json")
+ (if (logged-in-p)
+ (with-output-to-string (out)
+ (encode-json (get-all-stories) out))
+ 403))
+
+(define-route stories-new ("stories/new" :method :post)
+ (if (logged-in-p)
+ (let ((role (hunchentoot:post-parameter "role"))
+ (necessity (hunchentoot:post-parameter "necessity"))
+ (title (hunchentoot:post-parameter "headline"))
+ (content (hunchentoot:post-parameter "content")))
+ (format t "~s;~s;~s;~s;~s~%" role necessity title content
+ (hunchentoot:session-value :username))
+ (post-story role necessity title content
+ (hunchentoot:session-value :username))
+ 200)
+ 403))
+
+(define-route stories-state ("stories/state" :method :post)
+ (if (logged-in-p)
+ (let* ((id (hunchentoot:post-parameter "id"))
+ (current-state (story-get-state id)))
+ (story-set-state id (ecase (intern current-state :scrumli)
+ (todo "DOING")
+ (doing "DONE")
+ (done "TODO")))
+ 200)
+ 403))
+
+(define-route stories-priority ("stories/:dir" :method :post)
+ (if (logged-in-p)
+ (let* ((id (hunchentoot:post-parameter "id")))
+ (story-change-priority id (intern (string-upcase dir) :keyword))
+ 200)
+ 403))
+
+(define-route login-page ("login")
+ (if (not (logged-in-p))
+ (<:html :lang "en"
+ (<:head (<:meta :charset "utf-8")
+ (<:title "Login")
+ (<:script :src "https://login.persona.org/include.js")
+ (<:script :src "/js/login.js"))
+ (<:body
+ (<:form :id "login-form" :method "POST" :action ""
+ (<:input :id "assertion-field" :type "hidden"
+ :name "assertion" :value ""))
+ (<:p (<:a :href "javascript:login()" "Login"))))
+ (redirect 'main)))
+
+(define-route logout-page ("logout")
+ (if (logged-in-p)
+ (progn
+ (setf (hunchentoot:session-value :username) nil)))
+ (redirect 'login-page))
+
+(defun json-to-string (obj)
+ (with-output-to-string (out)
+ (encode-json obj out)))
+
+(defun verify-credentials (audience assertion)
+ (let ((response
+ (http-request "https://verifier.login.persona.org/verify"
+ :method :post :content-type "application/json"
+ :content (json-to-string
+ `(("assertion" . ,assertion)
+ ("audience" . ,audience)))
+ :want-stream t)))
+ (decode-json response)))
+
+(define-route login-page/post ("login" :method :post)
+ (let ((result (verify-credentials
+ *scrumli-host*
+ (hunchentoot:post-parameter "assertion"))))
+ (if (equal (cdr (assoc :status result)) "okay")
+ (progn
+ (hunchentoot:start-session)
+ (setf (hunchentoot:session-value :username)
+ (cdr (assoc :email result)))
+ (redirect 'main))
+ 403)))
+
+(define-route scrumelo-story ("stories/:id")
+ (if (logged-in-p)
+ (with-output-to-string (out)
+ (encode-json (get-story id) out))
+ 403))
diff --git a/start b/start
new file mode 100755
index 0000000..ec4087d
--- /dev/null
+++ b/start
@@ -0,0 +1,7 @@
+#!/bin/bash
+
+sbcl --load scrumli.asd \
+ --eval "(asdf:operate 'asdf:load-op \"scrumli\")" \
+ --eval "(scrumli:start-scrumli \
+ :datastore-init '(:connection-spec \
+ (\"scrumli\" \"slash\" nil \"localhost\")))"
diff --git a/static/js/login.js b/static/js/login.js
new file mode 100644
index 0000000..bbd2a19
--- /dev/null
+++ b/static/js/login.js
@@ -0,0 +1,12 @@
+function login ()
+{
+ navigator.id.get(function(assertion) {
+ if (assertion) {
+ var assertion_field =
+ document.getElementById("assertion-field");
+ assertion_field.value = assertion;
+ var login_form = document.getElementById("login-form");
+ login_form.submit();
+ }
+ });
+}
diff --git a/static/js/main.js b/static/js/main.js
new file mode 100644
index 0000000..0bc589a
--- /dev/null
+++ b/static/js/main.js
@@ -0,0 +1,215 @@
+/** @jsx React.DOM */
+var StateIcon = React.createClass({
+ render: function() {
+ var icon_names = {"TODO": "icon-check-empty",
+ "DOING": "icon-sign-blank",
+ "DONE": "icon-check"};
+ return <i class={icon_names[this.props.state]}></i>;
+ }
+});
+
+var StoryData = React.createClass({
+ render: function() {
+ if (this.props.data) {
+ return (<div>
+ Assignee: {this.props.data.assignee}
+ <pre>{this.props.data.content}</pre>
+ </div>);
+ }
+
+ return null;
+ }
+});
+
+var StoryRow = React.createClass({
+ render: function() {
+ // A little ugly to get a space, but I don't know of any other
+ // way.
+ var state = " " + this.state.state;
+ var sdata = null;
+
+ if (this.state.content)
+ sdata = <StoryData data={this.state.content} />;
+
+ return (
+ <tr>
+ <td>
+ <i class="icon-arrow-up" onClick={this.moveUp}></i>
+ <i class="icon-arrow-down" onClick={this.moveDown}></i>
+ </td>
+ <td>
+ <span onClick={this.changeState}>
+ <StateIcon state={this.state.state} />
+ {state}
+ </span>
+ </td>
+ <td>
+ <a onClick={this.handleClick}>
+ As a {this.props.story.role}, I
+ {this.props.story.necessity} to
+ {this.props.story.title}
+ </a>
+ <br />
+ {sdata}
+ </td>
+ </tr>
+ );
+ },
+ getInitialState: function() {
+ return {state: this.props.story.state,
+ content: null};
+ },
+ handleClick: React.autoBind(function(event) {
+ if (!!this.state.content) {
+ this.setState({content: null});
+ return;
+ }
+ var self = this;
+
+ $.get('/stories/' + this.props.story.id, null,
+ function (data, textStatus, jqXHR) {
+ self.setState({content: data});
+ }, 'json');
+ }),
+ changeState: React.autoBind(function(event) {
+ $.ajax({
+ url: "/stories/state",
+ type: "POST",
+ data: {'id': this.props.story.id},
+ dataType: 'json',
+ mimeType: 'textPlain',
+ success: function(data) {
+ this.setState({state: eval(data).state});
+ }.bind(this)
+ });
+ }),
+ moveUp: React.autoBind(function(event) {
+ $.ajax({
+ url: "/stories/up",
+ type: "POST",
+ data: {'id': this.props.story.id},
+ dataType: 'json',
+ mimeType: 'textPlain',
+ success: function (data) {
+ this.props.onMoved(1);
+ }.bind(this)
+ });
+ }),
+ moveDown: React.autoBind(function(event) {
+ $.ajax({
+ url: "/stories/down",
+ type: "POST",
+ data: {'id': this.props.story.id},
+ dataType: 'json',
+ mimeType: 'textPlain',
+ success: function (data) {
+ this.props.onMoved(-1);
+ }.bind(this)
+ });
+ })
+});
+
+var StoryTable = React.createClass({
+ loadStoriesFromServer: function() {
+ $.ajax({
+ url: this.props.url,
+ mimeType: 'textPlain',
+ success: function(data) {
+ this.setState({data: eval(data)});
+ }.bind(this)
+ });
+ },
+ getInitialState: function() {
+ return {data: []};
+ },
+ componentWillMount: function() {
+ this.loadStoriesFromServer();
+ setInterval(
+ this.loadStoriesFromServer.bind(this),
+ this.props.pollInterval
+ );
+ },
+ handleMoved: React.autoBind(function(direction) {
+ this.loadStoriesFromServer();
+ }),
+ render: function() {
+ var storyNodes = this.state.data.map(function (story) {
+ return <StoryRow story={story} onMoved={this.handleMoved} />;
+ }.bind(this));
+ return (
+ <table class="table table-striped">
+ {storyNodes}
+ </table>
+ );
+ }
+});
+
+var StoryForm = React.createClass({
+ handleSubmit: React.autoBind(function() {
+ var role = this.refs.role.getDOMNode().value.trim();
+ var necessity = this.refs.necessity.getDOMNode().value.trim();
+ var headline = this.refs.headline.getDOMNode().value.trim();
+ var content = this.refs.content.getDOMNode().value.trim();
+
+ this.props.onStorySubmit({role: role,
+ necessity: necessity,
+ headline: headline,
+ content: content});
+
+ this.refs.role.getDOMNode().value = '';
+ this.refs.necessity.getDOMNode().value = '';
+ this.refs.headline.getDOMNode().value = '';
+ this.refs.content.getDOMNode().value = '';
+
+ return false;
+ }),
+ render: function() {
+ return (
+ <form onSubmit={this.handleSubmit}>
+ <fieldset>
+ <legend class="toggle">New story</legend>
+ <div id="new-story">
+ <div class="input-prepend input-append">
+ <span class="add-on">As a </span>
+ <input type="text" class="input-medium" ref="role"
+ placeholder="person" />
+ <span class="add-on"> I </span>
+ <input type="text" class="input-small"
+ ref="necessity" placeholder="would like" />
+ <span class="add-on"> to </span>
+ <input type="text" class="input-xxlarge"
+ ref="headline" placeholder="fill in this form..." />
+ <button class="btn" type="submit">!</button><br />
+ <textarea ref="content"></textarea>
+ </div>
+ </div>
+ </fieldset>
+ </form>
+ );
+ }
+});
+
+var StoryPage = React.createClass({
+ handleStorySubmit: React.autoBind(function (story) {
+ $.ajax({
+ url: "/stories/new",
+ type: "POST",
+ data: story,
+ dataType: 'json',
+ mimeType: 'textPlain'
+ });
+ }),
+ render: function() {
+ return (
+ <div>
+ <StoryTable url="/stories" pollInterval={5000} />
+ <StoryForm onStorySubmit={this.handleStorySubmit} />
+ </div>
+ );
+ }
+});
+
+React.renderComponent(
+ <StoryPage />,
+ document.getElementById('content')
+);
diff --git a/util.lisp b/util.lisp
new file mode 100644
index 0000000..4cadf53
--- /dev/null
+++ b/util.lisp
@@ -0,0 +1,9 @@
+(in-package #:scrumli)
+
+(defun start-scrumli (&key
+ (port 8080)
+ (datastore 'scrumli.pg-datastore:pg-datastore)
+ datastore-init)
+ (setf *datastore* (apply #'make-instance datastore datastore-init))
+ (init)
+ (start '#:scrumli :port port))