Implement reminder job

debian
Jan Dittberner 8 years ago committed by Jan Dittberner
parent dcdd5f715f
commit b6ad5d8ad3

@ -468,15 +468,16 @@ func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
type Config struct { type Config struct {
BoardMailAddress string `yaml:"board_mail_address"` BoardMailAddress string `yaml:"board_mail_address"`
NoticeSenderAddress string `yaml:"notice_sender_address"` NoticeSenderAddress string `yaml:"notice_sender_address"`
DatabaseFile string `yaml:"database_file"` ReminderSenderAddress string `yaml:"reminder_sender_address"`
ClientCACertificates string `yaml:"client_ca_certificates"` DatabaseFile string `yaml:"database_file"`
ServerCert string `yaml:"server_certificate"` ClientCACertificates string `yaml:"client_ca_certificates"`
ServerKey string `yaml:"server_key"` ServerCert string `yaml:"server_certificate"`
CookieSecret string `yaml:"cookie_secret"` ServerKey string `yaml:"server_key"`
BaseURL string `yaml:"base_url"` CookieSecret string `yaml:"cookie_secret"`
MailServer struct { BaseURL string `yaml:"base_url"`
MailServer struct {
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
} `yaml:"mail_server"` } `yaml:"mail_server"`

@ -1,6 +1,7 @@
--- ---
board_mail_address: cacert-board@lists.cacert.org board_mail_address: cacert-board@lists.cacert.org
notice_sender_address: cacert-board-votes@lists.cacert.org notice_sender_address: cacert-board-votes@lists.cacert.org
reminder_sender_address: returns@cacert.org
database_file: database.sqlite database_file: database.sqlite
client_ca_certificates: cacert_class3.pem client_ca_certificates: cacert_class3.pem
server_certificate: server.crt server_certificate: server.crt

@ -12,13 +12,15 @@ type jobIdentifier int
const ( const (
JobIdCloseDecisions jobIdentifier = iota JobIdCloseDecisions jobIdentifier = iota
JobIdRemindVotersJob
) )
var rescheduleChannel = make(chan jobIdentifier, 1) var rescheduleChannel = make(chan jobIdentifier, 1)
func JobScheduler(quitChannel chan int) { func JobScheduler(quitChannel chan int) {
var jobs = map[jobIdentifier]Job{ var jobs = map[jobIdentifier]Job{
JobIdCloseDecisions: NewCloseDecisionsJob(), JobIdCloseDecisions: NewCloseDecisionsJob(),
JobIdRemindVotersJob: NewRemindVotersJob(),
} }
logger.Println("INFO started job scheduler") logger.Println("INFO started job scheduler")
@ -60,11 +62,10 @@ func (j *CloseDecisionsJob) Schedule() {
return return
} }
if nextDue == nil { if nextDue == nil {
if j.timer != nil { logger.Println("INFO no next planned execution of CloseDecisionsJob")
j.timer.Stop() j.Stop()
j.timer = nil
}
} else { } else {
nextDue := nextDue.Add(time.Minute)
logger.Println("INFO scheduling CloseDecisionsJob for", nextDue) logger.Println("INFO scheduling CloseDecisionsJob for", nextDue)
when := nextDue.Sub(time.Now()) when := nextDue.Sub(time.Now())
if j.timer != nil { if j.timer != nil {
@ -78,6 +79,7 @@ func (j *CloseDecisionsJob) Schedule() {
func (j *CloseDecisionsJob) Stop() { func (j *CloseDecisionsJob) Stop() {
if j.timer != nil { if j.timer != nil {
j.timer.Stop() j.timer.Stop()
j.timer = nil
} }
} }
@ -93,3 +95,54 @@ func (j *CloseDecisionsJob) Run() {
func (j *CloseDecisionsJob) String() string { func (j *CloseDecisionsJob) String() string {
return "CloseDecisionsJob" 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}
}
}
}

@ -24,96 +24,85 @@ const (
sqlUpdateDecisionStatus sqlUpdateDecisionStatus
sqlSelectClosableDecisions sqlSelectClosableDecisions
sqlGetNextPendingDecisionDue sqlGetNextPendingDecisionDue
sqlGetReminderVoters
sqlFindUnvotedDecisionsForVoter
) )
var sqlStatements = map[sqlKey]string{ var sqlStatements = map[sqlKey]string{
sqlLoadDecisions: ` sqlLoadDecisions: `
SELECT decisions.id, decisions.tag, decisions.proponent, SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title,
voters.name AS proposer, decisions.proposed, decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified
decisions.content, decisions.votetype, decisions.status, decisions.due, FROM decisions
decisions.modified JOIN voters ON decisions.proponent=voters.id
FROM decisions
JOIN voters ON decisions.proponent=voters.id
ORDER BY proposed DESC ORDER BY proposed DESC
LIMIT 10 OFFSET 10 * $1`, LIMIT 10 OFFSET 10 * $1`,
sqlLoadUnvotedDecisions: ` sqlLoadUnvotedDecisions: `
SELECT decisions.id, decisions.tag, decisions.proponent, SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title,
voters.name AS proposer, decisions.proposed, decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified
decisions.content, decisions.votetype, decisions.status, decisions.due, FROM decisions
decisions.modified JOIN voters ON decisions.proponent=voters.id
FROM decisions WHERE decisions.status = 0 AND decisions.id NOT IN (SELECT votes.decision FROM votes WHERE votes.voter = $1)
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 ORDER BY proposed DESC
LIMIT 10 OFFSET 10 * $2;`, LIMIT 10 OFFSET 10 * $2;`,
sqlLoadDecisionByTag: ` sqlLoadDecisionByTag: `
SELECT decisions.id, decisions.tag, decisions.proponent, SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title,
voters.name AS proposer, decisions.proposed, decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified
decisions.content, decisions.votetype, decisions.status, decisions.due, FROM decisions
decisions.modified JOIN voters ON decisions.proponent=voters.id
FROM decisions WHERE decisions.tag=$1;`,
JOIN voters ON decisions.proponent=voters.id
WHERE decisions.tag=$1;`,
sqlLoadDecisionById: ` sqlLoadDecisionById: `
SELECT decisions.id, decisions.tag, decisions.proponent, decisions.proposed, SELECT decisions.id, decisions.tag, decisions.proponent, decisions.proposed, decisions.title, decisions.content,
decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.votetype, decisions.status, decisions.due, decisions.modified
decisions.due, decisions.modified FROM decisions
FROM decisions WHERE decisions.id=$1;`,
WHERE decisions.id=$1;`,
sqlLoadVoteCountsForDecision: ` sqlLoadVoteCountsForDecision: `
SELECT vote, COUNT(vote) SELECT vote, COUNT(vote) FROM votes WHERE decision=$1 GROUP BY vote`,
FROM votes
WHERE decision=$1 GROUP BY vote`,
sqlLoadVotesForDecision: ` sqlLoadVotesForDecision: `
SELECT votes.decision, votes.voter, voters.name, votes.vote, votes.voted, votes.notes SELECT votes.decision, votes.voter, voters.name, votes.vote, votes.voted, votes.notes
FROM votes FROM votes
JOIN voters ON votes.voter=voters.id JOIN voters ON votes.voter=voters.id
WHERE decision=$1`, WHERE decision=$1`,
sqlLoadEnabledVoterByEmail: ` sqlLoadEnabledVoterByEmail: `
SELECT voters.id, voters.name, voters.enabled, voters.reminder SELECT voters.id, voters.name, voters.enabled, voters.reminder
FROM voters FROM voters
JOIN emails ON voters.id=emails.voter JOIN emails ON voters.id=emails.voter
WHERE emails.address=$1 AND voters.enabled=1`, WHERE emails.address=$1 AND voters.enabled=1`,
sqlCountOlderThanDecision: ` sqlCountOlderThanDecision: `
SELECT COUNT(*) > 0 FROM decisions WHERE proposed < $1`, SELECT COUNT(*) > 0 FROM decisions WHERE proposed < $1`,
sqlCountOlderThanUnvotedDecision: ` sqlCountOlderThanUnvotedDecision: `
SELECT COUNT(*) > 0 FROM decisions WHERE proposed < $1 SELECT COUNT(*) > 0 FROM decisions
AND status=0 WHERE proposed < $1 AND status=0 AND id NOT IN (SELECT decision FROM votes WHERE votes.voter=$2)`,
AND id NOT IN (SELECT decision FROM votes WHERE votes.voter=$2)`,
sqlCreateDecision: ` sqlCreateDecision: `
INSERT INTO decisions ( INSERT INTO decisions (proposed, proponent, title, content, votetype, status, due, modified,tag)
proposed, proponent, title, content, votetype, status, due, modified,tag VALUES (
) VALUES ( :proposed, :proponent, :title, :content, :votetype, 0, :due, :proposed,
:proposed, :proponent, :title, :content, :votetype, 0,
:due,
:proposed,
'm' || strftime('%Y%m%d', :proposed) || '.' || ( 'm' || strftime('%Y%m%d', :proposed) || '.' || (
SELECT COUNT(*)+1 AS num SELECT COUNT(*)+1 AS num
FROM decisions FROM decisions
WHERE proposed WHERE proposed BETWEEN date(:proposed) AND date(:proposed, '1 day')
BETWEEN date(:proposed) AND date(:proposed, '1 day')
) )
)`, )`,
sqlUpdateDecision: ` sqlUpdateDecision: `
UPDATE decisions UPDATE decisions
SET proponent=:proponent, title=:title, content=:content, SET proponent=:proponent, title=:title, content=:content, votetype=:votetype, due=:due, modified=:modified
votetype=:votetype, due=:due, modified=:modified WHERE id=:id`,
WHERE id=:id`,
sqlUpdateDecisionStatus: ` sqlUpdateDecisionStatus: `
UPDATE decisions UPDATE decisions SET status=:status, modified=:modified WHERE id=:id`,
SET status=:status, modified=:modified WHERE id=:id
`,
sqlSelectClosableDecisions: ` sqlSelectClosableDecisions: `
SELECT decisions.id, decisions.tag, decisions.proponent, decisions.proposed, SELECT decisions.id, decisions.tag, decisions.proponent, decisions.proposed, decisions.title, decisions.content,
decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.votetype, decisions.status, decisions.due, decisions.modified
decisions.due, decisions.modified
FROM decisions FROM decisions
WHERE decisions.status=0 AND :now > due`, WHERE decisions.status=0 AND :now > due`,
sqlGetNextPendingDecisionDue: ` sqlGetNextPendingDecisionDue: `
SELECT due FROM decisions WHERE status=0 ORDER BY due LIMIT 1`, 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 var db *sqlx.DB
@ -644,3 +633,41 @@ func GetNextPendingDecisionDue() (due *time.Time, err error) {
return 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
}

@ -15,7 +15,15 @@ type NotificationMail interface {
GetHeaders() map[string]string GetHeaders() map[string]string
} }
type VoterMail interface {
GetData() interface{}
GetTemplate() string
GetSubject() string
GetRecipient() (string, string)
}
var notifyMail = make(chan NotificationMail, 1) var notifyMail = make(chan NotificationMail, 1)
var voterMail = make(chan VoterMail, 1)
var quitMailNotifier = make(chan int) var quitMailNotifier = make(chan int)
func CloseMailNotifier() { func CloseMailNotifier() {
@ -42,6 +50,24 @@ func MailNotifier() {
} }
m.SetBody("text/plain", mailText.String()) 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, "", "") d := gomail.NewDialer(config.MailServer.Host, config.MailServer.Port, "", "")
if err := d.DialAndSend(m); err != nil { if err := d.DialAndSend(m); err != nil {
logger.Println("ERROR sending mail:", err) logger.Println("ERROR sending mail:", err)
@ -78,9 +104,7 @@ func (n *NotificationClosedDecision) GetData() interface{} {
}{&n.decision, &n.voteSums} }{&n.decision, &n.voteSums}
} }
func (n *NotificationClosedDecision) GetTemplate() string { func (n *NotificationClosedDecision) GetTemplate() string { return "closed_motion_mail.txt" }
return "closed_motion_mail.txt"
}
func (n *NotificationClosedDecision) GetSubject() string { func (n *NotificationClosedDecision) GetSubject() string {
return fmt.Sprintf("Re: %s - %s - finalised", n.decision.Tag, n.decision.Title) 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} }{&n.decision, n.voter.Name, voteURL, unvotedURL}
} }
func (n *NotificationCreateMotion) GetTemplate() string { func (n *NotificationCreateMotion) GetTemplate() string { return "create_motion_mail.txt" }
return "create_motion_mail.txt"
}
func (n *NotificationCreateMotion) GetSubject() string { func (n *NotificationCreateMotion) GetSubject() string {
return fmt.Sprintf("%s - %s", n.decision.Tag, n.decision.Title) 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} }{&n.decision, n.voter.Name, voteURL, unvotedURL}
} }
func (n *NotificationUpdateMotion) GetTemplate() string { func (n *NotificationUpdateMotion) GetTemplate() string { return "update_motion_mail.txt" }
return "update_motion_mail.txt"
}
func (n *NotificationUpdateMotion) GetSubject() string { func (n *NotificationUpdateMotion) GetSubject() string {
return fmt.Sprintf("Re: %s - %s", n.decision.Tag, n.decision.Title) 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} }{&n.decision, n.voter.Name}
} }
func (n *NotificationWithDrawMotion) GetTemplate() string { func (n *NotificationWithDrawMotion) GetTemplate() string { return "withdraw_motion_mail.txt" }
return "withdraw_motion_mail.txt"
}
func (n *NotificationWithDrawMotion) GetSubject() string { func (n *NotificationWithDrawMotion) GetSubject() string {
return fmt.Sprintf("Re: %s - %s - withdrawn", n.decision.Tag, n.decision.Title) 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 { func (n *NotificationWithDrawMotion) GetHeaders() map[string]string {
return map[string]string{"References": fmt.Sprintf("<%s>", n.decision.Tag)} 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
}

@ -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);
}
}
?>

@ -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
Loading…
Cancel
Save