Implement motion editing

debian
Jan Dittberner 7 years ago
parent cc0f5c0b7b
commit bc194e8943

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

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

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

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

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

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

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

@ -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
Loading…
Cancel
Save