Implement direct voting

main
Jan Dittberner 2 years ago
parent 164495c818
commit 0c02daf29a

@ -18,16 +18,15 @@ limitations under the License.
package main package main
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"time" "time"
"git.cacert.org/cacert-boardvoting/internal/forms"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"git.cacert.org/cacert-boardvoting/internal/forms"
"git.cacert.org/cacert-boardvoting/internal/models" "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) { func (app *application) motionDetails(w http.ResponseWriter, r *http.Request) {
params := httprouter.ParamsFromContext(r.Context()) params := httprouter.ParamsFromContext(r.Context())
tag := params.ByName("tag")
showVotes := r.URL.Query().Has("showvotes") showVotes := r.URL.Query().Has("showvotes")
motion, err := app.motions.GetMotionByTag(r.Context(), tag, showVotes) motion := app.motionFromRequestParam(w, r, params, showVotes)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
app.serverError(w, err)
return
}
if motion == nil { if motion == nil {
app.notFound(w)
return return
} }
@ -267,7 +256,8 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request)
} }
app.mailNotifier.notifyChannel <- &NewDecisionNotification{ 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)) 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) { func (app *application) editMotionForm(w http.ResponseWriter, r *http.Request) {
params := httprouter.ParamsFromContext(r.Context()) params := httprouter.ParamsFromContext(r.Context())
tag := params.ByName("tag") motion := app.motionFromRequestParam(w, r, params, false)
if motion == nil {
motion, err := app.motions.GetMotionByTag(r.Context(), tag, false)
if err != nil {
app.serverError(w, err)
return
}
if motion.ID == 0 {
app.notFound(w)
return 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) { func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request) {
params := httprouter.ParamsFromContext(r.Context()) params := httprouter.ParamsFromContext(r.Context())
tag := params.ByName("tag") motion := app.motionFromRequestParam(w, r, params, false)
if motion == nil {
motion, err := app.motions.GetMotionByTag(r.Context(), tag, false)
if err != nil {
app.serverError(w, err)
return
}
if motion.ID == 0 {
app.notFound(w)
return return
} }
var form forms.EditMotionForm var form forms.EditMotionForm
err = app.decodePostForm(r, &form) err := app.decodePostForm(r, &form)
if err != nil { if err != nil {
app.clientError(w, http.StatusBadRequest) app.clientError(w, http.StatusBadRequest)
@ -362,6 +332,11 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request)
form.Content, form.Content,
now.Add(dueDuration), now.Add(dueDuration),
) )
if err != nil {
app.serverError(w, err)
return
}
decision, err := app.motions.GetByID(r.Context(), motion.ID) decision, err := app.motions.GetByID(r.Context(), motion.ID)
if err != nil { if err != nil {
@ -371,7 +346,8 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request)
} }
app.mailNotifier.notifyChannel <- &UpdateDecisionNotification{ app.mailNotifier.notifyChannel <- &UpdateDecisionNotification{
Decision: &models.UpdatedMotion{Decision: decision, User: user}, Decision: decision,
User: user,
} }
app.sessionManager.Put( app.sessionManager.Put(
@ -391,12 +367,79 @@ func (app *application) withdrawMotionSubmit(_ http.ResponseWriter, _ *http.Requ
panic("not implemented") panic("not implemented")
} }
func (app *application) voteForm(_ http.ResponseWriter, _ *http.Request) { func (app *application) voteForm(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) voteSubmit(_ http.ResponseWriter, _ *http.Request) { choice := app.choiceFromRequestParam(w, params)
panic("not implemented") 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(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) { func (app *application) proxyVoteForm(_ http.ResponseWriter, _ *http.Request) {

@ -19,6 +19,8 @@ package main
import ( import (
"bytes" "bytes"
"crypto/x509"
"encoding/pem"
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
@ -30,6 +32,7 @@ import (
"github.com/Masterminds/sprig/v3" "github.com/Masterminds/sprig/v3"
"github.com/go-playground/form/v4" "github.com/go-playground/form/v4"
"github.com/julienschmidt/httprouter"
"github.com/justinas/nosurf" "github.com/justinas/nosurf"
"git.cacert.org/cacert-boardvoting/internal/models" "git.cacert.org/cacert-boardvoting/internal/models"
@ -117,8 +120,8 @@ func newTemplateCache() (map[string]*template.Template, error) {
type templateData struct { type templateData struct {
PrevPage string PrevPage string
NextPage string NextPage string
Motion *models.MotionForDisplay Motion *models.Motion
Motions []*models.MotionForDisplay Motions []*models.Motion
User *models.User User *models.User
Users []*models.User Users []*models.User
Request *http.Request Request *http.Request
@ -167,3 +170,58 @@ func (app *application) render(w http.ResponseWriter, status int, page string, d
_, _ = buf.WriteTo(w) _, _ = 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
}

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

@ -19,6 +19,7 @@ package main
import ( import (
"context" "context"
"crypto/x509"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@ -33,6 +34,7 @@ type contextKey int
const ( const (
ctxUser contextKey = iota ctxUser contextKey = iota
ctxAuthenticatedCert
) )
func secureHeaders(next http.Handler) http.Handler { 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 { if r.TLS == nil {
return nil, nil return nil, nil, nil
} }
if len(r.TLS.PeerCertificates) < 1 { 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) user, err := app.users.GetUser(r.Context(), emails)
if err != nil { 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 { func (app *application) tryAuthenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := app.authenticateRequest(r) user, cert, err := app.authenticateRequest(r)
if err != nil { if err != nil {
app.serverError(w, err) app.serverError(w, err)
@ -92,7 +95,10 @@ func (app *application) tryAuthenticate(next http.Handler) http.Handler {
w.Header().Add("Cache-Control", "no-store") 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))
}) })
} }

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

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

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

@ -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;

@ -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;

@ -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;

@ -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;

@ -35,8 +35,6 @@ type VoteType struct {
id uint8 id uint8
} }
const unknownVariant = "unknown"
var ( var (
VoteTypeMotion = &VoteType{label: "motion", id: 0} VoteTypeMotion = &VoteType{label: "motion", id: 0}
VoteTypeVeto = &VoteType{label: "veto", id: 1} VoteTypeVeto = &VoteType{label: "veto", id: 1}
@ -103,19 +101,19 @@ func (v *VoteType) QuorumAndMajority() (int, float32) {
type VoteStatus struct { type VoteStatus struct {
Label string Label string
Id int8 ID int8
} }
var ( var (
voteStatusDeclined = &VoteStatus{Label: "declined", Id: -1} voteStatusDeclined = &VoteStatus{Label: "declined", ID: -1}
voteStatusPending = &VoteStatus{Label: "pending", Id: 0} voteStatusPending = &VoteStatus{Label: "pending", ID: 0}
voteStatusApproved = &VoteStatus{Label: "approved", Id: 1} voteStatusApproved = &VoteStatus{Label: "approved", ID: 1}
voteStatusWithdrawn = &VoteStatus{Label: "withdrawn", Id: -2} voteStatusWithdrawn = &VoteStatus{Label: "withdrawn", ID: -2}
) )
func VoteStatusFromInt(id int64) (*VoteStatus, error) { func VoteStatusFromInt(id int64) (*VoteStatus, error) {
for _, vs := range []*VoteStatus{voteStatusPending, voteStatusApproved, voteStatusWithdrawn, voteStatusDeclined} { for _, vs := range []*VoteStatus{voteStatusPending, voteStatusApproved, voteStatusWithdrawn, voteStatusDeclined} {
if int64(vs.Id) == id { if int64(vs.ID) == id {
return vs, nil return vs, nil
} }
} }
@ -144,23 +142,23 @@ func (v *VoteStatus) Scan(src any) error {
} }
func (v *VoteStatus) Value() (driver.Value, error) { func (v *VoteStatus) Value() (driver.Value, error) {
return int64(v.Id), nil return int64(v.ID), nil
} }
type VoteChoice struct { type VoteChoice struct {
label string Label string
id int8 ID int8
} }
var ( var (
VoteAye = &VoteChoice{label: "aye", id: 1} VoteAye = &VoteChoice{Label: "aye", ID: 1}
VoteNaye = &VoteChoice{label: "naye", id: -1} VoteNaye = &VoteChoice{Label: "naye", ID: -1}
VoteAbstain = &VoteChoice{label: "abstain", id: 0} VoteAbstain = &VoteChoice{Label: "abstain", ID: 0}
) )
func VoteChoiceFromString(label string) (*VoteChoice, error) { func VoteChoiceFromString(label string) (*VoteChoice, error) {
for _, vc := range []*VoteChoice{VoteAye, VoteNaye, VoteAbstain} { for _, vc := range []*VoteChoice{VoteAye, VoteNaye, VoteAbstain} {
if strings.EqualFold(vc.label, label) { if strings.EqualFold(vc.Label, label) {
return vc, nil return vc, nil
} }
} }
@ -170,7 +168,7 @@ func VoteChoiceFromString(label string) (*VoteChoice, error) {
func VoteChoiceFromInt(id int64) (*VoteChoice, error) { func VoteChoiceFromInt(id int64) (*VoteChoice, error) {
for _, vc := range []*VoteChoice{VoteAye, VoteNaye, VoteAbstain} { for _, vc := range []*VoteChoice{VoteAye, VoteNaye, VoteAbstain} {
if int64(vc.id) == id { if int64(vc.ID) == id {
return vc, nil return vc, nil
} }
} }
@ -179,7 +177,7 @@ func VoteChoiceFromInt(id int64) (*VoteChoice, error) {
} }
func (v *VoteChoice) String() string { func (v *VoteChoice) String() string {
return v.label return v.Label
} }
func (v *VoteChoice) Scan(src any) error { 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) { func (v *VoteChoice) Value() (driver.Value, error) {
return int64(v.id), nil return int64(v.ID), nil
} }
func (v *VoteChoice) Equal(other *VoteChoice) bool { func (v *VoteChoice) Equal(other *VoteChoice) bool {
return v.id == other.id return v.ID == other.ID
} }
type VoteSums struct { type VoteSums struct {
@ -234,6 +232,7 @@ type Motion struct {
ID int64 `db:"id"` ID int64 `db:"id"`
Proposed time.Time Proposed time.Time
Proponent int64 `db:"proponent"` Proponent int64 `db:"proponent"`
Proposer string `db:"proposer"`
Title string Title string
Content string Content string
Status *VoteStatus Status *VoteStatus
@ -241,22 +240,9 @@ type Motion struct {
Modified time.Time Modified time.Time
Tag string Tag string
Type *VoteType `db:"votetype"` Type *VoteType `db:"votetype"`
} Sums *VoteSums `db:"-"`
Votes []*Vote `db:"-"`
type ClosedMotion struct { Reasoning string `db:"-"`
Decision *Motion
VoteSums *VoteSums
Reasoning string
}
type NewMotion struct {
Decision *Motion
Proposer *User
}
type UpdatedMotion struct {
Decision *Motion
User *User
} }
type MotionModel struct { type MotionModel struct {
@ -279,6 +265,7 @@ func (m *MotionModel) Create(
Content: content, Content: content,
Due: due.UTC(), Due: due.UTC(),
Type: voteType, Type: voteType,
Status: voteStatusPending,
} }
result, err := m.DB.NamedExecContext( result, err := m.DB.NamedExecContext(
@ -305,7 +292,7 @@ VALUES (:proposed, :proponent, :title, :content, :votetype, :status, :due, :prop
return id, nil 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) tx, err := m.DB.BeginTxx(ctx, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not start transaction: %w", err) 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) 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 { for _, decision := range decisions {
m.InfoLog.Printf("found closable decision %s", decision.Tag) 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 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() quorum, majority := d.Type.QuorumAndMajority()
var ( 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) 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) { 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 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 { type VoteForDisplay struct {
Name string Name string
Vote *VoteChoice Vote *VoteChoice
@ -557,7 +531,7 @@ WHERE due >= ?
return firstTs, lastTs, nil 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 ( var (
rows *sqlx.Rows rows *sqlx.Rows
err error err error
@ -580,10 +554,10 @@ func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions
_ = rows.Close() _ = rows.Close()
}(rows) }(rows)
motions := make([]*MotionForDisplay, 0, options.Limit) motions := make([]*Motion, 0, options.Limit)
for rows.Next() { for rows.Next() {
var decision MotionForDisplay var decision Motion
if err = rows.Err(); err != nil { if err = rows.Err(); err != nil {
return nil, fmt.Errorf("could not fetch row: %w", err) 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 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)) decisionIds := make([]int64, len(decisions))
decisionMap := make(map[int64]*MotionForDisplay, len(decisions)) decisionMap := make(map[int64]*Motion, len(decisions))
for idx, decision := range decisions { for idx, decision := range decisions {
decision.Sums = &VoteSums{}
decisionIds[idx] = decision.ID decisionIds[idx] = decision.ID
decisionMap[decision.ID] = decision decisionMap[decision.ID] = decision
} }
@ -781,7 +756,7 @@ LIMIT ?`,
return rows, nil 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( row := m.DB.QueryRowxContext(
ctx, ctx,
`SELECT decisions.id, `SELECT decisions.id,
@ -804,13 +779,13 @@ WHERE decisions.tag = ?`,
return nil, fmt.Errorf("could not query motion: %w", err) return nil, fmt.Errorf("could not query motion: %w", err)
} }
var result MotionForDisplay var result Motion
if err := row.StructScan(&result); err != nil { if err := row.StructScan(&result); err != nil {
return nil, fmt.Errorf("could not fill motion from query result: %w", err) 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) return nil, fmt.Errorf("could not get vote sums: %w", err)
} }
@ -823,7 +798,7 @@ WHERE decisions.tag = ?`,
return &result, nil 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, rows, err := m.DB.QueryxContext(ctx,
`SELECT voters.name, votes.vote `SELECT voters.name, votes.vote
FROM voters FROM voters
@ -848,13 +823,13 @@ ORDER BY voters.name`,
return fmt.Errorf("could not get row: %w", err) 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) return fmt.Errorf("could not scan row: %w", err)
} }
md.Votes = append(md.Votes, &voteDisplay) md.Votes = append(md.Votes, &vote)
} }
return nil return nil
@ -876,7 +851,14 @@ func (m *MotionModel) GetByID(ctx context.Context, id int64) (*Motion, error) {
return &motion, nil 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( _, err := m.DB.ExecContext(
ctx, ctx,
`UPDATE decisions SET title=?, content=?, votetype=?, due=?, modified=? WHERE id=?`, `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 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
}

@ -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 }}

@ -11,7 +11,7 @@
</tr> </tr>
<tr> <tr>
<td>Proposed</td> <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>
<tr> <tr>
<td>Vote type:</td> <td>Vote type:</td>

Loading…
Cancel
Save