summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJan Dittberner <jandd@cacert.org>2017-04-17 16:24:37 +0200
committerJan Dittberner <jan@dittberner.info>2017-04-22 00:12:24 +0200
commit6fe515ea52493ea79a07efe9e1fc652dea272e32 (patch)
treeabb381406580fe6c9d7f29f6bf9cf7f3b2177653
parentf4360b98c8b3012bf27c75d9c04e7f9b737c9694 (diff)
downloadcacert-boardvoting-6fe515ea52493ea79a07efe9e1fc652dea272e32.tar.gz
cacert-boardvoting-6fe515ea52493ea79a07efe9e1fc652dea272e32.tar.xz
cacert-boardvoting-6fe515ea52493ea79a07efe9e1fc652dea272e32.zip
Implement proper model, actions and template structure
-rw-r--r--actions.go30
-rw-r--r--boardvoting.go473
-rw-r--r--models.go346
-rw-r--r--templates/footer.html4
-rw-r--r--templates/header.html15
-rw-r--r--templates/motion.html21
-rw-r--r--templates/motion_fragments.html43
-rw-r--r--templates/motions.html70
8 files changed, 597 insertions, 405 deletions
diff --git a/actions.go b/actions.go
new file mode 100644
index 0000000..fde767c
--- /dev/null
+++ b/actions.go
@@ -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
+}
diff --git a/boardvoting.go b/boardvoting.go
index a49b34e..77499a2 100644
--- a/boardvoting.go
+++ b/boardvoting.go
@@ -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
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
index 0000000..a7086ad
--- /dev/null
+++ b/templates/footer.html
@@ -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
index 0000000..5f777bb
--- /dev/null
+++ b/templates/header.html
@@ -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
index 0000000..66e4ce0
--- /dev/null
+++ b/templates/motion.html
@@ -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
index 0000000..38c7a01
--- /dev/null
+++ b/templates/motion_fragments.html
@@ -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 }}
diff --git a/templates/motions.html b/templates/motions.html
index f717d78..ef84757 100644
--- a/templates/motions.html
+++ b/templates/motions.html
@@ -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">
@@ -20,57 +17,30 @@
<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