commit a9935738595c7fcbf78696a67d9e31b45830297f Author: Tom Willemse Date: Sun Jun 30 22:38:05 2013 +0200 Initial commit 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 ; + } +}); + +var StoryData = React.createClass({ + render: function() { + if (this.props.data) { + return (
+ Assignee: {this.props.data.assignee} +
{this.props.data.content}
+
); + } + + 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 = ; + + return ( + + + + + + + + + {state} + + + + + As a {this.props.story.role}, I + {this.props.story.necessity} to + {this.props.story.title} + +
+ {sdata} + + + ); + }, + 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 ; + }.bind(this)); + return ( + + {storyNodes} +
+ ); + } +}); + +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 ( +
+
+ New story +
+
+ As a + + I + + to + +
+ +
+
+
+
+ ); + } +}); + +var StoryPage = React.createClass({ + handleStorySubmit: React.autoBind(function (story) { + $.ajax({ + url: "/stories/new", + type: "POST", + data: story, + dataType: 'json', + mimeType: 'textPlain' + }); + }), + render: function() { + return ( +
+ + +
+ ); + } +}); + +React.renderComponent( + , + 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))