Remove old code
- remove the old code and its dependencies - perform some refactoring and fix notifications - add TODO tags for observed shortcomings - rename voters.go to users.go - implement health check for SMTP connectionmain
parent
28ddbd2ce6
commit
368bd8eefb
File diff suppressed because it is too large
Load Diff
@ -1,66 +0,0 @@
|
||||
{{ template "header.html" . }}
|
||||
{{ template "return_header" . }}
|
||||
<div class="ui raised segment">
|
||||
<form action="/newmotion/" method="post">
|
||||
{{ csrfField }}
|
||||
<div class="ui form{{ if .Form.Errors }} error{{ end }}">
|
||||
<div class="three fields">
|
||||
<div class="field">
|
||||
<label>ID:</label>
|
||||
(generated on submit)
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Proponent:</label>
|
||||
{{ .Voter.Name }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Proposed date/time:</label>
|
||||
(auto filled to current date/time)
|
||||
</div>
|
||||
</div>
|
||||
<div class="required field{{ if .Form.Errors.Title }} error{{ end }}">
|
||||
<label for="Title">Title:</label>
|
||||
<input id="Title" name="Title" type="text" value="{{ .Form.Title }}">
|
||||
</div>
|
||||
<div class="required field{{ if .Form.Errors.Content }} error{{ end }}">
|
||||
<label for="Content">Text:</label>
|
||||
<textarea id="Content" name="Content">{{ .Form.Content }}</textarea>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="required field{{ if .Form.Errors.VoteType }} error{{ end }}">
|
||||
<label for="VoteType">Vote type:</label>
|
||||
<select id="VoteType" name="VoteType">
|
||||
<option value="0"
|
||||
{{ if eq "0" .Form.VoteType }}selected{{ end }}>
|
||||
Motion
|
||||
</option>
|
||||
<option value="1"
|
||||
{{ if eq "1" .Form.VoteType }}selected{{ end }}>
|
||||
Veto
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="required field{{ if .Form.Errors.Due }} error{{ end }}">
|
||||
<label for="Due">Due: (autofilled from chosen
|
||||
option)</label>
|
||||
<select id="Due" name="Due">
|
||||
<option value="+3 days">In 3 Days</option>
|
||||
<option value="+7 days">In 1 Week</option>
|
||||
<option value="+14 days">In 2 Weeks</option>
|
||||
<option value="+28 days">In 4 Weeks</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{{ with .Form.Errors }}
|
||||
<div class="ui error message">
|
||||
{{ with .Title }}<p>{{ . }}</p>{{ end }}
|
||||
{{ with .Content }}<p>{{ . }}</p>{{ end }}
|
||||
{{ with .VoteType }}<p>{{ . }}</p>{{ end }}
|
||||
{{ with .Due }}<p>{{ . }}</p>{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<button class="ui button" type="submit">Propose</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{ template "footer.html" . }}
|
@ -1,23 +0,0 @@
|
||||
{{ template "header.html" . }}
|
||||
<div class="ui container">
|
||||
<div class="ui negative icon message">
|
||||
<i class="ban icon "></i>
|
||||
<div class="content">
|
||||
<div class="header">You are not authorized to act here!</div>
|
||||
<p>If you think this is in error, please contact the administrator.</p>
|
||||
<p>If you don't know who that is, it is definitely not an error ;)</p>
|
||||
{{ if .Emails }}
|
||||
<p>The following addresses were present in your certificate:<p>
|
||||
<div class="ui list">
|
||||
{{ range .Emails }}
|
||||
<div class="item">
|
||||
<i class="address card outline icon"></i>
|
||||
<div class="content">{{ . }}</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ template "footer.html" . }}
|
@ -1,23 +0,0 @@
|
||||
{{ template "header.html" . }}
|
||||
{{ template "return_header" . }}
|
||||
{{ with .Decision }}
|
||||
<div class="ui raised segment">
|
||||
{{ template "motion_fragment" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<form action="/vote/{{ .Decision.Tag }}/{{ .VoteChoice }}" method="post">
|
||||
{{ csrfField }}
|
||||
<div class="ui form">
|
||||
{{ if eq 1 .VoteChoice }}
|
||||
<button class="ui right labeled green icon button" type="submit"><i class="check circle icon"></i>
|
||||
Vote {{ .VoteChoice }}</button>
|
||||
{{ else if eq -1 .VoteChoice }}
|
||||
<button class="ui right labeled red icon button" type="submit"><i class="minus circle icon"></i>
|
||||
Vote {{ .VoteChoice }}</button>
|
||||
{{ else }}
|
||||
<button class="ui right labeled grey icon button" type="submit"><i class="circle icon"></i>
|
||||
Vote {{ .VoteChoice }}</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
</form>
|
||||
{{ template "footer.html" . }}
|
@ -1,66 +0,0 @@
|
||||
{{ template "header.html" . }}
|
||||
{{ template "return_header" . }}
|
||||
<div class="ui raised segment">
|
||||
<form action="/motions/{{ .Form.Decision.Tag }}/edit" method="post">
|
||||
{{ csrfField }}
|
||||
<div class="ui form{{ if .Form.Errors }} error{{ end }}">
|
||||
<div class="three fields">
|
||||
<div class="field">
|
||||
<label>ID:</label>
|
||||
<a href="/motions/{{ .Form.Decision.Tag }}">{{ .Form.Decision.Tag }}</a>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Proponent:</label>
|
||||
{{ .Voter.Name }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Proposed date/time:</label>
|
||||
{{ .Form.Decision.Proposed|date "2006-01-02 15:04:05 UTC" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="required field{{ if .Form.Errors.Title }} error{{ end }}">
|
||||
<label for="Title">Title:</label>
|
||||
<input name="Title" type="text" value="{{ .Form.Title }}">
|
||||
</div>
|
||||
<div class="required field{{ if .Form.Errors.Content }} error{{ end }}">
|
||||
<label for="Content">Text:</label>
|
||||
<textarea name="Content">{{ .Form.Content }}</textarea>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="required field{{ if .Form.Errors.VoteType }} error{{ end }}">
|
||||
<label for="VoteType">Vote type:</label>
|
||||
<select name="VoteType">
|
||||
<option value="0"
|
||||
{{ if eq "0" .Form.VoteType }}selected{{ end }}>
|
||||
Motion
|
||||
</option>
|
||||
<option value="1"
|
||||
{{ if eq "1" .Form.VoteType }}selected{{ end }}>
|
||||
Veto
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="required field{{ if .Form.Errors.Due }} error{{ end }}">
|
||||
<label for="Due">Due: (autofilled from chosen
|
||||
option)</label>
|
||||
<select name="Due">
|
||||
<option value="+3 days">In 3 Days</option>
|
||||
<option value="+7 days">In 1 Week</option>
|
||||
<option value="+14 days">In 2 Weeks</option>
|
||||
<option value="+28 days">In 4 Weeks</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{{ with .Form.Errors }}
|
||||
<div class="ui error message">
|
||||
{{ with .Title }}<p>{{ . }}</p>{{ end }}
|
||||
{{ with .Content }}<p>{{ . }}</p>{{ end }}
|
||||
{{ with .VoteType }}<p>{{ . }}</p>{{ end }}
|
||||
{{ with .Due }}<p>{{ . }}</p>{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<button class="ui button" type="submit">Propose</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{ template "footer.html" . }}
|
@ -1,12 +0,0 @@
|
||||
{{ define "footer.html" }}
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
$('.message .close').on('click', function () {
|
||||
$(this).closest('.message').transition('fade');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
@ -1,42 +0,0 @@
|
||||
{{ define "header.html" -}}
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/html">
|
||||
<head>
|
||||
<title>{{ block "pagetitle" . }}CAcert Board Decisions{{ end }}{{ if .PageTitle }} - {{ .PageTitle }}{{ end }}</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
<link rel="stylesheet" type="text/css" href="/static/lato-fonts.css">
|
||||
<link rel="stylesheet" type="text/css" href="/static/semantic.min.css"/>
|
||||
<script type="text/javascript" src="/static/jquery.min.js"></script>
|
||||
<script type="text/javascript" src="/static/semantic.min.js"></script>
|
||||
<link rel="icon" href="/static/images/favicon.ico">
|
||||
</head>
|
||||
<body id="cacert-boardvoting">
|
||||
<div class="pusher">
|
||||
<div class="ui vertical masthead center aligned segment">
|
||||
<div class="ui left secondary container">
|
||||
<img src="/static/images/CAcert-logo-colour.svg" alt="CAcert" height="40rem"/>
|
||||
</div>
|
||||
<div class="ui text container">
|
||||
<h1 class="ui header">
|
||||
{{ template "pagetitle" . }}
|
||||
{{ if .Voter }}
|
||||
<div class="ui left pointing label">Authenticated as {{ .Voter.Name }} <{{ .Voter.Reminder }}>
|
||||
</div>{{ end }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui container">
|
||||
{{ with .Flashes }}
|
||||
<div class="basic segment">
|
||||
<div class="ui info message">
|
||||
<i class="close icon"></i>
|
||||
<div class="ui list">
|
||||
{{ range . }}
|
||||
<div class="ui item">{{ . }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
@ -1,20 +0,0 @@
|
||||
{{ template "header.html" . }}
|
||||
{{ $voter := .Voter }}
|
||||
<div class="ui basic segment">
|
||||
<div class="ui secondary pointing menu">
|
||||
<a href="/motions/" class="item" title="Show all votes">All votes</a>
|
||||
{{ if $voter }}
|
||||
<a href="/motions/?unvoted=1" class="item" title="Show my outstanding votes">My outstanding votes</a>
|
||||
<div class="right item">
|
||||
<a class="ui primary button" href="/newmotion/">New motion</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ with .Decision }}
|
||||
<div class="ui raised segment">
|
||||
{{ template "motion_fragment" . }}
|
||||
{{ if $voter }}{{ template "motion_actions" . }}{{ end }}
|
||||
</div>
|
||||
{{ end}}
|
||||
{{ template "footer.html" . }}
|
@ -1,68 +0,0 @@
|
||||
{{ define "motion_fragment" }}
|
||||
<span class="ui {{ template "status_class" .Status }} ribbon label">{{ .Status|toString|title }}</span>
|
||||
<span class="header.html">{{ .Modified|date "2006-01-02 15:04:05 UTC" }}</span>
|
||||
<h3 class="header.html"><a href="/motions/{{ .Tag }}">{{ .Tag }}: {{ .Title }}</a></h3>
|
||||
<p>{{ wrap 76 .Content | nl2br }}</p>
|
||||
<table class="ui small definition table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Due</td>
|
||||
<td>{{.Due|date "2006-01-02 15:04:05 UTC"}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Proposed</td>
|
||||
<td>{{.Proposer}} ({{.Proposed|date "2006-01-02 15:04:05 UTC"}})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Vote type:</td>
|
||||
<td>{{ .VoteType|toString|title }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Votes:</td>
|
||||
<td>
|
||||
<div class="ui labels">
|
||||
<div class="ui basic label green"><i
|
||||
class="check circle icon"></i>Aye
|
||||
<div class="detail">{{.Ayes}}</div>
|
||||
</div>
|
||||
<div class="ui basic label red"><i
|
||||
class="minus circle icon"></i>Naye
|
||||
<div class="detail">{{.Nayes}}</div>
|
||||
</div>
|
||||
<div class="ui basic label grey"><i class="circle icon"></i>Abstain
|
||||
<div class="detail">{{.Abstains}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .Votes }}
|
||||
<div class="list">
|
||||
{{ range .Votes }}
|
||||
<div class="item">{{ .Name }}: {{ .Vote.Vote }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<a href="/motions/{{ .Tag }}">Hide Votes</a>
|
||||
{{ else if or (ne 0 .Ayes) (ne 0 .Nayes) (ne 0 .Abstains) }}
|
||||
<a href="/motions/{{ .Tag }}?showvotes=1">Show Votes</a>
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{{ end }}
|
||||
|
||||
{{ define "status_class" }}{{ if eq . 0 }}blue{{ else if eq . 1 }}green{{ else if eq . -1 }}red{{ else if eq . -2 }}grey{{ end }}{{ end }}
|
||||
|
||||
{{ define "motion_actions" }}
|
||||
{{ if eq .Status 0 }}
|
||||
<a class="ui compact right labeled green icon button" href="/vote/{{ .Tag }}/aye"><i
|
||||
class="check circle icon"></i> Aye</a>
|
||||
<a class="ui compact right labeled red icon button" href="/vote/{{ .Tag }}/naye"><i
|
||||
class="minus circle icon"></i> Naye</a>
|
||||
<a class="ui compact right labeled grey icon button" href="/vote/{{ .Tag }}/abstain"><i class="circle icon"></i>
|
||||
Abstain</a>
|
||||
<a class="ui compact left labeled icon button" href="/proxy/{{ .Tag }}"><i class="users icon"></i> Proxy
|
||||
Vote</a>
|
||||
<a class="ui compact left labeled icon button" href="/motions/{{ .Tag }}/edit"><i class="edit icon"></i> Modify</a>
|
||||
<a class="ui compact left labeled icon button" href="/motions/{{ .Tag }}/withdraw"><i class="trash icon"></i>
|
||||
Withdraw</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
@ -1,45 +0,0 @@
|
||||
{{ template "header.html" . }}
|
||||
{{ $voter := .Voter }}
|
||||
{{ $page := . }}
|
||||
<div class="ui basic segment">
|
||||
<div class="ui secondary pointing menu">
|
||||
<a href="/motions/" class="{{ if not .Params.Flags.Unvoted }}active {{ end }}item" title="Show all votes">All
|
||||
votes</a>
|
||||
{{ if $voter }}
|
||||
<a href="/motions/?unvoted=1" class="{{ if .Params.Flags.Unvoted }}active {{ end}}item"
|
||||
title="Show my outstanding votes">My outstanding votes</a>
|
||||
<div class="right item">
|
||||
<a class="ui primary button" href="/newmotion/">New motion</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ if .Decisions }}
|
||||
<div class="ui labeled icon menu">
|
||||
{{ template "pagination_fragment" $page }}
|
||||
</div>
|
||||
{{ range .Decisions }}
|
||||
<div class="ui raised segment">
|
||||
{{ template "motion_fragment" . }}
|
||||
{{ if $voter }}{{ template "motion_actions" . }}{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="ui labeled icon menu">
|
||||
{{ template "pagination_fragment" $page }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="ui basic segment">
|
||||
<div class="ui icon message">
|
||||
<i class="inbox icon"></i>
|
||||
<div class="content">
|
||||
<div class="header">No motions available</div>
|
||||
{{ if .Params.Flags.Unvoted }}
|
||||
<p>There are no motions requiring a vote from you.</p>
|
||||
{{ else }}
|
||||
<p>There are no motions in the system yet.</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ template "footer.html" . }}
|
@ -1,20 +0,0 @@
|
||||
{{ define "pagination_fragment" }}
|
||||
{{ if .PrevPage -}}
|
||||
<a class="item" href="?page={{ .PrevPage }}{{ if .Params.Flags.Unvoted }}&unvoted=1{{ end }}">
|
||||
<i class="left arrow icon"></i> newer
|
||||
</a>
|
||||
{{- end }}
|
||||
{{ if .NextPage -}}
|
||||
<a class="right item" href="?page={{ .NextPage }}{{ if .Params.Flags.Unvoted }}&unvoted=1{{ end }}">
|
||||
<i class="right arrow icon"></i> older
|
||||
</a>
|
||||
{{- end }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "return_header" }}
|
||||
<div class="ui basic segment">
|
||||
<div class="ui secondary pointing menu">
|
||||
<a href="/motions/" class="item" title="Show all votes">Back to motions</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
@ -1,47 +0,0 @@
|
||||
{{ template "header.html" . }}
|
||||
{{ template "return_header" . }}
|
||||
{{ $form := .Form }}
|
||||
<div class="ui raised segment">
|
||||
{{ with .Decision }}
|
||||
{{ template "motion_fragment" . }}
|
||||
{{ end }}
|
||||
<form action="/proxy/{{ .Decision.Tag }}" method="post">
|
||||
{{ csrfField }}
|
||||
<div class="ui form{{ if .Form.Errors }} error{{ end }}">
|
||||
<div class="two fields">
|
||||
<div class="required field{{ if .Form.Errors.Voter }} error{{ end }}">
|
||||
<label for="Voter">Voter</label>
|
||||
<select name="Voter">
|
||||
{{ range .Voters }}
|
||||
<option value="{{ .Id }}"
|
||||
{{ if eq (.Id | print) $form.Voter }}
|
||||
selected{{ end }}>{{ .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
<div class="required field{{ if .Form.Errors.Vote }} error{{ end }}">
|
||||
<label for="Vote">Vote</label>
|
||||
<select name="Vote">
|
||||
<option value="1"{{ if eq .Form.Vote "1" }} selected{{ end }}>Aye</option>
|
||||
<option value="0"{{ if eq .Form.Vote "0" }} selected{{ end }}>Abstain</option>
|
||||
<option value="-1"{{ if eq .Form.Vote "-1" }} selected{{ end }}>Naye</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="required field{{ if .Form.Errors.Justification }} error{{ end }}">
|
||||
<label for="Justification">Justification</label>
|
||||
<textarea name="Justification" rows="2">{{ .Form.Justification }}</textarea>
|
||||
</div>
|
||||
{{ with .Form.Errors }}
|
||||
<div class="ui error message">
|
||||
{{ with .Voter }}<p>{{ . }}</p>{{ end }}
|
||||
{{ with .Vote }}<p>{{ . }}</p>{{ end }}
|
||||
{{ with .Justification }}<p>{{ . }}</p>{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<button class="ui primary left labeled icon button" type="submit"><i class="users icon"></i> Proxy Vote
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{ template "footer.html" . }}
|
@ -1,17 +0,0 @@
|
||||
{{ template "header.html" . }}
|
||||
{{ template "return_header" . }}
|
||||
{{ with .Decision }}
|
||||
<div class="ui raised segment">
|
||||
{{ template "motion_fragment" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="ui basic segment">
|
||||
<form action="/motions/{{ .Decision.Tag }}/withdraw" method="post">
|
||||
{{ csrfField }}
|
||||
<div class="ui form">
|
||||
<button class="ui primary left labeled icon button" type="submit"><i class="trash icon"></i> Withdraw
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{ template "footer.html" . }}
|
@ -1,171 +0,0 @@
|
||||
/*
|
||||
Copyright 2017-2022 CAcert Inc.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
minimumContentLen = 3
|
||||
minimumTitleLen = 3
|
||||
base10 = 10
|
||||
size8Bit = 8
|
||||
size64Bit = 64
|
||||
)
|
||||
|
||||
const (
|
||||
hoursInADay = 24
|
||||
dueThreeDays = 3
|
||||
dueOneWeek = 7
|
||||
dueTwoWeeks = 14
|
||||
dueFourWeeks = 28
|
||||
)
|
||||
|
||||
var validDueDurations = map[string]time.Duration{
|
||||
"+3 days": time.Hour * hoursInADay * dueThreeDays,
|
||||
"+7 days": time.Hour * hoursInADay * dueOneWeek,
|
||||
"+14 days": time.Hour * hoursInADay * dueTwoWeeks,
|
||||
"+28 days": time.Hour * hoursInADay * dueFourWeeks,
|
||||
}
|
||||
|
||||
type NewDecisionForm struct {
|
||||
Title string
|
||||
Content string
|
||||
VoteType string
|
||||
Due string
|
||||
Errors map[string]string
|
||||
}
|
||||
|
||||
func (f *NewDecisionForm) Validate() (bool, *Decision) {
|
||||
f.Errors = make(map[string]string)
|
||||
|
||||
data := &Decision{}
|
||||
|
||||
data.Title = strings.TrimSpace(f.Title)
|
||||
if len(data.Title) < minimumTitleLen {
|
||||
f.Errors["Title"] = fmt.Sprintf("Please enter at least %d characters for Title.", minimumTitleLen)
|
||||
}
|
||||
|
||||
data.Content = strings.TrimSpace(f.Content)
|
||||
if len(strings.Fields(data.Content)) < minimumContentLen {
|
||||
f.Errors["Content"] = fmt.Sprintf("Please enter at least %d words as Text.", minimumContentLen)
|
||||
}
|
||||
|
||||
if voteType, err := strconv.ParseUint(f.VoteType, base10, size8Bit); err != nil || (voteType != 0 && voteType != 1) {
|
||||
f.Errors["VoteType"] = fmt.Sprint("Please choose a valid vote type.", err)
|
||||
} else {
|
||||
data.VoteType = VoteType(uint8(voteType))
|
||||
}
|
||||
|
||||
if dueDuration, ok := validDueDurations[f.Due]; !ok {
|
||||
f.Errors["Due"] = "Please choose a valid due date."
|
||||
} else {
|
||||
year, month, day := time.Now().UTC().Add(dueDuration).Date()
|
||||
data.Due = time.Date(year, month, day, 23, 59, 59, 0, time.UTC)
|
||||
}
|
||||
|
||||
return len(f.Errors) == 0, data
|
||||
}
|
||||
|
||||
type EditDecisionForm struct {
|
||||
Title string
|
||||
Content string
|
||||
VoteType string
|
||||
Due string
|
||||
Decision *Decision
|
||||
Errors map[string]string
|
||||
}
|
||||
|
||||
func (f *EditDecisionForm) Validate() (bool, *Decision) {
|
||||
f.Errors = make(map[string]string)
|
||||
|
||||
data := f.Decision
|
||||
|
||||
data.Title = strings.TrimSpace(f.Title)
|
||||
if len(data.Title) < minimumTitleLen {
|
||||
f.Errors["Title"] = fmt.Sprintf("Please enter at least %d characters for Title.", minimumTitleLen)
|
||||
}
|
||||
|
||||
data.Content = strings.TrimSpace(f.Content)
|
||||
if len(strings.Fields(data.Content)) < minimumContentLen {
|
||||
f.Errors["Content"] = fmt.Sprintf("Please enter at least %d words as Text.", minimumContentLen)
|
||||
}
|
||||
|
||||
if voteType, err := strconv.ParseUint(f.VoteType, base10, size8Bit); err != nil || (voteType != 0 && voteType != 1) {
|
||||
f.Errors["VoteType"] = fmt.Sprint("Please choose a valid vote type.", err)
|
||||
} else {
|
||||
data.VoteType = VoteType(uint8(voteType))
|
||||
}
|
||||
|
||||
if dueDuration, ok := validDueDurations[f.Due]; !ok {
|
||||
f.Errors["Due"] = "Please choose a valid due date."
|
||||
} else {
|
||||
year, month, day := time.Now().UTC().Add(dueDuration).Date()
|
||||
data.Due = time.Date(year, month, day, 23, 59, 59, 0, time.UTC)
|
||||
}
|
||||
|
||||
return len(f.Errors) == 0, data
|
||||
}
|
||||
|
||||
type ProxyVoteForm struct {
|
||||
Voter string
|
||||
Vote string
|
||||
Justification string
|
||||
Errors map[string]string
|
||||
}
|
||||
|
||||
func (f *ProxyVoteForm) Validate() (bool, *Voter, *Vote, string) {
|
||||
f.Errors = make(map[string]string)
|
||||
|
||||
const minimumJustificationLen = 3
|
||||
|
||||
var (
|
||||
voter *Voter
|
||||
err error
|
||||
voterID, vote int64
|
||||
)
|
||||
|
||||
data := &Vote{}
|
||||
|
||||
if voterID, err = strconv.ParseInt(f.Voter, base10, size64Bit); err != nil {
|
||||
f.Errors["Voter"] = fmt.Sprintf("Please choose a valid voter: %v.", err)
|
||||
} else if voter, err = GetVoterByID(voterID); err != nil {
|
||||
f.Errors["Voter"] = fmt.Sprintf("Please choose a valid voter: %v.", err)
|
||||
} else {
|
||||
data.VoterID = voter.ID
|
||||
}
|
||||
|
||||
if vote, err = strconv.ParseInt(f.Vote, base10, size8Bit); err != nil {
|
||||
f.Errors["Vote"] = fmt.Sprintf("Please choose a valid vote: %v.", err)
|
||||
} else if voteChoice, ok := VoteChoices[vote]; !ok {
|
||||
f.Errors["Vote"] = "Please choose a valid vote."
|
||||
} else {
|
||||
data.Vote = voteChoice
|
||||
}
|
||||
|
||||
justification := strings.TrimSpace(f.Justification)
|
||||
if len(justification) < minimumJustificationLen {
|
||||
f.Errors["Justification"] = "Please enter at least 3 characters for justification."
|
||||
}
|
||||
|
||||
return len(f.Errors) == 0, voter, data, justification
|
||||
}
|
@ -1,199 +0,0 @@
|
||||
/*
|
||||
Copyright 2017-2022 CAcert Inc.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Job interface {
|
||||
Schedule()
|
||||
Stop()
|
||||
Run()
|
||||
}
|
||||
|
||||
type jobIdentifier int
|
||||
|
||||
const (
|
||||
JobIDCloseDecisions jobIdentifier = iota
|
||||
JobIDRemindVotersJob
|
||||
reminderDays = 3
|
||||
)
|
||||
|
||||
var rescheduleChannel = make(chan jobIdentifier, 1)
|
||||
|
||||
func JobScheduler(quitChannel chan int) {
|
||||
var jobs = map[jobIdentifier]Job{
|
||||
JobIDCloseDecisions: NewCloseDecisionsJob(),
|
||||
JobIDRemindVotersJob: NewRemindVotersJob(),
|
||||
}
|
||||
|
||||
log.Info("started job scheduler")
|
||||
|
||||
for {
|
||||
select {
|
||||
case jobID := <-rescheduleChannel:
|
||||
job := jobs[jobID]
|
||||
|
||||
log.Infof("reschedule job %s", job)
|
||||
job.Schedule()
|
||||
case <-quitChannel:
|
||||
for _, job := range jobs {
|
||||
job.Stop()
|
||||
}
|
||||
|
||||
log.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
|
||||
err error
|
||||
)
|
||||
|
||||
nextDue, err = GetNextPendingDecisionDue()
|
||||
if err != nil {
|
||||
log.Error("Could not get next pending due date")
|
||||
|
||||
if j.timer != nil {
|
||||
j.timer.Stop()
|
||||
j.timer = nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if nextDue == nil {
|
||||
log.Info("no next planned execution of CloseDecisionsJob")
|
||||
j.Stop()
|
||||
} else {
|
||||
nextDue := nextDue.Add(time.Second)
|
||||
log.Infof("scheduling CloseDecisionsJob for %s", nextDue)
|
||||
when := time.Until(nextDue)
|
||||
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()
|
||||
j.timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (j *CloseDecisionsJob) Run() {
|
||||
log.Debug("running CloseDecisionsJob")
|
||||
|
||||
err := CloseDecisions()
|
||||
if err != nil {
|
||||
log.Errorf("closing decisions %v", err)
|
||||
}
|
||||
|
||||
rescheduleChannel <- JobIDCloseDecisions
|
||||
}
|
||||
|
||||
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, reminderDays)
|
||||
|
||||
log.Infof("scheduling RemindVotersJob for %s", nextExecution)
|
||||
|
||||
when := time.Until(nextExecution)
|
||||
|
||||
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() {
|
||||
log.Info("running RemindVotersJob")
|
||||
|
||||
defer func() { rescheduleChannel <- JobIDRemindVotersJob }()
|
||||
|
||||
var (
|
||||
voters []Voter
|
||||
err error
|
||||
)
|
||||
|
||||
voters, err = GetReminderVoters()
|
||||
if err != nil {
|
||||
log.Errorf("problem getting voters %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var decisions []Decision
|
||||
|
||||
for i := range voters {
|
||||
decisions, err = FindUnVotedDecisionsForVoter(&voters[i])
|
||||
if err != nil {
|
||||
log.Errorf("problem getting unvoted decisions: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(decisions) > 0 {
|
||||
NotifyMailChannel <- &RemindVoterNotification{voter: voters[i], decisions: decisions}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,876 +0,0 @@
|
||||
/*
|
||||
Copyright 2017-2022 CAcert Inc.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/johejo/golang-migrate-extra/source/iofs"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type sqlKey int
|
||||
|
||||
const (
|
||||
sqlLoadDecisions sqlKey = iota
|
||||
sqlLoadUnVotedDecisions
|
||||
sqlLoadDecisionByTag
|
||||
sqlLoadDecisionByID
|
||||
sqlLoadVoteCountsForDecision
|
||||
sqlLoadVotesForDecision
|
||||
sqlLoadEnabledVoterByEmail
|
||||
sqlCountOlderThanDecision
|
||||
sqlCountOlderThanUnVotedDecision
|
||||
sqlCreateDecision
|
||||
sqlUpdateDecision
|
||||
sqlUpdateDecisionStatus
|
||||
sqlSelectClosableDecisions
|
||||
sqlGetNextPendingDecisionDue
|
||||
sqlGetReminderVoters
|
||||
sqlFindUnVotedDecisionsForVoter
|
||||
sqlGetEnabledVoterByID
|
||||
sqlCreateVote
|
||||
sqlLoadVote
|
||||
sqlGetVotersForProxy
|
||||
)
|
||||
|
||||
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
|
||||
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)
|
||||
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;`,
|
||||
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;`,
|
||||
sqlLoadVoteCountsForDecision: `
|
||||
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`,
|
||||
sqlLoadEnabledVoterByEmail: `
|
||||
SELECT voters.id, voters.name, voters.reminder
|
||||
FROM voters
|
||||
JOIN emails ON voters.id=emails.voter
|
||||
JOIN user_roles ON user_roles.voter_id=voters.id
|
||||
WHERE emails.address=$1 AND user_roles.role='VOTER'`,
|
||||
sqlGetEnabledVoterByID: `
|
||||
SELECT voters.id, voters.name, voters.reminder
|
||||
FROM voters
|
||||
JOIN user_roles ON user_roles.voter_id=voters.id
|
||||
WHERE user_roles.role='VOTER' AND voters.id=$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)`,
|
||||
sqlCreateDecision: `
|
||||
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')
|
||||
)
|
||||
)`,
|
||||
sqlUpdateDecision: `
|
||||
UPDATE decisions
|
||||
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`,
|
||||
sqlSelectClosableDecisions: `
|
||||
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`,
|
||||
sqlGetVotersForProxy: `
|
||||
SELECT voters.id, voters.name, voters.reminder
|
||||
FROM voters
|
||||
JOIN user_roles ON user_roles.voter_id=voters.id
|
||||
WHERE user_roles.role='VOTER' AND voters.id != $1`,
|
||||
sqlGetReminderVoters: `
|
||||
SELECT voters.id, voters.name, voters.reminder
|
||||
FROM voters
|
||||
JOIN user_roles ON user_roles.voter_id=voters.id
|
||||
WHERE user_roles.role='VOTER' 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`,
|
||||
sqlCreateVote: `
|
||||
INSERT OR REPLACE INTO votes (decision, voter, vote, voted, notes)
|
||||
VALUES (:decision, :voter, :vote, :voted, :notes)`,
|
||||
sqlLoadVote: `
|
||||
SELECT decision, voter, vote, voted, notes
|
||||
FROM votes
|
||||
WHERE decision=$1 AND voter=$2`,
|
||||
}
|
||||
|
||||
type VoteType uint8
|
||||
type VoteStatus int8
|
||||
|
||||
type Decision struct {
|
||||
ID int64 `db:"id"`
|
||||
Proposed time.Time
|
||||
ProponentID int64 `db:"proponent"`
|
||||
Title string
|
||||
Content string
|
||||
Quorum int
|
||||
Majority int
|
||||
Status VoteStatus
|
||||
Due time.Time
|
||||
Modified time.Time
|
||||
Tag string
|
||||
VoteType VoteType
|
||||
}
|
||||
|
||||
type Voter struct {
|
||||
ID int64 `db:"id"`
|
||||
Name string
|
||||
Reminder string // reminder email address
|
||||
}
|
||||
|
||||
type VoteChoice int
|
||||
|
||||
const (
|
||||
voteAye = 1
|
||||
voteNaye = -1
|
||||
voteAbstain = 0
|
||||
)
|
||||
|
||||
const (
|
||||
voteTypeMotion = 0
|
||||
voteTypeVeto = 1
|
||||
)
|
||||
|
||||
const (
|
||||
voteTypeLabelMotion = "motion"
|
||||
voteTypeLabelUnknown = "unknown"
|
||||
voteTypeLabelVeto = "veto"
|
||||
)
|
||||
|
||||
func (v VoteType) String() string {
|
||||
switch v {
|
||||
case voteTypeMotion:
|
||||
return voteTypeLabelMotion
|
||||
case voteTypeVeto:
|
||||
return voteTypeLabelVeto
|
||||
default:
|
||||
return voteTypeLabelUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func (v VoteType) QuorumAndMajority() (int, float32) {
|
||||
const (
|
||||
majorityDefault = 0.99
|
||||
majorityMotion = 0.50
|
||||
quorumDefault = 1
|
||||
quorumMotion = 3
|
||||
)
|
||||
|
||||
switch v {
|
||||
case voteTypeMotion:
|
||||
return quorumMotion, majorityMotion
|
||||
default:
|
||||
return quorumDefault, majorityDefault
|
||||
}
|
||||
}
|
||||
|
||||
func (v VoteChoice) String() string {
|
||||
switch v {
|
||||
case voteAye:
|
||||
return "aye"
|
||||
case voteNaye:
|
||||
return "naye"
|
||||
case voteAbstain:
|
||||
return "abstain"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
var VoteValues = map[string]VoteChoice{
|
||||
"aye": voteAye,
|
||||
"naye": voteNaye,
|
||||
"abstain": voteAbstain,
|
||||
}
|
||||
|
||||
var VoteChoices = map[int64]VoteChoice{
|
||||
1: voteAye,
|
||||
0: voteAbstain,
|
||||
-1: voteNaye,
|
||||
}
|
||||
|
||||
const (
|
||||
voteStatusDeclined = -1
|
||||
voteStatusPending = 0
|
||||
voteStatusApproved = 1
|
||||
voteStatusWithdrawn = -2
|
||||
)
|
||||
|
||||
func (v VoteStatus) String() string {
|
||||
switch v {
|
||||
case voteStatusDeclined:
|
||||
return "declined"
|
||||
case voteStatusPending:
|
||||
return "pending"
|
||||
case voteStatusApproved:
|
||||
return "approved"
|
||||
case voteStatusWithdrawn:
|
||||
return "withdrawn"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
type Vote struct {
|
||||
DecisionID int64 `db:"decision"`
|
||||
VoterID int64 `db:"voter"`
|
||||
Vote VoteChoice
|
||||
Voted time.Time
|
||||
Notes string
|
||||
}
|
||||
|
||||
type DbHandler struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
var db *DbHandler
|
||||
|
||||
// go:embed boardvoting/migrations/*
|
||||
var migrations embed.FS
|
||||
|
||||
func NewDB(database *sql.DB) *DbHandler {
|
||||
handler := &DbHandler{db: sqlx.NewDb(database, "sqlite3")}
|
||||
|
||||
source, err := iofs.New(migrations, "boardvoting/migrations")
|
||||
if err != nil {
|
||||
log.Panicf("could not create migration source: %v", err)
|
||||
}
|
||||
|
||||
driver, err := sqlite3.WithInstance(database, &sqlite3.Config{})
|
||||
if err != nil {
|
||||
log.Panicf("could not create migration driver: %v", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", source, "sqlite3", driver)
|
||||
if err != nil {
|
||||
log.Panicf("could not create migration instance: %v", err)
|
||||
}
|
||||
|
||||
m.Log = NewLogger()
|
||||
|
||||
err = m.Up()
|
||||
if err != nil {
|
||||
if !errors.Is(err, migrate.ErrNoChange) {
|
||||
log.Panicf("running database migration failed: %v", err)
|
||||
}
|
||||
|
||||
log.Info("no database migrations required")
|
||||
} else {
|
||||
log.Info("applied database migrations")
|
||||
}
|
||||
|
||||
failedStatements := make([]string, 0)
|
||||
|
||||
for _, sqlStatement := range sqlStatements {
|
||||
var stmt *sqlx.Stmt
|
||||
|
||||
stmt, err := handler.db.Preparex(sqlStatement)
|
||||
if err != nil {
|
||||
log.Errorf("error parsing statement %s: %s", sqlStatement, err)
|
||||
failedStatements = append(failedStatements, sqlStatement)
|
||||
}
|
||||
|
||||
_ = stmt.Close()
|
||||
}
|
||||
|
||||
if len(failedStatements) > 0 {
|
||||
log.Panicf("%d statements failed to prepare", len(failedStatements))
|
||||
}
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
type migrationLogger struct{}
|
||||
|
||||
func (m migrationLogger) Printf(format string, v ...interface{}) {
|
||||
log.Printf(format, v...)
|
||||
}
|
||||
|
||||
func (m migrationLogger) Verbose() bool {
|
||||
return log.IsLevelEnabled(log.DebugLevel)
|
||||
}
|
||||
|
||||
func NewLogger() migrate.Logger {
|
||||
return &migrationLogger{}
|
||||
}
|
||||
|
||||
func (d *DbHandler) Close() error {
|
||||
if err := d.db.Close(); err != nil {
|
||||
return fmt.Errorf("could not close database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DbHandler) getPreparedNamedStatement(statementKey sqlKey) *sqlx.NamedStmt {
|
||||
statement, err := d.db.PrepareNamed(sqlStatements[statementKey])
|
||||
if err != nil {
|
||||
log.Panicf("Preparing statement failed: %v", err)
|
||||
}
|
||||
|
||||
return statement
|
||||
}
|
||||
|
||||
func (d *DbHandler) getPreparedStatement(statementKey sqlKey) *sqlx.Stmt {
|
||||
statement, err := d.db.Preparex(sqlStatements[statementKey])
|
||||
if err != nil {
|
||||
log.Panicf("Preparing statement failed: %v", err)
|
||||
}
|
||||
|
||||
return statement
|
||||
}
|
||||
|
||||
func (v *Vote) Save() error {
|
||||
insertVoteStmt := db.getPreparedNamedStatement(sqlCreateVote)
|
||||
|
||||
defer func() { _ = insertVoteStmt.Close() }()
|
||||
|
||||
var err error
|
||||
|
||||
if _, err = insertVoteStmt.Exec(v); err != nil {
|
||||
return fmt.Errorf("saving vote failed: %w", err)
|
||||
}
|
||||
|
||||
getVoteStmt := db.getPreparedStatement(sqlLoadVote)
|
||||
|
||||
defer func() { _ = getVoteStmt.Close() }()
|
||||
|
||||
if err = getVoteStmt.Get(v, v.DecisionID, v.VoterID); err != nil {
|
||||
return fmt.Errorf("getting inserted vote failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type VoteSums struct {
|
||||
Ayes int
|
||||
Nayes int
|
||||
Abstains int
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (v *VoteSums) CalculateResult(quorum int, majority float32) (VoteStatus, string) {
|
||||
if v.VoteCount() < quorum {
|
||||
return voteStatusDeclined, fmt.Sprintf("Needed quorum of %d has not been reached.", quorum)
|
||||
}
|
||||
|
||||
if (float32(v.Ayes) / float32(v.TotalVotes())) < majority {
|
||||
return voteStatusDeclined, fmt.Sprintf("Needed majority of %0.2f%% has not been reached.", majority)
|
||||
}
|
||||
|
||||
return voteStatusApproved, "Quorum and majority have been reached"
|
||||
}
|
||||
|
||||
type VoteForDisplay struct {
|
||||
Vote
|
||||
Name string
|
||||
}
|
||||
|
||||
type DecisionForDisplay struct {
|
||||
Decision
|
||||
Proposer string `db:"proposer"`
|
||||
*VoteSums
|
||||
Votes []VoteForDisplay
|
||||
}
|
||||
|
||||
func FindDecisionForDisplayByTag(tag string) (*DecisionForDisplay, error) {
|
||||
decisionStmt := db.getPreparedStatement(sqlLoadDecisionByTag)
|
||||
|
||||
defer func() { _ = decisionStmt.Close() }()
|
||||
|
||||
decision := &DecisionForDisplay{}
|
||||
|
||||
var err error
|
||||
|
||||
if err = decisionStmt.Get(decision, tag); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("getting motion %s failed: %w", tag, err)
|
||||
}
|
||||
|
||||
decision.VoteSums, err = decision.Decision.VoteSums()
|
||||
|
||||
return decision, err
|
||||
}
|
||||
|
||||
// FindDecisionsForDisplayOnPage loads a set of decisions from the database.
|
||||
//
|
||||
// This function uses OFFSET for pagination which is not a good idea for larger data sets.
|
||||
//
|
||||
// TODO: migrate to timestamp base pagination
|
||||
func FindDecisionsForDisplayOnPage(page int, unVoted bool, voter *Voter) ([]*DecisionForDisplay, error) {
|
||||
var decisionsStmt *sqlx.Stmt
|
||||
|
||||
if unVoted && voter != nil {
|
||||
decisionsStmt = db.getPreparedStatement(sqlLoadUnVotedDecisions)
|
||||
} else {
|
||||
decisionsStmt = db.getPreparedStatement(sqlLoadDecisions)
|
||||
}
|
||||
|
||||
defer func() { _ = decisionsStmt.Close() }()
|
||||
|
||||
var (
|
||||
rows *sqlx.Rows
|
||||
err error
|
||||
decisions []*DecisionForDisplay
|
||||
)
|
||||
|
||||
if unVoted && voter != nil {
|
||||
rows, err = decisionsStmt.Queryx(voter.ID, page-1)
|
||||
} else {
|
||||
rows, err = decisionsStmt.Queryx(page - 1)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading motions for page %d failed: %w", page, err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var d DecisionForDisplay
|
||||
|
||||
if err = rows.StructScan(&d); err != nil {
|
||||
return nil, fmt.Errorf("loading motions for page %d failed: %w", page, err)
|
||||
}
|
||||
|
||||
d.VoteSums, err = d.Decision.VoteSums()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decisions = append(decisions, &d)
|
||||
}
|
||||
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
func (d *Decision) VoteSums() (*VoteSums, error) {
|
||||
votesStmt := db.getPreparedStatement(sqlLoadVoteCountsForDecision)
|
||||
|
||||
defer func() { _ = votesStmt.Close() }()
|
||||
|
||||
voteRows, err := votesStmt.Queryx(d.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching vote sums for motion %s failed: %w", d.Tag, err)
|
||||
}
|
||||
|
||||
defer func() { _ = voteRows.Close() }()
|
||||
|
||||
sums := &VoteSums{}
|
||||
|
||||
for voteRows.Next() {
|
||||
var (
|
||||
vote VoteChoice
|
||||
count int
|
||||
)
|
||||
|
||||
if err = voteRows.Scan(&vote, &count); err != nil {
|
||||
return nil, fmt.Errorf("fetching vote sums for motion %s failed: %w", d.Tag, err)
|
||||
}
|
||||
|
||||
switch vote {
|
||||
case voteAye:
|
||||
sums.Ayes = count
|
||||
case voteNaye:
|
||||
sums.Nayes = count
|
||||
case voteAbstain:
|
||||
sums.Abstains = count
|
||||
}
|
||||
}
|
||||
|
||||
return sums, nil
|
||||
}
|
||||
|
||||
func (d *DecisionForDisplay) LoadVotes() (err error) {
|
||||
votesStmt := db.getPreparedStatement(sqlLoadVotesForDecision)
|
||||
|
||||
defer func() { _ = votesStmt.Close() }()
|
||||
|
||||
err = votesStmt.Select(&d.Votes, d.ID)
|
||||
if err != nil {
|
||||
log.Errorf("selecting votes for motion %s failed: %v", d.Tag, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Decision) OlderExists(unvoted bool, voter *Voter) (bool, error) {
|
||||
var result bool
|
||||
|
||||
if unvoted && voter != nil {
|
||||
olderStmt := db.getPreparedStatement(sqlCountOlderThanUnVotedDecision)
|
||||
|
||||
defer func() { _ = olderStmt.Close() }()
|
||||
|
||||
if err := olderStmt.Get(&result, d.Proposed, voter.ID); err != nil {
|
||||
return false, fmt.Errorf("finding older motions than %s failed: %w", d.Tag, err)
|
||||
}
|
||||
} else {
|
||||
olderStmt := db.getPreparedStatement(sqlCountOlderThanDecision)
|
||||
|
||||
defer func() { _ = olderStmt.Close() }()
|
||||
|
||||
if err := olderStmt.Get(&result, d.Proposed); err != nil {
|
||||
return false, fmt.Errorf("finding older motions than %s failed: %w", d.Tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Decision) Create() error {
|
||||
insertDecisionStmt := db.getPreparedNamedStatement(sqlCreateDecision)
|
||||
|
||||
defer func() { _ = insertDecisionStmt.Close() }()
|
||||
|
||||
result, err := insertDecisionStmt.Exec(d)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating motion failed: %w", err)
|
||||
}
|
||||
|
||||
decisionID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting id of inserted motion failed: %w", err)
|
||||
}
|
||||
rescheduleChannel <- JobIDCloseDecisions
|
||||
|
||||
getDecisionStmt := db.getPreparedStatement(sqlLoadDecisionByID)
|
||||
|
||||
defer func() { _ = getDecisionStmt.Close() }()
|
||||
|
||||
err = getDecisionStmt.Get(d, decisionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting inserted motion failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Decision) LoadWithID() (err error) {
|
||||
getDecisionStmt := db.getPreparedStatement(sqlLoadDecisionByID)
|
||||
|
||||
defer func() { _ = getDecisionStmt.Close() }()
|
||||
|
||||
err = getDecisionStmt.Get(d, d.ID)
|
||||
if err != nil {
|
||||
log.Errorf("loading updated motion failed: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Decision) Update() (err error) {
|
||||
updateDecisionStmt := db.getPreparedNamedStatement(sqlUpdateDecision)
|
||||
|
||||
defer func() { _ = updateDecisionStmt.Close() }()
|
||||
|
||||
result, err := updateDecisionStmt.Exec(d)
|
||||
if err != nil {
|
||||
log.Errorf("updating motion failed: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
affectedRows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
log.Error("Problem determining the affected rows")
|
||||
|
||||
return
|
||||
} else if affectedRows != 1 {
|
||||
log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows)
|
||||
}
|
||||
|
||||
rescheduleChannel <- JobIDCloseDecisions
|
||||
|
||||
err = d.LoadWithID()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Decision) UpdateStatus() error {
|
||||
updateStatusStmt := db.getPreparedNamedStatement(sqlUpdateDecisionStatus)
|
||||
|
||||
defer func() { _ = updateStatusStmt.Close() }()
|
||||
|
||||
result, err := updateStatusStmt.Exec(d)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting motion status failed: %w", err)
|
||||
}
|
||||
|
||||
affectedRows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("determining the affected rows failed: %w", err)
|
||||
} else if affectedRows != 1 {
|
||||
log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows)
|
||||
}
|
||||
|
||||
rescheduleChannel <- JobIDCloseDecisions
|
||||
|
||||
err = d.LoadWithID()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Decision) String() string {
|
||||
return fmt.Sprintf("%s %s (ID %d)", d.Tag, d.Title, d.ID)
|
||||
}
|
||||
|
||||
func FindVoterByAddress(emailAddress string) (*Voter, error) {
|
||||
findVoterStmt := db.getPreparedStatement(sqlLoadEnabledVoterByEmail)
|
||||
|
||||
defer func() { _ = findVoterStmt.Close() }()
|
||||
|
||||
voter := &Voter{}
|
||||
if err := findVoterStmt.Get(voter, emailAddress); err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("getting voter for address %s failed: %w", emailAddress, err)
|
||||
}
|
||||
|
||||
voter = nil
|
||||
}
|
||||
|
||||
return voter, nil
|
||||
}
|
||||
|
||||
func (d *Decision) Close() error {
|
||||
quorum, majority := d.VoteType.QuorumAndMajority()
|
||||
|
||||
var (
|
||||
voteSums *VoteSums
|
||||
err error
|
||||
)
|
||||
|
||||
if voteSums, err = d.VoteSums(); err != nil {
|
||||
log.Errorf("getting vote sums failed: %v", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
var reasoning string
|
||||
|
||||
d.Status, reasoning = voteSums.CalculateResult(quorum, majority)
|
||||
|
||||
closeDecisionStmt := db.getPreparedNamedStatement(sqlUpdateDecisionStatus)
|
||||
|
||||
defer func() { _ = closeDecisionStmt.Close() }()
|
||||
|
||||
result, err := closeDecisionStmt.Exec(d)
|
||||
if err != nil {
|
||||
return fmt.Errorf("closing vote failed: %w", err)
|
||||
}
|
||||
|
||||
affectedRows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting affected rows failed: %w", err)
|
||||
}
|
||||
|
||||
if affectedRows != 1 {
|
||||
log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows)
|
||||
}
|
||||
|
||||
NotifyMailChannel <- NewNotificationClosedDecision(d, voteSums, reasoning)
|
||||
|
||||
log.Infof("decision %s closed with result %s: reasoning %s", d.Tag, d.Status, reasoning)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CloseDecisions() error {
|
||||
getClosableDecisionsStmt := db.getPreparedNamedStatement(sqlSelectClosableDecisions)
|
||||
|
||||
defer func() { _ = getClosableDecisionsStmt.Close() }()
|
||||
|
||||
decisions := make([]*Decision, 0)
|
||||
|
||||
rows, err := getClosableDecisionsStmt.Queryx(struct{ Now time.Time }{time.Now().UTC()})
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching closable decisions failed: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
decision := &Decision{}
|
||||
if err = rows.StructScan(decision); err != nil {
|
||||
return fmt.Errorf("scanning row failed: %w", err)
|
||||
}
|
||||
|
||||
decisions = append(decisions, decision)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for _, decision := range decisions {
|
||||
log.Infof("found closable decision %s", decision.Tag)
|
||||
|
||||
if err = decision.Close(); err != nil {
|
||||
return fmt.Errorf("closing decision %s failed: %w", decision.Tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetNextPendingDecisionDue() (*time.Time, error) {
|
||||
getNextPendingDecisionDueStmt := db.getPreparedStatement(sqlGetNextPendingDecisionDue)
|
||||
|
||||
defer func() { _ = getNextPendingDecisionDueStmt.Close() }()
|
||||
|
||||
row := getNextPendingDecisionDueStmt.QueryRow()
|
||||
|
||||
due := &time.Time{}
|
||||
if err := row.Scan(due); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Debug("No pending decisions")
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("parsing result failed: %w", err)
|
||||
}
|
||||
|
||||
return due, nil
|
||||
}
|
||||
|
||||
func GetReminderVoters() ([]Voter, error) {
|
||||
getReminderVotersStmt := db.getPreparedStatement(sqlGetReminderVoters)
|
||||
|
||||
defer func() { _ = getReminderVotersStmt.Close() }()
|
||||
|
||||
var voters []Voter
|
||||
|
||||
if err := getReminderVotersStmt.Select(&voters); err != nil {
|
||||
return nil, fmt.Errorf("getting voters failed: %w", err)
|
||||
}
|
||||
|
||||
return voters, nil
|
||||
}
|
||||
|
||||
func FindUnVotedDecisionsForVoter(voter *Voter) ([]Decision, error) {
|
||||
findUnVotedDecisionsForVoterStmt := db.getPreparedStatement(sqlFindUnVotedDecisionsForVoter)
|
||||
|
||||
defer func() { _ = findUnVotedDecisionsForVoterStmt.Close() }()
|
||||
|
||||
var decisions []Decision
|
||||
|
||||
if err := findUnVotedDecisionsForVoterStmt.Select(&decisions, voter.ID); err != nil {
|
||||
return nil, fmt.Errorf("getting unvoted decisions failed: %w", err)
|
||||
}
|
||||
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
func GetVoterByID(id int64) (*Voter, error) {
|
||||
getVoterByIDStmt := db.getPreparedStatement(sqlGetEnabledVoterByID)
|
||||
|
||||
defer func() { _ = getVoterByIDStmt.Close() }()
|
||||
|
||||
voter := &Voter{}
|
||||
if err := getVoterByIDStmt.Get(voter, id); err != nil {
|
||||
return nil, fmt.Errorf("getting voter failed: %w", err)
|
||||
}
|
||||
|
||||
return voter, nil
|
||||
}
|
||||
|
||||
func GetVotersForProxy(proxy *Voter) (voters *[]Voter, err error) {
|
||||
getVotersForProxyStmt := db.getPreparedStatement(sqlGetVotersForProxy)
|
||||
|
||||
defer func() { _ = getVotersForProxyStmt.Close() }()
|
||||
|
||||
votersSlice := make([]Voter, 0)
|
||||
|
||||
if err = getVotersForProxyStmt.Select(&votersSlice, proxy.ID); err != nil {
|
||||
log.Errorf("Error getting voters for proxy failed: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
voters = &votersSlice
|
||||
|
||||
return
|
||||
}
|
@ -1,347 +0,0 @@
|
||||
/*
|
||||
Copyright 2017-2022 CAcert Inc.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"text/template"
|
||||
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
"gopkg.in/mail.v2"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type headerData struct {
|
||||
name string
|
||||
value []string
|
||||
}
|
||||
|
||||
type headerList []headerData
|
||||
|
||||
type recipientData struct {
|
||||
field, address, name string
|
||||
}
|
||||
|
||||
type NotificationContent struct {
|
||||
template string
|
||||
data interface{}
|
||||
subject string
|
||||
headers headerList
|
||||
recipients []recipientData
|
||||
}
|
||||
|
||||
type NotificationMail interface {
|
||||
GetNotificationContent() *NotificationContent
|
||||
}
|
||||
|
||||
var NotifyMailChannel = make(chan NotificationMail, 1)
|
||||
|
||||
func MailNotifier(quitMailNotifier chan int) {
|
||||
log.Info("Launched mail notifier")
|
||||
|
||||
for {
|
||||
select {
|
||||
case notification := <-NotifyMailChannel:
|
||||
content := notification.GetNotificationContent()
|
||||
|
||||
mailText, err := buildMail(content.template, content.data)
|
||||
if err != nil {
|
||||
log.Errorf("building mail failed: %v", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
m := mail.NewMessage()
|
||||
m.SetAddressHeader("From", config.NotificationSenderAddress, "CAcert board voting system")
|
||||
|
||||
for _, recipient := range content.recipients {
|
||||
m.SetAddressHeader(recipient.field, recipient.address, recipient.name)
|
||||
}
|
||||
|
||||
m.SetHeader("Subject", content.subject)
|
||||
|
||||
for _, header := range content.headers {
|
||||
m.SetHeader(header.name, header.value...)
|
||||
}
|
||||
|
||||
m.SetBody("text/plain", mailText.String())
|
||||
|
||||
d := mail.NewDialer(config.MailServer.Host, config.MailServer.Port, "", "")
|
||||
if err := d.DialAndSend(m); err != nil {
|
||||
log.Errorf("sending mail failed: %v", err)
|
||||
}
|
||||
case <-quitMailNotifier:
|
||||
log.Info("Ending mail notifier")
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//go:embed boardvoting/templates
|
||||
var mailTemplates embed.FS
|
||||
|
||||
func buildMail(templateName string, context interface{}) (mailText *bytes.Buffer, err error) {
|
||||
b, err := mailTemplates.ReadFile(fmt.Sprintf("templates/%s", templateName))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
t, err := template.New(templateName).Funcs(sprig.GenericFuncMap()).Parse(string(b))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
mailText = bytes.NewBufferString("")
|
||||
if err := t.Execute(mailText, context); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to execute template %s with context %+v: %w",
|
||||
templateName, context, err,
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
type notificationBase struct{}
|
||||
|
||||
func (n *notificationBase) getRecipient() recipientData {
|
||||
return recipientData{field: "To", address: config.NoticeMailAddress, name: "CAcert board mailing list"}
|
||||
}
|
||||
|
||||
type decisionReplyBase struct {
|
||||
decision Decision
|
||||
}
|
||||
|
||||
func (n *decisionReplyBase) getHeaders() headerList {
|
||||
headers := make(headerList, 0)
|
||||
headers = append(headers, headerData{
|
||||
name: "References", value: []string{fmt.Sprintf("<%s>", n.decision.Tag)},
|
||||
})
|
||||
headers = append(headers, headerData{
|
||||
name: "In-Reply-To", value: []string{fmt.Sprintf("<%s>", n.decision.Tag)},
|
||||
})
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
func (n *decisionReplyBase) getSubject() string {
|
||||
return fmt.Sprintf("Re: %s - %s", n.decision.Tag, n.decision.Title)
|
||||
}
|
||||
|
||||
type notificationClosedDecision struct {
|
||||
notificationBase
|
||||
decisionReplyBase
|
||||
voteSums VoteSums
|
||||
reasoning string
|
||||
}
|
||||
|
||||
func NewNotificationClosedDecision(decision *Decision, voteSums *VoteSums, reasoning string) NotificationMail {
|
||||
notification := ¬ificationClosedDecision{voteSums: *voteSums, reasoning: reasoning}
|
||||
notification.decision = *decision
|
||||
|
||||
return notification
|
||||
}
|
||||
|
||||
func (n *notificationClosedDecision) GetNotificationContent() *NotificationContent {
|
||||
return &NotificationContent{
|
||||
template: "closed_motion_mail.txt",
|
||||
data: struct {
|
||||
*Decision
|
||||
*VoteSums
|
||||
Reasoning string
|
||||
}{&n.decision, &n.voteSums, n.reasoning},
|
||||
subject: fmt.Sprintf("Re: %s - %s - finalised", n.decision.Tag, n.decision.Title),
|
||||
headers: n.decisionReplyBase.getHeaders(),
|
||||
recipients: []recipientData{n.notificationBase.getRecipient()},
|
||||
}
|
||||
}
|
||||
|
||||
type NotificationCreateMotion struct {
|
||||
notificationBase
|
||||
decision Decision
|
||||
voter Voter
|
||||
}
|
||||
|
||||
func (n *NotificationCreateMotion) GetNotificationContent() *NotificationContent {
|
||||
voteURL := fmt.Sprintf("%s/vote/%s", config.BaseURL, n.decision.Tag)
|
||||
unvotedURL := fmt.Sprintf("%s/motions/?unvoted=1", config.BaseURL)
|
||||
|
||||
return &NotificationContent{
|
||||
template: "create_motion_mail.txt",
|
||||
data: struct {
|
||||
*Decision
|
||||
Name string
|
||||
VoteURL string
|
||||
UnvotedURL string
|
||||
}{&n.decision, n.voter.Name, voteURL, unvotedURL},
|
||||
subject: fmt.Sprintf("%s - %s", n.decision.Tag, n.decision.Title),
|
||||
headers: headerList{headerData{"Message-ID", []string{fmt.Sprintf("<%s>", n.decision.Tag)}}},
|
||||
recipients: []recipientData{n.notificationBase.getRecipient()},
|
||||
}
|
||||
}
|
||||
|
||||
type notificationUpdateMotion struct {
|
||||
notificationBase
|
||||
decisionReplyBase
|
||||
voter Voter
|
||||
}
|
||||
|
||||
func NewNotificationUpdateMotion(decision Decision, voter Voter) NotificationMail {
|
||||
notification := notificationUpdateMotion{voter: voter}
|
||||
notification.decision = decision
|
||||
|
||||
return ¬ification
|
||||
}
|
||||
|
||||
func (n *notificationUpdateMotion) GetNotificationContent() *NotificationContent {
|
||||
voteURL := fmt.Sprintf("%s/vote/%s", config.BaseURL, n.decision.Tag)
|
||||
unvotedURL := fmt.Sprintf("%s/motions/?unvoted=1", config.BaseURL)
|
||||
|
||||
return &NotificationContent{
|
||||
template: "update_motion_mail.txt",
|
||||
data: struct {
|
||||
*Decision
|
||||
Name string
|
||||
VoteURL string
|
||||
UnvotedURL string
|
||||
}{&n.decision, n.voter.Name, voteURL, unvotedURL},
|
||||
subject: n.decisionReplyBase.getSubject(),
|
||||
headers: n.decisionReplyBase.getHeaders(),
|
||||
recipients: []recipientData{n.notificationBase.getRecipient()},
|
||||
}
|
||||
}
|
||||
|
||||
type notificationWithDrawMotion struct {
|
||||
notificationBase
|
||||
decisionReplyBase
|
||||
voter Voter
|
||||
}
|
||||
|
||||
func NewNotificationWithDrawMotion(decision *Decision, voter *Voter) NotificationMail {
|
||||
notification := ¬ificationWithDrawMotion{voter: *voter}
|
||||
notification.decision = *decision
|
||||
|
||||
return notification
|
||||
}
|
||||
|
||||
func (n *notificationWithDrawMotion) GetNotificationContent() *NotificationContent {
|
||||
return &NotificationContent{
|
||||
template: "withdraw_motion_mail.txt",
|
||||
data: struct {
|
||||
*Decision
|
||||
Name string
|
||||
}{&n.decision, n.voter.Name},
|
||||
subject: fmt.Sprintf("Re: %s - %s - withdrawn", n.decision.Tag, n.decision.Title),
|
||||
headers: n.decisionReplyBase.getHeaders(),
|
||||
recipients: []recipientData{n.notificationBase.getRecipient()},
|
||||
}
|
||||
}
|
||||
|
||||
type RemindVoterNotification struct {
|
||||
voter Voter
|
||||
decisions []Decision
|
||||
}
|
||||
|
||||
func (n *RemindVoterNotification) GetNotificationContent() *NotificationContent {
|
||||
return &NotificationContent{
|
||||
template: "remind_voter_mail.txt",
|
||||
data: struct {
|
||||
Decisions []Decision
|
||||
Name string
|
||||
BaseURL string
|
||||
}{n.decisions, n.voter.Name, config.BaseURL},
|
||||
subject: "Outstanding CAcert board votes",
|
||||
recipients: []recipientData{{"To", n.voter.Reminder, n.voter.Name}},
|
||||
}
|
||||
}
|
||||
|
||||
type voteNotificationBase struct{}
|
||||
|
||||
func (n *voteNotificationBase) getRecipient() recipientData {
|
||||
return recipientData{"To", config.VoteNoticeMailAddress, "CAcert board votes mailing list"}
|
||||
}
|
||||
|
||||
type notificationProxyVote struct {
|
||||
voteNotificationBase
|
||||
decisionReplyBase
|
||||
proxy Voter
|
||||
voter Voter
|
||||
vote Vote
|
||||
justification string
|
||||
}
|
||||
|
||||
func NewNotificationProxyVote(
|
||||
decision *Decision,
|
||||
proxy *Voter,
|
||||
voter *Voter,
|
||||
vote *Vote,
|
||||
justification string,
|
||||
) NotificationMail {
|
||||
notification := ¬ificationProxyVote{proxy: *proxy, voter: *voter, vote: *vote, justification: justification}
|
||||
notification.decision = *decision
|
||||
|
||||
return notification
|
||||
}
|
||||
|
||||
func (n *notificationProxyVote) GetNotificationContent() *NotificationContent {
|
||||
return &NotificationContent{
|
||||
template: "proxy_vote_mail.txt",
|
||||
data: struct {
|
||||
Proxy string
|
||||
Vote VoteChoice
|
||||
Voter string
|
||||
Decision *Decision
|
||||
Justification string
|
||||
}{n.proxy.Name, n.vote.Vote, n.voter.Name, &n.decision, n.justification},
|
||||
subject: n.decisionReplyBase.getSubject(),
|
||||
headers: n.decisionReplyBase.getHeaders(),
|
||||
recipients: []recipientData{n.voteNotificationBase.getRecipient()},
|
||||
}
|
||||
}
|
||||
|
||||
type notificationDirectVote struct {
|
||||
voteNotificationBase
|
||||
decisionReplyBase
|
||||
voter Voter
|
||||
vote Vote
|
||||
}
|
||||
|
||||
func NewNotificationDirectVote(decision *Decision, voter *Voter, vote *Vote) NotificationMail {
|
||||
notification := ¬ificationDirectVote{voter: *voter, vote: *vote}
|
||||
notification.decision = *decision
|
||||
|
||||
return notification
|
||||
}
|
||||
|
||||
func (n *notificationDirectVote) GetNotificationContent() *NotificationContent {
|
||||
return &NotificationContent{
|
||||
template: "direct_vote_mail.txt",
|
||||
data: struct {
|
||||
Vote VoteChoice
|
||||
Voter string
|
||||
Decision *Decision
|
||||
}{n.vote.Vote, n.voter.Name, &n.decision},
|
||||
subject: n.decisionReplyBase.getSubject(),
|
||||
headers: n.decisionReplyBase.getHeaders(),
|
||||
recipients: []recipientData{n.voteNotificationBase.getRecipient()},
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue