Implement motion editing

main
Jan Dittberner 2 years ago
parent 335ce16547
commit 2b98712aa8

@ -25,10 +25,10 @@ import (
"net/http" "net/http"
"time" "time"
"git.cacert.org/cacert-boardvoting/internal/forms"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"git.cacert.org/cacert-boardvoting/internal/models" "git.cacert.org/cacert-boardvoting/internal/models"
"git.cacert.org/cacert-boardvoting/internal/validator"
) )
func checkRole(v *models.User, roles []string) (bool, error) { func checkRole(v *models.User, roles []string) (bool, error) {
@ -202,78 +202,19 @@ func (app *application) motionDetails(w http.ResponseWriter, r *http.Request) {
app.render(w, http.StatusOK, "motion.html", data) app.render(w, http.StatusOK, "motion.html", data)
} }
type NewMotionForm struct {
Title string `form:"title"`
Content string `form:"content"`
Type *models.VoteType `form:"type"`
Due int `form:"due"`
User *models.User `form:"-"`
validator.Validator `form:"-"`
}
func (app *application) newMotionForm(w http.ResponseWriter, r *http.Request) { func (app *application) newMotionForm(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r, "motions", "all-motions") data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll)
data.Form = &NewMotionForm{ data.Form = &forms.NewMotionForm{
User: data.User,
Type: models.VoteTypeMotion, Type: models.VoteTypeMotion,
} }
app.render(w, http.StatusOK, "create_motion.html", data) app.render(w, http.StatusOK, "create_motion.html", data)
} }
func (form *NewMotionForm) Validate() { const hoursInDay = 24
const (
minimumTitleLength = 3
maximumTitleLength = 200
minimumContentLength = 3
maximumContentLength = 8000
threeDays = 3
oneWeek = 7
twoWeeks = 14
threeWeeks = 28
)
form.CheckField(
validator.NotBlank(form.Title),
"title",
"This field cannot be blank",
)
form.CheckField(
validator.MinChars(form.Title, minimumTitleLength),
"title",
fmt.Sprintf("This field must be at least %d characters long", minimumTitleLength),
)
form.CheckField(
validator.MaxChars(form.Title, maximumTitleLength),
"title",
fmt.Sprintf("This field must be at most %d characters long", maximumTitleLength),
)
form.CheckField(
validator.NotBlank(form.Content),
"content",
"This field cannot be blank",
)
form.CheckField(
validator.MinChars(form.Content, minimumContentLength),
"content",
fmt.Sprintf("This field must be at least %d characters long", minimumContentLength),
)
form.CheckField(
validator.MaxChars(form.Content, maximumContentLength),
"content",
fmt.Sprintf("This field must be at most %d characters long", maximumContentLength),
)
form.CheckField(validator.PermittedInt(
form.Due, threeDays, oneWeek, twoWeeks, threeWeeks), "due", "invalid duration choice",
)
}
func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) { func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) {
const hoursInDay = 24 var form forms.NewMotionForm
var form NewMotionForm
err := app.decodePostForm(r, &form) err := app.decodePostForm(r, &form)
if err != nil { if err != nil {
@ -285,8 +226,6 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request)
form.Validate() form.Validate()
if !form.Valid() { if !form.Valid() {
form.User = &models.User{}
data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll)
data.Form = form data.Form = form
@ -328,20 +267,120 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request)
} }
app.mailNotifier.notifyChannel <- &NewDecisionNotification{ app.mailNotifier.notifyChannel <- &NewDecisionNotification{
decision: &models.NewMotion{Decision: decision, Proposer: user}, Decision: &models.NewMotion{Decision: decision, Proposer: user},
} }
app.sessionManager.Put(r.Context(), "flash", fmt.Sprintf("Started new motion %s: %s", decision.Tag, decision.Title)) app.sessionManager.Put(r.Context(), "flash", fmt.Sprintf("Started new motion %s: %s", decision.Tag, decision.Title))
http.Redirect(w, r, fmt.Sprintf("/motions/%s", decision.Tag), http.StatusSeeOther) http.Redirect(w, r, "/motions/", http.StatusSeeOther)
} }
func (app *application) editMotionForm(_ http.ResponseWriter, _ *http.Request) { func (app *application) editMotionForm(w http.ResponseWriter, r *http.Request) {
panic("not implemented") params := httprouter.ParamsFromContext(r.Context())
tag := params.ByName("tag")
motion, err := app.motions.GetMotionByTag(r.Context(), tag, false)
if err != nil {
app.serverError(w, err)
return
}
if motion.ID == 0 {
app.notFound(w)
return
}
data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll)
data.Motion = motion
data.Form = &forms.EditMotionForm{
Title: motion.Title,
Content: motion.Content,
Type: motion.Type,
}
app.render(w, http.StatusOK, "edit_motion.html", data)
} }
func (app *application) editMotionSubmit(_ http.ResponseWriter, _ *http.Request) { func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request) {
panic("not implemented") params := httprouter.ParamsFromContext(r.Context())
tag := params.ByName("tag")
motion, err := app.motions.GetMotionByTag(r.Context(), tag, false)
if err != nil {
app.serverError(w, err)
return
}
if motion.ID == 0 {
app.notFound(w)
return
}
var form forms.EditMotionForm
err = app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.Validate()
if !form.Valid() {
data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "edit_motion.html", data)
return
}
user, err := app.GetUser(r)
if err != nil {
app.clientError(w, http.StatusUnauthorized)
return
}
now := time.Now().UTC()
dueDuration := time.Duration(form.Due) * hoursInDay * time.Hour
err = app.motions.Update(
r.Context(),
motion.ID,
form.Type,
form.Title,
form.Content,
now.Add(dueDuration),
)
decision, err := app.motions.GetByID(r.Context(), motion.ID)
if err != nil {
app.serverError(w, err)
return
}
app.mailNotifier.notifyChannel <- &UpdateDecisionNotification{
Decision: &models.UpdatedMotion{Decision: decision, User: user},
}
app.sessionManager.Put(
r.Context(),
"flash",
fmt.Sprintf("The motion %s has been modified!", decision.Tag),
)
http.Redirect(w, r, "/motions/", http.StatusSeeOther)
} }
func (app *application) withdrawMotionForm(_ http.ResponseWriter, _ *http.Request) { func (app *application) withdrawMotionForm(_ http.ResponseWriter, _ *http.Request) {

@ -173,11 +173,11 @@ func (c *ClosedDecisionNotification) getHeaders() map[string][]string {
} }
type NewDecisionNotification struct { type NewDecisionNotification struct {
decision *models.NewMotion Decision *models.NewMotion
} }
func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent { func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent {
voteURL := fmt.Sprintf("/vote/%s", n.decision.Decision.Tag) voteURL := fmt.Sprintf("/vote/%s", n.Decision.Decision.Tag)
unvotedURL := "/motions/?unvoted=1" unvotedURL := "/motions/?unvoted=1"
return &NotificationContent{ return &NotificationContent{
@ -187,8 +187,8 @@ func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *Notific
Name string Name string
VoteURL string VoteURL string
UnvotedURL string UnvotedURL string
}{n.decision.Decision, n.decision.Proposer.Name, voteURL, unvotedURL}, }{n.Decision.Decision, n.Decision.Proposer.Name, voteURL, unvotedURL},
subject: fmt.Sprintf("%s - %s", n.decision.Decision.Tag, n.decision.Decision.Title), subject: fmt.Sprintf("%s - %s", n.Decision.Decision.Tag, n.Decision.Decision.Title),
headers: n.getHeaders(), headers: n.getHeaders(),
recipients: []recipientData{defaultRecipient(mc)}, recipients: []recipientData{defaultRecipient(mc)},
} }
@ -196,6 +196,35 @@ func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *Notific
func (n NewDecisionNotification) getHeaders() map[string][]string { func (n NewDecisionNotification) getHeaders() map[string][]string {
return map[string][]string{ return map[string][]string{
"Message-ID": {fmt.Sprintf("<%s>", n.decision.Decision.Tag)}, "Message-ID": {fmt.Sprintf("<%s>", n.Decision.Decision.Tag)},
}
}
type UpdateDecisionNotification struct {
Decision *models.UpdatedMotion
}
func (u UpdateDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent {
voteURL := fmt.Sprintf("/vote/%s", u.Decision.Decision.Tag)
unvotedURL := "/motions/?unvoted=1"
return &NotificationContent{
template: "update_motion_mail.txt",
data: struct {
*models.Motion
Name string
VoteURL string
UnvotedURL string
}{u.Decision.Decision, u.Decision.User.Name, voteURL, unvotedURL},
subject: fmt.Sprintf("%s - %s", u.Decision.Decision.Tag, u.Decision.Decision.Title),
headers: u.getHeaders(),
recipients: []recipientData{defaultRecipient(mc)},
}
}
func (u UpdateDecisionNotification) getHeaders() map[string][]string {
return map[string][]string{
"References": {fmt.Sprintf("<%s>", u.Decision.Decision.Tag)},
"In-Reply-To": {fmt.Sprintf("<%s>", u.Decision.Decision.Tag)},
} }
} }

@ -0,0 +1,127 @@
/*
Copyright 2022 CAcert Inc.
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package forms
import (
"fmt"
"git.cacert.org/cacert-boardvoting/internal/models"
"git.cacert.org/cacert-boardvoting/internal/validator"
)
type NewMotionForm struct {
Title string `form:"title"`
Content string `form:"content"`
Type *models.VoteType `form:"type"`
Due int `form:"due"`
validator.Validator `form:"-"`
}
const (
minimumTitleLength = 3
maximumTitleLength = 200
minimumContentLength = 3
maximumContentLength = 8000
threeDays = 3
oneWeek = 7
twoWeeks = 14
threeWeeks = 28
)
func (f *NewMotionForm) Validate() {
f.CheckField(
validator.NotBlank(f.Title),
"title",
"This field cannot be blank",
)
f.CheckField(
validator.MinChars(f.Title, minimumTitleLength),
"title",
fmt.Sprintf("This field must be at least %d characters long", minimumTitleLength),
)
f.CheckField(
validator.MaxChars(f.Title, maximumTitleLength),
"title",
fmt.Sprintf("This field must be at most %d characters long", maximumTitleLength),
)
f.CheckField(
validator.NotBlank(f.Content),
"content",
"This field cannot be blank",
)
f.CheckField(
validator.MinChars(f.Content, minimumContentLength),
"content",
fmt.Sprintf("This field must be at least %d characters long", minimumContentLength),
)
f.CheckField(
validator.MaxChars(f.Content, maximumContentLength),
"content",
fmt.Sprintf("This field must be at most %d characters long", maximumContentLength),
)
f.CheckField(validator.PermittedInt(
f.Due, threeDays, oneWeek, twoWeeks, threeWeeks), "due", "invalid duration choice",
)
}
type EditMotionForm struct {
Title string `form:"title"`
Content string `form:"content"`
Type *models.VoteType `form:"type"`
Due int `form:"due"`
validator.Validator `form:"-"`
}
func (f EditMotionForm) Validate() {
f.CheckField(
validator.NotBlank(f.Title),
"title",
"This field cannot be blank",
)
f.CheckField(
validator.MinChars(f.Title, minimumTitleLength),
"title",
fmt.Sprintf("This field must be at least %d characters long", minimumTitleLength),
)
f.CheckField(
validator.MaxChars(f.Title, maximumTitleLength),
"title",
fmt.Sprintf("This field must be at most %d characters long", maximumTitleLength),
)
f.CheckField(
validator.NotBlank(f.Content),
"content",
"This field cannot be blank",
)
f.CheckField(
validator.MinChars(f.Content, minimumContentLength),
"content",
fmt.Sprintf("This field must be at least %d characters long", minimumContentLength),
)
f.CheckField(
validator.MaxChars(f.Content, maximumContentLength),
"content",
fmt.Sprintf("This field must be at most %d characters long", maximumContentLength),
)
f.CheckField(validator.PermittedInt(
f.Due, threeDays, oneWeek, twoWeeks, threeWeeks), "due", "invalid duration choice",
)
}

@ -1,26 +1,26 @@
Dear Board, Dear Board,
{{ .Name }} has modified motion {{ .Tag }} to the following: {{ .Data.Name }} has modified motion {{ .Data.Tag }} to the following:
{{ .Title }} {{ .Data.Title }}
{{ wrap 76 .Content }} {{ wrap 76 .Data.Content }}
Vote type: {{ .VoteType }} Vote type: {{ .Data.Type }}
Voting will close {{ .Due }} Voting will close {{ .Data.Due }}
To vote please choose: To vote please choose:
Aye: {{ .VoteURL }}/aye Aye: {{ .BaseURL }}{{ .Data.VoteURL }}/aye
Naye: {{ .VoteURL }}/naye Naye: {{ .BaseURL }}{{ .Data.VoteURL }}/naye
Abstain: {{ .VoteURL }}/abstain Abstain: {{ .BaseURL }}{{ .Data.VoteURL }}/abstain
Please be aware, that if you have voted already your vote is still 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 registered and valid. If this modification has an impact on how you wish to
vote, you are responsible for voting again. vote, you are responsible for voting again.
To see all your pending votes: {{ .UnvotedURL }} To see all your pending votes: {{ .BaseURL }}{{ .Data.UnvotedURL }}
Kind regards, Kind regards,
the voting system the voting system

@ -195,6 +195,11 @@ type NewMotion struct {
Proposer *User Proposer *User
} }
type UpdatedMotion struct {
Decision *Motion
User *User
}
type MotionModel struct { type MotionModel struct {
DB *sqlx.DB DB *sqlx.DB
InfoLog *log.Logger InfoLog *log.Logger
@ -750,7 +755,7 @@ WHERE decisions.tag = ?`,
return nil, fmt.Errorf("could not get vote sums: %w", err) return nil, fmt.Errorf("could not get vote sums: %w", err)
} }
if withVotes { if result.ID != 0 && withVotes {
if err := m.FillVotes(ctx, &result); err != nil { if err := m.FillVotes(ctx, &result); err != nil {
return nil, fmt.Errorf("could not get votes for %s: %w", result.Tag, err) return nil, fmt.Errorf("could not get votes for %s: %w", result.Tag, err)
} }
@ -811,3 +816,16 @@ func (m *MotionModel) GetByID(ctx context.Context, id int64) (*Motion, error) {
return &motion, nil return &motion, nil
} }
func (m *MotionModel) Update(ctx context.Context, id int64, voteType *VoteType, title string, content string, due time.Time) error {
_, err := m.DB.ExecContext(
ctx,
`UPDATE decisions SET title=?, content=?, votetype=?, due=? WHERE id=?`,
title, content, voteType, due.UTC(), id,
)
if err != nil {
return fmt.Errorf("could not update decision: %w", err)
}
return nil
}

@ -1,72 +1,70 @@
{{ define "title" }}New motion{{ end }} {{ define "title" }}New motion{{ end }}
{{ define "main" }} {{ define "main" }}
<div class="ui raised segment"> <form action="/newmotion/" method="post">
<form action="/newmotion/" method="post"> <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> <div class="ui form segment{{ if .Form.FieldErrors }} error{{ end }}">
<div class="ui form{{ if .Form.FieldErrors }} error{{ end }}"> <div class="three fields">
<div class="three fields"> <div class="field">
<div class="field"> <label>ID:</label>
<label>ID:</label> (generated on submit)
(generated on submit)
</div>
<div class="field">
<label>Proponent:</label>
{{ .Form.User.Name }}
</div>
<div class="field">
<label>Proposed date/time:</label>
(auto filled to current date/time)
</div>
</div> </div>
<div class="required field{{ if .Form.FieldErrors.title }} error{{ end }}"> <div class="field">
<label for="title">Title:</label> <label>Proponent:</label>
<input id="title" name="title" type="text" value="{{ .Form.Title }}"> {{ .User.Name }}
{{ if .Form.FieldErrors.title }}
<span class="ui small error text">{{ .Form.FieldErrors.title }}</span>
{{ end }}
</div> </div>
<div class="required field{{ if .Form.FieldErrors.content }} error{{ end }}"> <div class="field">
<label for="content">Text:</label> <label>Proposed date/time:</label>
<textarea id="content" name="content">{{ .Form.Content }}</textarea> (auto filled to current date/time)
{{ if .Form.FieldErrors.content }}
<span class="ui small error text">{{ .Form.FieldErrors.content }}</span>
{{ end }}
</div> </div>
<div class="two fields"> </div>
<div class="required field{{ if .Form.FieldErrors.type }} error{{ end }}"> <div class="required field{{ if .Form.FieldErrors.title }} error{{ end }}">
<label for="type">Vote type:</label> <label for="title">Title:</label>
{{ $voteType := toString .Form.Type }} <input id="title" name="title" type="text" value="{{ .Form.Title }}">
<select id="type" name="type"> {{ if .Form.FieldErrors.title }}
<option value="motion" <span class="ui small error text">{{ .Form.FieldErrors.title }}</span>
{{ if eq "motion" $voteType }}selected{{ end }}> {{ end }}
Motion </div>
</option> <div class="required field{{ if .Form.FieldErrors.content }} error{{ end }}">
<option value="veto" <label for="content">Text:</label>
{{ if eq "veto" $voteType }}selected{{ end }}> <textarea id="content" name="content">{{ .Form.Content }}</textarea>
Veto {{ if .Form.FieldErrors.content }}
</option> <span class="ui small error text">{{ .Form.FieldErrors.content }}</span>
</select> {{ end }}
{{ if .Form.FieldErrors.type }} </div>
<span class="ui small error text">{{ .Form.FieldErrors.type}}</span> <div class="two fields">
{{ end}} <div class="required field{{ if .Form.FieldErrors.type }} error{{ end }}">
</div> <label for="type">Vote type:</label>
<div class="required field{{ if .Form.FieldErrors.due }} error{{ end }}"> {{ $voteType := toString .Form.Type }}
<label for="due">Due: (autofilled from chosen <select id="type" name="type">
option)</label> <option value="motion"
<select id="due" name="due"> {{ if eq "motion" $voteType }}selected{{ end }}>
<option value="3"{{ if eq 3 .Form.Due }} selected{{ end }}>In 3 Days</option> Motion
<option value="7"{{ if eq 7 .Form.Due }} selected{{ end }}>In 1 Week</option> </option>
<option value="14"{{ if eq 14 .Form.Due }} selected{{ end }}>In 2 Weeks</option> <option value="veto"
<option value="28"{{ if eq 28 .Form.Due }} selected{{ end }}>In 4 Weeks</option> {{ if eq "veto" $voteType }}selected{{ end }}>
</select> Veto
{{ if .Form.FieldErrors.due }} </option>
<span class="ui small error text">{{ .Form.FieldErrors.due }}</span> </select>
{{ end }} {{ if .Form.FieldErrors.type }}
</div> <span class="ui small error text">{{ .Form.FieldErrors.type}}</span>
{{ end}}
</div>
<div class="required field{{ if .Form.FieldErrors.due }} error{{ end }}">
<label for="due">Due: (autofilled from chosen
option)</label>
<select id="due" name="due">
<option value="3"{{ if eq 3 .Form.Due }} selected{{ end }}>In 3 Days</option>
<option value="7"{{ if eq 7 .Form.Due }} selected{{ end }}>In 1 Week</option>
<option value="14"{{ if eq 14 .Form.Due }} selected{{ end }}>In 2 Weeks</option>
<option value="28"{{ if eq 28 .Form.Due }} selected{{ end }}>In 4 Weeks</option>
</select>
{{ if .Form.FieldErrors.due }}
<span class="ui small error text">{{ .Form.FieldErrors.due }}</span>
{{ end }}
</div> </div>
<button class="ui button" type="submit">Propose</button>
</div> </div>
</form> <button class="ui button" type="submit">Propose</button>
</div> </div>
</form>
{{ end }} {{ end }}

@ -0,0 +1,70 @@
{{ define "title" }}Edit motion {{ .Motion.Tag }}{{ end }}
{{ define "main" }}
<form action="/motions/{{ .Motion.Tag }}/edit" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="ui form segment{{ if .Form.FieldErrors }} error{{ end }}">
<div class="three fields">
<div class="field">
<label>ID:</label>
{{ .Motion.Tag }}
</div>
<div class="field">
<label>Proponent:</label>
{{ .User.Name }}
</div>
<div class="field">
<label>Proposed date/time:</label>
{{ .Motion.Proposed }}
</div>
</div>
<div class="required field{{ if .Form.FieldErrors.title }} error{{ end }}">
<label for="title">Title:</label>
<input id="title" name="title" type="text" value="{{ .Form.Title }}">
{{ if .Form.FieldErrors.title }}
<span class="ui small error text">{{ .Form.FieldErrors.title }}</span>
{{ end }}
</div>
<div class="required field{{ if .Form.FieldErrors.content }} error{{ end }}">
<label for="content">Text:</label>
<textarea id="content" name="content">{{ .Form.Content }}</textarea>
{{ if .Form.FieldErrors.content }}
<span class="ui small error text">{{ .Form.FieldErrors.content }}</span>
{{ end }}
</div>
<div class="two fields">
<div class="required field{{ if .Form.FieldErrors.type }} error{{ end }}">
<label for="type">Vote type:</label>
{{ $voteType := toString .Form.Type }}
<select id="type" name="type">
<option value="motion"
{{ if eq "motion" $voteType }}selected{{ end }}>
Motion
</option>
<option value="veto"
{{ if eq "veto" $voteType }}selected{{ end }}>
Veto
</option>
</select>
{{ if .Form.FieldErrors.type }}
<span class="ui small error text">{{ .Form.FieldErrors.type}}</span>
{{ end}}
</div>
<div class="required field{{ if .Form.FieldErrors.due }} error{{ end }}">
<label for="due">Due: (autofilled from chosen
option)</label>
<select id="due" name="due">
<option value="3"{{ if eq 3 .Form.Due }} selected{{ end }}>In 3 Days</option>
<option value="7"{{ if eq 7 .Form.Due }} selected{{ end }}>In 1 Week</option>
<option value="14"{{ if eq 14 .Form.Due }} selected{{ end }}>In 2 Weeks</option>
<option value="28"{{ if eq 28 .Form.Due }} selected{{ end }}>In 4 Weeks</option>
</select>
{{ if .Form.FieldErrors.due }}
<span class="ui small error text">{{ .Form.FieldErrors.due }}</span>
{{ end }}
</div>
</div>
<button class="ui button" type="submit">Modify</button>
</div>
</form>
{{ end }}
Loading…
Cancel
Save