Implement proxy voting
This commit is contained in:
parent
0c02daf29a
commit
c12aaf4d89
8 changed files with 451 additions and 88 deletions
|
@ -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) {
|
||||||
|
|
|
@ -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)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
66
ui/html/pages/proxy_vote.html
Normal file
66
ui/html/pages/proxy_vote.html
Normal 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 }}
|
Loading…
Reference in a new issue