Implement motion editing

This commit is contained in:
Jan Dittberner 2022-05-26 21:04:47 +02:00
parent 335ce16547
commit 2b98712aa8
7 changed files with 432 additions and 151 deletions

View file

@ -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 (
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) {
const hoursInDay = 24 const hoursInDay = 24
var form NewMotionForm func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) {
var form forms.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
} }
func (app *application) editMotionSubmit(_ http.ResponseWriter, _ *http.Request) { if motion.ID == 0 {
panic("not implemented") 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(w http.ResponseWriter, r *http.Request) {
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) {

View file

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

127
internal/forms/forms.go Normal file
View file

@ -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",
)
}

View file

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

View file

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

View file

@ -1,10 +1,9 @@
{{ 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{{ if .Form.FieldErrors }} error{{ end }}"> <div class="ui form segment{{ 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>
@ -12,7 +11,7 @@
</div> </div>
<div class="field"> <div class="field">
<label>Proponent:</label> <label>Proponent:</label>
{{ .Form.User.Name }} {{ .User.Name }}
</div> </div>
<div class="field"> <div class="field">
<label>Proposed date/time:</label> <label>Proposed date/time:</label>
@ -68,5 +67,4 @@
<button class="ui button" type="submit">Propose</button> <button class="ui button" type="submit">Propose</button>
</div> </div>
</form> </form>
</div>
{{ end }} {{ end }}

View file

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