Implement reminder job
authorJan Dittberner <jandd@cacert.org>
Thu, 20 Apr 2017 18:58:22 +0000 (20:58 +0200)
committerJan Dittberner <jan@dittberner.info>
Fri, 21 Apr 2017 22:12:38 +0000 (00:12 +0200)
boardvoting.go
config.yaml.example
jobs.go
models.go
notifications.go
remind.php [deleted file]
templates/remind_voter_mail.txt [new file with mode: 0644]

index 3b03f22..152d969 100644 (file)
@@ -468,15 +468,16 @@ func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 }
 
 type Config struct {
-       BoardMailAddress     string `yaml:"board_mail_address"`
-       NoticeSenderAddress  string `yaml:"notice_sender_address"`
-       DatabaseFile         string `yaml:"database_file"`
-       ClientCACertificates string `yaml:"client_ca_certificates"`
-       ServerCert           string `yaml:"server_certificate"`
-       ServerKey            string `yaml:"server_key"`
-       CookieSecret         string `yaml:"cookie_secret"`
-       BaseURL              string `yaml:"base_url"`
-       MailServer           struct {
+       BoardMailAddress      string `yaml:"board_mail_address"`
+       NoticeSenderAddress   string `yaml:"notice_sender_address"`
+       ReminderSenderAddress string `yaml:"reminder_sender_address"`
+       DatabaseFile          string `yaml:"database_file"`
+       ClientCACertificates  string `yaml:"client_ca_certificates"`
+       ServerCert            string `yaml:"server_certificate"`
+       ServerKey             string `yaml:"server_key"`
+       CookieSecret          string `yaml:"cookie_secret"`
+       BaseURL               string `yaml:"base_url"`
+       MailServer            struct {
                Host string `yaml:"host"`
                Port int    `yaml:"port"`
        } `yaml:"mail_server"`
index f971540..d2c24e3 100644 (file)
@@ -1,6 +1,7 @@
 ---
 board_mail_address: cacert-board@lists.cacert.org
 notice_sender_address: cacert-board-votes@lists.cacert.org
+reminder_sender_address: returns@cacert.org
 database_file: database.sqlite
 client_ca_certificates: cacert_class3.pem
 server_certificate: server.crt
diff --git a/jobs.go b/jobs.go
index 02683c8..3f15e2d 100644 (file)
--- a/jobs.go
+++ b/jobs.go
@@ -12,13 +12,15 @@ type jobIdentifier int
 
 const (
        JobIdCloseDecisions jobIdentifier = iota
+       JobIdRemindVotersJob
 )
 
 var rescheduleChannel = make(chan jobIdentifier, 1)
 
 func JobScheduler(quitChannel chan int) {
        var jobs = map[jobIdentifier]Job{
-               JobIdCloseDecisions: NewCloseDecisionsJob(),
+               JobIdCloseDecisions:  NewCloseDecisionsJob(),
+               JobIdRemindVotersJob: NewRemindVotersJob(),
        }
        logger.Println("INFO started job scheduler")
 
@@ -60,11 +62,10 @@ func (j *CloseDecisionsJob) Schedule() {
                return
        }
        if nextDue == nil {
-               if j.timer != nil {
-                       j.timer.Stop()
-                       j.timer = nil
-               }
+               logger.Println("INFO no next planned execution of CloseDecisionsJob")
+               j.Stop()
        } else {
+               nextDue := nextDue.Add(time.Minute)
                logger.Println("INFO scheduling CloseDecisionsJob for", nextDue)
                when := nextDue.Sub(time.Now())
                if j.timer != nil {
@@ -78,6 +79,7 @@ func (j *CloseDecisionsJob) Schedule() {
 func (j *CloseDecisionsJob) Stop() {
        if j.timer != nil {
                j.timer.Stop()
+               j.timer = nil
        }
 }
 
@@ -93,3 +95,54 @@ func (j *CloseDecisionsJob) Run() {
 func (j *CloseDecisionsJob) String() string {
        return "CloseDecisionsJob"
 }
+
+type RemindVotersJob struct {
+       timer *time.Timer
+}
+
+func NewRemindVotersJob() *RemindVotersJob {
+       job := &RemindVotersJob{}
+       job.Schedule()
+       return job
+}
+
+func (j *RemindVotersJob) Schedule() {
+       year, month, day := time.Now().UTC().Date()
+       nextExecution := time.Date(year, month, day, 0, 0, 0, 0, time.UTC).AddDate(0, 0, 3)
+       logger.Println("INFO scheduling RemindVotersJob for", nextExecution)
+       when := nextExecution.Sub(time.Now())
+       if j.timer != nil {
+               j.timer.Reset(when)
+       } else {
+               j.timer = time.AfterFunc(when, j.Run)
+       }
+}
+
+func (j *RemindVotersJob) Stop() {
+       if j.timer != nil {
+               j.timer.Stop()
+               j.timer = nil
+       }
+}
+
+func (j *RemindVotersJob) Run() {
+       logger.Println("INFO running RemindVotersJob")
+       defer func() { rescheduleChannel <- JobIdRemindVotersJob }()
+
+       voters, err := GetReminderVoters()
+       if err != nil {
+               logger.Println("ERROR problem getting voters", err)
+               return
+       }
+
+       for _, voter := range *voters {
+               decisions, err := FindUnvotedDecisionsForVoter(&voter)
+               if err != nil {
+                       logger.Println("ERROR problem getting unvoted decisions")
+                       return
+               }
+               if len(*decisions) > 0 {
+                       voterMail <- &RemindVoterNotification{voter: voter, decisions: *decisions}
+               }
+       }
+}
index d27610f..5297cf2 100644 (file)
--- a/models.go
+++ b/models.go
@@ -24,96 +24,85 @@ const (
        sqlUpdateDecisionStatus
        sqlSelectClosableDecisions
        sqlGetNextPendingDecisionDue
+       sqlGetReminderVoters
+       sqlFindUnvotedDecisionsForVoter
 )
 
 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
+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)
+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;`,
+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;`,
+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`,
+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`,
+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`,
+FROM   voters
+JOIN   emails ON voters.id=emails.voter
+WHERE  emails.address=$1 AND voters.enabled=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)`,
+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,
+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')
+               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`,
+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
-`,
+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
+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`,
+       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
+`,
 }
 
 var db *sqlx.DB
@@ -644,3 +633,41 @@ func GetNextPendingDecisionDue() (due *time.Time, err error) {
 
        return
 }
+
+func GetReminderVoters() (voters *[]Voter, err error) {
+       getReminderVotersStmt, err := db.Preparex(sqlStatements[sqlGetReminderVoters])
+       if err != nil {
+               logger.Println("Error preparing statement:", err)
+               return
+       }
+       defer getReminderVotersStmt.Close()
+
+       voterSlice := make([]Voter, 0)
+
+       if err = getReminderVotersStmt.Select(&voterSlice); err != nil {
+               logger.Println("Error getting voters:", err)
+               return
+       }
+       voters = &voterSlice
+
+       return
+}
+
+func FindUnvotedDecisionsForVoter(voter *Voter) (decisions *[]Decision, err error) {
+       findUnvotedDecisionsForVoterStmt, err := db.Preparex(sqlStatements[sqlFindUnvotedDecisionsForVoter])
+       if err != nil {
+               logger.Println("Error preparing statement:", err)
+               return
+       }
+       defer findUnvotedDecisionsForVoterStmt.Close()
+
+       decisionsSlice := make([]Decision, 0)
+
+       if err = findUnvotedDecisionsForVoterStmt.Select(&decisionsSlice, voter.Id); err != nil {
+               logger.Println("Error getting unvoted decisions:", err)
+               return
+       }
+       decisions = &decisionsSlice
+
+       return
+}
index beb9b3f..d4b9715 100644 (file)
@@ -15,7 +15,15 @@ type NotificationMail interface {
        GetHeaders() map[string]string
 }
 
+type VoterMail interface {
+       GetData() interface{}
+       GetTemplate() string
+       GetSubject() string
+       GetRecipient() (string, string)
+}
+
 var notifyMail = make(chan NotificationMail, 1)
+var voterMail = make(chan VoterMail, 1)
 var quitMailNotifier = make(chan int)
 
 func CloseMailNotifier() {
@@ -46,6 +54,24 @@ func MailNotifier() {
                        if err := d.DialAndSend(m); err != nil {
                                logger.Println("ERROR sending mail:", err)
                        }
+               case notification := <-voterMail:
+                       mailText, err := buildMail(notification.GetTemplate(), notification.GetData())
+                       if err != nil {
+                               logger.Println("ERROR building mail:", err)
+                               continue
+                       }
+
+                       m := gomail.NewMessage()
+                       m.SetHeader("From", config.ReminderSenderAddress)
+                       address, name := notification.GetRecipient()
+                       m.SetAddressHeader("To", address, name)
+                       m.SetHeader("Subject", notification.GetSubject())
+                       m.SetBody("text/plain", mailText.String())
+
+                       d := gomail.NewDialer(config.MailServer.Host, config.MailServer.Port, "", "")
+                       if err := d.DialAndSend(m); err != nil {
+                               logger.Println("ERROR sending mail:", err)
+                       }
                case <-quitMailNotifier:
                        fmt.Println("Ending mail notifier")
                        return
@@ -78,9 +104,7 @@ func (n *NotificationClosedDecision) GetData() interface{} {
        }{&n.decision, &n.voteSums}
 }
 
-func (n *NotificationClosedDecision) GetTemplate() string {
-       return "closed_motion_mail.txt"
-}
+func (n *NotificationClosedDecision) GetTemplate() string { return "closed_motion_mail.txt" }
 
 func (n *NotificationClosedDecision) GetSubject() string {
        return fmt.Sprintf("Re: %s - %s - finalised", n.decision.Tag, n.decision.Title)
@@ -106,9 +130,7 @@ func (n *NotificationCreateMotion) GetData() interface{} {
        }{&n.decision, n.voter.Name, voteURL, unvotedURL}
 }
 
-func (n *NotificationCreateMotion) GetTemplate() string {
-       return "create_motion_mail.txt"
-}
+func (n *NotificationCreateMotion) GetTemplate() string { return "create_motion_mail.txt" }
 
 func (n *NotificationCreateMotion) GetSubject() string {
        return fmt.Sprintf("%s - %s", n.decision.Tag, n.decision.Title)
@@ -134,9 +156,7 @@ func (n *NotificationUpdateMotion) GetData() interface{} {
        }{&n.decision, n.voter.Name, voteURL, unvotedURL}
 }
 
-func (n *NotificationUpdateMotion) GetTemplate() string {
-       return "update_motion_mail.txt"
-}
+func (n *NotificationUpdateMotion) GetTemplate() string { return "update_motion_mail.txt" }
 
 func (n *NotificationUpdateMotion) GetSubject() string {
        return fmt.Sprintf("Re: %s - %s", n.decision.Tag, n.decision.Title)
@@ -158,9 +178,7 @@ func (n *NotificationWithDrawMotion) GetData() interface{} {
        }{&n.decision, n.voter.Name}
 }
 
-func (n *NotificationWithDrawMotion) GetTemplate() string {
-       return "withdraw_motion_mail.txt"
-}
+func (n *NotificationWithDrawMotion) GetTemplate() string { return "withdraw_motion_mail.txt" }
 
 func (n *NotificationWithDrawMotion) GetSubject() string {
        return fmt.Sprintf("Re: %s - %s - withdrawn", n.decision.Tag, n.decision.Title)
@@ -169,3 +187,25 @@ func (n *NotificationWithDrawMotion) GetSubject() string {
 func (n *NotificationWithDrawMotion) GetHeaders() map[string]string {
        return map[string]string{"References": fmt.Sprintf("<%s>", n.decision.Tag)}
 }
+
+type RemindVoterNotification struct {
+       voter     Voter
+       decisions []Decision
+}
+
+func (n *RemindVoterNotification) GetData() interface{} {
+       return struct {
+               Decisions []Decision
+               Name      string
+               BaseURL   string
+       }{n.decisions, n.voter.Name, config.BaseURL}
+}
+
+func (n *RemindVoterNotification) GetTemplate() string { return "remind_voter_mail.txt" }
+
+func (n *RemindVoterNotification) GetSubject() string { return "Outstanding CAcert board votes" }
+
+func (n *RemindVoterNotification) GetRecipient() (address string, name string) {
+       address, name = n.voter.Reminder, n.voter.Name
+       return
+}
diff --git a/remind.php b/remind.php
deleted file mode 100755 (executable)
index b3ce74c..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/usr/bin/php
-<?
-require_once("database.php");
-$db = new DB();
-
-$id = 0;
-$page = 1;
-
-$voters = $db->getStatement('get reminder voters');
-$voters->execute();
-
-$outstanding = $db->getStatement('list my unvoted decisions');
-$outstanding->bindParam(':id',$id);
-$outstanding->bindParam(':page',$page);
-
-while ($v = $voters->fetch()) {
-       $id = $v['id'];
-       $outstanding->execute();
-       $msg ='';
-       while ($row=$outstanding->fetch()) {
-               $msg .= ($row['votetype'] ? 'vote ' : 'motion ') . $row['tag'] . ' ' . $row['title'] . "\nDue: " . $row['due'] . "\nhttps://community.cacert.org/board/motions.php?motion=" . $row['tag'] . "\n\n";
-       }
-       if ($msg) {
-               // form email
-               $name = $v['name'];
-               $body = <<<BODY
-Dear $name,
-
-You have not voted in the following CAcert Board vote(s)/motion(s):
-
-$msg
-
-
-To view all your outstanding motions: https://community.cacert.org/board/motions.php?unvoted=1
-
-Kind regards,
-the vote system
-
-BODY;
-               $db->remind_notify($v['email'],"Outstanding CAcert board votes",$body); 
-       }
-}
-?>
diff --git a/templates/remind_voter_mail.txt b/templates/remind_voter_mail.txt
new file mode 100644 (file)
index 0000000..ddf5366
--- /dev/null
@@ -0,0 +1,15 @@
+{{ $baseurl := .BaseURL }}
+Dear {{ .Name }},
+
+You have not voted in the following CAcert Board vote(s)/motion(s):
+
+{{ range .Decisions -}}
+{{ .VoteType }} {{ .Tag }} {{ .Title }}
+Due: {{ .Due }}
+{{ $baseurl }}/motions/{{ .Tag }}
+{{ end }}
+
+To view all your outstanding motions: {{ $baseurl }}/motions/?unvoted=1
+
+Kind regards,
+the vote system
\ No newline at end of file