summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--boardvoting.go4
-rwxr-xr-xclosevotes.php8
-rw-r--r--closevotes.php-script2
-rw-r--r--jobs.go95
-rw-r--r--models.go97
-rw-r--r--templates/closed_motion_mail.txt18
6 files changed, 192 insertions, 32 deletions
diff --git a/boardvoting.go b/boardvoting.go
index 30de17e..3b03f22 100644
--- a/boardvoting.go
+++ b/boardvoting.go
@@ -518,6 +518,10 @@ func main() {
go MailNotifier()
defer CloseMailNotifier()
+ quitChannel := make(chan int)
+ go JobScheduler(quitChannel)
+ defer func() { quitChannel <- 1 }()
+
http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{}))
http.Handle("/newmotion/", motionsHandler{})
http.Handle("/static/", http.FileServer(http.Dir(".")))
diff --git a/closevotes.php b/closevotes.php
deleted file mode 100755
index ca95905..0000000
--- a/closevotes.php
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/php
-<?
-require_once("database.php");
-$db = new DB();
-
-$db->closeVotes();
-
-?>
diff --git a/closevotes.php-script b/closevotes.php-script
deleted file mode 100644
index 5246205..0000000
--- a/closevotes.php-script
+++ /dev/null
@@ -1,2 +0,0 @@
-# echo "select strftime('%H:%M %m%d%Y',due) from decisions where status=0;" | sqlite3 database.sqlite | xargs -n1 -I^ sudo -u www-data at -f closevotes.php-script ^ +1minute
-/var/www/board/closevotes.php
diff --git a/jobs.go b/jobs.go
new file mode 100644
index 0000000..02683c8
--- /dev/null
+++ b/jobs.go
@@ -0,0 +1,95 @@
+package main
+
+import "time"
+
+type Job interface {
+ Schedule()
+ Stop()
+ Run()
+}
+
+type jobIdentifier int
+
+const (
+ JobIdCloseDecisions jobIdentifier = iota
+)
+
+var rescheduleChannel = make(chan jobIdentifier, 1)
+
+func JobScheduler(quitChannel chan int) {
+ var jobs = map[jobIdentifier]Job{
+ JobIdCloseDecisions: NewCloseDecisionsJob(),
+ }
+ logger.Println("INFO started job scheduler")
+
+ for {
+ select {
+ case jobId := <-rescheduleChannel:
+ job := jobs[jobId]
+ logger.Println("INFO reschedule job", job)
+ job.Schedule()
+ case <-quitChannel:
+ for _, job := range jobs {
+ job.Stop()
+ }
+ logger.Println("INFO stop job scheduler")
+ return
+ }
+ }
+}
+
+type CloseDecisionsJob struct {
+ timer *time.Timer
+}
+
+func NewCloseDecisionsJob() *CloseDecisionsJob {
+ job := &CloseDecisionsJob{}
+ job.Schedule()
+ return job
+}
+
+func (j *CloseDecisionsJob) Schedule() {
+ var nextDue *time.Time
+ nextDue, err := GetNextPendingDecisionDue()
+ if err != nil {
+ logger.Fatal("ERROR Could not get next pending due date")
+ if j.timer != nil {
+ j.timer.Stop()
+ j.timer = nil
+ }
+ return
+ }
+ if nextDue == nil {
+ if j.timer != nil {
+ j.timer.Stop()
+ j.timer = nil
+ }
+ } else {
+ logger.Println("INFO scheduling CloseDecisionsJob for", nextDue)
+ when := nextDue.Sub(time.Now())
+ if j.timer != nil {
+ j.timer.Reset(when)
+ } else {
+ j.timer = time.AfterFunc(when, j.Run)
+ }
+ }
+}
+
+func (j *CloseDecisionsJob) Stop() {
+ if j.timer != nil {
+ j.timer.Stop()
+ }
+}
+
+func (j *CloseDecisionsJob) Run() {
+ logger.Println("INFO running CloseDecisionsJob")
+ err := CloseDecisions()
+ if err != nil {
+ logger.Println("ERROR closing decisions", err)
+ }
+ rescheduleChannel <- JobIdCloseDecisions
+}
+
+func (j *CloseDecisionsJob) String() string {
+ return "CloseDecisionsJob"
+}
diff --git a/models.go b/models.go
index 5df7d95..d27610f 100644
--- a/models.go
+++ b/models.go
@@ -2,6 +2,7 @@ package main
import (
"database/sql"
+ "fmt"
"github.com/jmoiron/sqlx"
"time"
)
@@ -22,6 +23,7 @@ const (
sqlUpdateDecision
sqlUpdateDecisionStatus
sqlSelectClosableDecisions
+ sqlGetNextPendingDecisionDue
)
var sqlStatements = map[sqlKey]string{
@@ -105,11 +107,13 @@ UPDATE decisions
SET status=:status, modified=:modified WHERE id=:id
`,
sqlSelectClosableDecisions: `
-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
+SELECT decisions.id, decisions.tag, decisions.proponent, 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 :now > due`,
+ sqlGetNextPendingDecisionDue: `
+SELECT due FROM decisions WHERE status=0 ORDER BY due LIMIT 1`,
}
var db *sqlx.DB
@@ -241,6 +245,18 @@ 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
+}
+
type VoteForDisplay struct {
Vote
Name string
@@ -413,6 +429,7 @@ func (d *Decision) Create() (err error) {
logger.Println("Error getting id of inserted motion:", err)
return
}
+ rescheduleChannel <- JobIdCloseDecisions
getDecisionStmt, err := db.Preparex(sqlStatements[sqlLoadDecisionById])
if err != nil {
@@ -465,6 +482,7 @@ func (d *Decision) Update() (err error) {
} else if affectedRows != 1 {
logger.Printf("WARNING wrong number of affected rows: %d (1 expected)\n", affectedRows)
}
+ rescheduleChannel <- JobIdCloseDecisions
err = d.LoadWithId()
return
@@ -486,14 +504,20 @@ func (d *Decision) UpdateStatus() (err error) {
affectedRows, err := result.RowsAffected()
if err != nil {
logger.Print("Problem determining the affected rows")
+ return
} else if affectedRows != 1 {
logger.Printf("WARNING wrong number of affected rows: %d (1 expected)\n", 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, err := db.Preparex(sqlStatements[sqlLoadEnabledVoterByEmail])
if err != nil {
@@ -515,13 +539,6 @@ func FindVoterByAddress(emailAddress string) (voter *Voter, err error) {
}
func (d *Decision) Close() (err error) {
- closeDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlUpdateDecisionStatus])
- if err != nil {
- logger.Println("Error preparing statement:", err)
- return
- }
- defer closeDecisionStmt.Close()
-
quorum, majority := d.VoteType.QuorumAndMajority()
voteSums, err := d.VoteSums()
@@ -535,7 +552,7 @@ func (d *Decision) Close() (err error) {
if votes < quorum {
d.Status = voteStatusDeclined
} else {
- votes = voteSums.Ayes + voteSums.Nayes
+ votes = voteSums.TotalVotes()
if (voteSums.Ayes / votes) > (majority / 100) {
d.Status = voteStatusApproved
} else {
@@ -543,6 +560,13 @@ func (d *Decision) Close() (err error) {
}
}
+ closeDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlUpdateDecisionStatus])
+ if err != nil {
+ logger.Println("Error preparing statement:", err)
+ return
+ }
+ defer closeDecisionStmt.Close()
+
result, err := closeDecisionStmt.Exec(d)
if err != nil {
logger.Println("Error closing vote:", err)
@@ -562,32 +586,61 @@ func (d *Decision) Close() (err error) {
}
func CloseDecisions() (err error) {
- getClosedDecisionsStmt, err := db.PrepareNamed(sqlStatements[sqlSelectClosableDecisions])
+ getClosableDecisionsStmt, err := db.PrepareNamed(sqlStatements[sqlSelectClosableDecisions])
if err != nil {
logger.Println("Error preparing statement:", err)
return
}
- defer getClosedDecisionsStmt.Close()
+ defer getClosableDecisionsStmt.Close()
- params := make(map[string]interface{}, 1)
- params["now"] = time.Now().UTC()
- rows, err := getClosedDecisionsStmt.Queryx(params)
+ decisions := make([]*Decision, 0)
+ rows, err := getClosableDecisionsStmt.Queryx(struct{ Now time.Time }{time.Now().UTC()})
if err != nil {
- logger.Println("Error fetching closed decisions", err)
+ logger.Println("Error fetching closable decisions", err)
return
}
defer rows.Close()
for rows.Next() {
- d := &Decision{}
- if err = rows.StructScan(d); err != nil {
- logger.Println("Error filling decision from database row", err)
+ decision := &Decision{}
+ if err = rows.StructScan(decision); err != nil {
+ logger.Println("Error scanning row", err)
return
}
- if err = d.Close(); err != nil {
- logger.Printf("Error closing decision %s: %s\n", d.Tag, err)
+ decisions = append(decisions, decision)
+ }
+ rows.Close()
+
+ for _, decision := range decisions {
+ logger.Println("DEBUG found closable decision", decision)
+ if err = decision.Close(); err != nil {
+ logger.Printf("Error closing decision %s: %s\n", decision, err)
return
}
}
return
}
+
+func GetNextPendingDecisionDue() (due *time.Time, err error) {
+ getNextPendingDecisionDueStmt, err := db.Preparex(sqlStatements[sqlGetNextPendingDecisionDue])
+ if err != nil {
+ logger.Println("Error preparing statement:", err)
+ return
+ }
+ defer getNextPendingDecisionDueStmt.Close()
+
+ row := getNextPendingDecisionDueStmt.QueryRow()
+
+ var dueTimestamp time.Time
+ if err = row.Scan(&dueTimestamp); err != nil {
+ if err == sql.ErrNoRows {
+ logger.Println("DEBUG No pending decisions")
+ return nil, nil
+ }
+ logger.Println("Error parsing result", err)
+ return
+ }
+ due = &dueTimestamp
+
+ return
+}
diff --git a/templates/closed_motion_mail.txt b/templates/closed_motion_mail.txt
new file mode 100644
index 0000000..ee6bd45
--- /dev/null
+++ b/templates/closed_motion_mail.txt
@@ -0,0 +1,18 @@
+Dear Board,
+
+{{ with .Decision }}The motion with the identifier {{.Tag}} has been {{.Status}}.
+
+Motion:
+ {{.Title}}
+ {{.Content}}
+
+Vote type: {{.VoteType}}{{end}}
+
+{{ with .VoteSums }} Ayes: {{ .Ayes }}
+ Nayes: {{ .Nayes }}
+ Abstentions: {{ .Abstains }}
+
+ Percentage: {{ .Percent }}%{{ end }}
+
+Kind regards,
+the voting system. \ No newline at end of file