summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJan Dittberner <jan@dittberner.info>2017-04-19 00:05:42 +0200
committerJan Dittberner <jan@dittberner.info>2017-04-22 00:12:38 +0200
commitbc194e8943bdec58ef462e393ef2a8a59d928718 (patch)
tree1d0ecdfe5c2eb131437ced9dfd2ec44484ad3763
parentcc0f5c0b7b931440704bc31a858d52ae92585616 (diff)
downloadcacert-boardvoting-bc194e8943bdec58ef462e393ef2a8a59d928718.tar.gz
cacert-boardvoting-bc194e8943bdec58ef462e393ef2a8a59d928718.tar.xz
cacert-boardvoting-bc194e8943bdec58ef462e393ef2a8a59d928718.zip
Implement motion editing
-rw-r--r--actions.go84
-rw-r--r--boardvoting.go162
-rw-r--r--config.yaml.example3
-rw-r--r--forms.go42
-rw-r--r--models.go46
-rw-r--r--templates/create_motion_form.html (renamed from templates/newmotion_form.html)0
-rw-r--r--templates/create_motion_mail.txt7
-rw-r--r--templates/edit_motion_form.html73
-rw-r--r--templates/update_motion_mail.txt26
9 files changed, 368 insertions, 75 deletions
diff --git a/actions.go b/actions.go
index 0619587..a8e78fb 100644
--- a/actions.go
+++ b/actions.go
@@ -1,15 +1,32 @@
package main
import (
+ "bytes"
"fmt"
"github.com/Masterminds/sprig"
"os"
"text/template"
+ "gopkg.in/gomail.v2"
)
+type templateBody string
+
+func buildMail(templateName string, context interface{}) (mailText *bytes.Buffer, err error) {
+ t, err := template.New(templateName).Funcs(
+ sprig.GenericFuncMap()).ParseFiles(fmt.Sprintf("templates/%s", templateName))
+ if err != nil {
+ return
+ }
+
+ mailText = bytes.NewBufferString("")
+ t.Execute(mailText, context)
+
+ return
+}
+
func CreateMotion(decision *Decision, voter *Voter) (err error) {
decision.ProponentId = voter.Id
- err = decision.Save()
+ err = decision.Create()
if err != nil {
logger.Println("Error saving motion:", err)
return
@@ -18,26 +35,69 @@ func CreateMotion(decision *Decision, voter *Voter) (err error) {
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}
+ context := mailContext{*decision, voter.Name, voteURL, unvotedURL}
- t, err := template.New("create_motion_mail.txt").Funcs(
- sprig.GenericFuncMap()).ParseFiles("templates/create_motion_mail.txt")
+ mailText, err := buildMail("create_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("%s - %s", decision.Tag, decision.Title))
+ m.SetHeader("Message-ID", 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)
+ }
+
+ return
+}
+
+func UpdateMotion(decision *Decision, voter *Voter) (err error) {
+ err = decision.Update()
+ if err != nil {
+ logger.Println("Error updating motion:", err)
+ return
+ }
+
+ type mailContext struct {
+ Decision
+ Name 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, voteURL, unvotedURL}
+
+ mailText, err := buildMail("update_motion_mail.txt", context)
+ if err != nil {
+ logger.Println("Error", err)
+ return
+ }
+
+ 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("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)
}
- t.Execute(os.Stdout, context)
- // TODO: implement mail sending
return
}
diff --git a/boardvoting.go b/boardvoting.go
index 367179f..efa9ecc 100644
--- a/boardvoting.go
+++ b/boardvoting.go
@@ -47,8 +47,8 @@ type contextKey int
const (
ctxNeedsAuth contextKey = iota
- ctxVoter contextKey = iota
- ctxDecision contextKey = iota
+ ctxVoter
+ ctxDecision
)
func authenticateRequest(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request)) {
@@ -260,82 +260,83 @@ type editMotionAction struct {
authenticationRequiredHandler
}
-func (editMotionAction) Handle(w http.ResponseWriter, r *http.Request) {
- decision, ok := getDecisionFromRequest(r)
- if !ok || decision.Status != voteStatusPending {
+func newMotionHandler(w http.ResponseWriter, r *http.Request) {
+ voter, ok := getVoterFromRequest(r)
+ if !ok {
http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
- return
}
- fmt.Fprintln(w, "Edit motion", decision.Tag)
- // TODO: implement
-}
-
-type motionsHandler struct{}
-func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- if err := db.Ping(); err != nil {
- logger.Fatal(err)
+ templates := []string{"create_motion_form.html", "header.html", "footer.html"}
+ var templateContext struct {
+ Form NewDecisionForm
+ PageTitle string
+ Voter *Voter
+ Flashes interface{}
}
+ switch r.Method {
+ case http.MethodPost:
+ form := NewDecisionForm{
+ Title: r.FormValue("Title"),
+ Content: r.FormValue("Content"),
+ VoteType: r.FormValue("VoteType"),
+ Due: r.FormValue("Due"),
+ }
- subURL := r.URL.Path
-
- var motionActionMap = map[string]motionActionHandler{
- "withdraw": withDrawMotionAction{},
- "edit": editMotionAction{},
- }
+ if valid, data := form.Validate(); !valid {
+ templateContext.Voter = voter
+ templateContext.Form = form
+ renderTemplate(w, templates, templateContext)
+ } else {
+ 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 {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ session.AddFlash("The motion has been proposed!")
+ session.Save(r, w)
- switch {
- case subURL == "":
- authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)), motionListHandler)
- return
- case strings.Count(subURL, "/") == 1:
- parts := strings.Split(subURL, "/")
- logger.Printf("handle %v\n", parts)
- motionTag := parts[0]
- action, ok := motionActionMap[parts[1]]
- if !ok {
- http.NotFound(w, r)
- return
+ http.Redirect(w, r, "/motions/", http.StatusMovedPermanently)
}
- authenticateRequest(
- w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, action.NeedsAuth())),
- func(w http.ResponseWriter, r *http.Request) {
- singleDecisionHandler(w, r, motionTag, action.Handle)
- })
- logger.Printf("motion: %s, action: %s\n", motionTag, action)
- return
- case strings.Count(subURL, "/") == 0:
- authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)),
- func(w http.ResponseWriter, r *http.Request) {
- singleDecisionHandler(w, r, subURL, motionHandler)
- })
+
return
default:
- http.NotFound(w, r)
- return
+ templateContext.Voter = voter
+ templateContext.Form = NewDecisionForm{
+ VoteType: strconv.FormatInt(voteTypeMotion, 10),
+ }
+ renderTemplate(w, templates, templateContext)
}
}
-func newMotionHandler(w http.ResponseWriter, r *http.Request) {
+func (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)
+ return
+ }
voter, ok := getVoterFromRequest(r)
if !ok {
http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
}
-
- templates := []string{"newmotion_form.html", "header.html", "footer.html"}
+ templates := []string{"edit_motion_form.html", "header.html", "footer.html"}
var templateContext struct {
- Form NewDecisionForm
+ Form EditDecisionForm
PageTitle string
Voter *Voter
Flashes interface{}
}
switch r.Method {
case http.MethodPost:
- form := NewDecisionForm{
+ form := EditDecisionForm{
Title: r.FormValue("Title"),
Content: r.FormValue("Content"),
VoteType: r.FormValue("VoteType"),
Due: r.FormValue("Due"),
+ Decision: &decision.Decision,
}
if valid, data := form.Validate(); !valid {
@@ -343,7 +344,7 @@ func newMotionHandler(w http.ResponseWriter, r *http.Request) {
templateContext.Form = form
renderTemplate(w, templates, templateContext)
} else {
- if err := CreateMotion(data, voter); err != nil {
+ if err := UpdateMotion(data, voter); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -352,7 +353,7 @@ func newMotionHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
- session.AddFlash("The motion has been proposed!")
+ session.AddFlash("The motion has been modified!")
session.Save(r, w)
http.Redirect(w, r, "/motions/", http.StatusMovedPermanently)
@@ -361,13 +362,60 @@ func newMotionHandler(w http.ResponseWriter, r *http.Request) {
return
default:
templateContext.Voter = voter
- templateContext.Form = NewDecisionForm{
- VoteType: strconv.FormatInt(voteTypeMotion, 10),
+ templateContext.Form = EditDecisionForm{
+ Title: decision.Title,
+ Content: decision.Content,
+ VoteType: fmt.Sprintf("%d", decision.VoteType),
+ Decision: &decision.Decision,
}
renderTemplate(w, templates, templateContext)
}
}
+type motionsHandler struct{}
+
+func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if err := db.Ping(); err != nil {
+ logger.Fatal(err)
+ }
+
+ subURL := r.URL.Path
+
+ var motionActionMap = map[string]motionActionHandler{
+ "withdraw": withDrawMotionAction{},
+ "edit": editMotionAction{},
+ }
+
+ switch {
+ case subURL == "":
+ authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)), motionListHandler)
+ return
+ case strings.Count(subURL, "/") == 1:
+ parts := strings.Split(subURL, "/")
+ motionTag := parts[0]
+ action, ok := motionActionMap[parts[1]]
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ authenticateRequest(
+ w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, action.NeedsAuth())),
+ func(w http.ResponseWriter, r *http.Request) {
+ singleDecisionHandler(w, r, motionTag, action.Handle)
+ })
+ return
+ case strings.Count(subURL, "/") == 0:
+ authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)),
+ func(w http.ResponseWriter, r *http.Request) {
+ singleDecisionHandler(w, r, subURL, motionHandler)
+ })
+ return
+ default:
+ http.NotFound(w, r)
+ return
+ }
+}
+
type Config struct {
BoardMailAddress string `yaml:"board_mail_address"`
NoticeSenderAddress string `yaml:"notice_sender_address"`
@@ -377,6 +425,10 @@ type Config struct {
ServerKey string `yaml:"server_key"`
CookieSecret string `yaml:"cookie_secret"`
BaseURL string `yaml:"base_url"`
+ MailServer struct {
+ Host string `yaml:"host"`
+ Port int `yaml:"port"`
+ } `yaml:"mail_server"`
}
func init() {
diff --git a/config.yaml.example b/config.yaml.example
index 7a9390c..f5da83d 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -6,4 +6,5 @@ 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
-base_url: https://motions.cacert.org \ No newline at end of file
+base_url: https://motions.cacert.org
+mail_server: localhost:smtp \ No newline at end of file
diff --git a/forms.go b/forms.go
index f3cd942..503e7e5 100644
--- a/forms.go
+++ b/forms.go
@@ -54,3 +54,45 @@ func (f *NewDecisionForm) Validate() (bool, *Decision) {
return len(f.Errors) == 0, data
}
+
+type EditDecisionForm struct {
+ Title string
+ Content string
+ VoteType string
+ Due string
+ Decision *Decision
+ Errors map[string]string
+}
+
+func (f *EditDecisionForm) Validate() (bool, *Decision) {
+ f.Errors = make(map[string]string)
+
+ data := f.Decision
+
+ data.Title = strings.TrimSpace(f.Title)
+ if len(data.Title) < 3 {
+ f.Errors["Title"] = "Please enter at least 3 characters."
+ }
+
+ data.Content = strings.TrimSpace(f.Content)
+ if len(strings.Fields(data.Content)) < 3 {
+ f.Errors["Content"] = "Please enter at least 3 words."
+ }
+
+ if voteType, err := strconv.ParseUint(f.VoteType, 10, 8); err != nil || (voteType != 0 && voteType != 1) {
+ f.Errors["VoteType"] = fmt.Sprint("Please choose a valid vote type", err)
+ } else {
+ data.VoteType = VoteType(uint8(voteType))
+ }
+
+ if dueDuration, ok := validDueDurations[f.Due]; !ok {
+ 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.Modified = time.Now().UTC()
+
+ return len(f.Errors) == 0, data
+}
diff --git a/models.go b/models.go
index a8d24ad..8194dc9 100644
--- a/models.go
+++ b/models.go
@@ -19,6 +19,7 @@ const (
sqlCountOlderThanDecision
sqlCountOlderThanUnvotedDecision
sqlCreateDecision
+ sqlUpdateDecision
)
var sqlStatements = map[sqlKey]string{
@@ -92,6 +93,11 @@ INSERT INTO decisions (
BETWEEN date(:proposed) AND date(:proposed, '1 day')
)
)`,
+ sqlUpdateDecision: `
+UPDATE decisions
+SET proponent=:proponent, title=:title, content=:content,
+ votetype=:votetype, due=:due, modified=:modified
+WHERE id=:id`,
}
var db *sqlx.DB
@@ -376,7 +382,7 @@ func (d *Decision) OlderExists(unvoted bool, voter *Voter) (result bool, err err
return
}
-func (d *Decision) Save() (err error) {
+func (d *Decision) Create() (err error) {
insertDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlCreateDecision])
if err != nil {
logger.Println("Error preparing statement:", err)
@@ -393,8 +399,8 @@ func (d *Decision) Save() (err error) {
lastInsertId, err := result.LastInsertId()
if err != nil {
logger.Println("Error getting id of inserted motion:", err)
+ return
}
- logger.Println("DEBUG new motion has id", lastInsertId)
getDecisionStmt, err := db.Preparex(sqlStatements[sqlLoadDecisionById])
if err != nil {
@@ -411,6 +417,42 @@ func (d *Decision) Save() (err error) {
return
}
+func (d *Decision) Update() (err error) {
+ updateDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlUpdateDecision])
+ if err != nil {
+ logger.Println("Error preparing statement:", err)
+ return
+ }
+ defer updateDecisionStmt.Close()
+
+ result, err := updateDecisionStmt.Exec(d)
+ if err != nil {
+ logger.Println("Error updating motion:", err)
+ return
+ }
+ affectedRows, err := result.RowsAffected()
+ if err != nil {
+ logger.Print("Problem determining the affected rows")
+ return
+ } else if affectedRows != 1 {
+ logger.Printf("WARNING wrong number of affected rows: %d (1 expected)\n", affectedRows)
+ }
+
+ 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 FindVoterByAddress(emailAddress string) (voter *Voter, err error) {
findVoterStmt, err := db.Preparex(sqlStatements[sqlLoadEnabledVoterByEmail])
if err != nil {
diff --git a/templates/newmotion_form.html b/templates/create_motion_form.html
index b7573d4..b7573d4 100644
--- a/templates/newmotion_form.html
+++ b/templates/create_motion_form.html
diff --git a/templates/create_motion_mail.txt b/templates/create_motion_mail.txt
index f26577e..b526615 100644
--- a/templates/create_motion_mail.txt
+++ b/templates/create_motion_mail.txt
@@ -1,13 +1,10 @@
-From: {{ .Sender }}
-To: {{ .Recipient }}
-Subject: {{ .Tag }} - {{ .Title }}
-
Dear Board,
{{ .Name }} has made the following motion:
{{ .Title }}
-{{ .Content }}
+
+{{ wrap 76 .Content }}
Vote type: {{ .VoteType }}
diff --git a/templates/edit_motion_form.html b/templates/edit_motion_form.html
new file mode 100644
index 0000000..d17389c
--- /dev/null
+++ b/templates/edit_motion_form.html
@@ -0,0 +1,73 @@
+{{ template "header" . }}
+<form action="/motions/{{ .Form.Decision.Tag }}/edit" method="post">
+ <table>
+ <tr>
+ <td>ID:</td>
+ <td>{{ .Form.Decision.Tag }}</td>
+ </tr>
+ <tr>
+ <td>Proponent:</td>
+ <td>{{ .Voter.Name }}</td>
+ </tr>
+ <tr>
+ <td>Proposed date/time:</td>
+ <td>{{ .Form.Decision.Proposed }}</td>
+ </tr>
+ <tr>
+ <td>Title:</td>
+ <td><input name="Title" value="{{ .Form.Title }}"/>
+ {{ with .Form.Errors.Title }}
+ <span class="error">{{ . }}</span>
+ {{ end }}
+ </td>
+ </tr>
+ <tr>
+ <td>Text:</td>
+ <td><textarea name="Content">{{ .Form.Content }}</textarea>
+ {{ with .Form.Errors.Content }}
+ <span class="error">{{ . }}</span>
+ {{ end }}
+ </td>
+ </tr>
+ <tr>
+ <td>Vote type:</td>
+ <td>
+ <select name="VoteType">
+ <option value="0"
+ {{ if eq "0" .Form.VoteType }}selected{{ end }}>
+ Motion
+ </option>
+ <option value="1"
+ {{ if eq "1" .Form.VoteType }}selected{{ end }}>Veto
+ </option>
+ </select>
+ {{ with .Form.Errors.VoteType }}
+ <span class="error">{{ . }}</span>
+ {{ end }}
+ </td>
+ </tr>
+ <tr>
+ <td rowspan="2">Due:</td>
+ <td>(autofilled from option below)</td>
+ </tr>
+ <tr>
+ <td>
+ <select name="Due">
+ <option value="+3 days">In 3 Days</option>
+ <option value="+7 days">In 1 Week</option>
+ <option value="+14 days">In 2 Weeks</option>
+ <option value="+28 days">In 4 Weeks</option>
+ </select>
+ {{ with .Form.Errors.Due }}
+ <span class="error">{{ . }}</span>
+ {{ end }}
+ </td>
+ </tr>
+ <tr>
+ <td>&nbsp;</td>
+ <td><input type="submit" value="Propose"/></td>
+ </tr>
+ </table>
+</form>
+<a href="/motions/">Back to motions</a>
+{{ template "footer" . }} \ No newline at end of file
diff --git a/templates/update_motion_mail.txt b/templates/update_motion_mail.txt
new file mode 100644
index 0000000..ddb0996
--- /dev/null
+++ b/templates/update_motion_mail.txt
@@ -0,0 +1,26 @@
+Dear Board,
+
+{{ .Name }} has modified motion {{ .Tag }} to the following:
+
+{{ .Title }}
+
+{{ wrap 76 .Content }}
+
+Vote type: {{ .VoteType }}
+
+Voting will close {{ .Due }}
+
+To vote please choose:
+
+Aye: {{ .VoteURL }}/aye
+Naye: {{ .VoteURL }}/naye
+Abstain: {{ .VoteURL }}/abstain
+
+Please be aware, that if you have voted already your vote is still
+registered and valid. If this modification has an impact on how you wish to
+vote, you are responsible for voting again.
+
+To see all your pending votes: {{ .UnvotedURL }}
+
+Kind regards,
+the voting system \ No newline at end of file