From c12aaf4d89913177a5f779aedce074c2be9c1302 Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Fri, 27 May 2022 20:45:04 +0200 Subject: [PATCH] Implement proxy voting --- cmd/boardvoting/handlers.go | 134 ++++++++++++++++++--- cmd/boardvoting/notifications.go | 60 ++++++++- internal/forms/forms.go | 80 ++++++------ internal/mailtemplates/proxy_vote_mail.txt | 8 +- internal/models/motions.go | 47 ++++++-- internal/models/voters.go | 134 +++++++++++++++++++-- internal/validator/validator.go | 10 +- ui/html/pages/proxy_vote.html | 66 ++++++++++ 8 files changed, 451 insertions(+), 88 deletions(-) create mode 100644 ui/html/pages/proxy_vote.html diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go index 968cc27..b7c1757 100644 --- a/cmd/boardvoting/handlers.go +++ b/cmd/boardvoting/handlers.go @@ -21,6 +21,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" "github.com/julienschmidt/httprouter" @@ -193,7 +194,7 @@ func (app *application) motionDetails(w http.ResponseWriter, r *http.Request) { func (app *application) newMotionForm(w http.ResponseWriter, r *http.Request) { data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) - data.Form = &forms.NewMotionForm{ + data.Form = &forms.EditMotionForm{ Type: models.VoteTypeMotion, } @@ -203,7 +204,7 @@ func (app *application) newMotionForm(w http.ResponseWriter, r *http.Request) { const hoursInDay = 24 func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) { - var form forms.NewMotionForm + var form forms.EditMotionForm err := app.decodePostForm(r, &form) if err != nil { @@ -314,6 +315,8 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request) return } + form.Normalize() + user, err := app.GetUser(r) if err != nil { app.clientError(w, http.StatusUnauthorized) @@ -327,10 +330,12 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request) err = app.motions.Update( r.Context(), motion.ID, - form.Type, - form.Title, - form.Content, - now.Add(dueDuration), + func(m *models.Motion) { + m.Type = form.Type + m.Title = strings.TrimSpace(form.Title) + m.Content = strings.TrimSpace(form.Content) + m.Due = now.Add(dueDuration) + }, ) if err != nil { app.serverError(w, err) @@ -418,12 +423,11 @@ func (app *application) voteSubmit(w http.ResponseWriter, r *http.Request) { return } - err = app.motions.DirectVote(r.Context(), user.ID, motion.ID, func(v *models.Vote) { + if err := app.motions.UpdateVote(r.Context(), user.ID, motion.ID, func(v *models.Vote) { v.Vote = choice v.Voted = time.Now().UTC() v.Notes = fmt.Sprintf("Direct Vote\n\n%s", clientCert) - }) - if err != nil { + }); err != nil { app.serverError(w, err) return @@ -439,15 +443,117 @@ func (app *application) voteSubmit(w http.ResponseWriter, r *http.Request) { fmt.Sprintf("Your vote for motion %s has been registered.", motion.Tag), ) - http.Redirect(w, r, "/motions", http.StatusSeeOther) + http.Redirect(w, r, "/motions/", http.StatusSeeOther) } -func (app *application) proxyVoteForm(_ http.ResponseWriter, _ *http.Request) { - panic("not implemented") +func (app *application) proxyVoteForm(w http.ResponseWriter, r *http.Request) { + params := httprouter.ParamsFromContext(r.Context()) + + motion := app.motionFromRequestParam(w, r, params, false) + if motion == nil { + return + } + + data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) + + data.Motion = motion + + potentialVoters, err := app.users.PotentialVoters(r.Context()) + if err != nil { + app.serverError(w, err) + + return + } + + data.Form = &forms.ProxyVoteForm{ + Voters: potentialVoters, + } + + app.render(w, http.StatusOK, "proxy_vote.html", data) } -func (app *application) proxyVoteSubmit(_ http.ResponseWriter, _ *http.Request) { - panic("not implemented") +func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request) { + params := httprouter.ParamsFromContext(r.Context()) + + motion := app.motionFromRequestParam(w, r, params, false) + if motion == nil { + return + } + + var form forms.ProxyVoteForm + + 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.Motion = motion + + potentialVoters, err := app.users.PotentialVoters(r.Context()) + if err != nil { + app.serverError(w, err) + + return + } + + form.Voters = potentialVoters + data.Form = form + + app.render(w, http.StatusUnprocessableEntity, "proxy_vote.html", data) + + return + } + + form.Normalize() + + user, err := app.GetUser(r) + if err != nil { + app.clientError(w, http.StatusUnauthorized) + + return + } + + clientCert, err := getPEMClientCert(r) + if err != nil { + app.serverError(w, err) + + return + } + + voter, err := app.users.LoadVoter(r.Context(), form.VoterID) + if err != nil { + app.serverError(w, err) + + return + } + + if err := app.motions.UpdateVote(r.Context(), form.VoterID, motion.ID, func(v *models.Vote) { + v.Vote = form.Choice + v.Voted = time.Now().UTC() + v.Notes = fmt.Sprintf("Proxy-Vote by %s\n\n%s\n\n%s", user.Name, form.Justification, clientCert) + }); err != nil { + app.serverError(w, err) + + return + } + + app.mailNotifier.notifyChannel <- &ProxyVoteNotification{ + Decision: motion, User: user, Voter: voter, Choice: form.Choice, Justification: form.Justification, + } + + app.sessionManager.Put( + r.Context(), + "flash", + fmt.Sprintf("Your proxy vote for %s for motion %s has been registered.", voter.Name, motion.Tag), + ) + + http.Redirect(w, r, "/motions/", http.StatusSeeOther) } func (app *application) userList(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/boardvoting/notifications.go b/cmd/boardvoting/notifications.go index 34a541d..ae24522 100644 --- a/cmd/boardvoting/notifications.go +++ b/cmd/boardvoting/notifications.go @@ -142,8 +142,12 @@ func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *Notificati Decisions []*models.Motion Name string }{Decisions: r.decisions, Name: r.voter.Name}, - subject: "Outstanding CAcert board votes", - recipients: []recipientData{{"To", r.voter.Reminder, r.voter.Name}}, + subject: "Outstanding CAcert board votes", + recipients: []recipientData{{ + field: "To", + address: r.voter.Reminder, + name: r.voter.Name, + }}, } } @@ -179,7 +183,7 @@ func (c *ClosedDecisionNotification) GetNotificationContent(mc *mailConfig) *Not template: "closed_motion_mail.txt", data: struct { *models.Motion - }{c.Decision}, + }{Motion: c.Decision}, subject: fmt.Sprintf("Re: %s - %s - finalised", c.Decision.Tag, c.Decision.Title), headers: motionReplyHeaders(c.Decision), recipients: []recipientData{defaultRecipient(mc)}, @@ -202,7 +206,12 @@ func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *Notific Name string VoteURL string UnvotedURL string - }{n.Decision, n.Proposer.Name, voteURL, unvotedURL}, + }{ + Motion: n.Decision, + Name: n.Proposer.Name, + VoteURL: voteURL, + UnvotedURL: unvotedURL, + }, subject: fmt.Sprintf("%s - %s", n.Decision.Tag, n.Decision.Title), headers: n.getHeaders(), recipients: []recipientData{defaultRecipient(mc)}, @@ -231,7 +240,12 @@ func (u UpdateDecisionNotification) GetNotificationContent(mc *mailConfig) *Noti Name string VoteURL string UnvotedURL string - }{u.Decision, u.User.Name, voteURL, unvotedURL}, + }{ + Motion: u.Decision, + Name: u.User.Name, + VoteURL: voteURL, + UnvotedURL: unvotedURL, + }, subject: fmt.Sprintf("%s - %s", u.Decision.Tag, u.Decision.Title), headers: motionReplyHeaders(u.Decision), recipients: []recipientData{defaultRecipient(mc)}, @@ -251,9 +265,43 @@ func (d DirectVoteNotification) GetNotificationContent(mc *mailConfig) *Notifica *models.Motion Name string Choice *models.VoteChoice - }{d.Decision, d.User.Name, d.Choice}, + }{ + Motion: d.Decision, + Name: d.User.Name, + Choice: d.Choice, + }, subject: fmt.Sprintf("Re: %s - %s", d.Decision.Tag, d.Decision.Title), headers: motionReplyHeaders(d.Decision), recipients: []recipientData{voteNoticeRecipient(mc)}, } } + +type ProxyVoteNotification struct { + Decision *models.Motion + User *models.User + Voter *models.User + Choice *models.VoteChoice + Justification string +} + +func (p ProxyVoteNotification) GetNotificationContent(mc *mailConfig) *NotificationContent { + return &NotificationContent{ + template: "proxy_vote_mail.txt", + data: struct { + *models.Motion + Name string + Voter string + Choice *models.VoteChoice + Justification string + }{ + Motion: p.Decision, + Name: p.User.Name, + Voter: p.Voter.Name, + Choice: p.Choice, + Justification: p.Justification, + }, + subject: fmt.Sprintf("Re: %s - %s", p.Decision.Tag, p.Decision.Title), + headers: motionReplyHeaders(p.Decision), + recipients: []recipientData{voteNoticeRecipient(mc)}, + } +} diff --git a/internal/forms/forms.go b/internal/forms/forms.go index 9c7025d..d728cab 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -19,68 +19,26 @@ package forms import ( "fmt" + "strings" "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 + minimumJustificationLen = 3 + 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"` @@ -89,7 +47,7 @@ type EditMotionForm struct { validator.Validator `form:"-"` } -func (f EditMotionForm) Validate() { +func (f *EditMotionForm) Validate() { f.CheckField( validator.NotBlank(f.Title), "title", @@ -126,6 +84,36 @@ func (f EditMotionForm) Validate() { ) } +func (f *EditMotionForm) Normalize() { + f.Title = strings.TrimSpace(f.Title) + f.Content = strings.TrimSpace(f.Content) +} + type DirectVoteForm struct { Choice *models.VoteChoice } + +type ProxyVoteForm struct { + VoterID int64 `form:"voter"` + Choice *models.VoteChoice `form:"choice"` + Justification string `form:"justification"` + Voters []*models.User `form:"-"` + validator.Validator `form:"-"` +} + +func (f *ProxyVoteForm) Validate() { + f.CheckField(validator.NotBlank(f.Justification), "justification", "This field cannot be blank") + f.CheckField( + validator.MinChars( + f.Justification, + minimumJustificationLen, + ), + "justification", + fmt.Sprintf("This field must be at least %d characters long", minimumJustificationLen), + ) + f.CheckField(validator.NotNil(f.Choice), "choice", "A choice has to be made") +} + +func (f *ProxyVoteForm) Normalize() { + f.Justification = strings.TrimSpace(f.Justification) +} diff --git a/internal/mailtemplates/proxy_vote_mail.txt b/internal/mailtemplates/proxy_vote_mail.txt index dd7403f..e75864b 100644 --- a/internal/mailtemplates/proxy_vote_mail.txt +++ b/internal/mailtemplates/proxy_vote_mail.txt @@ -1,13 +1,13 @@ Dear Board, -{{ .Proxy }} has just registered a proxy vote of {{ .Vote }} for {{ .Voter }} on motion {{ .Decision.Tag }}. +{{ .Data.Name }} has just registered a proxy vote of {{ .Data.Choice }} for {{ .Data.Voter }} on motion {{ .Data.Tag }}. The justification for this was: -{{ .Justification }} +{{ .Data.Justification }} Motion: -{{ .Decision.Title }} -{{ .Decision.Content }} +{{ .Data.Title }} +{{ .Data.Content }} Kind regards, the vote system \ No newline at end of file diff --git a/internal/models/motions.go b/internal/models/motions.go index f119021..104c2b3 100644 --- a/internal/models/motions.go +++ b/internal/models/motions.go @@ -854,20 +854,51 @@ func (m *MotionModel) GetByID(ctx context.Context, id int64) (*Motion, error) { func (m *MotionModel) Update( ctx context.Context, id int64, - voteType *VoteType, - title string, - content string, - due time.Time, + updateFn func(*Motion), ) error { - _, err := m.DB.ExecContext( + tx, err := m.DB.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("could not start transaction: %w", err) + } + + defer func(tx *sqlx.Tx) { + _ = tx.Rollback() + }(tx) + + row := tx.QueryRowxContext(ctx, `SELECT * FROM decisions WHERE id=?`, id) + if err := row.Err(); err != nil { + return fmt.Errorf("could not select motion: %w", err) + } + + var motion Motion + + if err := row.StructScan(&motion); err != nil { + return fmt.Errorf("could not scan row: %w", err) + } + + updateFn(&motion) + + motion.Modified = time.Now().UTC() + + _, err = tx.NamedExecContext( ctx, - `UPDATE decisions SET title=?, content=?, votetype=?, due=?, modified=? WHERE id=?`, - title, content, voteType, due.UTC(), time.Now().UTC(), id, + `UPDATE decisions +SET title=:title, + content=:content, + votetype=:votetype, + due=:due, + modified=:modified +WHERE id = :id`, + motion, ) if err != nil { return fmt.Errorf("could not update decision: %w", err) } + if err := tx.Commit(); err != nil { + return fmt.Errorf("could not commit transaction: %w", err) + } + return nil } @@ -880,7 +911,7 @@ type Vote struct { Name string `db:"name"` } -func (m *MotionModel) DirectVote(ctx context.Context, userID, motionID int64, performVoteFn func(v *Vote)) error { +func (m *MotionModel) UpdateVote(ctx context.Context, userID, motionID int64, performVoteFn func(v *Vote)) error { tx, err := m.DB.BeginTxx(ctx, nil) if err != nil { return fmt.Errorf("could not start transaction: %w", err) diff --git a/internal/models/voters.go b/internal/models/voters.go index 01a05d6..234193f 100644 --- a/internal/models/voters.go +++ b/internal/models/voters.go @@ -32,10 +32,18 @@ const ( RoleVoter string = "VOTER" ) +// The User type is used for mapping users from the voters table. The table +// has two columns that are obsolete: +// +// enabled -> replaced with user_roles where role is 'VOTER' +// reminder -> replaced with address from emails where reminder is true +// +// The columns cannot be dropped in SQLite without dropping and recreating +// the voters table and all foreign key indices pointing to that table. type User struct { - ID int64 `db:"id"` - Name string - Reminder string // reminder email address + ID int64 `db:"id"` + Name string `db:"name"` + Reminder string `db:"reminder"` // reminder email address roles []*Role `db:"-"` } @@ -73,15 +81,53 @@ type UserModel struct { DB *sqlx.DB } -func (m *UserModel) GetReminderVoters(_ context.Context) ([]*User, error) { - panic("not implemented") +func (m *UserModel) GetReminderVoters(ctx context.Context) ([]*User, error) { + rows, err := m.DB.QueryxContext( + ctx, + `SELECT v.id, v.name, e.address AS reminder +FROM voters v + JOIN emails e ON v.id = e.voter + JOIN user_roles ur ON v.id = ur.voter_id +WHERE ur.role = ? + AND e.reminder = TRUE`, + RoleVoter, + ) + if err != nil { + return nil, fmt.Errorf("could not run query: %w", err) + } + + defer func(rows *sqlx.Rows) { + _ = rows.Close() + }(rows) + + result := make([]*User, 0) + + for rows.Next() { + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("could not fetch row: %w", err) + } + + var user User + + if err := rows.StructScan(&user); err != nil { + return nil, fmt.Errorf("could not scan row: %w", err) + } + + result = append(result, &user) + } + + return result, nil } func (m *UserModel) GetUser(ctx context.Context, emails []string) (*User, error) { query, args, err := sqlx.In( - `SELECT DISTINCT v.id, v.name, v.reminder + `WITH reminders AS (SELECT voter, address + FROM emails + WHERE reminder = TRUE) +SELECT DISTINCT v.id, v.name, reminders.address AS reminder FROM voters v JOIN emails e ON e.voter = v.id + LEFT JOIN reminders ON v.id = reminders.voter WHERE e.address IN (?)`, emails) if err != nil { return nil, fmt.Errorf("could not build query: %w", err) @@ -170,7 +216,10 @@ func (m *UserModel) CreateUser(ctx context.Context, name string, reminder string _ = tx.Rollback() }(tx) - res, err := tx.Exec(`INSERT INTO voters (name, reminder, enabled) VALUES (?, ?, 0)`, name, reminder) + res, err := tx.ExecContext( + ctx, + `INSERT INTO voters (name) VALUES (?)`, + name) if err != nil { return 0, fmt.Errorf("could not insert user: %w", err) } @@ -181,7 +230,14 @@ func (m *UserModel) CreateUser(ctx context.Context, name string, reminder string } for i := range emails { - _, err := tx.Exec(`INSERT INTO emails (voter, address) VALUES (?, ?)`, userID, emails[i]) + _, err := tx.ExecContext( + ctx, + `INSERT INTO emails (voter, address, reminder) +VALUES (?, ?, ?)`, + userID, + emails[i], + emails[i] == reminder, + ) if err != nil { return 0, fmt.Errorf("could not insert email %s for voter %s: %w", emails[i], name, err) } @@ -193,3 +249,65 @@ func (m *UserModel) CreateUser(ctx context.Context, name string, reminder string return userID, nil } + +func (m *UserModel) PotentialVoters(ctx context.Context) ([]*User, error) { + rows, err := m.DB.QueryxContext( + ctx, + `SELECT voters.id, voters.name +FROM voters + JOIN user_roles ur ON voters.id = ur.voter_id +WHERE ur.role = 'VOTER' +ORDER BY voters.name`, + ) + + if err != nil { + return nil, fmt.Errorf("could not execute query: %w", err) + } + + defer func(rows *sqlx.Rows) { + _ = rows.Close() + }(rows) + + result := make([]*User, 0) + + for rows.Next() { + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("could not get row: %w", err) + } + + var user User + + if err := rows.StructScan(&user); err != nil { + return nil, fmt.Errorf("could not scan row: %w", err) + } + + result = append(result, &user) + } + + return result, nil +} + +func (m *UserModel) LoadVoter(ctx context.Context, voterID int64) (*User, error) { + row := m.DB.QueryRowxContext( + ctx, + `SELECT DISTINCT v.id, v.name +FROM voters v + JOIN user_roles ur ON v.id = ur.voter_id +WHERE v.id = ? + AND ur.role = ?`, + voterID, + RoleVoter, + ) + + if err := row.Err(); err != nil { + return nil, fmt.Errorf("could not execute query: %w", err) + } + + var user User + + if err := row.StructScan(&user); err != nil { + return nil, fmt.Errorf("could not scan row: %w", err) + } + + return &user, nil +} diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 331d4ce..c5f5780 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -18,6 +18,7 @@ limitations under the License. package validator import ( + "reflect" "strings" "unicode/utf8" ) @@ -50,12 +51,17 @@ func NotBlank(value string) bool { return strings.TrimSpace(value) != "" } +func NotNil(value any) bool { + val := reflect.ValueOf(value) + return !val.IsNil() +} + func MaxChars(value string, n int) bool { - return utf8.RuneCountInString(value) <= n + return utf8.RuneCountInString(strings.TrimSpace(value)) <= n } func MinChars(value string, n int) bool { - return utf8.RuneCountInString(value) >= n + return utf8.RuneCountInString(strings.TrimSpace(value)) >= n } func PermittedInt(value int, permittedValues ...int) bool { diff --git a/ui/html/pages/proxy_vote.html b/ui/html/pages/proxy_vote.html new file mode 100644 index 0000000..6bc9d9a --- /dev/null +++ b/ui/html/pages/proxy_vote.html @@ -0,0 +1,66 @@ +{{ define "title" }}Proxy Vote on Motion {{ .Motion.Tag }}{{ end }} + +{{ define "main" }} + {{ $form := .Form }} + {{ $user := .User }} +
+ {{ template "motion_display" .Motion }} +
+ +
+
+ + + {{ if .Form.FieldErrors.user }} + {{ .Form.FieldErrors.user }} + {{ end }} +
+
+ +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ {{ if .Form.FieldErrors.choice }} +
+ {{ .Form.FieldErrors.choice }} +
+ {{ end }} +
+
+ + + {{ if .Form.FieldErrors.justification }} + {{ .Form.FieldErrors.justification }} + {{ end }} +
+ +
+
+
+{{ end }} \ No newline at end of file