Implement motion editing

This commit is contained in:
Jan Dittberner 2017-04-19 00:05:42 +02:00
parent cc0f5c0b7b
commit bc194e8943
9 changed files with 372 additions and 79 deletions

View 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
}

View 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,69 +260,13 @@ type editMotionAction struct {
authenticationRequiredHandler
}
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
}
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)
}
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, "/")
logger.Printf("handle %v\n", parts)
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)
})
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
}
}
func newMotionHandler(w http.ResponseWriter, r *http.Request) {
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{"create_motion_form.html", "header.html", "footer.html"}
var templateContext struct {
Form NewDecisionForm
PageTitle string
@ -368,6 +312,110 @@ 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{"edit_motion_form.html", "header.html", "footer.html"}
var templateContext struct {
Form EditDecisionForm
PageTitle string
Voter *Voter
Flashes interface{}
}
switch r.Method {
case http.MethodPost:
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 {
templateContext.Voter = voter
templateContext.Form = form
renderTemplate(w, templates, templateContext)
} else {
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 {
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
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() {

View 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
base_url: https://motions.cacert.org
mail_server: localhost:smtp

View file

@ -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
}

View file

@ -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 {

View 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 }}

View file

@ -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" . }}

View file

@ -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