You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cacert-boardvoting/models.go

877 lines
21 KiB
Go

/*
Copyright 2017-2022 CAcert Inc.
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"database/sql"
"embed"
"errors"
"fmt"
"time"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/jmoiron/sqlx"
"github.com/johejo/golang-migrate-extra/source/iofs"
log "github.com/sirupsen/logrus"
)
type sqlKey int
const (
sqlLoadDecisions sqlKey = iota
sqlLoadUnVotedDecisions
sqlLoadDecisionByTag
sqlLoadDecisionByID
sqlLoadVoteCountsForDecision
sqlLoadVotesForDecision
sqlLoadEnabledVoterByEmail
sqlCountOlderThanDecision
sqlCountOlderThanUnVotedDecision
sqlCreateDecision
sqlUpdateDecision
sqlUpdateDecisionStatus
sqlSelectClosableDecisions
sqlGetNextPendingDecisionDue
sqlGetReminderVoters
sqlFindUnVotedDecisionsForVoter
sqlGetEnabledVoterByID
sqlCreateVote
sqlLoadVote
sqlGetVotersForProxy
)
var sqlStatements = map[sqlKey]string{
sqlLoadDecisions: `
SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title,
decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified
FROM decisions
JOIN voters ON decisions.proponent=voters.id
ORDER BY proposed DESC
LIMIT 10 OFFSET 10 * $1`,
sqlLoadUnVotedDecisions: `
SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title,
decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified
FROM decisions
JOIN voters ON decisions.proponent=voters.id
WHERE decisions.status = 0 AND decisions.id NOT IN (SELECT votes.decision FROM votes WHERE votes.voter = $1)
ORDER BY proposed DESC
LIMIT 10 OFFSET 10 * $2;`,
sqlLoadDecisionByTag: `
SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title,
decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified
FROM decisions
JOIN voters ON decisions.proponent=voters.id
WHERE decisions.tag=$1;`,
sqlLoadDecisionByID: `
SELECT decisions.id, decisions.tag, decisions.proponent, decisions.proposed, decisions.title, decisions.content,
decisions.votetype, decisions.status, decisions.due, decisions.modified
FROM decisions
WHERE decisions.id=$1;`,
sqlLoadVoteCountsForDecision: `
SELECT vote, COUNT(vote) FROM votes WHERE decision=$1 GROUP BY vote`,
sqlLoadVotesForDecision: `
SELECT votes.decision, votes.voter, voters.name, votes.vote, votes.voted, votes.notes
FROM votes
JOIN voters ON votes.voter=voters.id
WHERE decision=$1`,
sqlLoadEnabledVoterByEmail: `
SELECT voters.id, voters.name, voters.reminder
FROM voters
JOIN emails ON voters.id=emails.voter
JOIN user_roles ON user_roles.voter_id=voters.id
WHERE emails.address=$1 AND user_roles.role='VOTER'`,
sqlGetEnabledVoterByID: `
SELECT voters.id, voters.name, voters.reminder
FROM voters
JOIN user_roles ON user_roles.voter_id=voters.id
WHERE user_roles.role='VOTER' AND voters.id=$1`,
sqlCountOlderThanDecision: `
SELECT COUNT(*) > 0 FROM decisions WHERE proposed < $1`,
sqlCountOlderThanUnVotedDecision: `
SELECT COUNT(*) > 0 FROM decisions
WHERE proposed < $1 AND status=0 AND id NOT IN (SELECT decision FROM votes WHERE votes.voter=$2)`,
sqlCreateDecision: `
INSERT INTO decisions (proposed, proponent, title, content, votetype, status, due, modified,tag)
VALUES (
:proposed, :proponent, :title, :content, :votetype, 0, :due, :proposed,
'm' || strftime('%Y%m%d', :proposed) || '.' || (
SELECT COUNT(*)+1 AS num
FROM decisions
WHERE proposed BETWEEN date(:proposed) AND date(:proposed, '1 day')
)
)`,
sqlUpdateDecision: `
UPDATE decisions
SET proponent=:proponent, title=:title, content=:content, votetype=:votetype, due=:due, modified=:modified
WHERE id=:id`,
sqlUpdateDecisionStatus: `
UPDATE decisions SET status=:status, modified=:modified WHERE id=:id`,
sqlSelectClosableDecisions: `
SELECT decisions.id, decisions.tag, decisions.proponent, decisions.proposed, decisions.title, decisions.content,
decisions.votetype, decisions.status, decisions.due, decisions.modified
FROM decisions
WHERE decisions.status=0 AND :now > due`,
sqlGetNextPendingDecisionDue: `
SELECT due FROM decisions WHERE status=0 ORDER BY due LIMIT 1`,
sqlGetVotersForProxy: `
SELECT voters.id, voters.name, voters.reminder
FROM voters
JOIN user_roles ON user_roles.voter_id=voters.id
WHERE user_roles.role='VOTER' AND voters.id != $1`,
sqlGetReminderVoters: `
SELECT voters.id, voters.name, voters.reminder
FROM voters
JOIN user_roles ON user_roles.voter_id=voters.id
WHERE user_roles.role='VOTER' AND reminder!='' AND reminder IS NOT NULL`,
sqlFindUnVotedDecisionsForVoter: `
SELECT tag, title, votetype, due
FROM decisions
WHERE status = 0 AND id NOT IN (SELECT decision FROM votes WHERE voter = $1)
ORDER BY due ASC`,
sqlCreateVote: `
INSERT OR REPLACE INTO votes (decision, voter, vote, voted, notes)
VALUES (:decision, :voter, :vote, :voted, :notes)`,
sqlLoadVote: `
SELECT decision, voter, vote, voted, notes
FROM votes
WHERE decision=$1 AND voter=$2`,
}
type VoteType uint8
type VoteStatus int8
type Decision struct {
ID int64 `db:"id"`
Proposed time.Time
ProponentID int64 `db:"proponent"`
Title string
Content string
Quorum int
Majority int
Status VoteStatus
Due time.Time
Modified time.Time
Tag string
VoteType VoteType
}
type Voter struct {
ID int64 `db:"id"`
Name string
Reminder string // reminder email address
}
type VoteChoice int
const (
voteAye = 1
voteNaye = -1
voteAbstain = 0
)
const (
voteTypeMotion = 0
voteTypeVeto = 1
)
const (
voteTypeLabelMotion = "motion"
voteTypeLabelUnknown = "unknown"
voteTypeLabelVeto = "veto"
)
func (v VoteType) String() string {
switch v {
case voteTypeMotion:
return voteTypeLabelMotion
case voteTypeVeto:
return voteTypeLabelVeto
default:
return voteTypeLabelUnknown
}
}
func (v VoteType) QuorumAndMajority() (int, float32) {
const (
majorityDefault = 0.99
majorityMotion = 0.50
quorumDefault = 1
quorumMotion = 3
)
switch v {
case voteTypeMotion:
return quorumMotion, majorityMotion
default:
return quorumDefault, majorityDefault
}
}
func (v VoteChoice) String() string {
switch v {
case voteAye:
return "aye"
case voteNaye:
return "naye"
case voteAbstain:
return "abstain"
default:
return "unknown"
}
}
var VoteValues = map[string]VoteChoice{
"aye": voteAye,
"naye": voteNaye,
"abstain": voteAbstain,
}
var VoteChoices = map[int64]VoteChoice{
1: voteAye,
0: voteAbstain,
-1: voteNaye,
}
const (
voteStatusDeclined = -1
voteStatusPending = 0
voteStatusApproved = 1
voteStatusWithdrawn = -2
)
func (v VoteStatus) String() string {
switch v {
case voteStatusDeclined:
return "declined"
case voteStatusPending:
return "pending"
case voteStatusApproved:
return "approved"
case voteStatusWithdrawn:
return "withdrawn"
default:
return "unknown"
}
}
type Vote struct {
DecisionID int64 `db:"decision"`
VoterID int64 `db:"voter"`
Vote VoteChoice
Voted time.Time
Notes string
}
type DbHandler struct {
db *sqlx.DB
}
var db *DbHandler
//go:embed boardvoting/migrations/*
var migrations embed.FS
func NewDB(database *sql.DB) *DbHandler {
handler := &DbHandler{db: sqlx.NewDb(database, "sqlite3")}
source, err := iofs.New(migrations, "boardvoting/migrations")
if err != nil {
log.Panicf("could not create migration source: %v", err)
}
driver, err := sqlite3.WithInstance(database, &sqlite3.Config{})
if err != nil {
log.Panicf("could not create migration driver: %v", err)
}
m, err := migrate.NewWithInstance("iofs", source, "sqlite3", driver)
if err != nil {
log.Panicf("could not create migration instance: %v", err)
}
m.Log = NewLogger()
err = m.Up()
if err != nil {
if !errors.Is(err, migrate.ErrNoChange) {
log.Panicf("running database migration failed: %v", err)
}
log.Info("no database migrations required")
} else {
log.Info("applied database migrations")
}
failedStatements := make([]string, 0)
for _, sqlStatement := range sqlStatements {
var stmt *sqlx.Stmt
stmt, err := handler.db.Preparex(sqlStatement)
if err != nil {
log.Errorf("error parsing statement %s: %s", sqlStatement, err)
failedStatements = append(failedStatements, sqlStatement)
}
_ = stmt.Close()
}
if len(failedStatements) > 0 {
log.Panicf("%d statements failed to prepare", len(failedStatements))
}
return handler
}
type migrationLogger struct{}
func (m migrationLogger) Printf(format string, v ...interface{}) {
log.Printf(format, v...)
}
func (m migrationLogger) Verbose() bool {
return log.IsLevelEnabled(log.DebugLevel)
}
func NewLogger() migrate.Logger {
return &migrationLogger{}
}
func (d *DbHandler) Close() error {
if err := d.db.Close(); err != nil {
return fmt.Errorf("could not close database: %w", err)
}
return nil
}
func (d *DbHandler) getPreparedNamedStatement(statementKey sqlKey) *sqlx.NamedStmt {
statement, err := d.db.PrepareNamed(sqlStatements[statementKey])
if err != nil {
log.Panicf("Preparing statement failed: %v", err)
}
return statement
}
func (d *DbHandler) getPreparedStatement(statementKey sqlKey) *sqlx.Stmt {
statement, err := d.db.Preparex(sqlStatements[statementKey])
if err != nil {
log.Panicf("Preparing statement failed: %v", err)
}
return statement
}
func (v *Vote) Save() error {
insertVoteStmt := db.getPreparedNamedStatement(sqlCreateVote)
defer func() { _ = insertVoteStmt.Close() }()
var err error
if _, err = insertVoteStmt.Exec(v); err != nil {
return fmt.Errorf("saving vote failed: %w", err)
}
getVoteStmt := db.getPreparedStatement(sqlLoadVote)
defer func() { _ = getVoteStmt.Close() }()
if err = getVoteStmt.Get(v, v.DecisionID, v.VoterID); err != nil {
return fmt.Errorf("getting inserted vote failed: %w", err)
}
return nil
}
type VoteSums struct {
Ayes int
Nayes int
Abstains int
}
func (v *VoteSums) VoteCount() int {
return v.Ayes + v.Nayes + v.Abstains
}
func (v *VoteSums) TotalVotes() int {
return v.Ayes + v.Nayes
}
func (v *VoteSums) Percent() int {
totalVotes := v.TotalVotes()
if totalVotes == 0 {
return 0
}
return v.Ayes * 100 / totalVotes
}
func (v *VoteSums) CalculateResult(quorum int, majority float32) (VoteStatus, string) {
if v.VoteCount() < quorum {
return voteStatusDeclined, fmt.Sprintf("Needed quorum of %d has not been reached.", quorum)
}
if (float32(v.Ayes) / float32(v.TotalVotes())) < majority {
return voteStatusDeclined, fmt.Sprintf("Needed majority of %0.2f%% has not been reached.", majority)
}
return voteStatusApproved, "Quorum and majority have been reached"
}
type VoteForDisplay struct {
Vote
Name string
}
type DecisionForDisplay struct {
Decision
Proposer string `db:"proposer"`
*VoteSums
Votes []VoteForDisplay
}
func FindDecisionForDisplayByTag(tag string) (*DecisionForDisplay, error) {
decisionStmt := db.getPreparedStatement(sqlLoadDecisionByTag)
defer func() { _ = decisionStmt.Close() }()
decision := &DecisionForDisplay{}
var err error
if err = decisionStmt.Get(decision, tag); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("getting motion %s failed: %w", tag, err)
}
decision.VoteSums, err = decision.Decision.VoteSums()
return decision, err
}
// FindDecisionsForDisplayOnPage loads a set of decisions from the database.
//
// This function uses OFFSET for pagination which is not a good idea for larger data sets.
//
// TODO: migrate to timestamp base pagination
func FindDecisionsForDisplayOnPage(page int, unVoted bool, voter *Voter) ([]*DecisionForDisplay, error) {
var decisionsStmt *sqlx.Stmt
if unVoted && voter != nil {
decisionsStmt = db.getPreparedStatement(sqlLoadUnVotedDecisions)
} else {
decisionsStmt = db.getPreparedStatement(sqlLoadDecisions)
}
defer func() { _ = decisionsStmt.Close() }()
var (
rows *sqlx.Rows
err error
decisions []*DecisionForDisplay
)
if unVoted && voter != nil {
rows, err = decisionsStmt.Queryx(voter.ID, page-1)
} else {
rows, err = decisionsStmt.Queryx(page - 1)
}
if err != nil {
return nil, fmt.Errorf("loading motions for page %d failed: %w", page, err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var d DecisionForDisplay
if err = rows.StructScan(&d); err != nil {
return nil, fmt.Errorf("loading motions for page %d failed: %w", page, err)
}
d.VoteSums, err = d.Decision.VoteSums()
if err != nil {
return nil, err
}
decisions = append(decisions, &d)
}
return decisions, nil
}
func (d *Decision) VoteSums() (*VoteSums, error) {
votesStmt := db.getPreparedStatement(sqlLoadVoteCountsForDecision)
defer func() { _ = votesStmt.Close() }()
voteRows, err := votesStmt.Queryx(d.ID)
if err != nil {
return nil, fmt.Errorf("fetching vote sums for motion %s failed: %w", d.Tag, err)
}
defer func() { _ = voteRows.Close() }()
sums := &VoteSums{}
for voteRows.Next() {
var (
vote VoteChoice
count int
)
if err = voteRows.Scan(&vote, &count); err != nil {
return nil, fmt.Errorf("fetching vote sums for motion %s failed: %w", d.Tag, err)
}
switch vote {
case voteAye:
sums.Ayes = count
case voteNaye:
sums.Nayes = count
case voteAbstain:
sums.Abstains = count
}
}
return sums, nil
}
func (d *DecisionForDisplay) LoadVotes() (err error) {
votesStmt := db.getPreparedStatement(sqlLoadVotesForDecision)
defer func() { _ = votesStmt.Close() }()
err = votesStmt.Select(&d.Votes, d.ID)
if err != nil {
log.Errorf("selecting votes for motion %s failed: %v", d.Tag, err)
return
}
return
}
func (d *Decision) OlderExists(unvoted bool, voter *Voter) (bool, error) {
var result bool
if unvoted && voter != nil {
olderStmt := db.getPreparedStatement(sqlCountOlderThanUnVotedDecision)
defer func() { _ = olderStmt.Close() }()
if err := olderStmt.Get(&result, d.Proposed, voter.ID); err != nil {
return false, fmt.Errorf("finding older motions than %s failed: %w", d.Tag, err)
}
} else {
olderStmt := db.getPreparedStatement(sqlCountOlderThanDecision)
defer func() { _ = olderStmt.Close() }()
if err := olderStmt.Get(&result, d.Proposed); err != nil {
return false, fmt.Errorf("finding older motions than %s failed: %w", d.Tag, err)
}
}
return result, nil
}
func (d *Decision) Create() error {
insertDecisionStmt := db.getPreparedNamedStatement(sqlCreateDecision)
defer func() { _ = insertDecisionStmt.Close() }()
result, err := insertDecisionStmt.Exec(d)
if err != nil {
return fmt.Errorf("creating motion failed: %w", err)
}
decisionID, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("getting id of inserted motion failed: %w", err)
}
rescheduleChannel <- JobIDCloseDecisions
getDecisionStmt := db.getPreparedStatement(sqlLoadDecisionByID)
defer func() { _ = getDecisionStmt.Close() }()
err = getDecisionStmt.Get(d, decisionID)
if err != nil {
return fmt.Errorf("getting inserted motion failed: %w", err)
}
return nil
}
func (d *Decision) LoadWithID() (err error) {
getDecisionStmt := db.getPreparedStatement(sqlLoadDecisionByID)
defer func() { _ = getDecisionStmt.Close() }()
err = getDecisionStmt.Get(d, d.ID)
if err != nil {
log.Errorf("loading updated motion failed: %v", err)
return
}
return
}
func (d *Decision) Update() (err error) {
updateDecisionStmt := db.getPreparedNamedStatement(sqlUpdateDecision)
defer func() { _ = updateDecisionStmt.Close() }()
result, err := updateDecisionStmt.Exec(d)
if err != nil {
log.Errorf("updating motion failed: %v", err)
return
}
affectedRows, err := result.RowsAffected()
if err != nil {
log.Error("Problem determining the affected rows")
return
} else if affectedRows != 1 {
log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows)
}
rescheduleChannel <- JobIDCloseDecisions
err = d.LoadWithID()