Implement direct voting
This commit is contained in:
parent
164495c818
commit
0c02daf29a
15 changed files with 431 additions and 158 deletions
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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)},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -125,3 +125,7 @@ func (f EditMotionForm) Validate() {
|
|||
f.Due, threeDays, oneWeek, twoWeeks, threeWeeks), "due", "invalid duration choice",
|
||||
)
|
||||
}
|
||||
|
||||
type DirectVoteForm struct {
|
||||
Choice *models.VoteChoice
|
||||
}
|
||||
|
|
|
@ -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
|
16
internal/migrations/2022052701_add_vote_constrains.down.sql
Normal file
16
internal/migrations/2022052701_add_vote_constrains.down.sql
Normal 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;
|
24
internal/migrations/2022052701_add_vote_constrains.up.sql
Normal file
24
internal/migrations/2022052701_add_vote_constrains.up.sql
Normal 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;
|
|
@ -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;
|
18
internal/migrations/2022052702_add_emails_constrains.up.sql
Normal file
18
internal/migrations/2022052702_add_emails_constrains.up.sql
Normal 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;
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
24
ui/html/pages/direct_vote.html
Normal file
24
ui/html/pages/direct_vote.html
Normal 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 }}
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue