Implement more RESTful URLs for motions

This commit implements URLs /motions/ and /motions/{:tag} handlers.
debian
Jan Dittberner 8 years ago
parent 74987ce184
commit f4360b98c8

@ -1,25 +1,26 @@
package main package main
import ( import (
"crypto/tls"
"crypto/x509"
"database/sql"
"fmt" "fmt"
"log" "github.com/Masterminds/sprig"
"strings" "github.com/jmoiron/sqlx"
"net/http"
"io/ioutil"
"time"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
"github.com/jmoiron/sqlx"
"github.com/Masterminds/sprig"
"os"
"crypto/x509"
"crypto/tls"
"database/sql"
"html/template" "html/template"
"io/ioutil"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
) )
const ( const (
list_decisions_sql = ` sqlGetDecisions = `
SELECT decisions.id, decisions.tag, decisions.proponent, SELECT decisions.id, decisions.tag, decisions.proponent,
voters.name AS proposer, decisions.proposed, decisions.title, voters.name AS proposer, decisions.proposed, decisions.title,
decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.content, decisions.votetype, decisions.status, decisions.due,
@ -27,24 +28,28 @@ SELECT decisions.id, decisions.tag, decisions.proponent,
FROM decisions FROM decisions
JOIN voters ON decisions.proponent=voters.id JOIN voters ON decisions.proponent=voters.id
ORDER BY proposed DESC ORDER BY proposed DESC
LIMIT 10 OFFSET 10 * ($1 - 1)` LIMIT 10 OFFSET 10 * $1`
get_decision_sql = ` sqlGetDecision = `
SELECT decisions.id, decisions.tag, decisions.proponent, SELECT decisions.id, decisions.tag, decisions.proponent,
voters.name AS proposer, decisions.proposed, decisions.title, voters.name AS proposer, decisions.proposed, decisions.title,
decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.content, decisions.votetype, decisions.status, decisions.due,
decisions.modified decisions.modified
FROM decisions FROM decisions
JOIN voters ON decisions.proponent=voters.id JOIN voters ON decisions.proponent=voters.id
WHERE decisions.id=$1;` WHERE decisions.tag=$1;`
get_voter = ` sqlGetVoter = `
SELECT voters.id, voters.name SELECT voters.id, voters.name
FROM voters FROM voters
JOIN emails ON voters.id=emails.voter JOIN emails ON voters.id=emails.voter
WHERE emails.address=$1 AND voters.enabled=1` WHERE emails.address=$1 AND voters.enabled=1`
vote_count_sql = ` sqlVoteCount = `
SELECT vote, COUNT(vote) SELECT vote, COUNT(vote)
FROM votes FROM votes
WHERE decision=$1` WHERE decision=$1 GROUP BY vote`
sqlCountNewerOlderThanMotion = `
SELECT "newer" AS label, COUNT(*) AS value FROM decisions WHERE proposed > $1
UNION
SELECT "older", COUNT(*) FROM decisions WHERE proposed < $2`
) )
var db *sqlx.DB var db *sqlx.DB
@ -65,16 +70,21 @@ type VoteType int
func (v VoteType) String() string { func (v VoteType) String() string {
switch v { switch v {
case voteTypeMotion: return "motion" case voteTypeMotion:
case voteTypeVeto: return "veto" return "motion"
default: return "unknown" case voteTypeVeto:
return "veto"
default:
return "unknown"
} }
} }
func (v VoteType) QuorumAndMajority() (int, int) { func (v VoteType) QuorumAndMajority() (int, int) {
switch v { switch v {
case voteTypeMotion: return 3, 50 case voteTypeMotion:
default: return 1, 99 return 3, 50
default:
return 1, 99
} }
} }
@ -92,11 +102,16 @@ type VoteStatus int
func (v VoteStatus) String() string { func (v VoteStatus) String() string {
switch v { switch v {
case -1: return "declined" case -1:
case 0: return "pending" return "declined"
case 1: return "approved" case 0:
case -2: return "withdrawn" return "pending"
default: return "unknown" case 1:
return "approved"
case -2:
return "withdrawn"
default:
return "unknown"
} }
} }
@ -104,10 +119,14 @@ type VoteKind int
func (v VoteKind) String() string { func (v VoteKind) String() string {
switch v { switch v {
case voteAye: return "Aye" case voteAye:
case voteNaye: return "Naye" return "Aye"
case voteAbstain: return "Abstain" case voteNaye:
default: return "unknown" return "Naye"
case voteAbstain:
return "Abstain"
default:
return "unknown"
} }
} }
@ -150,114 +169,251 @@ type Voter struct {
Name string Name string
} }
func authenticateVoter(emailAddress string, voter *Voter) bool { func withDrawMotion(tag string, voter *Voter, config *Config) {
err := db.Ping() err := db.Ping()
if err != nil { if err != nil {
logger.Fatal(err) logger.Fatal(err)
} }
auth_stmt, err := db.Preparex(get_voter) decision_stmt, err := db.Preparex(sqlGetDecision)
if err != nil { if err != nil {
logger.Fatal(err) logger.Fatal(err)
} }
defer auth_stmt.Close() defer decision_stmt.Close()
var found = false
err = auth_stmt.Get(voter, emailAddress) var d Decision
err = decision_stmt.Get(&d, tag)
if err == nil { if err == nil {
found = true logger.Println(d)
} else { }
if err != sql.ErrNoRows {
type MailContext struct {
Decision
Name string
Sender string
Recipient string
}
context := MailContext{d, voter.Name, config.NoticeSenderAddress, config.BoardMailAddress}
// TODO: implement
// fill withdraw_mail.txt
t, err := template.New("withdraw_mail.txt").Funcs(sprig.FuncMap()).ParseFiles("templates/withdraw_mail.txt")
if err != nil {
logger.Fatal(err) logger.Fatal(err)
} }
t.Execute(os.Stdout, context)
} }
return found
func authenticateVoter(emailAddress string) *Voter {
if err := db.Ping(); err != nil {
logger.Fatal(err)
}
auth_stmt, err := db.Preparex(sqlGetVoter)
if err != nil {
logger.Println("Problem getting voter", err)
return nil
}
defer auth_stmt.Close()
var voter = &Voter{}
if err = auth_stmt.Get(voter, emailAddress); err != nil {
if err != sql.ErrNoRows {
logger.Println("Problem getting voter", err)
}
return nil
}
return voter
} }
func redirectToMotionsHandler(w http.ResponseWriter, _ *http.Request) { func redirectToMotionsHandler(w http.ResponseWriter, _ *http.Request) {
w.Header().Add("Location", "/motions") w.Header().Set("Location", "/motions/")
w.WriteHeader(http.StatusMovedPermanently) w.WriteHeader(http.StatusMovedPermanently)
} }
func renderTemplate(w http.ResponseWriter, tmpl string, context interface{}) { func renderTemplate(w http.ResponseWriter, tmpl string, context interface{}) {
t := template.New("motions.html") t, err := template.New(fmt.Sprintf("%s.html", tmpl)).Funcs(sprig.FuncMap()).ParseFiles(fmt.Sprintf("templates/%s.html", tmpl))
t.Funcs(sprig.FuncMap())
t, err := t.ParseFiles(fmt.Sprintf("templates/%s.html", tmpl))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
err = t.Execute(w, context) if err := t.Execute(w, context); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }
func authenticateRequest( func authenticateRequest(w http.ResponseWriter, r *http.Request, authRequired bool, handler func(http.ResponseWriter, *http.Request, *Voter)) {
w http.ResponseWriter, r *http.Request, for _, cert := range r.TLS.PeerCertificates {
handler func(http.ResponseWriter, *http.Request, *Voter)) {
var voter Voter
var found = false
authLoop: for _, cert := range r.TLS.PeerCertificates {
var isClientCert = false
for _, extKeyUsage := range cert.ExtKeyUsage { for _, extKeyUsage := range cert.ExtKeyUsage {
if extKeyUsage == x509.ExtKeyUsageClientAuth { if extKeyUsage == x509.ExtKeyUsageClientAuth {
isClientCert = true for _, emailAddress := range cert.EmailAddresses {
break if voter := authenticateVoter(emailAddress); voter != nil {
} handler(w, r, voter)
return
} }
if !isClientCert {
continue
} }
for _, emailAddress := range cert.EmailAddresses {
if authenticateVoter(emailAddress, &voter) {
found = true
break authLoop
} }
} }
} }
if !found { if authRequired {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
renderTemplate(w, "denied", nil) renderTemplate(w, "denied", nil)
return return
} }
handler(w, r, &voter) handler(w, r, nil)
} }
func motionsHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) { type motionParameters struct {
err := db.Ping() ShowVotes bool
if err != nil { }
logger.Fatal(err)
type motionListParameters struct {
Page int64
Flags struct {
Confirmed, Withdraw, Unvoted bool
}
}
func parseMotionParameters(r *http.Request) motionParameters {
var m = motionParameters{}
m.ShowVotes, _ = strconv.ParseBool(r.URL.Query().Get("showvotes"))
logger.Printf("parsed parameters: %+v\n", m)
return m
}
func parseMotionListParameters(r *http.Request) motionListParameters {
var m = motionListParameters{}
if page, err := strconv.ParseInt(r.URL.Query().Get("page"), 10, 0); err != nil {
m.Page = 1
} else {
m.Page = page
}
m.Flags.Withdraw, _ = strconv.ParseBool(r.URL.Query().Get("withdraw"))
m.Flags.Unvoted, _ = strconv.ParseBool(r.URL.Query().Get("unvoted"))
if r.Method == http.MethodPost {
m.Flags.Confirmed, _ = strconv.ParseBool(r.PostFormValue("confirm"))
}
logger.Printf("parsed parameters: %+v\n", m)
return m
} }
// $page = is_numeric($_REQUEST['page'])?$_REQUEST['page']:1; func motionListHandler(w http.ResponseWriter, r *http.Request, voter *Voter) {
params := parseMotionListParameters(r)
motion_stmt, err := db.Preparex(list_decisions_sql) votes_stmt, err := db.Preparex(sqlVoteCount)
votes_stmt, err := db.Preparex(vote_count_sql)
if err != nil { if err != nil {
logger.Fatal(err) logger.Fatal(err)
} }
defer motion_stmt.Close()
defer votes_stmt.Close() defer votes_stmt.Close()
beforeAfterStmt, err := db.Preparex(sqlCountNewerOlderThanMotion)
rows, err := motion_stmt.Queryx(1)
if err != nil { if err != nil {
logger.Fatal(err) logger.Fatal(err)
} }
defer rows.Close() defer beforeAfterStmt.Close()
var page struct { var context struct {
Decisions []Decision Decisions []Decision
Voter *Voter Voter *Voter
Params *motionListParameters
PrevPage, NextPage int64
} }
page.Voter = voter context.Voter = voter
context.Params = &params
motion_stmt, err := db.Preparex(sqlGetDecisions)
if err != nil {
logger.Fatal(err)
}
defer motion_stmt.Close()
rows, err := motion_stmt.Queryx(params.Page - 1)
if err != nil {
logger.Fatal(err)
}
for rows.Next() { for rows.Next() {
var d Decision var d Decision
err := rows.StructScan(&d) err := rows.StructScan(&d)
if err != nil { if err != nil {
rows.Close()
logger.Fatal(err) logger.Fatal(err)
} }
voteRows, err := votes_stmt.Queryx(d.Id)
if err != nil {
rows.Close()
logger.Fatal(err)
}
for voteRows.Next() {
var vote int
var count int
if err := voteRows.Scan(&vote, &count); err != nil {
voteRows.Close()
logger.Fatalf("Error fetching counts for motion %s: %s", d.Tag, err)
}
d.parseVote(vote, count)
}
context.Decisions = append(context.Decisions, d)
voteRows.Close()
}
rows.Close()
rows, err = beforeAfterStmt.Queryx(
context.Decisions[0].Proposed,
context.Decisions[len(context.Decisions)-1].Proposed)
if err != nil {
logger.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var key string
var value int
if err := rows.Scan(&key, &value); err != nil {
rows.Close()
logger.Fatal(err)
}
if key == "older" && value > 0 {
context.NextPage = params.Page + 1
}
}
if params.Page > 1 {
context.PrevPage = params.Page - 1
}
renderTemplate(w, "motions", context)
}
func motionHandler(w http.ResponseWriter, r *http.Request, voter *Voter, decision *Decision) {
params := parseMotionParameters(r)
var context struct {
Decisions []Decision
Voter *Voter
Params *motionParameters
PrevPage, NextPage int64
}
context.Voter = voter
context.Params = &params
context.Decisions = append(context.Decisions, *decision)
renderTemplate(w, "motions", context)
}
func singleDecisionHandler(w http.ResponseWriter, r *http.Request, v *Voter, tag string, handler func(http.ResponseWriter, *http.Request, *Voter, *Decision)) {
votes_stmt, err := db.Preparex(sqlVoteCount)
if err != nil {
logger.Fatal(err)
}
defer votes_stmt.Close()
motion_stmt, err := db.Preparex(sqlGetDecision)
if err != nil {
logger.Fatal(err)
}
defer motion_stmt.Close()
var d *Decision = &Decision{}
err = motion_stmt.Get(d, tag)
if err != nil {
logger.Fatal(err)
}
voteRows, err := votes_stmt.Queryx(d.Id) voteRows, err := votes_stmt.Queryx(d.Id)
if err != nil { if err != nil {
logger.Fatal(err) logger.Fatal(err)
@ -272,12 +428,39 @@ func motionsHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) {
} }
d.parseVote(vote, count) d.parseVote(vote, count)
} }
page.Decisions = append(page.Decisions, d)
voteRows.Close() voteRows.Close()
handler(w, r, v, d)
}
func motionsHandler(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(); err != nil {
logger.Fatal(err)
} }
renderTemplate(w, "motions", page) subURL := r.URL.Path[len("/motions/"):]
switch {
case subURL == "":
authenticateRequest(w, r, false, motionListHandler)
return
case strings.Count(subURL, "/") == 1:
parts := strings.Split(subURL, "/")
logger.Printf("handle %v\n", parts)
fmt.Fprintf(w, "No handler for '%s'", subURL)
motionTag := parts[0]
action := parts[1]
logger.Printf("motion: %s, action: %s\n", motionTag, action)
return
case strings.Count(subURL, "/") == 0:
authenticateRequest(w, r, false, func(w http.ResponseWriter, r *http.Request, v *Voter) {
singleDecisionHandler(w, r, v, subURL, motionHandler)
})
return
default:
fmt.Fprintf(w, "No handler for '%s'", subURL)
return
}
} }
func votersHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) { func votersHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) {
@ -325,7 +508,7 @@ type Config struct {
} }
func main() { func main() {
logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags | log.LUTC) logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags|log.LUTC|log.Lshortfile)
var filename = "config.yaml" var filename = "config.yaml"
if len(os.Args) == 2 { if len(os.Args) == 2 {
@ -352,13 +535,11 @@ func main() {
logger.Fatal(err) logger.Fatal(err)
} }
http.HandleFunc("/motions", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/motions/", motionsHandler)
authenticateRequest(w, r, motionsHandler) http.HandleFunc("/voters/", func(w http.ResponseWriter, r *http.Request) {
}) authenticateRequest(w, r, true, votersHandler)
http.HandleFunc("/voters", func(w http.ResponseWriter, r *http.Request) {
authenticateRequest(w, r, votersHandler)
}) })
http.HandleFunc("/static/", http.FileServer(http.Dir(".")).ServeHTTP) http.Handle("/static/", http.FileServer(http.Dir(".")))
http.HandleFunc("/", redirectToMotionsHandler) http.HandleFunc("/", redirectToMotionsHandler)
// load CA certificates for client authentication // load CA certificates for client authentication

@ -6,14 +6,15 @@
<link rel="stylesheet" type="text/css" href="/static/styles.css"/> <link rel="stylesheet" type="text/css" href="/static/styles.css"/>
</head> </head>
<body> <body>
<a href="?unvoted=1">Show my outstanding votes</a><br/> <a href="/motions/?unvoted=1">Show my outstanding votes</a><br/>
{{ $voter := .Voter }}
{{ if .Decisions }} {{ if .Decisions }}
<table class="list"> <table class="list">
<thead> <thead>
<tr> <tr>
<th>Status</th> <th>Status</th>
<th>Motion</th> <th>Motion</th>
<th>Actions</th> {{ if $voter }}<th>Actions</th>{{ end }}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -28,7 +29,7 @@
{{ end }} {{ end }}
</td> </td>
<td> <td>
<i><a href="/motions?motion={{ .Tag}}">{{ .Tag}}</a></i><br /> <i><a href="/motions/{{ .Tag}}">{{ .Tag}}</a></i><br />
<b>{{ .Title}}</b><br /> <b>{{ .Title}}</b><br />
<pre>{{ wrap 76 .Content }}</pre> <pre>{{ wrap 76 .Content }}</pre>
<br /> <br />
@ -42,9 +43,10 @@
<i>{{ .Name }}: {{ .Vote}}</i><br /> <i>{{ .Name }}: {{ .Vote}}</i><br />
{{ end }} {{ end }}
{{ else}} {{ else}}
<i><a href="/motions?motion={{.Tag}}&showvotes=1">Show Votes</a></i> <i><a href="/motions/{{.Tag}}?showvotes=1">Show Votes</a></i>
{{ end }} {{ end }}
</td> </td>
{{ if $voter }}
<td> <td>
{{ if eq .Status 0 }} {{ if eq .Status 0 }}
<ul> <ul>
@ -52,13 +54,19 @@
<li><a href="/vote/{{ .Tag }}/abstain">Abstain</a></li> <li><a href="/vote/{{ .Tag }}/abstain">Abstain</a></li>
<li><a href="/vote/{{ .Tag }}/naye">Naye</a></li> <li><a href="/vote/{{ .Tag }}/naye">Naye</a></li>
<li><a href="/proxy/{{ .Tag }}">Proxy Vote</a></li> <li><a href="/proxy/{{ .Tag }}">Proxy Vote</a></li>
<li><a href="/motion/{{ .Tag }}">Modify</a></li> <li><a href="/motions/{{ .Tag }}/edit">Modify</a></li>
<li><a href="/motions?motion={{ .Tag }}&withdraw=1">Withdraw</a></li> <li><a href="/motions/{{ .Tag }}/withdraw">Withdraw</a></li>
</ul> </ul>
{{ end }} {{ end }}
</td> </td>{{ end }}
</tr> </tr>
{{end}} {{end}}
<tr>
<td colspan="{{ if $voter }}3{{ else }}2{{ end }}" class="navigation">
{{ if .PrevPage }}<a href="?page={{ .PrevPage }}" title="previous page">&lt;</a>{{ end }}
{{ if .NextPage }}<a href="?page={{ .NextPage }}" title="next page">&gt;</a>{{ end }}
</td>
</tr>
</tbody> </tbody>
</table> </table>
{{else}} {{else}}

@ -0,0 +1,14 @@
From: {{ .Sender }}
To: {{ .Recipient }}
Subject: Re: {{ .Tag }} - {{ .Title }} - withdrawn
Dear Board,
{{ .Name }} has withdrawn the motion {{ .Tag }} that was as follows:
{{ .Title }}
{{ wrap 76 .Content }}
Kind regards,
the voting system
Loading…
Cancel
Save