Implement motion editing

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

@ -25,10 +25,10 @@ import (
"net/http"
"time"
"git.cacert.org/cacert-boardvoting/internal/forms"
"github.com/julienschmidt/httprouter"
"git.cacert.org/cacert-boardvoting/internal/models"
"git.cacert.org/cacert-boardvoting/internal/validator"
)
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)
}
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) {
data := app.newTemplateData(r, "motions", "all-motions")
data.Form = &NewMotionForm{
User: data.User,
data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll)
data.Form = &forms.NewMotionForm{
Type: models.VoteTypeMotion,
}
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",
)
}
const hoursInDay = 24
func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) {
const hoursInDay = 24
var form NewMotionForm
var form forms.NewMotionForm
err := app.decodePostForm(r, &form)
if err != nil {
@ -285,8 +226,6 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request)
form.Validate()
if !form.Valid() {
form.User = &models.User{}
data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll)
data.Form = form
@ -328,20 +267,120 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request)
}
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))
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) {
panic("not implemented")
func (app *application) editMotionForm(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
}
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) {
panic("not implemented")
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) {

@ -173,11 +173,11 @@ func (c *ClosedDecisionNotification) getHeaders() map[string][]string {
}
type NewDecisionNotification struct {
decision *models.NewMotion
Decision *models.NewMotion
}
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"
return &NotificationContent{
@ -187,8 +187,8 @@ func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *Notific
Name string
VoteURL string
UnvotedURL string
}{n.decision.Decision, n.decision.Proposer.Name, voteURL, unvotedURL},
subject: fmt.Sprintf("%s - %s", n.decision.Decision.Tag, n.decision.Decision.Title),
}{n.Decision.Decision, n.Decision.Proposer.Name, voteURL, unvotedURL},
subject: fmt.Sprintf("%s - %s", n.Decision.Decision.Tag, n.Decision.Decision.Title),
headers: n.getHeaders(),
recipients: []recipientData{defaultRecipient(mc)},
}
@ -196,6 +196,35 @@ func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *Notific
func (n NewDecisionNotification) getHeaders() 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,
{{ .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:
Aye: {{ .VoteURL }}/aye
Naye: {{ .VoteURL }}/naye
Abstain: {{ .VoteURL }}/abstain
Aye: {{ .BaseURL }}{{ .Data.VoteURL }}/aye
Naye: {{ .BaseURL }}{{ .Data.VoteURL }}/naye
Abstain: {{ .BaseURL }}{{ .Data.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 }}
To see all your pending votes: {{ .BaseURL }}{{ .Data.UnvotedURL }}
Kind regards,
the voting system

@ -195,6 +195,11 @@ type NewMotion struct {
Proposer *User
}
type UpdatedMotion struct {
Decision *Motion
User *User
}
type MotionModel struct {
DB *sqlx.DB
InfoLog *log.Logger
@ -750,7 +755,7 @@ WHERE decisions.tag = ?`,
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 {
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
}
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 "main" }}
<div class="ui raised segment">
<form action="/newmotion/" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="ui form{{ if .Form.FieldErrors }} error{{ end }}">
<div class="three fields">
<div class="field">
<label>ID:</label>
(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>
<form action="/newmotion/" 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>
(generated on submit)
</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 class="field">
<label>Proponent:</label>
{{ .User.Name }}
</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 class="field">
<label>Proposed date/time:</label>
(auto filled to current date/time)
</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>
<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>
<button class="ui button" type="submit">Propose</button>
</div>
</form>
</div>
<button class="ui button" type="submit">Propose</button>
</div>
</form>
{{ 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