From b6ad5d8ad327066b21bdb690f5a5017f6bed9740 Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Thu, 20 Apr 2017 20:58:22 +0200 Subject: [PATCH] Implement reminder job --- boardvoting.go | 19 +++-- config.yaml.example | 1 + jobs.go | 63 ++++++++++++-- models.go | 141 +++++++++++++++++++------------- notifications.go | 64 ++++++++++++--- remind.php | 43 ---------- templates/remind_voter_mail.txt | 15 ++++ 7 files changed, 220 insertions(+), 126 deletions(-) delete mode 100755 remind.php create mode 100644 templates/remind_voter_mail.txt diff --git a/boardvoting.go b/boardvoting.go index 3b03f22..152d969 100644 --- a/boardvoting.go +++ b/boardvoting.go @@ -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"` diff --git a/config.yaml.example b/config.yaml.example index f971540..d2c24e3 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -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 --- 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} + } + } +} diff --git a/models.go b/models.go index d27610f..5297cf2 100644 --- 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 +} diff --git a/notifications.go b/notifications.go index beb9b3f..d4b9715 100644 --- a/notifications.go +++ b/notifications.go @@ -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() { @@ -42,6 +50,24 @@ func MailNotifier() { } 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 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) @@ -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 index b3ce74c..0000000 --- a/remind.php +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/php -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 = <<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 index 0000000..ddf5366 --- /dev/null +++ b/templates/remind_voter_mail.txt @@ -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