Implement withdraw motion
authorJan Dittberner <jandd@cacert.org>
Wed, 19 Apr 2017 19:35:08 +0000 (21:35 +0200)
committerJan Dittberner <jan@dittberner.info>
Fri, 21 Apr 2017 22:12:38 +0000 (00:12 +0200)
actions.go
boardvoting.go
config.yaml.example
forms.go
models.go
templates/withdraw_mail.txt [deleted file]
templates/withdraw_motion_form.html [new file with mode: 0644]
templates/withdraw_motion_mail.txt [new file with mode: 0644]

index a8e78fb..19e9bf3 100644 (file)
@@ -4,9 +4,8 @@ import (
        "bytes"
        "fmt"
        "github.com/Masterminds/sprig"
-       "os"
-       "text/template"
        "gopkg.in/gomail.v2"
+       "text/template"
 )
 
 type templateBody string
@@ -89,7 +88,7 @@ func UpdateMotion(decision *Decision, voter *Voter) (err error) {
        m := gomail.NewMessage()
        m.SetHeader("From", config.NoticeSenderAddress)
        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.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) {
-       // load template, fill name, tag, title, content
+       err = decision.UpdateStatus()
+
        type mailContext struct {
                *Decision
-               Name      string
-               Sender    string
-               Recipient string
+               Name string
        }
-       context := mailContext{decision, voter.Name, config.NoticeSenderAddress, config.BoardMailAddress}
+       context := mailContext{decision, voter.Name}
 
-       // fill withdraw_mail.txt
-       t, err := template.New("withdraw_mail.txt").Funcs(
-               sprig.GenericFuncMap()).ParseFiles("templates/withdraw_mail.txt")
+       mailText, err := buildMail("withdraw_motion_mail.txt", context)
        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
 }
index efa9ecc..187a867 100644 (file)
@@ -18,6 +18,7 @@ import (
        "os"
        "strconv"
        "strings"
+       "time"
 )
 
 var logger *log.Logger
@@ -216,10 +217,6 @@ func (authenticationRequiredHandler) NeedsAuth() bool {
        return true
 }
 
-type withDrawMotionAction struct {
-       authenticationRequiredHandler
-}
-
 func getVoterFromRequest(r *http.Request) (voter *Voter, ok bool) {
        voter, ok = r.Context().Value(ctxVoter).(*Voter)
        return
@@ -230,37 +227,71 @@ func getDecisionFromRequest(r *http.Request) (decision *DecisionForDisplay, ok b
        return
 }
 
-func (withDrawMotionAction) Handle(w http.ResponseWriter, r *http.Request) {
-       voter, voter_ok := getVoterFromRequest(r)
-       decision, decision_ok := getDecisionFromRequest(r)
+type FlashMessageAction struct{}
 
-       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)
+               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 {
        case 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)
+               decision.Status = voteStatusWithdrawn
+               decision.Modified = time.Now().UTC()
+               if err := WithdrawMotion(&decision.Decision, voter); err != nil {
+                       http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+                       return
+               }
+               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)
                return
        default:
-               fmt.Fprintln(w, "Withdraw motion", decision.Tag)
+               templateContext.Decision = decision
+               renderTemplate(w, templates, templateContext)
        }
 }
 
-type editMotionAction struct {
-       authenticationRequiredHandler
+type newMotionHandler struct {
+       FlashMessageAction
 }
 
-func newMotionHandler(w http.ResponseWriter, r *http.Request) {
+func (h *newMotionHandler) Handle(w http.ResponseWriter, r *http.Request) {
        voter, ok := getVoterFromRequest(r)
        if !ok {
                http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
@@ -287,17 +318,15 @@ func newMotionHandler(w http.ResponseWriter, r *http.Request) {
                        templateContext.Form = form
                        renderTemplate(w, templates, templateContext)
                } else {
+                       data.Proposed = time.Now().UTC()
                        if err := CreateMotion(data, voter); err != nil {
                                http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
                                return
                        }
-                       session, err := store.Get(r, sessionCookieName)
-                       if err != nil {
+                       if err := h.AddFlash(w, r, "The motion has been proposed!"); err != nil {
                                http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
                                return
                        }
-                       session.AddFlash("The motion has been proposed!")
-                       session.Save(r, w)
 
                        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)
        if !ok || decision.Status != voteStatusPending {
                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)
        if !ok {
                http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
+               return
        }
        templates := []string{"edit_motion_form.html", "header.html", "footer.html"}
        var templateContext struct {
@@ -344,21 +379,18 @@ func (editMotionAction) Handle(w http.ResponseWriter, r *http.Request) {
                        templateContext.Form = form
                        renderTemplate(w, templates, templateContext)
                } else {
+                       data.Modified = time.Now().UTC()
                        if err := UpdateMotion(data, voter); err != nil {
                                http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
                                return
                        }
-                       session, err := store.Get(r, sessionCookieName)
-                       if err != nil {
+                       if err := a.AddFlash(w, r, "The motion has been modified!"); err != nil {
                                http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
                                return
                        }
-                       session.AddFlash("The motion has been modified!")
-                       session.Save(r, w)
 
                        http.Redirect(w, r, "/motions/", http.StatusMovedPermanently)
                }
-
                return
        default:
                templateContext.Voter = voter
@@ -382,14 +414,20 @@ func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        subURL := r.URL.Path
 
        var motionActionMap = map[string]motionActionHandler{
-               "withdraw": withDrawMotionAction{},
-               "edit":     editMotionAction{},
+               "withdraw": &withDrawMotionAction{},
+               "edit":     &editMotionAction{},
        }
 
        switch {
        case subURL == "":
                authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)), motionListHandler)
                return
+       case subURL == "/newmotion/":
+               handler := &newMotionHandler{}
+               authenticateRequest(
+                       w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)),
+                       handler.Handle)
+               return
        case strings.Count(subURL, "/") == 1:
                parts := strings.Split(subURL, "/")
                motionTag := parts[0]
@@ -427,7 +465,7 @@ type Config struct {
        BaseURL              string `yaml:"base_url"`
        MailServer           struct {
                Host string `yaml:"host"`
-               Port int `yaml:"port"`
+               Port int    `yaml:"port"`
        } `yaml:"mail_server"`
 }
 
@@ -465,9 +503,7 @@ func main() {
        defer db.Close()
 
        http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{}))
-       http.HandleFunc("/newmotion/", func(w http.ResponseWriter, r *http.Request) {
-               authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)), newMotionHandler)
-       })
+       http.Handle("/newmotion/", motionsHandler{})
        http.Handle("/static/", http.FileServer(http.Dir(".")))
        http.Handle("/", http.RedirectHandler("/motions/", http.StatusMovedPermanently))
 
@@ -495,7 +531,18 @@ func main() {
 
        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 {
                logger.Fatal("ListenAndServerTLS: ", err)
        }
+       if err := <-errs; err != nil {
+               logger.Fatal("ListenAndServe: ", err)
+       }
 }
index f5da83d..f971540 100644 (file)
@@ -7,4 +7,6 @@ server_certificate: server.crt
 server_key: server.key
 cookie_secret: base64encoded_random_byte_value_of_at_least_32_bytes
 base_url: https://motions.cacert.org
-mail_server: localhost:smtp
\ No newline at end of file
+mail_server:
+  host: localhost
+  port: 25
\ No newline at end of file
index 503e7e5..b7b7225 100644 (file)
--- a/forms.go
+++ b/forms.go
@@ -50,8 +50,6 @@ func (f *NewDecisionForm) Validate() (bool, *Decision) {
                data.Due = time.Date(year, month, day, 23, 59, 59, 0, time.UTC)
        }
 
-       data.Proposed = time.Now().UTC()
-
        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.Modified = time.Now().UTC()
-
        return len(f.Errors) == 0, data
 }
index 8194dc9..d6eee15 100644 (file)
--- a/models.go
+++ b/models.go
@@ -20,6 +20,7 @@ const (
        sqlCountOlderThanUnvotedDecision
        sqlCreateDecision
        sqlUpdateDecision
+       sqlUpdateDecisionStatus
 )
 
 var sqlStatements = map[sqlKey]string{
@@ -98,6 +99,10 @@ UPDATE decisions
 SET proponent=:proponent, title=:title, content=:content,
     votetype=:votetype, due=:due, modified=:modified
 WHERE id=:id`,
+       sqlUpdateDecisionStatus: `
+UPDATE decisions
+SET status=:status, modified=:modified WHERE id=:id
+`,
 }
 
 var db *sqlx.DB
@@ -417,6 +422,22 @@ func (d *Decision) Create() (err error) {
        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) {
        updateDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlUpdateDecision])
        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)
        }
 
-       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 {
                logger.Println("Error preparing statement:", err)
                return
        }
-       defer getDecisionStmt.Close()
+       defer updateStatusStmt.Close()
 
-       err = getDecisionStmt.Get(d, d.Id)
+       result, err := updateStatusStmt.Exec(d)
        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
 }
 
diff --git a/templates/withdraw_mail.txt b/templates/withdraw_mail.txt
deleted file mode 100644 (file)
index 4ccf4de..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-From: {{ .Sender }}
-To: {{ .Recipient }}
-Subject: Re: {{ .Tag }} - {{ .Title }} - withdrawn
-
-Dear Board,
-
-{{ .Name }} has withdrawn the motion {{ .Tag }} that was as follows:
-
-{{ .Title }}
-
-{{ wrap 76 .Content }}
-
-Kind regards,
-the voting system
\ No newline at end of file
diff --git a/templates/withdraw_motion_form.html b/templates/withdraw_motion_form.html
new file mode 100644 (file)
index 0000000..809ce28
--- /dev/null
@@ -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" . }}
diff --git a/templates/withdraw_motion_mail.txt b/templates/withdraw_motion_mail.txt
new file mode 100644 (file)
index 0000000..fb3ed87
--- /dev/null
@@ -0,0 +1,10 @@
+Dear Board,
+
+{{ .Name }} has withdrawn the motion {{ .Tag }} that was as follows:
+
+{{ .Title }}
+
+{{ wrap 76 .Content }}
+
+Kind regards,
+the voting system
\ No newline at end of file