Implement withdraw motion

This commit is contained in:
Jan Dittberner 2017-04-19 21:35:08 +02:00 committed by Jan Dittberner
parent bc194e8943
commit 0ce9ad6dcc
7 changed files with 166 additions and 63 deletions

View file

@ -4,9 +4,8 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"github.com/Masterminds/sprig" "github.com/Masterminds/sprig"
"os"
"text/template"
"gopkg.in/gomail.v2" "gopkg.in/gomail.v2"
"text/template"
) )
type templateBody string type templateBody string
@ -89,7 +88,7 @@ func UpdateMotion(decision *Decision, voter *Voter) (err error) {
m := gomail.NewMessage() m := gomail.NewMessage()
m.SetHeader("From", config.NoticeSenderAddress) m.SetHeader("From", config.NoticeSenderAddress)
m.SetHeader("To", config.BoardMailAddress) m.SetHeader("To", config.BoardMailAddress)
m.SetHeader("Subject", fmt.Sprintf("%s - %s", decision.Tag, decision.Title)) m.SetHeader("Subject", fmt.Sprintf("Re: %s - %s", decision.Tag, decision.Title))
m.SetHeader("References", fmt.Sprintf("<%s>", decision.Tag)) m.SetHeader("References", fmt.Sprintf("<%s>", decision.Tag))
m.SetBody("text/plain", mailText.String()) m.SetBody("text/plain", mailText.String())
@ -102,24 +101,31 @@ func UpdateMotion(decision *Decision, voter *Voter) (err error) {
} }
func WithdrawMotion(decision *Decision, voter *Voter) (err error) { func WithdrawMotion(decision *Decision, voter *Voter) (err error) {
// load template, fill name, tag, title, content err = decision.UpdateStatus()
type mailContext struct { type mailContext struct {
*Decision *Decision
Name string Name string
Sender string
Recipient string
} }
context := mailContext{decision, voter.Name, config.NoticeSenderAddress, config.BoardMailAddress} context := mailContext{decision, voter.Name}
// fill withdraw_mail.txt mailText, err := buildMail("withdraw_motion_mail.txt", context)
t, err := template.New("withdraw_mail.txt").Funcs(
sprig.GenericFuncMap()).ParseFiles("templates/withdraw_mail.txt")
if err != nil { if err != nil {
logger.Fatal(err) logger.Println("Error", err)
return
}
m := gomail.NewMessage()
m.SetHeader("From", config.NoticeSenderAddress)
m.SetHeader("To", config.BoardMailAddress)
m.SetHeader("Subject", fmt.Sprintf("Re: %s - %s - withdrawn", decision.Tag, decision.Title))
m.SetHeader("References", fmt.Sprintf("<%s>", decision.Tag))
m.SetBody("text/plain", mailText.String())
d := gomail.NewDialer(config.MailServer.Host, config.MailServer.Port, "", "")
if err := d.DialAndSend(m); err != nil {
logger.Println("Error sending mail", err)
} }
// TODO: send mail
t.Execute(os.Stdout, context)
// TODO: implement call decision.Close()
return return
} }

View file

@ -18,6 +18,7 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
) )
var logger *log.Logger var logger *log.Logger
@ -216,10 +217,6 @@ func (authenticationRequiredHandler) NeedsAuth() bool {
return true return true
} }
type withDrawMotionAction struct {
authenticationRequiredHandler
}
func getVoterFromRequest(r *http.Request) (voter *Voter, ok bool) { func getVoterFromRequest(r *http.Request) (voter *Voter, ok bool) {
voter, ok = r.Context().Value(ctxVoter).(*Voter) voter, ok = r.Context().Value(ctxVoter).(*Voter)
return return
@ -230,37 +227,71 @@ func getDecisionFromRequest(r *http.Request) (decision *DecisionForDisplay, ok b
return return
} }
func (withDrawMotionAction) Handle(w http.ResponseWriter, r *http.Request) { type FlashMessageAction struct{}
voter, voter_ok := getVoterFromRequest(r)
decision, decision_ok := getDecisionFromRequest(r)
if !voter_ok || !decision_ok || decision.Status != voteStatusPending { func (a *FlashMessageAction) AddFlash(w http.ResponseWriter, r *http.Request, message interface{}, tags ...string) (err error) {
session, err := store.Get(r, sessionCookieName)
if err != nil {
logger.Println("ERROR getting session:", err)
return
}
session.AddFlash(message, tags...)
session.Save(r, w)
if err != nil {
logger.Println("ERROR saving session:", err)
return
}
return
}
type withDrawMotionAction struct {
FlashMessageAction
authenticationRequiredHandler
}
func (a *withDrawMotionAction) Handle(w http.ResponseWriter, r *http.Request) {
decision, ok := getDecisionFromRequest(r)
if !ok || decision.Status != voteStatusPending {
http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
return return
} }
voter, ok := getVoterFromRequest(r)
if !ok {
http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
return
}
templates := []string{"withdraw_motion_form.html", "header.html", "footer.html", "motion_fragments.html"}
var templateContext struct {
PageTitle string
Decision *DecisionForDisplay
Flashes interface{}
}
switch r.Method { switch r.Method {
case http.MethodPost: case http.MethodPost:
if confirm, err := strconv.ParseBool(r.PostFormValue("confirm")); err != nil { decision.Status = voteStatusWithdrawn
log.Println("could not parse confirm parameter:", err) decision.Modified = time.Now().UTC()
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) if err := WithdrawMotion(&decision.Decision, voter); err != nil {
} else if confirm { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
WithdrawMotion(&decision.Decision, voter) return
} else { }
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) if err := a.AddFlash(w, r, fmt.Sprintf("Motion %s has been withdrawn!", decision.Tag)); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
} }
http.Redirect(w, r, "/motions/", http.StatusTemporaryRedirect) http.Redirect(w, r, "/motions/", http.StatusTemporaryRedirect)
return return
default: default:
fmt.Fprintln(w, "Withdraw motion", decision.Tag) templateContext.Decision = decision
renderTemplate(w, templates, templateContext)
} }
} }
type editMotionAction struct { type newMotionHandler struct {
authenticationRequiredHandler FlashMessageAction
} }
func newMotionHandler(w http.ResponseWriter, r *http.Request) { func (h *newMotionHandler) Handle(w http.ResponseWriter, r *http.Request) {
voter, ok := getVoterFromRequest(r) voter, ok := getVoterFromRequest(r)
if !ok { if !ok {
http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
@ -287,17 +318,15 @@ func newMotionHandler(w http.ResponseWriter, r *http.Request) {
templateContext.Form = form templateContext.Form = form
renderTemplate(w, templates, templateContext) renderTemplate(w, templates, templateContext)
} else { } else {
data.Proposed = time.Now().UTC()
if err := CreateMotion(data, voter); err != nil { if err := CreateMotion(data, voter); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
session, err := store.Get(r, sessionCookieName) if err := h.AddFlash(w, r, "The motion has been proposed!"); err != nil {
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
session.AddFlash("The motion has been proposed!")
session.Save(r, w)
http.Redirect(w, r, "/motions/", http.StatusMovedPermanently) http.Redirect(w, r, "/motions/", http.StatusMovedPermanently)
} }
@ -312,7 +341,12 @@ func newMotionHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
func (editMotionAction) Handle(w http.ResponseWriter, r *http.Request) { type editMotionAction struct {
FlashMessageAction
authenticationRequiredHandler
}
func (a editMotionAction) Handle(w http.ResponseWriter, r *http.Request) {
decision, ok := getDecisionFromRequest(r) decision, ok := getDecisionFromRequest(r)
if !ok || decision.Status != voteStatusPending { if !ok || decision.Status != voteStatusPending {
http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
@ -321,6 +355,7 @@ func (editMotionAction) Handle(w http.ResponseWriter, r *http.Request) {
voter, ok := getVoterFromRequest(r) voter, ok := getVoterFromRequest(r)
if !ok { if !ok {
http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
return
} }
templates := []string{"edit_motion_form.html", "header.html", "footer.html"} templates := []string{"edit_motion_form.html", "header.html", "footer.html"}
var templateContext struct { var templateContext struct {
@ -344,21 +379,18 @@ func (editMotionAction) Handle(w http.ResponseWriter, r *http.Request) {
templateContext.Form = form templateContext.Form = form
renderTemplate(w, templates, templateContext) renderTemplate(w, templates, templateContext)
} else { } else {
data.Modified = time.Now().UTC()
if err := UpdateMotion(data, voter); err != nil { if err := UpdateMotion(data, voter); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
session, err := store.Get(r, sessionCookieName) if err := a.AddFlash(w, r, "The motion has been modified!"); err != nil {
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
session.AddFlash("The motion has been modified!")
session.Save(r, w)
http.Redirect(w, r, "/motions/", http.StatusMovedPermanently) http.Redirect(w, r, "/motions/", http.StatusMovedPermanently)
} }
return return
default: default:
templateContext.Voter = voter templateContext.Voter = voter
@ -382,14 +414,20 @@ func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
subURL := r.URL.Path subURL := r.URL.Path
var motionActionMap = map[string]motionActionHandler{ var motionActionMap = map[string]motionActionHandler{
"withdraw": withDrawMotionAction{}, "withdraw": &withDrawMotionAction{},
"edit": editMotionAction{}, "edit": &editMotionAction{},
} }
switch { switch {
case subURL == "": case subURL == "":
authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)), motionListHandler) authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)), motionListHandler)
return return
case subURL == "/newmotion/":
handler := &newMotionHandler{}
authenticateRequest(
w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)),
handler.Handle)
return
case strings.Count(subURL, "/") == 1: case strings.Count(subURL, "/") == 1:
parts := strings.Split(subURL, "/") parts := strings.Split(subURL, "/")
motionTag := parts[0] motionTag := parts[0]
@ -427,7 +465,7 @@ type Config struct {
BaseURL string `yaml:"base_url"` BaseURL string `yaml:"base_url"`
MailServer struct { MailServer struct {
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
} `yaml:"mail_server"` } `yaml:"mail_server"`
} }
@ -465,9 +503,7 @@ func main() {
defer db.Close() defer db.Close()
http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{})) http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{}))
http.HandleFunc("/newmotion/", func(w http.ResponseWriter, r *http.Request) { http.Handle("/newmotion/", motionsHandler{})
authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)), newMotionHandler)
})
http.Handle("/static/", http.FileServer(http.Dir("."))) http.Handle("/static/", http.FileServer(http.Dir(".")))
http.Handle("/", http.RedirectHandler("/motions/", http.StatusMovedPermanently)) http.Handle("/", http.RedirectHandler("/motions/", http.StatusMovedPermanently))
@ -495,7 +531,18 @@ func main() {
logger.Printf("Launching application on https://localhost%s/\n", server.Addr) logger.Printf("Launching application on https://localhost%s/\n", server.Addr)
errs := make(chan error, 1)
go func() {
if err := http.ListenAndServe(":8080", http.RedirectHandler(config.BaseURL, http.StatusMovedPermanently)); err != nil {
errs <- err
}
close(errs)
}()
if err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey); err != nil { if err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey); err != nil {
logger.Fatal("ListenAndServerTLS: ", err) logger.Fatal("ListenAndServerTLS: ", err)
} }
if err := <-errs; err != nil {
logger.Fatal("ListenAndServe: ", err)
}
} }

View file

@ -7,4 +7,6 @@ server_certificate: server.crt
server_key: server.key server_key: server.key
cookie_secret: base64encoded_random_byte_value_of_at_least_32_bytes cookie_secret: base64encoded_random_byte_value_of_at_least_32_bytes
base_url: https://motions.cacert.org base_url: https://motions.cacert.org
mail_server: localhost:smtp mail_server:
host: localhost
port: 25

View file

@ -50,8 +50,6 @@ func (f *NewDecisionForm) Validate() (bool, *Decision) {
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 return len(f.Errors) == 0, data
} }
@ -92,7 +90,5 @@ func (f *EditDecisionForm) Validate() (bool, *Decision) {
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.Modified = time.Now().UTC()
return len(f.Errors) == 0, data return len(f.Errors) == 0, data
} }

View file

@ -20,6 +20,7 @@ const (
sqlCountOlderThanUnvotedDecision sqlCountOlderThanUnvotedDecision
sqlCreateDecision sqlCreateDecision
sqlUpdateDecision sqlUpdateDecision
sqlUpdateDecisionStatus
) )
var sqlStatements = map[sqlKey]string{ var sqlStatements = map[sqlKey]string{
@ -98,6 +99,10 @@ UPDATE decisions
SET proponent=:proponent, title=:title, content=:content, SET proponent=:proponent, title=:title, content=:content,
votetype=:votetype, due=:due, modified=:modified votetype=:votetype, due=:due, modified=:modified
WHERE id=:id`, WHERE id=:id`,
sqlUpdateDecisionStatus: `
UPDATE decisions
SET status=:status, modified=:modified WHERE id=:id
`,
} }
var db *sqlx.DB var db *sqlx.DB
@ -417,6 +422,22 @@ func (d *Decision) Create() (err error) {
return return
} }
func (d *Decision) LoadWithId() (err error) {
getDecisionStmt, err := db.Preparex(sqlStatements[sqlLoadDecisionById])
if err != nil {
logger.Println("Error preparing statement:", err)
return
}
defer getDecisionStmt.Close()
err = getDecisionStmt.Get(d, d.Id)
if err != nil {
logger.Println("Error loading updated motion:", err)
}
return
}
func (d *Decision) Update() (err error) { func (d *Decision) Update() (err error) {
updateDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlUpdateDecision]) updateDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlUpdateDecision])
if err != nil { if err != nil {
@ -438,18 +459,31 @@ func (d *Decision) Update() (err error) {
logger.Printf("WARNING wrong number of affected rows: %d (1 expected)\n", affectedRows) logger.Printf("WARNING wrong number of affected rows: %d (1 expected)\n", affectedRows)
} }
getDecisionStmt, err := db.Preparex(sqlStatements[sqlLoadDecisionById]) err = d.LoadWithId()
return
}
func (d *Decision) UpdateStatus() (err error) {
updateStatusStmt, err := db.PrepareNamed(sqlStatements[sqlUpdateDecisionStatus])
if err != nil { if err != nil {
logger.Println("Error preparing statement:", err) logger.Println("Error preparing statement:", err)
return return
} }
defer getDecisionStmt.Close() defer updateStatusStmt.Close()
err = getDecisionStmt.Get(d, d.Id) result, err := updateStatusStmt.Exec(d)
if err != nil { if err != nil {
logger.Println("Error loading updated motion:", err) logger.Println("Error setting motion status:", err)
return
}
affectedRows, err := result.RowsAffected()
if err != nil {
logger.Print("Problem determining the affected rows")
} else if affectedRows != 1 {
logger.Printf("WARNING wrong number of affected rows: %d (1 expected)\n", affectedRows)
} }
err = d.LoadWithId()
return return
} }

View file

@ -0,0 +1,22 @@
{{ template "header" . }}
<a href="/motions/">Show all votes</a>
<table class="list">
<thead>
<tr>
<th>Status</th>
<th>Motion</th>
</tr>
</thead>
<tbody>
<tr>
{{ with .Decision }}
{{ template "motion_fragment" .}}
{{ end}}
</tr>
</tbody>
</table>
<form action="/motions/{{ .Decision.Tag }}/withdraw" method="post">
<input type="submit" value="Withdraw" />
</form>
{{ template "footer" . }}

View file

@ -1,7 +1,3 @@
From: {{ .Sender }}
To: {{ .Recipient }}
Subject: Re: {{ .Tag }} - {{ .Title }} - withdrawn
Dear Board, Dear Board,
{{ .Name }} has withdrawn the motion {{ .Tag }} that was as follows: {{ .Name }} has withdrawn the motion {{ .Tag }} that was as follows: