Implement decision closing job
authorJan Dittberner <jan@dittberner.info>
Thu, 20 Apr 2017 09:35:33 +0000 (11:35 +0200)
committerJan Dittberner <jan@dittberner.info>
Fri, 21 Apr 2017 22:12:38 +0000 (00:12 +0200)
boardvoting.go
closevotes.php [deleted file]
closevotes.php-script [deleted file]
jobs.go [new file with mode: 0644]
models.go
templates/closed_motion_mail.txt [new file with mode: 0644]

index 30de17e..3b03f22 100644 (file)
@@ -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 (executable)
index ca95905..0000000
+++ /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 (file)
index 5246205..0000000
+++ /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 (file)
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"
+}
index 5df7d95..d27610f 100644 (file)
--- 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 (file)
index 0000000..ee6bd45
--- /dev/null
@@ -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