Implement more RESTful URLs for motions
authorJan Dittberner <jan@dittberner.info>
Sun, 16 Apr 2017 23:33:51 +0000 (01:33 +0200)
committerJan Dittberner <jan@dittberner.info>
Fri, 21 Apr 2017 22:12:24 +0000 (00:12 +0200)
This commit implements URLs /motions/ and /motions/{:tag} handlers.

boardvoting.go
templates/motions.html
templates/withdraw_mail.txt [new file with mode: 0644]

index 68210d9..a49b34e 100644 (file)
@@ -1,25 +1,26 @@
 package main
 
 import (
+       "crypto/tls"
+       "crypto/x509"
+       "database/sql"
        "fmt"
-       "log"
-       "strings"
-       "net/http"
-       "io/ioutil"
-       "time"
+       "github.com/Masterminds/sprig"
+       "github.com/jmoiron/sqlx"
        _ "github.com/mattn/go-sqlite3"
        "gopkg.in/yaml.v2"
-       "github.com/jmoiron/sqlx"
-       "github.com/Masterminds/sprig"
-       "os"
-       "crypto/x509"
-       "crypto/tls"
-       "database/sql"
        "html/template"
+       "io/ioutil"
+       "log"
+       "net/http"
+       "os"
+       "strconv"
+       "strings"
+       "time"
 )
 
 const (
-       list_decisions_sql = `
+       sqlGetDecisions = `
 SELECT decisions.id, decisions.tag, decisions.proponent,
        voters.name AS proposer, decisions.proposed, decisions.title,
        decisions.content, decisions.votetype, decisions.status, decisions.due,
@@ -27,54 +28,63 @@ SELECT decisions.id, decisions.tag, decisions.proponent,
 FROM decisions
 JOIN voters ON decisions.proponent=voters.id
 ORDER BY proposed DESC
-LIMIT 10 OFFSET 10 * ($1 - 1)`
-       get_decision_sql = `
+LIMIT 10 OFFSET 10 * $1`
+       sqlGetDecision = `
 SELECT decisions.id, decisions.tag, decisions.proponent,
        voters.name AS proposer, decisions.proposed, decisions.title,
        decisions.content, decisions.votetype, decisions.status, decisions.due,
        decisions.modified
 FROM decisions
 JOIN voters ON decisions.proponent=voters.id
-WHERE decisions.id=$1;`
-       get_voter = `
+WHERE decisions.tag=$1;`
+       sqlGetVoter = `
 SELECT voters.id, voters.name
 FROM voters
 JOIN emails ON voters.id=emails.voter
 WHERE emails.address=$1 AND voters.enabled=1`
-       vote_count_sql = `
+       sqlVoteCount = `
 SELECT vote, COUNT(vote)
 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 logger *log.Logger
 
 const (
-       voteAye = 1
-       voteNaye = -1
+       voteAye     = 1
+       voteNaye    = -1
        voteAbstain = 0
 )
 
 const (
        voteTypeMotion = 0
-       voteTypeVeto = 1
+       voteTypeVeto   = 1
 )
 
 type VoteType int
 
 func (v VoteType) String() string {
        switch v {
-       case voteTypeMotion: return "motion"
-       case voteTypeVeto: return "veto"
-       default: return "unknown"
+       case voteTypeMotion:
+               return "motion"
+       case voteTypeVeto:
+               return "veto"
+       default:
+               return "unknown"
        }
 }
 
 func (v VoteType) QuorumAndMajority() (int, int) {
        switch v {
-       case voteTypeMotion: return 3, 50
-       default: return 1, 99
+       case voteTypeMotion:
+               return 3, 50
+       default:
+               return 1, 99
        }
 }
 
@@ -92,11 +102,16 @@ type VoteStatus int
 
 func (v VoteStatus) String() string {
        switch v {
-       case -1: return "declined"
-       case 0: return "pending"
-       case 1: return "approved"
-       case -2: return "withdrawn"
-       default: return "unknown"
+       case -1:
+               return "declined"
+       case 0:
+               return "pending"
+       case 1:
+               return "approved"
+       case -2:
+               return "withdrawn"
+       default:
+               return "unknown"
        }
 }
 
@@ -104,10 +119,14 @@ type VoteKind int
 
 func (v VoteKind) String() string {
        switch v {
-       case voteAye: return "Aye"
-       case voteNaye: return "Naye"
-       case voteAbstain: return "Abstain"
-       default: return "unknown"
+       case voteAye:
+               return "Aye"
+       case voteNaye:
+               return "Naye"
+       case voteAbstain:
+               return "Abstain"
+       default:
+               return "unknown"
        }
 }
 
@@ -131,7 +150,7 @@ type Decision struct {
        Due       time.Time
        Modified  time.Time
        VoteSums
-       Votes     []Vote
+       Votes []Vote
 }
 
 func (d *Decision) parseVote(vote int, count int) {
@@ -150,134 +169,298 @@ type Voter struct {
        Name string
 }
 
-func authenticateVoter(emailAddress string, voter *Voter) bool {
+func withDrawMotion(tag string, voter *Voter, config *Config) {
        err := db.Ping()
        if err != nil {
                logger.Fatal(err)
        }
 
-       auth_stmt, err := db.Preparex(get_voter)
+       decision_stmt, err := db.Preparex(sqlGetDecision)
        if err != nil {
                logger.Fatal(err)
        }
-       defer auth_stmt.Close()
-       var found = false
-       err = auth_stmt.Get(voter, emailAddress)
+       defer decision_stmt.Close()
+
+       var d Decision
+       err = decision_stmt.Get(&d, tag)
        if err == nil {
-               found = true
-       } else {
+               logger.Println(d)
+       }
+
+       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)
+       }
+       t.Execute(os.Stdout, context)
+}
+
+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.Fatal(err)
+                       logger.Println("Problem getting voter", err)
                }
+               return nil
        }
-       return found
+       return voter
 }
 
 func redirectToMotionsHandler(w http.ResponseWriter, _ *http.Request) {
-       w.Header().Add("Location", "/motions")
+       w.Header().Set("Location", "/motions/")
        w.WriteHeader(http.StatusMovedPermanently)
 }
 
 func renderTemplate(w http.ResponseWriter, tmpl string, context interface{}) {
-       t := template.New("motions.html")
-       t.Funcs(sprig.FuncMap())
-       t, err := t.ParseFiles(fmt.Sprintf("templates/%s.html", tmpl))
+       t, err := template.New(fmt.Sprintf("%s.html", tmpl)).Funcs(sprig.FuncMap()).ParseFiles(fmt.Sprintf("templates/%s.html", tmpl))
        if err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)
        }
-       err = t.Execute(w, context)
-       if err != nil {
+       if err := t.Execute(w, context); err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)
        }
 }
 
-func authenticateRequest(
-w http.ResponseWriter, r *http.Request,
-handler func(http.ResponseWriter, *http.Request, *Voter)) {
-       var voter Voter
-       var found = false
-       authLoop: for _, cert := range r.TLS.PeerCertificates {
-               var isClientCert = false
+func authenticateRequest(w http.ResponseWriter, r *http.Request, authRequired bool, handler func(http.ResponseWriter, *http.Request, *Voter)) {
+       for _, cert := range r.TLS.PeerCertificates {
                for _, extKeyUsage := range cert.ExtKeyUsage {
                        if extKeyUsage == x509.ExtKeyUsageClientAuth {
-                               isClientCert = true
-                               break
-                       }
-               }
-               if !isClientCert {
-                       continue
-               }
-
-               for _, emailAddress := range cert.EmailAddresses {
-                       if authenticateVoter(emailAddress, &voter) {
-                               found = true
-                               break authLoop
+                               for _, emailAddress := range cert.EmailAddresses {
+                                       if voter := authenticateVoter(emailAddress); voter != nil {
+                                               handler(w, r, voter)
+                                               return
+                                       }
+                               }
                        }
                }
        }
-       if !found {
+       if authRequired {
                w.WriteHeader(http.StatusForbidden)
                renderTemplate(w, "denied", nil)
                return
        }
-       handler(w, r, &voter)
+       handler(w, r, nil)
 }
 
-func motionsHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) {
-       err := db.Ping()
-       if err != nil {
-               logger.Fatal(err)
+type motionParameters struct {
+       ShowVotes bool
+}
+
+type motionListParameters struct {
+       Page  int64
+       Flags struct {
+               Confirmed, Withdraw, Unvoted bool
        }
+}
 
-       // $page = is_numeric($_REQUEST['page'])?$_REQUEST['page']:1;
+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
+}
 
-       motion_stmt, err := db.Preparex(list_decisions_sql)
-       votes_stmt, err := db.Preparex(vote_count_sql)
+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
+}
+
+func motionListHandler(w http.ResponseWriter, r *http.Request, voter *Voter) {
+       params := parseMotionListParameters(r)
+
+       votes_stmt, err := db.Preparex(sqlVoteCount)
        if err != nil {
                logger.Fatal(err)
        }
-       defer motion_stmt.Close()
        defer votes_stmt.Close()
-
-       rows, err := motion_stmt.Queryx(1)
+       beforeAfterStmt, err := db.Preparex(sqlCountNewerOlderThanMotion)
        if err != nil {
                logger.Fatal(err)
        }
-       defer rows.Close()
+       defer beforeAfterStmt.Close()
 
-       var page struct {
-               Decisions []Decision
-               Voter     *Voter
+       var context struct {
+               Decisions          []Decision
+               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() {
                var d Decision
                err := rows.StructScan(&d)
                if err != nil {
+                       rows.Close()
                        logger.Fatal(err)
                }
 
                voteRows, err := votes_stmt.Queryx(d.Id)
                if err != nil {
+                       rows.Close()
                        logger.Fatal(err)
                }
 
                for voteRows.Next() {
-                       var vote, count int
-                       err = voteRows.Scan(&vote, &count)
-                       if err != nil {
+                       var vote int
+                       var count int
+                       if err := voteRows.Scan(&vote, &count); err != nil {
                                voteRows.Close()
-                               logger.Fatal(err)
+                               logger.Fatalf("Error fetching counts for motion %s: %s", d.Tag, err)
                        }
                        d.parseVote(vote, count)
                }
-               page.Decisions = append(page.Decisions, d)
+               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", page)
+       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)
+       if err != nil {
+               logger.Fatal(err)
+       }
+
+       for voteRows.Next() {
+               var vote, count int
+               err = voteRows.Scan(&vote, &count)
+               if err != nil {
+                       voteRows.Close()
+                       logger.Fatal(err)
+               }
+               d.parseVote(vote, count)
+       }
+       voteRows.Close()
+
+       handler(w, r, v, d)
+}
+
+func motionsHandler(w http.ResponseWriter, r *http.Request) {
+       if err := db.Ping(); err != nil {
+               logger.Fatal(err)
+       }
+
+       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) {
@@ -325,7 +508,7 @@ type Config struct {
 }
 
 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"
        if len(os.Args) == 2 {
@@ -352,13 +535,11 @@ func main() {
                logger.Fatal(err)
        }
 
-       http.HandleFunc("/motions", func(w http.ResponseWriter, r *http.Request) {
-               authenticateRequest(w, r, motionsHandler)
-       })
-       http.HandleFunc("/voters", func(w http.ResponseWriter, r *http.Request) {
-               authenticateRequest(w, r, votersHandler)
+       http.HandleFunc("/motions/", motionsHandler)
+       http.HandleFunc("/voters/", func(w http.ResponseWriter, r *http.Request) {
+               authenticateRequest(w, r, true, votersHandler)
        })
-       http.HandleFunc("/static/", http.FileServer(http.Dir(".")).ServeHTTP)
+       http.Handle("/static/", http.FileServer(http.Dir(".")))
        http.HandleFunc("/", redirectToMotionsHandler)
 
        // load CA certificates for client authentication
@@ -373,14 +554,14 @@ func main() {
 
        // setup HTTPS server
        tlsConfig := &tls.Config{
-               ClientCAs:caCertPool,
-               ClientAuth:tls.RequireAndVerifyClientCert,
+               ClientCAs:  caCertPool,
+               ClientAuth: tls.RequireAndVerifyClientCert,
        }
        tlsConfig.BuildNameToCertificate()
 
        server := &http.Server{
-               Addr: ":8443",
-               TLSConfig:tlsConfig,
+               Addr:      ":8443",
+               TLSConfig: tlsConfig,
        }
 
        err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey)
index fa1c07c..f717d78 100644 (file)
@@ -6,14 +6,15 @@
     <link rel="stylesheet" type="text/css" href="/static/styles.css"/>
 </head>
 <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 }}
 <table class="list">
     <thead>
     <tr>
         <th>Status</th>
         <th>Motion</th>
-        <th>Actions</th>
+        {{ if $voter }}<th>Actions</th>{{ end }}
     </tr>
     </thead>
     <tbody>
@@ -28,7 +29,7 @@
             {{ end }}
         </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 />
             <pre>{{ wrap 76 .Content }}</pre>
             <br />
             <i>{{ .Name }}: {{ .Vote}}</i><br />
             {{ end }}
             {{ 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 }}
         </td>
+        {{ if $voter }}
         <td>
             {{ if eq .Status 0 }}
             <ul>
                 <li><a href="/vote/{{ .Tag }}/abstain">Abstain</a></li>
                 <li><a href="/vote/{{ .Tag }}/naye">Naye</a></li>
                 <li><a href="/proxy/{{ .Tag }}">Proxy Vote</a></li>
-                <li><a href="/motion/{{ .Tag }}">Modify</a></li>
-                <li><a href="/motions?motion={{ .Tag }}&withdraw=1">Withdraw</a></li>
+                <li><a href="/motions/{{ .Tag }}/edit">Modify</a></li>
+                <li><a href="/motions/{{ .Tag }}/withdraw">Withdraw</a></li>
             </ul>
             {{ end }}
-        </td>
+        </td>{{ end }}
     </tr>
     {{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>
 </table>
 {{else}}
 <p>There are no motions in the system yet.</p>
 {{end}}
 </body>
-</html>
\ No newline at end of file
+</html>
diff --git a/templates/withdraw_mail.txt b/templates/withdraw_mail.txt
new file mode 100644 (file)
index 0000000..4ccf4de
--- /dev/null
@@ -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
\ No newline at end of file