diff --git a/actions.go b/actions.go index 190314c..0619587 100644 --- a/actions.go +++ b/actions.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "github.com/Masterminds/sprig" "os" "text/template" @@ -14,7 +15,29 @@ func CreateMotion(decision *Decision, voter *Voter) (err error) { return } - // TODO: implement fetching new decision, implement mail + type mailContext struct { + Decision + Name string + Sender string + Recipient string + VoteURL string + UnvotedURL string + } + voteURL := fmt.Sprintf("%s/vote", config.BaseURL) + unvotedURL := fmt.Sprintf("%s/motions/?unvoted=1", config.BaseURL) + context := mailContext{ + *decision, voter.Name, config.NoticeSenderAddress, + config.BoardMailAddress, voteURL, + unvotedURL} + + t, err := template.New("create_motion_mail.txt").Funcs( + sprig.GenericFuncMap()).ParseFiles("templates/create_motion_mail.txt") + if err != nil { + logger.Fatal(err) + } + t.Execute(os.Stdout, context) + + // TODO: implement mail sending return } diff --git a/boardvoting.go b/boardvoting.go index 8ad5438..367179f 100644 --- a/boardvoting.go +++ b/boardvoting.go @@ -83,7 +83,7 @@ type motionParameters struct { } type motionListParameters struct { - Page int64 + Page int64 Flags struct { Confirmed, Withdraw, Unvoted bool } @@ -136,21 +136,13 @@ func motionListHandler(w http.ResponseWriter, r *http.Request) { session.Save(r, w) templateContext.Params = ¶ms - if params.Flags.Unvoted && templateContext.Voter != nil { - if templateContext.Decisions, err = FindVotersUnvotedDecisionsForDisplayOnPage( - params.Page, templateContext.Voter); err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - } else { - if templateContext.Decisions, err = FindDecisionsForDisplayOnPage(params.Page); err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } + if templateContext.Decisions, err = FindDecisionsForDisplayOnPage(params.Page, params.Flags.Unvoted, templateContext.Voter); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } if len(templateContext.Decisions) > 0 { - olderExists, err := templateContext.Decisions[len(templateContext.Decisions)-1].OlderExists() + olderExists, err := templateContext.Decisions[len(templateContext.Decisions)-1].OlderExists(params.Flags.Unvoted, templateContext.Voter) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return @@ -363,7 +355,7 @@ func newMotionHandler(w http.ResponseWriter, r *http.Request) { session.AddFlash("The motion has been proposed!") session.Save(r, w) - http.Redirect(w, r, "/motions/", http.StatusTemporaryRedirect) + http.Redirect(w, r, "/motions/", http.StatusMovedPermanently) } return @@ -384,6 +376,7 @@ type Config struct { ServerCert string `yaml:"server_certificate"` ServerKey string `yaml:"server_key"` CookieSecret string `yaml:"cookie_secret"` + BaseURL string `yaml:"base_url"` } func init() { @@ -406,16 +399,17 @@ func init() { } store = sessions.NewCookieStore(cookieSecret) logger.Println("read configuration") -} -func main() { - logger.Printf("CAcert Board Voting version %s, build %s\n", version, build) - - var err error db, err = sqlx.Open("sqlite3", config.DatabaseFile) if err != nil { logger.Fatal(err) } + logger.Println("opened database connection") +} + +func main() { + logger.Printf("CAcert Board Voting version %s, build %s\n", version, build) + defer db.Close() http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{})) diff --git a/config.yaml.example b/config.yaml.example index fac503d..7a9390c 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -5,4 +5,5 @@ database_file: database.sqlite client_ca_certificates: cacert_class3.pem server_certificate: server.crt server_key: server.key -cookie_secret: base64encoded_random_byte_value_of_at_least_32_bytes \ No newline at end of file +cookie_secret: base64encoded_random_byte_value_of_at_least_32_bytes +base_url: https://motions.cacert.org \ No newline at end of file diff --git a/forms.go b/forms.go index d5359ff..f3cd942 100644 --- a/forms.go +++ b/forms.go @@ -47,8 +47,10 @@ func (f *NewDecisionForm) Validate() (bool, *Decision) { f.Errors["Due"] = "Please choose a valid due date" } else { year, month, day := time.Now().UTC().Add(dueDuration).Date() - data.Due = time.Date(year, month, day, 23,59,59,0, time.UTC) + data.Due = time.Date(year, month, day, 23, 59, 59, 0, time.UTC) } + data.Proposed = time.Now().UTC() + return len(f.Errors) == 0, data } diff --git a/models.go b/models.go index 410ed3e..a8d24ad 100644 --- a/models.go +++ b/models.go @@ -6,8 +6,23 @@ import ( "time" ) +type sqlKey int + const ( - sqlGetDecisions = ` + sqlLoadDecisions sqlKey = iota + sqlLoadUnvotedDecisions + sqlLoadDecisionByTag + sqlLoadDecisionById + sqlLoadVoteCountsForDecision + sqlLoadVotesForDecision + sqlLoadEnabledVoterByEmail + sqlCountOlderThanDecision + sqlCountOlderThanUnvotedDecision + sqlCreateDecision +) + +var sqlStatements = map[sqlKey]string{ + sqlLoadDecisions: ` SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.due, @@ -15,60 +30,83 @@ 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` - sqlGetDecision = ` +LIMIT 10 OFFSET 10 * $1`, + sqlLoadUnvotedDecisions: ` 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, voters.enabled, voters.reminder -FROM voters -JOIN emails ON voters.id=emails.voter -WHERE emails.address=$1 AND voters.enabled=1` - sqlVoteCount = ` +WHERE decisions.status = 0 AND decisions.id NOT IN ( + SELECT votes.decision + FROM votes + WHERE votes.voter = $1) +ORDER BY proposed DESC +LIMIT 10 OFFSET 10 * $2;`, + sqlLoadDecisionByTag: ` +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;`, + sqlLoadDecisionById: ` +SELECT decisions.id, decisions.tag, decisions.proponent, decisions.proposed, + decisions.title, decisions.content, decisions.votetype, decisions.status, + decisions.due, decisions.modified +FROM decisions +WHERE decisions.id=$1;`, + sqlLoadVoteCountsForDecision: ` SELECT vote, COUNT(vote) FROM votes -WHERE decision=$1 GROUP BY vote` - sqlCountOlderThanDecision = ` -SELECT COUNT(*) > 0 FROM decisions WHERE proposed < $1` - sqlGetVotesForDecision = ` +WHERE decision=$1 GROUP BY vote`, + sqlLoadVotesForDecision: ` 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` - sqlCreateDecision = ` +WHERE decision=$1`, + sqlLoadEnabledVoterByEmail: ` +SELECT voters.id, voters.name, voters.enabled, voters.reminder +FROM voters +JOIN emails ON voters.id=emails.voter +WHERE emails.address=$1 AND voters.enabled=1`, + sqlCountOlderThanDecision: ` +SELECT COUNT(*) > 0 FROM decisions WHERE proposed < $1`, + sqlCountOlderThanUnvotedDecision: ` +SELECT COUNT(*) > 0 FROM decisions WHERE proposed < $1 + AND status=0 + AND id NOT IN (SELECT decision FROM votes WHERE votes.voter=$2)`, + sqlCreateDecision: ` INSERT INTO decisions ( proposed, proponent, title, content, votetype, status, due, modified,tag ) VALUES ( - datetime('now','utc'), :proponent, :title, :content, :votetype, 0, + :proposed, :proponent, :title, :content, :votetype, 0, :due, - datetime('now','utc'), - 'm' || strftime('%Y%m%d','now') || '.' || ( - SELECT COUNT(*)+1 AS num - FROM decisions - WHERE proposed BETWEEN date('now') AND date('now','1 day') + :proposed, + 'm' || strftime('%Y%m%d', :proposed) || '.' || ( + SELECT COUNT(*)+1 AS num + FROM decisions + WHERE proposed + BETWEEN date(:proposed) AND date(:proposed, '1 day') ) -) -` -) +)`, +} var db *sqlx.DB +func init() { + for _, sqlStatement := range sqlStatements { + var stmt *sqlx.Stmt + stmt, err := db.Preparex(sqlStatement) + if err != nil { + logger.Fatalf("ERROR parsing statement %s: %s", sqlStatement, err) + } + stmt.Close() + } +} + type VoteType uint8 type VoteStatus int8 @@ -198,7 +236,7 @@ type DecisionForDisplay struct { } func FindDecisionForDisplayByTag(tag string) (decision *DecisionForDisplay, err error) { - decisionStmt, err := db.Preparex(sqlGetDecision) + decisionStmt, err := db.Preparex(sqlStatements[sqlLoadDecisionByTag]) if err != nil { logger.Println("Error preparing statement:", err) return @@ -223,45 +261,25 @@ func FindDecisionForDisplayByTag(tag string) (decision *DecisionForDisplay, err // 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) +func FindDecisionsForDisplayOnPage(page int64, unvoted bool, voter *Voter) (decisions []*DecisionForDisplay, err error) { + var decisionsStmt *sqlx.Stmt + if unvoted && voter != nil { + decisionsStmt, err = db.Preparex(sqlStatements[sqlLoadUnvotedDecisions]) + } else { + decisionsStmt, err = db.Preparex(sqlStatements[sqlLoadDecisions]) } - 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) + var rows *sqlx.Rows + if unvoted && voter != nil { + rows, err = decisionsStmt.Queryx(voter.Id, page-1) + } else { + rows, err = decisionsStmt.Queryx(page - 1) + } if err != nil { logger.Printf("Error loading motions for page %d: %v\n", page, err) return @@ -284,7 +302,7 @@ func FindVotersUnvotedDecisionsForDisplayOnPage(page int64, voter *Voter) (decis } func (d *Decision) VoteSums() (sums *VoteSums, err error) { - votesStmt, err := db.Preparex(sqlVoteCount) + votesStmt, err := db.Preparex(sqlStatements[sqlLoadVoteCountsForDecision]) if err != nil { logger.Println("Error preparing statement:", err) return @@ -319,7 +337,7 @@ func (d *Decision) VoteSums() (sums *VoteSums, err error) { } func (d *DecisionForDisplay) LoadVotes() (err error) { - votesStmt, err := db.Preparex(sqlGetVotesForDecision) + votesStmt, err := db.Preparex(sqlStatements[sqlLoadVotesForDecision]) if err != nil { logger.Println("Error preparing statement:", err) return @@ -332,22 +350,34 @@ func (d *DecisionForDisplay) LoadVotes() (err error) { return } -func (d *Decision) OlderExists() (result bool, err error) { - olderStmt, err := db.Preparex(sqlCountOlderThanDecision) +func (d *Decision) OlderExists(unvoted bool, voter *Voter) (result bool, err error) { + var olderStmt *sqlx.Stmt + if unvoted && voter != nil { + olderStmt, err = db.Preparex(sqlStatements[sqlCountOlderThanUnvotedDecision]) + } else { + olderStmt, err = db.Preparex(sqlStatements[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) + if unvoted && voter != nil { + if err = olderStmt.Get(&result, d.Proposed, voter.Id); err != nil { + logger.Printf("Error finding older motions than %s: %v\n", d.Tag, err) + } + } else { + if err = olderStmt.Get(&result, d.Proposed); err != nil { + logger.Printf("Error finding older motions than %s: %v\n", d.Tag, err) + } } + return } func (d *Decision) Save() (err error) { - insertDecisionStmt, err := db.PrepareNamed(sqlCreateDecision) + insertDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlCreateDecision]) if err != nil { logger.Println("Error preparing statement:", err) return @@ -359,14 +389,30 @@ func (d *Decision) Save() (err error) { logger.Println("Error creating motion:", err) return } - logger.Println(result) - // TODO: implement fetch last id from result - // TODO: load decision from DB + + lastInsertId, err := result.LastInsertId() + if err != nil { + logger.Println("Error getting id of inserted motion:", err) + } + logger.Println("DEBUG new motion has id", lastInsertId) + + getDecisionStmt, err := db.Preparex(sqlStatements[sqlLoadDecisionById]) + if err != nil { + logger.Println("Error preparing statement:", err) + return + } + defer getDecisionStmt.Close() + + err = getDecisionStmt.Get(d, lastInsertId) + if err != nil { + logger.Println("Error getting inserted motion:", err) + } + return } func FindVoterByAddress(emailAddress string) (voter *Voter, err error) { - findVoterStmt, err := db.Preparex(sqlGetVoter) + findVoterStmt, err := db.Preparex(sqlStatements[sqlLoadEnabledVoterByEmail]) if err != nil { logger.Println("Error preparing statement:", err) return diff --git a/templates/create_motion_mail.txt b/templates/create_motion_mail.txt new file mode 100644 index 0000000..f26577e --- /dev/null +++ b/templates/create_motion_mail.txt @@ -0,0 +1,25 @@ +From: {{ .Sender }} +To: {{ .Recipient }} +Subject: {{ .Tag }} - {{ .Title }} + +Dear Board, + +{{ .Name }} has made the following motion: + +{{ .Title }} +{{ .Content }} + +Vote type: {{ .VoteType }} + +Voting will close {{ .Due }} + +To vote please choose: + +Aye: {{ .VoteURL }}/aye +Naye: {{ .VoteURL }}/naye +Abstain: {{ .VoteURL }}/abstain + +To see all your pending votes: {{ .UnvotedURL }} + +Kind regards, +the voting system \ No newline at end of file diff --git a/templates/motions.html b/templates/motions.html index 414c399..e5279bd 100644 --- a/templates/motions.html +++ b/templates/motions.html @@ -3,7 +3,7 @@ {{ if .Params.Flags.Unvoted }} Show all votes {{ else if $voter }} -Show my outstanding votes
+Show my pending votes
{{ end }} {{ if .Decisions }} @@ -23,8 +23,8 @@ {{end}} {{ if $voter }}