Implement motion editing
This commit is contained in:
parent
cc0f5c0b7b
commit
bc194e8943
9 changed files with 372 additions and 79 deletions
84
actions.go
84
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
|
||||
}
|
||||
|
||||
|
|
170
boardvoting.go
170
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,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() {
|
||||
|
|
|
@ -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
|
42
forms.go
42
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
|
||||
}
|
||||
|
|
46
models.go
46
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 {
|
||||
|
|
|
@ -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 }}
|
||||
|
||||
|
|
73
templates/edit_motion_form.html
Normal file
73
templates/edit_motion_form.html
Normal 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> </td>
|
||||
<td><input type="submit" value="Propose"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
<a href="/motions/">Back to motions</a>
|
||||
{{ template "footer" . }}
|
26
templates/update_motion_mail.txt
Normal file
26
templates/update_motion_mail.txt
Normal 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
|
Loading…
Reference in a new issue