Implement proper model, actions and template structure
[cacert-boardvoting.git] / boardvoting.go
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()
 }