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 -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