Implement motion editing
authorJan Dittberner <jan@dittberner.info>
Tue, 18 Apr 2017 22:05:42 +0000 (00:05 +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/create_motion_form.html [new file with mode: 0644]
templates/create_motion_mail.txt
templates/edit_motion_form.html [new file with mode: 0644]
templates/newmotion_form.html [deleted file]
templates/update_motion_mail.txt [new file with mode: 0644]

index 0619587..a8e78fb 100644 (file)
@@ -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
 }
 
index 367179f..efa9ecc 100644 (file)
@@ -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() {
index 7a9390c..f5da83d 100644 (file)
@@ -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
index f3cd942..503e7e5 100644 (file)
--- 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
+}
index a8d24ad..8194dc9 100644 (file)
--- 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/create_motion_form.html b/templates/create_motion_form.html
new file mode 100644 (file)
index 0000000..b7573d4
--- /dev/null
@@ -0,0 +1,73 @@
+{{ template "header" . }}
+<form action="/newmotion/" method="post">
+    <table>
+        <tr>
+            <td>ID:</td>
+            <td>(generated on submit)</td>
+        </tr>
+        <tr>
+            <td>Proponent:</td>
+            <td>{{ .Voter.Name }}</td>
+        </tr>
+        <tr>
+            <td>Proposed date/time:</td>
+            <td>(auto filled to current date/time)</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
index f26577e..b526615 100644 (file)
@@ -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 (file)
index 0000000..d17389c
--- /dev/null
@@ -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/newmotion_form.html b/templates/newmotion_form.html
deleted file mode 100644 (file)
index b7573d4..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-{{ template "header" . }}
-<form action="/newmotion/" method="post">
-    <table>
-        <tr>
-            <td>ID:</td>
-            <td>(generated on submit)</td>
-        </tr>
-        <tr>
-            <td>Proponent:</td>
-            <td>{{ .Voter.Name }}</td>
-        </tr>
-        <tr>
-            <td>Proposed date/time:</td>
-            <td>(auto filled to current date/time)</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 (file)
index 0000000..ddb0996
--- /dev/null
@@ -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