Implement proper model, actions and template structure
authorJan Dittberner <jandd@cacert.org>
Mon, 17 Apr 2017 14:24:37 +0000 (16:24 +0200)
committerJan Dittberner <jan@dittberner.info>
Fri, 21 Apr 2017 22:12:24 +0000 (00:12 +0200)
actions.go [new file with mode: 0644]
boardvoting.go
models.go [new file with mode: 0644]
templates/footer.html [new file with mode: 0644]
templates/header.html [new file with mode: 0644]
templates/motion.html [new file with mode: 0644]
templates/motion_fragments.html [new file with mode: 0644]
templates/motions.html

diff --git a/actions.go b/actions.go
new file mode 100644 (file)
index 0000000..fde767c
--- /dev/null
@@ -0,0 +1,30 @@
+package main
+
+import (
+       "github.com/Masterminds/sprig"
+       "os"
+       "text/template"
+)
+
+func WithdrawMotion(decision *Decision, voter *Voter) (err error) {
+       // load template, fill name, tag, title, content
+       type mailContext struct {
+               *Decision
+               Name string
+               Sender string
+               Recipient string
+       }
+       context := mailContext{decision, voter.Name, config.NoticeSenderAddress, config.BoardMailAddress}
+
+       // fill withdraw_mail.txt
+       t, err := template.New("withdraw_mail.txt").Funcs(
+               sprig.GenericFuncMap()).ParseFiles("templates/withdraw_mail.txt")
+       if err != nil {
+               logger.Fatal(err)
+       }
+       // TODO: send mail
+       t.Execute(os.Stdout, context)
+
+       // TODO: implement call decision.Close()
+       return
+}
index a49b34e..77499a2 100644 (file)
@@ -3,7 +3,6 @@ package main
 import (
        "crypto/tls"
        "crypto/x509"
-       "database/sql"
        "fmt"
        "github.com/Masterminds/sprig"
        "github.com/jmoiron/sqlx"
@@ -16,227 +15,21 @@ import (
        "os"
        "strconv"
        "strings"
-       "time"
 )
 
-const (
-       sqlGetDecisions = `
-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
-ORDER BY proposed DESC
-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.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`
-       sqlVoteCount = `
-SELECT vote, COUNT(vote)
-FROM votes
-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
+var config *Config
 
-const (
-       voteAye     = 1
-       voteNaye    = -1
-       voteAbstain = 0
-)
-
-const (
-       voteTypeMotion = 0
-       voteTypeVeto   = 1
-)
-
-type VoteType int
-
-func (v VoteType) String() string {
-       switch v {
-       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
-       }
-}
-
-type VoteSums struct {
-       Ayes     int
-       Nayes    int
-       Abstains int
-}
-
-func (v *VoteSums) voteCount() int {
-       return v.Ayes + v.Nayes + v.Abstains
-}
-
-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"
-       }
-}
-
-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"
-       }
-}
-
-type Vote struct {
-       Name string
-       Vote VoteKind
-}
-
-type Decision struct {
-       Id        int
-       Tag       string
-       Proponent int
-       Proposer  string
-       Proposed  time.Time
-       Title     string
-       Content   string
-       Majority  int
-       Quorum    int
-       VoteType  VoteType
-       Status    VoteStatus
-       Due       time.Time
-       Modified  time.Time
-       VoteSums
-       Votes []Vote
-}
-
-func (d *Decision) parseVote(vote int, count int) {
-       switch vote {
-       case voteAye:
-               d.Ayes = count
-       case voteAbstain:
-               d.Abstains = count
-       case voteNaye:
-               d.Nayes = count
-       }
-}
-
-type Voter struct {
-       Id   int
-       Name string
-}
-
-func withDrawMotion(tag string, voter *Voter, config *Config) {
-       err := db.Ping()
-       if err != nil {
-               logger.Fatal(err)
-       }
-
-       decision_stmt, err := db.Preparex(sqlGetDecision)
-       if err != nil {
-               logger.Fatal(err)
-       }
-       defer decision_stmt.Close()
-
-       var d Decision
-       err = decision_stmt.Get(&d, tag)
-       if err == nil {
-               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)
+func getTemplateFilenames(tmpl []string) (result []string) {
+       result = make([]string, len(tmpl))
+       for i := range tmpl {
+               result[i] = fmt.Sprintf("templates/%s", tmpl[i])
        }
-       t.Execute(os.Stdout, context)
+       return result
 }
 
-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) {
-       w.Header().Set("Location", "/motions/")
-       w.WriteHeader(http.StatusMovedPermanently)
-}
-
-func renderTemplate(w http.ResponseWriter, tmpl string, context interface{}) {
-       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)
-       }
+func renderTemplate(w http.ResponseWriter, tmpl []string, context interface{}) {
+       t := template.Must(template.New(tmpl[0]).Funcs(sprig.FuncMap()).ParseFiles(getTemplateFilenames(tmpl)...))
        if err := t.Execute(w, context); err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)
        }
@@ -247,7 +40,12 @@ func authenticateRequest(w http.ResponseWriter, r *http.Request, authRequired bo
                for _, extKeyUsage := range cert.ExtKeyUsage {
                        if extKeyUsage == x509.ExtKeyUsageClientAuth {
                                for _, emailAddress := range cert.EmailAddresses {
-                                       if voter := authenticateVoter(emailAddress); voter != nil {
+                                       voter, err := FindVoterByAddress(emailAddress)
+                                       if err != nil {
+                                               http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+                                               return
+                                       }
+                                       if voter != nil {
                                                handler(w, r, voter)
                                                return
                                        }
@@ -257,7 +55,7 @@ func authenticateRequest(w http.ResponseWriter, r *http.Request, authRequired bo
        }
        if authRequired {
                w.WriteHeader(http.StatusForbidden)
-               renderTemplate(w, "denied", nil)
+               renderTemplate(w, []string{"denied.html"}, nil)
                return
        }
        handler(w, r, nil)
@@ -268,7 +66,7 @@ type motionParameters struct {
 }
 
 type motionListParameters struct {
-       Page  int64
+       Page int64
        Flags struct {
                Confirmed, Withdraw, Unvoted bool
        }
@@ -301,144 +99,133 @@ func parseMotionListParameters(r *http.Request) motionListParameters {
 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 votes_stmt.Close()
-       beforeAfterStmt, err := db.Preparex(sqlCountNewerOlderThanMotion)
-       if err != nil {
-               logger.Fatal(err)
-       }
-       defer beforeAfterStmt.Close()
-
        var context struct {
-               Decisions          []Decision
+               Decisions          []*DecisionForDisplay
                Voter              *Voter
                Params             *motionListParameters
                PrevPage, NextPage int64
+               PageTitle          string
        }
        context.Voter = voter
        context.Params = &params
+       var err error
 
-       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)
+       if params.Flags.Unvoted {
+               if context.Decisions, err = FindVotersUnvotedDecisionsForDisplayOnPage(params.Page, voter); err != nil {
+                       http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+                       return
                }
-
-               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)
+       } else {
+               if context.Decisions, err = FindDecisionsForDisplayOnPage(params.Page); err != nil {
+                       http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+                       return
                }
-               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 len(context.Decisions) > 0 {
+               olderExists, err := context.Decisions[len(context.Decisions)-1].OlderExists()
+               if err != nil {
+                       http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+                       return
                }
-               if key == "older" && value > 0 {
+               if olderExists {
                        context.NextPage = params.Page + 1
                }
        }
+
        if params.Page > 1 {
                context.PrevPage = params.Page - 1
        }
 
-       renderTemplate(w, "motions", context)
+       renderTemplate(w, []string{"motions.html", "motion_fragments.html", "header.html", "footer.html"}, context)
 }
 
-func motionHandler(w http.ResponseWriter, r *http.Request, voter *Voter, decision *Decision) {
+func motionHandler(w http.ResponseWriter, r *http.Request, voter *Voter, decision *DecisionForDisplay) {
        params := parseMotionParameters(r)
 
        var context struct {
-               Decisions          []Decision
+               Decision           *DecisionForDisplay
                Voter              *Voter
                Params             *motionParameters
                PrevPage, NextPage int64
+               PageTitle          string
        }
        context.Voter = voter
        context.Params = &params
-       context.Decisions = append(context.Decisions, *decision)
-       renderTemplate(w, "motions", context)
+       if params.ShowVotes {
+               if err := decision.LoadVotes(); err != nil {
+                       http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+                       return
+               }
+       }
+       context.Decision = decision
+       context.PageTitle = fmt.Sprintf("Motion %s: %s", decision.Tag, decision.Title)
+       renderTemplate(w, []string{"motion.html", "motion_fragments.html", "header.html", "footer.html"}, 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)
+func singleDecisionHandler(w http.ResponseWriter, r *http.Request, v *Voter, tag string, handler func(http.ResponseWriter, *http.Request, *Voter, *DecisionForDisplay)) {
+       decision, err := FindDecisionForDisplayByTag(tag)
        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)
+               http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+               return
        }
-       voteRows, err := votes_stmt.Queryx(d.Id)
-       if err != nil {
-               logger.Fatal(err)
+       if decision == nil {
+               http.NotFound(w, r)
+               return
        }
+       handler(w, r, v, decision)
+}
 
-       for voteRows.Next() {
-               var vote, count int
-               err = voteRows.Scan(&vote, &count)
-               if err != nil {
-                       voteRows.Close()
-                       logger.Fatal(err)
+type motionsHandler struct{}
+
+type motionActionHandler interface {
+       Handle(w http.ResponseWriter, r *http.Request, voter *Voter, decision *DecisionForDisplay)
+       NeedsAuth() bool
+}
+
+type withDrawMotionAction struct{}
+
+func (withDrawMotionAction) Handle(w http.ResponseWriter, r *http.Request, voter *Voter, decision *DecisionForDisplay) {
+       fmt.Fprintln(w, "Withdraw motion", decision.Tag)
+       // TODO: implement
+       if r.Method == http.MethodPost {
+               if confirm, err := strconv.ParseBool(r.PostFormValue("confirm")); err != nil {
+                       log.Println("could not parse confirm parameter:", err)
+                       http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+               } else if confirm {
+                       WithdrawMotion(&decision.Decision, voter)
+               } else {
+                       http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
                }
-               d.parseVote(vote, count)
        }
-       voteRows.Close()
+}
+
+func (withDrawMotionAction) NeedsAuth() bool {
+       return true
+}
+
+type editMotionAction struct{}
+
+func (editMotionAction) Handle(w http.ResponseWriter, r *http.Request, voter *Voter, decision *DecisionForDisplay) {
+       fmt.Fprintln(w, "Edit motion", decision.Tag)
+       // TODO: implement
+}
 
-       handler(w, r, v, d)
+func (editMotionAction) NeedsAuth() bool {
+       return true
 }
 
-func motionsHandler(w http.ResponseWriter, r *http.Request) {
+func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        if err := db.Ping(); err != nil {
                logger.Fatal(err)
        }
 
-       subURL := r.URL.Path[len("/motions/"):]
+       subURL := r.URL.Path
+
+       var motionActionMap = map[string]motionActionHandler{
+               "withdraw": withDrawMotionAction{},
+               "edit": editMotionAction{},
+       }
 
        switch {
        case subURL == "":
@@ -447,9 +234,16 @@ func motionsHandler(w http.ResponseWriter, r *http.Request) {
        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]
+               action, ok := motionActionMap[parts[1]]
+               if !ok {
+                       http.NotFound(w, r)
+                       return
+               }
+               authenticateRequest(w, r, action.NeedsAuth(), func(w http.ResponseWriter, r *http.Request, v *Voter) {
+                       singleDecisionHandler(w, r, v, motionTag, action.Handle)
+               })
+
                logger.Printf("motion: %s, action: %s\n", motionTag, action)
                return
        case strings.Count(subURL, "/") == 0:
@@ -463,39 +257,9 @@ func motionsHandler(w http.ResponseWriter, r *http.Request) {
        }
 }
 
-func votersHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) {
-       err := db.Ping()
-       if err != nil {
-               logger.Fatal(err)
-       }
-
-       fmt.Fprintln(w, "Hello", voter.Name)
-
-       sqlStmt := "SELECT name, reminder FROM voters WHERE enabled=1"
-
-       rows, err := db.Query(sqlStmt)
-       if err != nil {
-               logger.Fatal(err)
-       }
-       defer rows.Close()
-
-       fmt.Print("Enabled voters\n\n")
-       fmt.Printf("%-30s %-30s\n", "Name", "Reminder E-Mail address")
-       fmt.Printf("%s %s\n", strings.Repeat("-", 30), strings.Repeat("-", 30))
-       for rows.Next() {
-               var name string
-               var reminder string
-
-               err = rows.Scan(&name, &reminder)
-               if err != nil {
-                       logger.Fatal(err)
-               }
-               fmt.Printf("%-30s %s\n", name, reminder)
-       }
-       err = rows.Err()
-       if err != nil {
-               logger.Fatal(err)
-       }
+func newMotionHandler(w http.ResponseWriter, _ *http.Request, _ *Voter) {
+       fmt.Fprintln(w,"New motion")
+       // TODO: implement
 }
 
 type Config struct {
@@ -517,7 +281,6 @@ func main() {
 
        var err error
 
-       var config Config
        var source []byte
 
        source, err = ioutil.ReadFile(filename)
@@ -534,13 +297,14 @@ func main() {
        if err != nil {
                logger.Fatal(err)
        }
+       defer db.Close()
 
-       http.HandleFunc("/motions/", motionsHandler)
-       http.HandleFunc("/voters/", func(w http.ResponseWriter, r *http.Request) {
-               authenticateRequest(w, r, true, votersHandler)
+       http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{}))
+       http.HandleFunc("/newmotion/", func(w http.ResponseWriter, r *http.Request) {
+               authenticateRequest(w, r, true, newMotionHandler)
        })
        http.Handle("/static/", http.FileServer(http.Dir(".")))
-       http.HandleFunc("/", redirectToMotionsHandler)
+       http.Handle("/", http.RedirectHandler("/motions/", http.StatusMovedPermanently))
 
        // load CA certificates for client authentication
        caCert, err := ioutil.ReadFile(config.ClientCACertificates)
@@ -564,10 +328,9 @@ func main() {
                TLSConfig: tlsConfig,
        }
 
-       err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey)
-       if err != nil {
+       logger.Printf("Launching application on https://localhost%s/\n", server.Addr)
+
+       if err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey); err != nil {
                logger.Fatal("ListenAndServerTLS: ", err)
        }
-
-       defer db.Close()
 }
diff --git a/models.go b/models.go
new file mode 100644 (file)
index 0000000..fe6cd23
--- /dev/null
+++ b/models.go
@@ -0,0 +1,346 @@
+package main
+
+import (
+       "database/sql"
+       "github.com/jmoiron/sqlx"
+       "time"
+)
+
+const (
+       sqlGetDecisions = `
+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
+ORDER BY proposed DESC
+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.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`
+       sqlVoteCount = `
+SELECT vote, COUNT(vote)
+FROM votes
+WHERE decision=$1 GROUP BY vote`
+       sqlCountOlderThanDecision = `
+SELECT COUNT(*) > 0 FROM decisions WHERE proposed < $1`
+       sqlGetVotesForDecision = `
+SELECT votes.decision, votes.voter, voters.name, votes.vote, votes.voted, votes.notes
+FROM votes
+JOIN voters ON votes.voter=voters.id
+WHERE decision=$1`
+       sqlListUnvotedDecisions = `
+SELECT decisions.id, decisions.tag, decisions.proponent,
+          voters.name AS proposer, decisions.proposed, decisions.title,
+          decisions.content AS content, decisions.votetype, decisions.status, decisions.due,
+          decisions.modified
+FROM decisions
+JOIN voters ON decisions.proponent=voters.id
+WHERE decisions.status=0 AND decisions.id NOT IN (
+       SELECT decision FROM votes WHERE votes.voter=$2)
+ORDER BY proposed DESC
+LIMIT 10 OFFSET 10 * $1`
+)
+
+var db *sqlx.DB
+
+type VoteType int
+type VoteStatus int
+
+type Decision struct {
+       Id          int
+       Proposed    time.Time
+       ProponentId int `db:"proponent"`
+       Title       string
+       Content     string
+       Quorum      int
+       Majority    int
+       Status      VoteStatus
+       Due         time.Time
+       Modified    time.Time
+       Tag         string
+       VoteType    VoteType
+}
+
+type Email struct {
+       VoterId int `db:"voter"`
+       Address string
+}
+
+type Voter struct {
+       Id       int
+       Name     string
+       Enabled  bool
+       Reminder string // reminder email address
+}
+
+type VoteChoice int
+
+type Vote struct {
+       DecisionId int `db:"decision"`
+       VoterId    int `db:"voter"`
+       Vote       VoteChoice
+       Voted      time.Time
+       Notes      string
+}
+
+const (
+       voteAye     = 1
+       voteNaye    = -1
+       voteAbstain = 0
+)
+
+const (
+       voteTypeMotion = 0
+       voteTypeVeto   = 1
+)
+
+func (v VoteType) String() string {
+       switch v {
+       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
+       }
+}
+
+func (v VoteChoice) String() string {
+       switch v {
+       case voteAye:
+               return "aye"
+       case voteNaye:
+               return "naye"
+       case voteAbstain:
+               return "abstain"
+       default:
+               return "unknown"
+       }
+}
+
+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"
+       }
+}
+
+type VoteSums struct {
+       Ayes     int
+       Nayes    int
+       Abstains int
+}
+
+func (v *VoteSums) VoteCount() int {
+       return v.Ayes + v.Nayes + v.Abstains
+}
+
+type VoteForDisplay struct {
+       Vote
+       Name string
+}
+
+type DecisionForDisplay struct {
+       Decision
+       Proposer string `db:"proposer"`
+       *VoteSums
+       Votes []VoteForDisplay
+}
+
+func FindDecisionForDisplayByTag(tag string) (decision *DecisionForDisplay, err error) {
+       decisionStmt, err := db.Preparex(sqlGetDecision)
+       if err != nil {
+               logger.Println("Error preparing statement:", err)
+               return
+       }
+       defer decisionStmt.Close()
+
+       decision = &DecisionForDisplay{}
+       if err = decisionStmt.Get(decision, tag); err != nil {
+               if err == sql.ErrNoRows {
+                       decision = nil
+                       err = nil
+               } else {
+                       logger.Printf("Error getting motion %s: %v\n", tag, err)
+               }
+       }
+       decision.VoteSums, err = decision.Decision.VoteSums()
+       return
+}
+
+// FindDecisionsForDisplayOnPage loads a set of decisions from the database.
+//
+// This function uses OFFSET for pagination which is not a good idea for larger data sets.
+//
+// TODO: migrate to timestamp base pagination
+func FindDecisionsForDisplayOnPage(page int64) (decisions []*DecisionForDisplay, err error) {
+       decisionsStmt, err := db.Preparex(sqlGetDecisions)
+       if err != nil {
+               logger.Println("Error preparing statement:", err)
+               return
+       }
+       defer decisionsStmt.Close()
+
+       rows, err := decisionsStmt.Queryx(page - 1)
+       if err != nil {
+               logger.Printf("Error loading motions for page %d: %v\n", page, err)
+               return
+       }
+       defer rows.Close()
+
+       for rows.Next() {
+               var d DecisionForDisplay
+               if err = rows.StructScan(&d); err != nil {
+                       logger.Printf("Error loading motions for page %d: %v\n", page, err)
+                       return
+               }
+               d.VoteSums, err = d.Decision.VoteSums()
+               if err != nil {
+                       return
+               }
+               decisions = append(decisions, &d)
+       }
+       return
+}
+
+func FindVotersUnvotedDecisionsForDisplayOnPage(page int64, voter *Voter) (decisions []*DecisionForDisplay, err error) {
+       decisionsStmt, err := db.Preparex(sqlListUnvotedDecisions)
+       if err != nil {
+               logger.Println("Error preparing statement:", err)
+               return
+       }
+       defer decisionsStmt.Close()
+
+       rows, err := decisionsStmt.Queryx(page - 1, voter.Id)
+       if err != nil {
+               logger.Printf("Error loading motions for page %d: %v\n", page, err)
+               return
+       }
+       defer rows.Close()
+
+       for rows.Next() {
+               var d DecisionForDisplay
+               if err = rows.StructScan(&d); err != nil {
+                       logger.Printf("Error loading motions for page %d: %v\n", page, err)
+                       return
+               }
+               d.VoteSums, err = d.Decision.VoteSums()
+               if err != nil {
+                       return
+               }
+               decisions = append(decisions, &d)
+       }
+       return
+}
+
+func (d *Decision) VoteSums() (sums *VoteSums, err error) {
+       votesStmt, err := db.Preparex(sqlVoteCount)
+       if err != nil {
+               logger.Println("Error preparing statement:", err)
+               return
+       }
+       defer votesStmt.Close()
+
+       voteRows, err := votesStmt.Queryx(d.Id)
+       if err != nil {
+               logger.Printf("Error fetching vote sums for motion %s: %v\n", d.Tag, err)
+               return
+       }
+       defer voteRows.Close()
+
+       sums = &VoteSums{}
+       for voteRows.Next() {
+               var vote VoteChoice
+               var count int
+               if err = voteRows.Scan(&vote, &count); err != nil {
+                       logger.Printf("Error fetching vote sums for motion %s: %v\n", d.Tag, err)
+                       return
+               }
+               switch vote {
+               case voteAye:
+                       sums.Ayes = count
+               case voteNaye:
+                       sums.Nayes = count
+               case voteAbstain:
+                       sums.Abstains = count
+               }
+       }
+       return
+}
+
+func (d *DecisionForDisplay) LoadVotes() (err error) {
+       votesStmt, err := db.Preparex(sqlGetVotesForDecision)
+       if err != nil {
+               logger.Println("Error preparing statement:", err)
+               return
+       }
+       defer votesStmt.Close()
+       err = votesStmt.Select(&d.Votes, d.Id)
+       if err != nil {
+               logger.Printf("Error selecting votes for motion %s: %v\n", d.Tag, err)
+       }
+       return
+}
+
+func (d *Decision) OlderExists() (result bool, err error) {
+       olderStmt, err := db.Preparex(sqlCountOlderThanDecision)
+       if err != nil {
+               logger.Println("Error preparing statement:", err)
+               return
+       }
+       defer olderStmt.Close()
+
+       if err := olderStmt.Get(&result, d.Proposed); err != nil {
+               logger.Printf("Error finding older motions than %s: %v\n", d.Tag, err)
+       }
+       return
+}
+
+func FindVoterByAddress(emailAddress string) (voter *Voter, err error) {
+       findVoterStmt, err := db.Preparex(sqlGetVoter)
+       if err != nil {
+               logger.Println("Error preparing statement:", err)
+               return
+       }
+       defer findVoterStmt.Close()
+
+       voter = &Voter{}
+       if err = findVoterStmt.Get(voter, emailAddress); err != nil {
+               if err != sql.ErrNoRows {
+                       logger.Printf("Error getting voter for address %s: %v\n", emailAddress, err)
+               } else {
+                       err = nil
+                       voter = nil
+               }
+       }
+       return
+}
diff --git a/templates/footer.html b/templates/footer.html
new file mode 100644 (file)
index 0000000..a7086ad
--- /dev/null
@@ -0,0 +1,4 @@
+{{ define "footer" }}
+</body>
+</html>
+{{ end }}
\ No newline at end of file
diff --git a/templates/header.html b/templates/header.html
new file mode 100644 (file)
index 0000000..5f777bb
--- /dev/null
@@ -0,0 +1,15 @@
+{{ define "pagetitle" }}
+CAcert Board Decisions{{ if .PageTitle }} - {{ .PageTitle }}{{ end}}
+{{ end }}
+
+{{ define "header" -}}
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/html">
+<head>
+    <title>{{ template "pagetitle" . }}</title>
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
+    <link rel="stylesheet" type="text/css" href="/static/styles.css"/>
+</head>
+<body>
+<h1>{{ template "pagetitle" . }}</h1>
+{{ end }}
\ No newline at end of file
diff --git a/templates/motion.html b/templates/motion.html
new file mode 100644 (file)
index 0000000..66e4ce0
--- /dev/null
@@ -0,0 +1,21 @@
+{{ template "header" . }}
+<a href="/motions/">Show all votes</a>
+{{ $voter := .Voter }}
+<table class="list">
+    <thead>
+    <th>Status</th>
+    <th>Motion</th>
+    {{ if $voter}}
+    <th>Actions</th>
+    {{ end }}
+    </thead>
+    <tbody>
+    <tr>
+        {{ with .Decision }}
+        {{ template "motion_fragment" .}}
+        {{ if $voter }}{{ template "motion_actions" . }}{{ end }}
+        {{ end}}
+    </tr>
+    </tbody>
+</table>
+{{ template "footer" . }}
\ No newline at end of file
diff --git a/templates/motion_fragments.html b/templates/motion_fragments.html
new file mode 100644 (file)
index 0000000..38c7a01
--- /dev/null
@@ -0,0 +1,43 @@
+{{ define "motion_fragment" }}
+    <td class="{{.Status}}">
+        {{ if eq .Status 0 }}Pending {{ .Due}}
+        {{ else if eq .Status 1}}Approved {{ .Modified}}
+        {{ else if eq .Status -1}}Declined {{ .Modified}}
+        {{ else if eq .Status -2}}Withdrawn {{ .Modified}}
+        {{ else }}Unknown
+        {{ end }}
+    </td>
+    <td>
+        <i><a href="/motions/{{ .Tag}}">{{ .Tag}}</a></i><br/>
+        <b>{{ .Title}}</b><br/>
+        <pre>{{ wrap 76 .Content }}</pre>
+        <br/>
+        <i>Due: {{.Due}}</i><br/>
+        <i>Proposed: {{.Proposer}} ({{.Proposed}})</i><br/>
+        <i>Vote type: {{.VoteType}}</i><br/>
+        <i>Aye|Naye|Abstain: {{.Ayes}}|{{.Nayes}}|{{.Abstains}}</i><br/>
+        {{ if .Votes }}
+        <i>Votes:</i><br/>
+        {{ range .Votes}}
+        <i>{{ .Name }}: {{ .Vote.Vote }}</i><br/>
+        {{ end }}
+        <i><a href="/motions/{{.Tag}}">Hide Votes</a></i>
+        {{ else}}
+        <i><a href="/motions/{{.Tag}}?showvotes=1">Show Votes</a></i>
+        {{ end }}
+    </td>
+{{ end }}
+{{ define "motion_actions" }}
+    <td>
+        {{ if eq .Status 0 }}
+        <ul>
+            <li><a href="/vote/{{ .Tag }}/aye">Aye</a></li>
+            <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="/motions/{{ .Tag }}/edit">Modify</a></li>
+            <li><a href="/motions/{{ .Tag }}/withdraw">Withdraw</a></li>
+        </ul>
+        {{ end }}
+    </td>
+{{ end }}
index f717d78..ef84757 100644 (file)
@@ -1,12 +1,9 @@
-<!DOCTYPE html>
-<html xmlns="http://www.w3.org/1999/html">
-<head>
-    <title>CAcert Board Decisions</title>
-    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
-    <link rel="stylesheet" type="text/css" href="/static/styles.css"/>
-</head>
-<body>
+{{ template "header" . }}
+{{ if .Params.Flags.Unvoted }}
+<a href="/motions/">Show all votes</a>
+{{ else }}
 <a href="/motions/?unvoted=1">Show my outstanding votes</a><br/>
+{{ end }}
 {{ $voter := .Voter }}
 {{ if .Decisions }}
 <table class="list">
     <tbody>
     {{range .Decisions }}
     <tr>
-        <td class="{{.Status}}">
-            {{ if eq .Status 0 }}Pending {{ .Due}}
-            {{ else if eq .Status 1}}Approved {{ .Modified}}
-            {{ else if eq .Status -1}}Declined {{ .Modified}}
-            {{ else if eq .Status -2}}Withdrawn {{ .Modified}}
-            {{ else }}Unknown
-            {{ end }}
-        </td>
-        <td>
-            <i><a href="/motions/{{ .Tag}}">{{ .Tag}}</a></i><br />
-            <b>{{ .Title}}</b><br />
-            <pre>{{ wrap 76 .Content }}</pre>
-            <br />
-            <i>Due: {{.Due}}</i><br/>
-            <i>Proposed: {{.Proposer}} ({{.Proposed}})</i><br/>
-            <i>Vote type: {{.VoteType}}</i><br/>
-            <i>Aye|Naye|Abstain: {{.Ayes}}|{{.Nayes}}|{{.Abstains}}</i><br />
-            {{ if .Votes }}
-            <i>Votes:</i><br/>
-            {{ range .Votes}}
-            <i>{{ .Name }}: {{ .Vote}}</i><br />
-            {{ end }}
-            {{ else}}
-            <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 }}/aye">Aye</a></li>
-                <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="/motions/{{ .Tag }}/edit">Modify</a></li>
-                <li><a href="/motions/{{ .Tag }}/withdraw">Withdraw</a></li>
-            </ul>
-            {{ end }}
-        </td>{{ end }}
+        {{ template "motion_fragment" . }}
+        {{ if $voter }}{{ template "motion_actions" . }}{{ end }}
     </tr>
     {{end}}
     <tr>
-        <td colspan="{{ if $voter }}3{{ else }}2{{ end }}" class="navigation">
+        <td colspan="2" 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>
+        {{ if $voter }}
+        <td class="actions">
+            <ul>
+                <li><a href="/newmotion/">New Motion</a></li>
+            </ul>
+        </td>
+        {{ end }}
     </tr>
     </tbody>
 </table>
 {{else}}
+{{ if .Params.Flags.Unvoted }}
+<p>There are no motions requiring a vote from you.</p>
+{{ else }}
 <p>There are no motions in the system yet.</p>
+{{ end }}
 {{end}}
-</body>
-</html>
+{{ template "footer" . }}
\ No newline at end of file