Implement reminder job

This commit is contained in:
Jan Dittberner 2017-04-20 20:58:22 +02:00 committed by Jan Dittberner
parent dcdd5f715f
commit b6ad5d8ad3
7 changed files with 220 additions and 126 deletions

View 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"`

View 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

63
jobs.go
View file

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

141
models.go
View file

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

View 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() {
@ -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
}

View file

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

View file

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