Implement proxy voting

This commit is contained in:
Jan Dittberner 2022-05-27 20:45:04 +02:00
parent 0c02daf29a
commit c12aaf4d89
8 changed files with 451 additions and 88 deletions

View file

@ -21,6 +21,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/julienschmidt/httprouter" "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) { func (app *application) newMotionForm(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll)
data.Form = &forms.NewMotionForm{ data.Form = &forms.EditMotionForm{
Type: models.VoteTypeMotion, Type: models.VoteTypeMotion,
} }
@ -203,7 +204,7 @@ func (app *application) newMotionForm(w http.ResponseWriter, r *http.Request) {
const hoursInDay = 24 const hoursInDay = 24
func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) { func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) {
var form forms.NewMotionForm var form forms.EditMotionForm
err := app.decodePostForm(r, &form) err := app.decodePostForm(r, &form)
if err != nil { if err != nil {
@ -314,6 +315,8 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request)
return return
} }
form.Normalize()
user, err := app.GetUser(r) user, err := app.GetUser(r)
if err != nil { if err != nil {
app.clientError(w, http.StatusUnauthorized) app.clientError(w, http.StatusUnauthorized)
@ -327,10 +330,12 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request)
err = app.motions.Update( err = app.motions.Update(
r.Context(), r.Context(),
motion.ID, motion.ID,
form.Type, func(m *models.Motion) {
form.Title, m.Type = form.Type
form.Content, m.Title = strings.TrimSpace(form.Title)
now.Add(dueDuration), m.Content = strings.TrimSpace(form.Content)
m.Due = now.Add(dueDuration)
},
) )
if err != nil { if err != nil {
app.serverError(w, err) app.serverError(w, err)
@ -418,12 +423,11 @@ func (app *application) voteSubmit(w http.ResponseWriter, r *http.Request) {
return 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.Vote = choice
v.Voted = time.Now().UTC() v.Voted = time.Now().UTC()
v.Notes = fmt.Sprintf("Direct Vote\n\n%s", clientCert) v.Notes = fmt.Sprintf("Direct Vote\n\n%s", clientCert)
}) }); err != nil {
if err != nil {
app.serverError(w, err) app.serverError(w, err)
return 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), 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) { func (app *application) proxyVoteForm(w http.ResponseWriter, r *http.Request) {
panic("not implemented") params := httprouter.ParamsFromContext(r.Context())
motion := app.motionFromRequestParam(w, r, params, false)
if motion == nil {
return
} }
func (app *application) proxyVoteSubmit(_ http.ResponseWriter, _ *http.Request) { data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll)
panic("not implemented")
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(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) { func (app *application) userList(w http.ResponseWriter, r *http.Request) {

View file

@ -143,7 +143,11 @@ func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *Notificati
Name string Name string
}{Decisions: r.decisions, Name: r.voter.Name}, }{Decisions: r.decisions, Name: r.voter.Name},
subject: "Outstanding CAcert board votes", subject: "Outstanding CAcert board votes",
recipients: []recipientData{{"To", r.voter.Reminder, r.voter.Name}}, 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", template: "closed_motion_mail.txt",
data: struct { data: struct {
*models.Motion *models.Motion
}{c.Decision}, }{Motion: c.Decision},
subject: fmt.Sprintf("Re: %s - %s - finalised", c.Decision.Tag, c.Decision.Title), subject: fmt.Sprintf("Re: %s - %s - finalised", c.Decision.Tag, c.Decision.Title),
headers: motionReplyHeaders(c.Decision), headers: motionReplyHeaders(c.Decision),
recipients: []recipientData{defaultRecipient(mc)}, recipients: []recipientData{defaultRecipient(mc)},
@ -202,7 +206,12 @@ func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *Notific
Name string Name string
VoteURL string VoteURL string
UnvotedURL 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), subject: fmt.Sprintf("%s - %s", n.Decision.Tag, n.Decision.Title),
headers: n.getHeaders(), headers: n.getHeaders(),
recipients: []recipientData{defaultRecipient(mc)}, recipients: []recipientData{defaultRecipient(mc)},
@ -231,7 +240,12 @@ func (u UpdateDecisionNotification) GetNotificationContent(mc *mailConfig) *Noti
Name string Name string
VoteURL string VoteURL string
UnvotedURL 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), subject: fmt.Sprintf("%s - %s", u.Decision.Tag, u.Decision.Title),
headers: motionReplyHeaders(u.Decision), headers: motionReplyHeaders(u.Decision),
recipients: []recipientData{defaultRecipient(mc)}, recipients: []recipientData{defaultRecipient(mc)},
@ -251,9 +265,43 @@ func (d DirectVoteNotification) GetNotificationContent(mc *mailConfig) *Notifica
*models.Motion *models.Motion
Name string Name string
Choice *models.VoteChoice 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), subject: fmt.Sprintf("Re: %s - %s", d.Decision.Tag, d.Decision.Title),
headers: motionReplyHeaders(d.Decision), headers: motionReplyHeaders(d.Decision),
recipients: []recipientData{voteNoticeRecipient(mc)}, 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)},
}
}

View file

@ -19,68 +19,26 @@ package forms
import ( import (
"fmt" "fmt"
"strings"
"git.cacert.org/cacert-boardvoting/internal/models" "git.cacert.org/cacert-boardvoting/internal/models"
"git.cacert.org/cacert-boardvoting/internal/validator" "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 ( const (
minimumTitleLength = 3 minimumTitleLength = 3
maximumTitleLength = 200 maximumTitleLength = 200
minimumContentLength = 3 minimumContentLength = 3
maximumContentLength = 8000 maximumContentLength = 8000
minimumJustificationLen = 3
threeDays = 3 threeDays = 3
oneWeek = 7 oneWeek = 7
twoWeeks = 14 twoWeeks = 14
threeWeeks = 28 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 { type EditMotionForm struct {
Title string `form:"title"` Title string `form:"title"`
Content string `form:"content"` Content string `form:"content"`
@ -89,7 +47,7 @@ type EditMotionForm struct {
validator.Validator `form:"-"` validator.Validator `form:"-"`
} }
func (f EditMotionForm) Validate() { func (f *EditMotionForm) Validate() {
f.CheckField( f.CheckField(
validator.NotBlank(f.Title), validator.NotBlank(f.Title),
"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 { type DirectVoteForm struct {
Choice *models.VoteChoice 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)
}

View file

@ -1,13 +1,13 @@
Dear Board, 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: The justification for this was:
{{ .Justification }} {{ .Data.Justification }}
Motion: Motion:
{{ .Decision.Title }} {{ .Data.Title }}
{{ .Decision.Content }} {{ .Data.Content }}
Kind regards, Kind regards,
the vote system the vote system

View file

@ -854,20 +854,51 @@ func (m *MotionModel) GetByID(ctx context.Context, id int64) (*Motion, error) {
func (m *MotionModel) Update( func (m *MotionModel) Update(
ctx context.Context, ctx context.Context,
id int64, id int64,
voteType *VoteType, updateFn func(*Motion),
title string,
content string,
due time.Time,
) error { ) 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, ctx,
`UPDATE decisions SET title=?, content=?, votetype=?, due=?, modified=? WHERE id=?`, `UPDATE decisions
title, content, voteType, due.UTC(), time.Now().UTC(), id, SET title=:title,
content=:content,
votetype=:votetype,
due=:due,
modified=:modified
WHERE id = :id`,
motion,
) )
if err != nil { if err != nil {
return fmt.Errorf("could not update decision: %w", err) 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 return nil
} }
@ -880,7 +911,7 @@ type Vote struct {
Name string `db:"name"` 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) tx, err := m.DB.BeginTxx(ctx, nil)
if err != nil { if err != nil {
return fmt.Errorf("could not start transaction: %w", err) return fmt.Errorf("could not start transaction: %w", err)

View file

@ -32,10 +32,18 @@ const (
RoleVoter string = "VOTER" 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 { type User struct {
ID int64 `db:"id"` ID int64 `db:"id"`
Name string Name string `db:"name"`
Reminder string // reminder email address Reminder string `db:"reminder"` // reminder email address
roles []*Role `db:"-"` roles []*Role `db:"-"`
} }
@ -73,15 +81,53 @@ type UserModel struct {
DB *sqlx.DB DB *sqlx.DB
} }
func (m *UserModel) GetReminderVoters(_ context.Context) ([]*User, error) { func (m *UserModel) GetReminderVoters(ctx context.Context) ([]*User, error) {
panic("not implemented") 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) { func (m *UserModel) GetUser(ctx context.Context, emails []string) (*User, error) {
query, args, err := sqlx.In( 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 FROM voters v
JOIN emails e ON e.voter = v.id JOIN emails e ON e.voter = v.id
LEFT JOIN reminders ON v.id = reminders.voter
WHERE e.address IN (?)`, emails) WHERE e.address IN (?)`, emails)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not build query: %w", err) 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.Rollback()
}(tx) }(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 { if err != nil {
return 0, fmt.Errorf("could not insert user: %w", err) 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 { 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 { if err != nil {
return 0, fmt.Errorf("could not insert email %s for voter %s: %w", emails[i], name, err) 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 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
}

View file

@ -18,6 +18,7 @@ limitations under the License.
package validator package validator
import ( import (
"reflect"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
) )
@ -50,12 +51,17 @@ func NotBlank(value string) bool {
return strings.TrimSpace(value) != "" return strings.TrimSpace(value) != ""
} }
func NotNil(value any) bool {
val := reflect.ValueOf(value)
return !val.IsNil()
}
func MaxChars(value string, n int) bool { 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 { 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 { func PermittedInt(value int, permittedValues ...int) bool {

View file

@ -0,0 +1,66 @@
{{ define "title" }}Proxy Vote on Motion {{ .Motion.Tag }}{{ end }}
{{ define "main" }}
{{ $form := .Form }}
{{ $user := .User }}
<div class="ui form segment">
{{ template "motion_display" .Motion }}
<form action="/proxy/{{ .Motion.Tag }}" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="ui form{{ if .Form.FieldErrors }} error{{ end }}">
<div class="required field{{ if .Form.FieldErrors.user }} error{{ end }}">
<label for="voter">Voter</label>
<select id="voter" name="voter">
{{ range .Form.Voters }}
{{ if not (eq .ID $user.ID) }}
<option value="{{ .ID }}"
{{ if eq .ID $form.VoterID }} selected{{ end }}>{{ .Name }}</option>
{{ end }}
{{ end }}
</select>
{{ if .Form.FieldErrors.user }}
<span class="ui small error text">{{ .Form.FieldErrors.user }}</span>
{{ end }}
</div>
<div class="inline fields required{{ if .Form.FieldErrors.choice }} error{{ end }}">
<label>Choice</label>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" aria-labelledby="label-aye" name="choice" id="choice-aye"
value="aye"{{ with .Form.Choice }}{{ if eq "aye" .Label }} checked{{ end }}{{ end }}>
<label id="label-aye" for="choice-aye">Aye</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" aria-labelledby="label-naye" name="choice" id="choice-naye"
value="naye"{{ with .Form.Choice }}{{ if eq "naye" .Label }} checked{{ end }}{{ end }}>
<label id="label-naye" for="choice-naye">Naye</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" aria-labelledby="label-abstain" name="choice" id="choice-abstain"
value="abstain"{{ with .Form.Choice }}{{ if eq "abstain" .Label }} checked{{ end }}{{ end }}>
<label id="label-abstain" for="choice-abstain">Abstain</label>
</div>
</div>
{{ if .Form.FieldErrors.choice }}
<div>
<span class="ui small error text">{{ .Form.FieldErrors.choice }}</span>
</div>
{{ end }}
</div>
<div class="required field{{ if .Form.FieldErrors.justification }} error{{ end }}">
<label for="justification">Justification</label>
<textarea id="justification" name="justification" rows="2">{{ .Form.Justification }}</textarea>
{{ if .Form.FieldErrors.justification }}
<span class="ui small error text">{{ .Form.FieldErrors.justification }}</span>
{{ end }}
</div>
<button class="ui primary labeled icon button" type="submit"><i class="users icon"></i> Proxy Vote
</button>
</div>
</form>
</div>
{{ end }}