Implement direct voting

This commit is contained in:
Jan Dittberner 2022-05-27 17:39:54 +02:00
parent 164495c818
commit 0c02daf29a
15 changed files with 431 additions and 158 deletions

View file

@ -18,16 +18,15 @@ limitations under the License.
package main
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"git.cacert.org/cacert-boardvoting/internal/forms"
"github.com/julienschmidt/httprouter"
"git.cacert.org/cacert-boardvoting/internal/forms"
"git.cacert.org/cacert-boardvoting/internal/models"
)
@ -178,20 +177,10 @@ func (app *application) calculateMotionListOptions(r *http.Request) (*models.Mot
func (app *application) motionDetails(w http.ResponseWriter, r *http.Request) {
params := httprouter.ParamsFromContext(r.Context())
tag := params.ByName("tag")
showVotes := r.URL.Query().Has("showvotes")
motion, err := app.motions.GetMotionByTag(r.Context(), tag, showVotes)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
app.serverError(w, err)
return
}
motion := app.motionFromRequestParam(w, r, params, showVotes)
if motion == nil {
app.notFound(w)
return
}
@ -267,7 +256,8 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request)
}
app.mailNotifier.notifyChannel <- &NewDecisionNotification{
Decision: &models.NewMotion{Decision: decision, Proposer: user},
Decision: decision,
Proposer: user,
}
app.sessionManager.Put(r.Context(), "flash", fmt.Sprintf("Started new motion %s: %s", decision.Tag, decision.Title))
@ -278,18 +268,8 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request)
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)
motion := app.motionFromRequestParam(w, r, params, false)
if motion == nil {
return
}
@ -309,24 +289,14 @@ func (app *application) editMotionForm(w http.ResponseWriter, r *http.Request) {
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)
motion := app.motionFromRequestParam(w, r, params, false)
if motion == nil {
return
}
var form forms.EditMotionForm
err = app.decodePostForm(r, &form)
err := app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
@ -362,6 +332,11 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request)
form.Content,
now.Add(dueDuration),
)
if err != nil {
app.serverError(w, err)
return
}
decision, err := app.motions.GetByID(r.Context(), motion.ID)
if err != nil {
@ -371,7 +346,8 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request)
}
app.mailNotifier.notifyChannel <- &UpdateDecisionNotification{
Decision: &models.UpdatedMotion{Decision: decision, User: user},
Decision: decision,
User: user,
}
app.sessionManager.Put(
@ -391,12 +367,79 @@ func (app *application) withdrawMotionSubmit(_ http.ResponseWriter, _ *http.Requ
panic("not implemented")
}
func (app *application) voteForm(_ http.ResponseWriter, _ *http.Request) {
panic("not implemented")
func (app *application) voteForm(w http.ResponseWriter, r *http.Request) {
params := httprouter.ParamsFromContext(r.Context())
motion := app.motionFromRequestParam(w, r, params, false)
if motion == nil {
return
}
choice := app.choiceFromRequestParam(w, params)
if choice == nil {
return
}
data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll)
data.Motion = motion
data.Form = &forms.DirectVoteForm{
Choice: choice,
}
app.render(w, http.StatusOK, "direct_vote.html", data)
}
func (app *application) voteSubmit(_ http.ResponseWriter, _ *http.Request) {
panic("not implemented")
func (app *application) voteSubmit(w http.ResponseWriter, r *http.Request) {
params := httprouter.ParamsFromContext(r.Context())
motion := app.motionFromRequestParam(w, r, params, false)
if motion == nil {
return
}
choice := app.choiceFromRequestParam(w, params)
if choice == nil {
return
}
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
}
err = app.motions.DirectVote(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 {
app.serverError(w, err)
return
}
app.mailNotifier.notifyChannel <- &DirectVoteNotification{
Decision: motion, User: user, Choice: choice,
}
app.sessionManager.Put(
r.Context(),
"flash",
fmt.Sprintf("Your vote for motion %s has been registered.", motion.Tag),
)
http.Redirect(w, r, "/motions", http.StatusSeeOther)
}
func (app *application) proxyVoteForm(_ http.ResponseWriter, _ *http.Request) {

View file

@ -19,6 +19,8 @@ package main
import (
"bytes"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"html/template"
@ -30,6 +32,7 @@ import (
"github.com/Masterminds/sprig/v3"
"github.com/go-playground/form/v4"
"github.com/julienschmidt/httprouter"
"github.com/justinas/nosurf"
"git.cacert.org/cacert-boardvoting/internal/models"
@ -117,8 +120,8 @@ func newTemplateCache() (map[string]*template.Template, error) {
type templateData struct {
PrevPage string
NextPage string
Motion *models.MotionForDisplay
Motions []*models.MotionForDisplay
Motion *models.Motion
Motions []*models.Motion
User *models.User
Users []*models.User
Request *http.Request
@ -167,3 +170,58 @@ func (app *application) render(w http.ResponseWriter, status int, page string, d
_, _ = buf.WriteTo(w)
}
func (app *application) motionFromRequestParam(
w http.ResponseWriter,
r *http.Request,
params httprouter.Params,
withVotes bool,
) *models.Motion {
tag := params.ByName("tag")
motion, err := app.motions.GetMotionByTag(r.Context(), tag, withVotes)
if err != nil {
app.serverError(w, err)
return nil
}
if motion.ID == 0 {
app.notFound(w)
return nil
}
return motion
}
func (app *application) choiceFromRequestParam(w http.ResponseWriter, params httprouter.Params) *models.VoteChoice {
choiceParam := params.ByName("choice")
choice, err := models.VoteChoiceFromString(choiceParam)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return nil
}
return choice
}
func getPEMClientCert(r *http.Request) (string, error) {
cert := r.Context().Value(ctxAuthenticatedCert)
authenticatedCertificate, ok := cert.(*x509.Certificate)
if !ok {
return "", errors.New("could not handle certificate as x509.Certificate")
}
clientCertPEM := bytes.NewBuffer(make([]byte, 0))
err := pem.Encode(clientCertPEM, &pem.Block{Type: "CERTIFICATE", Bytes: authenticatedCertificate.Raw})
if err != nil {
return "", fmt.Errorf("error encoding client certificate: %w", err)
}
return clientCertPEM.String(), nil
}

View file

@ -173,7 +173,7 @@ func (c *CloseDecisionsJob) Run() {
}
for _, res := range results {
c.notify <- &ClosedDecisionNotification{decision: res}
c.notify <- &ClosedDecisionNotification{Decision: res}
}
c.reschedule <- c

View file

@ -19,6 +19,7 @@ package main
import (
"context"
"crypto/x509"
"errors"
"fmt"
"net/http"
@ -33,6 +34,7 @@ type contextKey int
const (
ctxUser contextKey = iota
ctxAuthenticatedCert
)
func secureHeaders(next http.Handler) http.Handler {
@ -56,28 +58,29 @@ func (app *application) logRequest(next http.Handler) http.Handler {
})
}
func (app *application) authenticateRequest(r *http.Request) (*models.User, error) {
func (app *application) authenticateRequest(r *http.Request) (*models.User, *x509.Certificate, error) {
if r.TLS == nil {
return nil, nil
return nil, nil, nil
}
if len(r.TLS.PeerCertificates) < 1 {
return nil, nil
return nil, nil, nil
}
emails := r.TLS.PeerCertificates[0].EmailAddresses
clientCert := r.TLS.PeerCertificates[0]
emails := clientCert.EmailAddresses
user, err := app.users.GetUser(r.Context(), emails)
if err != nil {
return nil, fmt.Errorf("could not get user information from database: %w", err)
return nil, nil, fmt.Errorf("could not get user information from database: %w", err)
}
return user, nil
return user, clientCert, nil
}
func (app *application) tryAuthenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := app.authenticateRequest(r)
user, cert, err := app.authenticateRequest(r)
if err != nil {
app.serverError(w, err)
@ -92,7 +95,10 @@ func (app *application) tryAuthenticate(next http.Handler) http.Handler {
w.Header().Add("Cache-Control", "no-store")
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, user)))
certContext := context.WithValue(r.Context(), ctxAuthenticatedCert, cert)
userContext := context.WithValue(certContext, ctxUser, user)
next.ServeHTTP(w, r.WithContext(userContext))
})
}

View file

@ -148,36 +148,51 @@ func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *Notificati
}
func defaultRecipient(mc *mailConfig) recipientData {
return recipientData{field: "To", address: mc.NoticeMailAddress, name: "CAcert board mailing list"}
return recipientData{
field: "To",
address: mc.NoticeMailAddress,
name: "CAcert board mailing list",
}
}
func voteNoticeRecipient(mc *mailConfig) recipientData {
return recipientData{
field: "To",
address: mc.VoteNoticeMailAddress,
name: "CAcert board votes mailing list",
}
}
func motionReplyHeaders(m *models.Motion) map[string][]string {
return map[string][]string{
"References": {fmt.Sprintf("<%s>", m.Tag)},
"In-Reply-To": {fmt.Sprintf("<%s>", m.Tag)},
}
}
type ClosedDecisionNotification struct {
decision *models.ClosedMotion
Decision *models.Motion
}
func (c *ClosedDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent {
return &NotificationContent{
template: "closed_motion_mail.txt",
data: c.decision,
subject: fmt.Sprintf("Re: %s - %s - finalised", c.decision.Decision.Tag, c.decision.Decision.Title),
headers: c.getHeaders(),
template: "closed_motion_mail.txt",
data: struct {
*models.Motion
}{c.Decision},
subject: fmt.Sprintf("Re: %s - %s - finalised", c.Decision.Tag, c.Decision.Title),
headers: motionReplyHeaders(c.Decision),
recipients: []recipientData{defaultRecipient(mc)},
}
}
func (c *ClosedDecisionNotification) getHeaders() map[string][]string {
return map[string][]string{
"References": {fmt.Sprintf("<%s>", c.decision.Decision.Tag)},
"In-Reply-To": {fmt.Sprintf("<%s>", c.decision.Decision.Tag)},
}
}
type NewDecisionNotification struct {
Decision *models.NewMotion
Decision *models.Motion
Proposer *models.User
}
func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent {
voteURL := fmt.Sprintf("/vote/%s", n.Decision.Decision.Tag)
voteURL := fmt.Sprintf("/vote/%s", n.Decision.Tag)
unvotedURL := "/motions/?unvoted=1"
return &NotificationContent{
@ -187,8 +202,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, n.Proposer.Name, voteURL, unvotedURL},
subject: fmt.Sprintf("%s - %s", n.Decision.Tag, n.Decision.Title),
headers: n.getHeaders(),
recipients: []recipientData{defaultRecipient(mc)},
}
@ -196,16 +211,17 @@ 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.Tag)},
}
}
type UpdateDecisionNotification struct {
Decision *models.UpdatedMotion
Decision *models.Motion
User *models.User
}
func (u UpdateDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent {
voteURL := fmt.Sprintf("/vote/%s", u.Decision.Decision.Tag)
voteURL := fmt.Sprintf("/vote/%s", u.Decision.Tag)
unvotedURL := "/motions/?unvoted=1"
return &NotificationContent{
@ -215,16 +231,29 @@ func (u UpdateDecisionNotification) GetNotificationContent(mc *mailConfig) *Noti
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(),
}{u.Decision, u.User.Name, voteURL, unvotedURL},
subject: fmt.Sprintf("%s - %s", u.Decision.Tag, u.Decision.Title),
headers: motionReplyHeaders(u.Decision),
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)},
type DirectVoteNotification struct {
Decision *models.Motion
User *models.User
Choice *models.VoteChoice
}
func (d DirectVoteNotification) GetNotificationContent(mc *mailConfig) *NotificationContent {
return &NotificationContent{
template: "direct_vote_mail.txt",
data: struct {
*models.Motion
Name string
Choice *models.VoteChoice
}{d.Decision, d.User.Name, d.Choice},
subject: fmt.Sprintf("Re: %s - %s", d.Decision.Tag, d.Decision.Title),
headers: motionReplyHeaders(d.Decision),
recipients: []recipientData{voteNoticeRecipient(mc)},
}
}

View file

@ -125,3 +125,7 @@ func (f EditMotionForm) Validate() {
f.Due, threeDays, oneWeek, twoWeeks, threeWeeks), "due", "invalid duration choice",
)
}
type DirectVoteForm struct {
Choice *models.VoteChoice
}

View file

@ -1,10 +1,10 @@
Dear Board,
{{ .Voter }} has just voted {{ .Vote }} on motion {{ .Decision.Tag }}.
{{ .Data.Name }} has just voted {{ .Data.Choice }} on motion {{ .Data.Tag }}.
Motion:
{{ .Decision.Title }}
{{ .Decision.Content }}
{{ .Data.Title }}
{{ .Data.Content }}
Kind regards,
the vote system

View file

@ -0,0 +1,16 @@
-- drop unique constraint for motion by specific voter from votes
CREATE TABLE votes_new
(
decision INTEGER NOT NULL,
voter INTEGER NOT NULL,
vote INTEGER NOT NULL,
voted DATETIME NOT NULL,
notes TEXT NOT NULL DEFAULT ''
);
INSERT INTO votes_new (decision, voter, vote, voted, notes)
SELECT decision, voter, vote, voted, notes
FROM votes;
DROP TABLE votes;
ALTER TABLE votes_new RENAME TO votes;

View file

@ -0,0 +1,24 @@
-- add constraints on votes table
CREATE TABLE votes_new
(
decision INTEGER NOT NULL REFERENCES decisions (id),
voter INTEGER NOT NULL REFERENCES voters (id),
vote INTEGER NOT NULL,
voted DATETIME NOT NULL,
notes TEXT NOT NULL DEFAULT '',
PRIMARY KEY (decision, voter)
);
INSERT INTO votes_new (decision, voter, vote, voted, notes)
SELECT decision,
voter,
vote,
voted,
notes
FROM votes
GROUP BY decision, voter
HAVING MAX(voted) = voted;
ALTER TABLE votes
RENAME TO votes_orig_with_duplicates;
ALTER TABLE votes_new
RENAME TO votes;

View file

@ -0,0 +1,15 @@
-- drop constraints from votes table
CREATE TABLE emails_new
(
voter INT4,
address VARCHAR(255)
);
INSERT INTO emails_new (voter, address)
SELECT voter, address
FROM emails;
DROP TABLE emails;
ALTER TABLE emails_new
RENAME TO emails;
DROP TABLE emails_backup;

View file

@ -0,0 +1,18 @@
-- add constraints on votes table
CREATE TABLE emails_new
(
id INTEGER PRIMARY KEY,
voter INTEGER NOT NULL REFERENCES voters (id),
address VARCHAR(255) NOT NULL UNIQUE,
reminder bool NOT NULL DEFAULT FALSE
);
INSERT INTO emails_new (voter, address, reminder)
SELECT emails.voter,
emails.address,
EXISTS(SELECT * FROM voters WHERE voters.reminder = emails.address AND voters.id = emails.voter)
FROM emails;
ALTER TABLE emails
RENAME TO emails_backup;
ALTER TABLE emails_new
RENAME TO emails;

View file

@ -35,8 +35,6 @@ type VoteType struct {
id uint8
}
const unknownVariant = "unknown"
var (
VoteTypeMotion = &VoteType{label: "motion", id: 0}
VoteTypeVeto = &VoteType{label: "veto", id: 1}
@ -103,19 +101,19 @@ func (v *VoteType) QuorumAndMajority() (int, float32) {
type VoteStatus struct {
Label string
Id int8
ID int8
}
var (
voteStatusDeclined = &VoteStatus{Label: "declined", Id: -1}
voteStatusPending = &VoteStatus{Label: "pending", Id: 0}
voteStatusApproved = &VoteStatus{Label: "approved", Id: 1}
voteStatusWithdrawn = &VoteStatus{Label: "withdrawn", Id: -2}
voteStatusDeclined = &VoteStatus{Label: "declined", ID: -1}
voteStatusPending = &VoteStatus{Label: "pending", ID: 0}
voteStatusApproved = &VoteStatus{Label: "approved", ID: 1}
voteStatusWithdrawn = &VoteStatus{Label: "withdrawn", ID: -2}
)
func VoteStatusFromInt(id int64) (*VoteStatus, error) {
for _, vs := range []*VoteStatus{voteStatusPending, voteStatusApproved, voteStatusWithdrawn, voteStatusDeclined} {
if int64(vs.Id) == id {
if int64(vs.ID) == id {
return vs, nil
}
}
@ -144,23 +142,23 @@ func (v *VoteStatus) Scan(src any) error {
}
func (v *VoteStatus) Value() (driver.Value, error) {
return int64(v.Id), nil
return int64(v.ID), nil
}
type VoteChoice struct {
label string
id int8
Label string
ID int8
}
var (
VoteAye = &VoteChoice{label: "aye", id: 1}
VoteNaye = &VoteChoice{label: "naye", id: -1}
VoteAbstain = &VoteChoice{label: "abstain", id: 0}
VoteAye = &VoteChoice{Label: "aye", ID: 1}
VoteNaye = &VoteChoice{Label: "naye", ID: -1}
VoteAbstain = &VoteChoice{Label: "abstain", ID: 0}
)
func VoteChoiceFromString(label string) (*VoteChoice, error) {
for _, vc := range []*VoteChoice{VoteAye, VoteNaye, VoteAbstain} {
if strings.EqualFold(vc.label, label) {
if strings.EqualFold(vc.Label, label) {
return vc, nil
}
}
@ -170,7 +168,7 @@ func VoteChoiceFromString(label string) (*VoteChoice, error) {
func VoteChoiceFromInt(id int64) (*VoteChoice, error) {
for _, vc := range []*VoteChoice{VoteAye, VoteNaye, VoteAbstain} {
if int64(vc.id) == id {
if int64(vc.ID) == id {
return vc, nil
}
}
@ -179,7 +177,7 @@ func VoteChoiceFromInt(id int64) (*VoteChoice, error) {
}
func (v *VoteChoice) String() string {
return v.label
return v.Label
}
func (v *VoteChoice) Scan(src any) error {
@ -199,11 +197,11 @@ func (v *VoteChoice) Scan(src any) error {
}
func (v *VoteChoice) Value() (driver.Value, error) {
return int64(v.id), nil
return int64(v.ID), nil
}
func (v *VoteChoice) Equal(other *VoteChoice) bool {
return v.id == other.id
return v.ID == other.ID
}
type VoteSums struct {
@ -233,7 +231,8 @@ func (v *VoteSums) CalculateResult(quorum int, majority float32) (*VoteStatus, s
type Motion struct {
ID int64 `db:"id"`
Proposed time.Time
Proponent int64 `db:"proponent"`
Proponent int64 `db:"proponent"`
Proposer string `db:"proposer"`
Title string
Content string
Status *VoteStatus
@ -241,22 +240,9 @@ type Motion struct {
Modified time.Time
Tag string
Type *VoteType `db:"votetype"`
}
type ClosedMotion struct {
Decision *Motion
VoteSums *VoteSums
Reasoning string
}
type NewMotion struct {
Decision *Motion
Proposer *User
}
type UpdatedMotion struct {
Decision *Motion
User *User
Sums *VoteSums `db:"-"`
Votes []*Vote `db:"-"`
Reasoning string `db:"-"`
}
type MotionModel struct {
@ -279,6 +265,7 @@ func (m *MotionModel) Create(
Content: content,
Due: due.UTC(),
Type: voteType,
Status: voteStatusPending,
}
result, err := m.DB.NamedExecContext(
@ -305,7 +292,7 @@ VALUES (:proposed, :proponent, :title, :content, :votetype, :status, :due, :prop
return id, nil
}
func (m *MotionModel) CloseDecisions(ctx context.Context) ([]*ClosedMotion, error) {
func (m *MotionModel) CloseDecisions(ctx context.Context) ([]*Motion, error) {
tx, err := m.DB.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("could not start transaction: %w", err)
@ -343,9 +330,9 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now
decisions = append(decisions, decision)
}
results := make([]*ClosedMotion, 0, len(decisions))
results := make([]*Motion, 0, len(decisions))
var decisionResult *ClosedMotion
var decisionResult *Motion
for _, decision := range decisions {
m.InfoLog.Printf("found closable decision %s", decision.Tag)
@ -364,7 +351,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now
return results, nil
}
func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*ClosedMotion, error) {
func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*Motion, error) {
quorum, majority := d.Type.QuorumAndMajority()
var (
@ -400,7 +387,10 @@ func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion)
m.InfoLog.Printf("decision %s closed with result %s: reasoning '%s'", d.Tag, d.Status, reasoning)
return &ClosedMotion{d, voteSums, reasoning}, nil
d.Sums = voteSums
d.Reasoning = reasoning
return d, nil
}
func (m *MotionModel) UnVotedDecisionsForVoter(_ context.Context, _ *User) ([]*Motion, error) {
@ -480,22 +470,6 @@ func (m *MotionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, e
return &due, nil
}
type MotionForDisplay struct {
ID int64 `db:"id"`
Tag string
Proponent int64
Proposer string
Proposed time.Time
Title string
Content string
Type *VoteType `db:"votetype"`
Status *VoteStatus
Due time.Time
Modified time.Time
Sums VoteSums
Votes []*VoteForDisplay
}
type VoteForDisplay struct {
Name string
Vote *VoteChoice
@ -557,7 +531,7 @@ WHERE due >= ?
return firstTs, lastTs, nil
}
func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions) ([]*MotionForDisplay, error) {
func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions) ([]*Motion, error) {
var (
rows *sqlx.Rows
err error
@ -580,10 +554,10 @@ func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions
_ = rows.Close()
}(rows)
motions := make([]*MotionForDisplay, 0, options.Limit)
motions := make([]*Motion, 0, options.Limit)
for rows.Next() {
var decision MotionForDisplay
var decision Motion
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("could not fetch row: %w", err)
@ -606,11 +580,12 @@ func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions
return motions, nil
}
func (m *MotionModel) FillVoteSums(ctx context.Context, decisions []*MotionForDisplay) error {
func (m *MotionModel) FillVoteSums(ctx context.Context, decisions []*Motion) error {
decisionIds := make([]int64, len(decisions))
decisionMap := make(map[int64]*MotionForDisplay, len(decisions))
decisionMap := make(map[int64]*Motion, len(decisions))
for idx, decision := range decisions {
decision.Sums = &VoteSums{}
decisionIds[idx] = decision.ID
decisionMap[decision.ID] = decision
}
@ -781,7 +756,7 @@ LIMIT ?`,
return rows, nil
}
func (m *MotionModel) GetMotionByTag(ctx context.Context, tag string, withVotes bool) (*MotionForDisplay, error) {
func (m *MotionModel) GetMotionByTag(ctx context.Context, tag string, withVotes bool) (*Motion, error) {
row := m.DB.QueryRowxContext(
ctx,
`SELECT decisions.id,
@ -804,13 +779,13 @@ WHERE decisions.tag = ?`,
return nil, fmt.Errorf("could not query motion: %w", err)
}
var result MotionForDisplay
var result Motion
if err := row.StructScan(&result); err != nil {
return nil, fmt.Errorf("could not fill motion from query result: %w", err)
}
if err := m.FillVoteSums(ctx, []*MotionForDisplay{&result}); err != nil {
if err := m.FillVoteSums(ctx, []*Motion{&result}); err != nil {
return nil, fmt.Errorf("could not get vote sums: %w", err)
}
@ -823,7 +798,7 @@ WHERE decisions.tag = ?`,
return &result, nil
}
func (m *MotionModel) FillVotes(ctx context.Context, md *MotionForDisplay) error {
func (m *MotionModel) FillVotes(ctx context.Context, md *Motion) error {
rows, err := m.DB.QueryxContext(ctx,
`SELECT voters.name, votes.vote
FROM voters
@ -848,13 +823,13 @@ ORDER BY voters.name`,
return fmt.Errorf("could not get row: %w", err)
}
var voteDisplay VoteForDisplay
var vote Vote
if err := rows.StructScan(&voteDisplay); err != nil {
if err := rows.StructScan(&vote); err != nil {
return fmt.Errorf("could not scan row: %w", err)
}
md.Votes = append(md.Votes, &voteDisplay)
md.Votes = append(md.Votes, &vote)
}
return nil
@ -876,7 +851,14 @@ 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 {
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=?, modified=? WHERE id=?`,
@ -888,3 +870,57 @@ func (m *MotionModel) Update(ctx context.Context, id int64, voteType *VoteType,
return nil
}
type Vote struct {
UserID int64 `db:"voter"`
MotionID int64 `db:"decision"`
Vote *VoteChoice `db:"vote"`
Voted time.Time `db:"voted"`
Notes string `db:"notes"`
Name string `db:"name"`
}
func (m *MotionModel) DirectVote(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)
}
defer func(tx *sqlx.Tx) {
_ = tx.Rollback()
}(tx)
row := tx.QueryRowxContext(ctx, `SELECT * FROM votes WHERE voter=? AND decision=?`, userID, motionID)
if err := row.Err(); err != nil {
return fmt.Errorf("could not execute query: %w", err)
}
vote := Vote{UserID: userID, MotionID: motionID}
if err := row.StructScan(&vote); err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("could not scan vote structure: %w", err)
}
performVoteFn(&vote)
if _, err := tx.NamedExecContext(
ctx,
`INSERT INTO votes (decision, voter, vote, voted, notes)
VALUES (:decision, :voter, :vote, :voted, :notes)
ON CONFLICT (decision, voter)
DO UPDATE SET vote=:vote,
voted=:voted,
notes=:notes
WHERE decision = :decision
AND voter = :voter`,
vote,
); err != nil {
return fmt.Errorf("could not insert or update vote: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("could not commit transaction: %w", err)
}
return nil
}

View file

@ -285,7 +285,7 @@ type DbHandler struct {
var db *DbHandler
//go:embed boardvoting/migrations/*
// go:embed boardvoting/migrations/*
var migrations embed.FS
func NewDB(database *sql.DB) *DbHandler {

View file

@ -0,0 +1,24 @@
{{ define "title" }}Vote on Motion {{ .Motion.Tag }}{{ end }}
{{ define "main" }}
<div class="ui raised segment">
{{ template "motion_display" .Motion }}
<form action="/vote/{{ .Motion.Tag }}/{{ .Form.Choice }}" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
{{ with .Form }}
<div class="ui form">
{{ if eq "aye" .Choice.Label }}
<button class="ui right labeled green icon button" type="submit">
<i class="check circle icon"></i> Vote {{ .Choice }}</button>
{{ else if eq "naye" .Choice.Label }}
<button class="ui right labeled red icon button" type="submit">
<i class="minus circle icon"></i> Vote {{ .Choice }}</button>
{{ else }}
<button class="ui right labeled grey icon button" type="submit">
<i class="circle icon"></i> Vote {{ .Choice }}</button>
{{ end }}
</div>
{{ end }}
</form>
</div>
{{ end }}

View file

@ -11,7 +11,7 @@
</tr>
<tr>
<td>Proposed</td>
<td>{{ dateInZone "2006-01-02 15:04:05 UTC" .Proposed "UTC" }}</td>
<td>{{ dateInZone "2006-01-02 15:04:05 UTC" .Proposed "UTC" }} by {{ .Proposer }}</td>
</tr>
<tr>
<td>Vote type:</td>