cacert-boardvoting/models.go
Jan Dittberner 4dd5e09820 Embed database migrations
- switch from goose to github.com/rubenv/sql-migrate
- move assets (static, templates, migrations) to boardvoting package
- add generated boardvoting/assets.go
- remove unused static files from static directory
- add package db with db migration configuration
2018-03-29 20:08:41 +02:00

728 lines
19 KiB
Go

package main
import (
"database/sql"
"fmt"
dbmig "git.cacert.org/cacert-boardvoting/db"
"github.com/jmoiron/sqlx"
"github.com/rubenv/sql-migrate"
"time"
)
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.enabled, voters.reminder
FROM voters
JOIN emails ON voters.id=emails.voter
WHERE emails.address=$1 AND voters.enabled=1`,
sqlGetEnabledVoterById: `
SELECT id, name, enabled, reminder
FROM voters
WHERE enabled=1 AND 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 id, name, reminder
FROM voters WHERE enabled=1 AND id != $1`,
sqlGetReminderVoters: `
SELECT id, name, reminder FROM voters WHERE enabled=1 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
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
Name string
Enabled bool
Reminder string // reminder email address
}
type VoteChoice int
const (
voteAye = 1
voteNaye = -1
voteAbstain = 0
)
const (
voteTypeMotion = 0
voteTypeVeto = 1
)
func (v VoteType) String() string {
switch v {
case voteTypeMotion:
return "motion"
case voteTypeVeto:
return "veto"
default:
return "unknown"
}
}
func (v VoteType) QuorumAndMajority() (int, int) {
switch v {
case voteTypeMotion:
return 3, 50
default:
return 1, 99
}
}
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
func NewDB(database *sql.DB) *dbHandler {
handler := &dbHandler{db: sqlx.NewDb(database, "sqlite3")}
failedStatements := make([]string, 0)
for _, sqlStatement := range sqlStatements {
var stmt *sqlx.Stmt
stmt, err := handler.db.Preparex(sqlStatement)
if err != nil {
log.Critical("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))
}
version, err := migrate.Exec(database, "sqlite3", dbmig.Migrations(), migrate.Up)
if err != nil {
log.Panicf("running database migration failed: %v", err)
} else {
log.Infof("database is now at version %d", version)
}
return handler
}
func (d *dbHandler) Close() error {
return d.db.Close()
}
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() (err error) {
insertVoteStmt := db.getPreparedNamedStatement(sqlCreateVote)
defer insertVoteStmt.Close()
if _, err = insertVoteStmt.Exec(v); err != nil {
log.Errorf("saving vote failed: %v", err)
return
}
getVoteStmt := db.getPreparedStatement(sqlLoadVote)
defer getVoteStmt.Close()
if err = getVoteStmt.Get(v, v.DecisionId, v.VoterId); err != nil {
log.Errorf("getting inserted vote failed: %v", err)
return
}
return
}
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 int) (status VoteStatus, reasoning string) {
if v.VoteCount() < quorum {
status, reasoning = voteStatusDeclined, fmt.Sprintf("Needed quorum of %d has not been reached.", quorum)
} else if (v.Ayes / v.TotalVotes()) < (majority / 100) {
status, reasoning = voteStatusDeclined, fmt.Sprintf("Needed majority of %d%% has not been reached.", majority)
} else {
status, reasoning = voteStatusApproved, "Quorum and majority have been reached"
}
return
}
type VoteForDisplay struct {
Vote
Name string
}
type DecisionForDisplay struct {
Decision
Proposer string `db:"proposer"`
*VoteSums
Votes []VoteForDisplay
}
func FindDecisionForDisplayByTag(tag string) (decision *DecisionForDisplay, err error) {
decisionStmt := db.getPreparedStatement(sqlLoadDecisionByTag)
defer decisionStmt.Close()
decision = &DecisionForDisplay{}
if err = decisionStmt.Get(decision, tag); err != nil {
if err == sql.ErrNoRows {
decision = nil
err = nil
} else {
log.Errorf("getting motion %s failed: %v", tag, err)
return
}
}
decision.VoteSums, err = decision.Decision.VoteSums()
return
}
// 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 int64, unvoted bool, voter *Voter) (decisions []*DecisionForDisplay, err error) {
var decisionsStmt *sqlx.Stmt
if unvoted && voter != nil {
decisionsStmt = db.getPreparedStatement(sqlLoadUnvotedDecisions)
} else {
decisionsStmt = db.getPreparedStatement(sqlLoadDecisions)
}
defer decisionsStmt.Close()
var rows *sqlx.Rows
if unvoted && voter != nil {
rows, err = decisionsStmt.Queryx(voter.Id, page-1)
} else {
rows, err = decisionsStmt.Queryx(page - 1)
}
if err != nil {
log.Errorf("loading motions for page %d failed: %v", page, err)
return
}
defer rows.Close()
for rows.Next() {
var d DecisionForDisplay
if err = rows.StructScan(&d); err != nil {
log.Errorf("loading motions for page %d failed: %v", page, err)
return
}
d.VoteSums, err = d.Decision.VoteSums()
if err != nil {
return
}
decisions = append(decisions, &d)
}
return
}
func (d *Decision) VoteSums() (sums *VoteSums, err error) {
votesStmt := db.getPreparedStatement(sqlLoadVoteCountsForDecision)
defer votesStmt.Close()
voteRows, err := votesStmt.Queryx(d.Id)
if err != nil {
log.Errorf("fetching vote sums for motion %s failed: %v", d.Tag, err)
return
}
defer voteRows.Close()
sums = &VoteSums{}
for voteRows.Next() {
var vote VoteChoice
var count int
if err = voteRows.Scan(&vote, &count); err != nil {
log.Errorf("fetching vote sums for motion %s failed: %v", d.Tag, err)
return
}
switch vote {
case voteAye:
sums.Ayes = count
case voteNaye:
sums.Nayes = count
case voteAbstain:
sums.Abstains = count
}
}
return
}
func (d *DecisionForDisplay) LoadVotes() (err error) {
votesStmt := db.getPreparedStatement(sqlLoadVotesForDecision)
defer 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) (result bool, err error) {
if unvoted && voter != nil {
olderStmt := db.getPreparedStatement(sqlCountOlderThanUnvotedDecision)
defer olderStmt.Close()
if err = olderStmt.Get(&result, d.Proposed, voter.Id); err != nil {
log.Errorf("finding older motions than %s failed: %v", d.Tag, err)
return
}
} else {
olderStmt := db.getPreparedStatement(sqlCountOlderThanDecision)
defer olderStmt.Close()
if err = olderStmt.Get(&result, d.Proposed); err != nil {
log.Errorf("finding older motions than %s failed: %v", d.Tag, err)
return
}
}
return
}
func (d *Decision) Create() (err error) {
insertDecisionStmt := db.getPreparedNamedStatement(sqlCreateDecision)
defer insertDecisionStmt.Close()
result, err := insertDecisionStmt.Exec(d)
if err != nil {
log.Errorf("creating motion failed: %v", err)
return
}
lastInsertId, err := result.LastInsertId()
if err != nil {
log.Errorf("getting id of inserted motion failed: %v", err)
return
}
rescheduleChannel <- JobIdCloseDecisions
getDecisionStmt := db.getPreparedStatement(sqlLoadDecisionById)
defer getDecisionStmt.Close()
err = getDecisionStmt.Get(d, lastInsertId)
if err != nil {
log.Errorf("getting inserted motion failed: %v", err)
return
}
return
}
func (d *Decision) LoadWithId() (err error) {
getDecisionStmt := db.getPreparedStatement(sqlLoadDecisionById)
defer 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 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()
return
}
func (d *Decision) UpdateStatus() (err error) {
updateStatusStmt := db.getPreparedNamedStatement(sqlUpdateDecisionStatus)
defer updateStatusStmt.Close()
result, err := updateStatusStmt.Exec(d)
if err != nil {
log.Errorf("setting motion status failed: %v", err)
return
}
affectedRows, err := result.RowsAffected()
if err != nil {
log.Errorf("determining the affected rows failed: %v", err)
return
} else if affectedRows != 1 {
log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows)
}
rescheduleChannel <- JobIdCloseDecisions
err = d.LoadWithId()
return
}
func (d *Decision) String() string {
return fmt.Sprintf("%s %s (Id %d)", d.Tag, d.Title, d.Id)
}
func FindVoterByAddress(emailAddress string) (voter *Voter, err error) {
findVoterStmt := db.getPreparedStatement(sqlLoadEnabledVoterByEmail)
defer findVoterStmt.Close()
voter = &Voter{}
if err = findVoterStmt.Get(voter, emailAddress); err != nil {
if err != sql.ErrNoRows {
log.Errorf("getting voter for address %s failed: %v", emailAddress, err)
} else {
err = nil
voter = nil
}
}
return
}
func (d *Decision) Close() error {
quorum, majority := d.VoteType.QuorumAndMajority()
var voteSums *VoteSums
var err error
if voteSums, err = d.VoteSums(); err != nil {
log.Errorf("getting vote sums failed: %v", err)
return err
}
var reasoning string
d.Status, reasoning = voteSums.CalculateResult(quorum, majority)
closeDecisionStmt := db.getPreparedNamedStatement(sqlUpdateDecisionStatus)
defer closeDecisionStmt.Close()
result, err := closeDecisionStmt.Exec(d)
if err != nil {
log.Errorf("closing vote failed: %v", err)
return err
}
if affectedRows, err := result.RowsAffected(); err != nil {
log.Errorf("getting affected rows failed: %v", err)
return err
} else if affectedRows != 1 {
log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows)
}
NotifyMailChannel <- NewNotificationClosedDecision(d, voteSums, reasoning)
log.Infof("decision %s closed with result %s: reasoning %s", d.Tag, d.Status, reasoning)
return nil
}
func CloseDecisions() (err error) {
getClosableDecisionsStmt := db.getPreparedNamedStatement(sqlSelectClosableDecisions)
defer getClosableDecisionsStmt.Close()
decisions := make([]*Decision, 0)
rows, err := getClosableDecisionsStmt.Queryx(struct{ Now time.Time }{time.Now().UTC()})
if err != nil {
log.Errorf("fetching closable decisions failed: %v", err)
return
}
defer rows.Close()
for rows.Next() {
decision := &Decision{}
if err = rows.StructScan(decision); err != nil {
log.Errorf("scanning row failed: %v", err)
return
}
decisions = append(decisions, decision)
}
rows.Close()
for _, decision := range decisions {
log.Debugf("found closable decision %s", decision.Tag)
if err = decision.Close(); err != nil {
log.Errorf("closing decision %s failed: %s", decision.Tag, err)
return
}
}
return
}
func GetNextPendingDecisionDue() (due *time.Time, err error) {
getNextPendingDecisionDueStmt := db.getPreparedStatement(sqlGetNextPendingDecisionDue)
defer getNextPendingDecisionDueStmt.Close()
row := getNextPendingDecisionDueStmt.QueryRow()
due = &time.Time{}
if err = row.Scan(due); err != nil {
if err == sql.ErrNoRows {
log.Debug("No pending decisions")
return nil, nil
}
log.Errorf("parsing result failed: %v", err)
return nil, err
}
return
}
func GetReminderVoters() (voters *[]Voter, err error) {
getReminderVotersStmt := db.getPreparedStatement(sqlGetReminderVoters)
defer getReminderVotersStmt.Close()
voterSlice := make([]Voter, 0)
if err = getReminderVotersStmt.Select(&voterSlice); err != nil {
log.Errorf("getting voters failed: %v", err)
return
}
voters = &voterSlice
return
}
func FindUnvotedDecisionsForVoter(voter *Voter) (decisions *[]Decision, err error) {
findUnvotedDecisionsForVoterStmt := db.getPreparedStatement(sqlFindUnvotedDecisionsForVoter)
defer findUnvotedDecisionsForVoterStmt.Close()
decisionsSlice := make([]Decision, 0)
if err = findUnvotedDecisionsForVoterStmt.Select(&decisionsSlice, voter.Id); err != nil {
log.Errorf("getting unvoted decisions failed: %v", err)
return
}
decisions = &decisionsSlice
return
}
func GetVoterById(id int64) (voter *Voter, err error) {
getVoterByIdStmt := db.getPreparedStatement(sqlGetEnabledVoterById)
defer getVoterByIdStmt.Close()
voter = &Voter{}
if err = getVoterByIdStmt.Get(voter, id); err != nil {
log.Errorf("getting voter failed: %v", err)
return
}
return
}
func GetVotersForProxy(proxy *Voter) (voters *[]Voter, err error) {
getVotersForProxyStmt := db.getPreparedStatement(sqlGetVotersForProxy)
defer getVotersForProxyStmt.Close()
votersSlice := make([]Voter, 0)
if err = getVotersForProxyStmt.Select(&votersSlice, proxy.Id); err != nil {
log.Errorf("Error getting voters for proxy failed: %v", err)
return
}
voters = &votersSlice
return
}