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 connection
This commit is contained in:
parent
28ddbd2ce6
commit
368bd8eefb
35 changed files with 416 additions and 3406 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -4,10 +4,11 @@
|
||||||
*.pem
|
*.pem
|
||||||
*.req.conf
|
*.req.conf
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
*.sqlite-journal
|
||||||
.*.swp
|
.*.swp
|
||||||
.idea/
|
.idea/
|
||||||
|
/dist/
|
||||||
|
/ui/semantic/dist/
|
||||||
cacert-boardvoting
|
cacert-boardvoting
|
||||||
config.yaml
|
config.yaml
|
||||||
node_modules/
|
node_modules/
|
||||||
/dist/
|
|
||||||
/ui/semantic/dist/
|
|
||||||
|
|
1120
boardvoting.go
1120
boardvoting.go
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" . }}
|
|
|
@ -30,11 +30,13 @@ const (
|
||||||
httpReadHeaderTimeout = 5 * time.Second
|
httpReadHeaderTimeout = 5 * time.Second
|
||||||
httpReadTimeout = 5 * time.Second
|
httpReadTimeout = 5 * time.Second
|
||||||
httpWriteTimeout = 10 * time.Second
|
httpWriteTimeout = 10 * time.Second
|
||||||
|
smtpTimeout = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type mailConfig struct {
|
type mailConfig struct {
|
||||||
SMTPHost string `yaml:"smtp_host"`
|
SMTPHost string `yaml:"smtp_host"`
|
||||||
SMTPPort int `yaml:"smtp_port"`
|
SMTPPort int `yaml:"smtp_port"`
|
||||||
|
SMTPTimeOut time.Duration `yaml:"smtp_timeout,omitempty"`
|
||||||
NotificationSenderAddress string `yaml:"notification_sender_address"`
|
NotificationSenderAddress string `yaml:"notification_sender_address"`
|
||||||
NoticeMailAddress string `yaml:"notice_mail_address"`
|
NoticeMailAddress string `yaml:"notice_mail_address"`
|
||||||
VoteNoticeMailAddress string `yaml:"vote_notice_mail_address"`
|
VoteNoticeMailAddress string `yaml:"vote_notice_mail_address"`
|
||||||
|
@ -76,6 +78,11 @@ func parseConfig(configFile string) (*Config, error) {
|
||||||
Read: httpReadTimeout,
|
Read: httpReadTimeout,
|
||||||
Write: httpWriteTimeout,
|
Write: httpWriteTimeout,
|
||||||
},
|
},
|
||||||
|
MailConfig: &mailConfig{
|
||||||
|
SMTPHost: "localhost",
|
||||||
|
SMTPPort: 25,
|
||||||
|
SMTPTimeOut: smtpTimeout,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := yaml.Unmarshal(source, config); err != nil {
|
if err := yaml.Unmarshal(source, config); err != nil {
|
||||||
|
|
|
@ -31,8 +31,8 @@ import (
|
||||||
"git.cacert.org/cacert-boardvoting/internal/models"
|
"git.cacert.org/cacert-boardvoting/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func checkRole(v *models.User, roles []string) (bool, error) {
|
func checkRole(v *models.User, roles ...models.RoleName) (bool, error) {
|
||||||
hasRole, err := v.HasRole(roles)
|
hasRole, err := v.HasRole(roles...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("could not determine user roles: %w", err)
|
return false, fmt.Errorf("could not determine user roles: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -97,7 +97,7 @@ func (app *application) motionList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
motions, err := app.motions.GetMotions(ctx, listOptions)
|
motions, err := app.motions.List(ctx, listOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.serverError(w, err)
|
app.serverError(w, err)
|
||||||
|
|
||||||
|
@ -247,17 +247,19 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
decision, err := app.motions.GetByID(r.Context(), decisionID)
|
decision, err := app.motions.ByID(r.Context(), decisionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.serverError(w, err)
|
app.serverError(w, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.mailNotifier.notifyChannel <- &NewDecisionNotification{
|
app.mailNotifier.Notify(&NewDecisionNotification{
|
||||||
Decision: decision,
|
Decision: decision,
|
||||||
Proposer: user,
|
Proposer: user,
|
||||||
}
|
})
|
||||||
|
|
||||||
|
app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters)
|
||||||
|
|
||||||
app.sessionManager.Put(r.Context(), "flash", fmt.Sprintf("Started new motion %s: %s", decision.Tag, decision.Title))
|
app.sessionManager.Put(r.Context(), "flash", fmt.Sprintf("Started new motion %s: %s", decision.Tag, decision.Title))
|
||||||
|
|
||||||
|
@ -341,17 +343,19 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
decision, err := app.motions.GetByID(r.Context(), motion.ID)
|
decision, err := app.motions.ByID(r.Context(), motion.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.serverError(w, err)
|
app.serverError(w, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.mailNotifier.notifyChannel <- &UpdateDecisionNotification{
|
app.mailNotifier.Notify(&UpdateDecisionNotification{
|
||||||
Decision: decision,
|
Decision: decision,
|
||||||
User: user,
|
User: user,
|
||||||
}
|
})
|
||||||
|
|
||||||
|
app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters)
|
||||||
|
|
||||||
app.sessionManager.Put(
|
app.sessionManager.Put(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
|
@ -396,16 +400,17 @@ func (app *application) withdrawMotionSubmit(w http.ResponseWriter, r *http.Requ
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = app.motions.Update(r.Context(), motion.ID, func(m *models.Motion) {
|
err = app.motions.Withdraw(r.Context(), motion.ID)
|
||||||
m.Status = models.VoteStatusWithdrawn
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.serverError(w, err)
|
app.serverError(w, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.mailNotifier.notifyChannel <- &WithDrawMotionNotification{motion, user}
|
app.mailNotifier.Notify(&WithDrawMotionNotification{motion, user})
|
||||||
|
|
||||||
|
app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters)
|
||||||
|
|
||||||
app.sessionManager.Put(
|
app.sessionManager.Put(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
|
@ -481,9 +486,9 @@ func (app *application) voteSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.mailNotifier.notifyChannel <- &DirectVoteNotification{
|
app.mailNotifier.Notify(&DirectVoteNotification{
|
||||||
Decision: motion, User: user, Choice: choice,
|
Decision: motion, User: user, Choice: choice,
|
||||||
}
|
})
|
||||||
|
|
||||||
app.sessionManager.Put(
|
app.sessionManager.Put(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
|
@ -506,7 +511,7 @@ func (app *application) proxyVoteForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
data.Motion = motion
|
data.Motion = motion
|
||||||
|
|
||||||
potentialVoters, err := app.users.PotentialVoters(r.Context())
|
potentialVoters, err := app.users.Voters(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.serverError(w, err)
|
app.serverError(w, err)
|
||||||
|
|
||||||
|
@ -543,7 +548,7 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request)
|
||||||
data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll)
|
data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll)
|
||||||
data.Motion = motion
|
data.Motion = motion
|
||||||
|
|
||||||
potentialVoters, err := app.users.PotentialVoters(r.Context())
|
potentialVoters, err := app.users.Voters(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.serverError(w, err)
|
app.serverError(w, err)
|
||||||
|
|
||||||
|
@ -574,14 +579,14 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
voter, err := app.users.LoadVoter(r.Context(), form.VoterID)
|
voter, err := app.users.ByID(r.Context(), form.Voter.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.serverError(w, err)
|
app.serverError(w, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := app.motions.UpdateVote(r.Context(), form.VoterID, motion.ID, func(v *models.Vote) {
|
if err := app.motions.UpdateVote(r.Context(), form.Voter.ID, motion.ID, func(v *models.Vote) {
|
||||||
v.Vote = form.Choice
|
v.Vote = form.Choice
|
||||||
v.Voted = time.Now().UTC()
|
v.Voted = time.Now().UTC()
|
||||||
v.Notes = fmt.Sprintf("Proxy-Vote by %s\n\n%s\n\n%s", user.Name, form.Justification, clientCert)
|
v.Notes = fmt.Sprintf("Proxy-Vote by %s\n\n%s\n\n%s", user.Name, form.Justification, clientCert)
|
||||||
|
@ -591,9 +596,9 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.mailNotifier.notifyChannel <- &ProxyVoteNotification{
|
app.mailNotifier.Notify(&ProxyVoteNotification{
|
||||||
Decision: motion, User: user, Voter: voter, Choice: form.Choice, Justification: form.Justification,
|
Decision: motion, User: user, Voter: voter, Choice: form.Choice, Justification: form.Justification,
|
||||||
}
|
})
|
||||||
|
|
||||||
app.sessionManager.Put(
|
app.sessionManager.Put(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
|
@ -638,19 +643,41 @@ func (app *application) deleteUserSubmit(_ http.ResponseWriter, _ *http.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) healthCheck(w http.ResponseWriter, _ *http.Request) {
|
func (app *application) healthCheck(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
const (
|
||||||
|
ok = "OK"
|
||||||
|
failed = "FAILED"
|
||||||
|
)
|
||||||
|
|
||||||
response := struct {
|
response := struct {
|
||||||
DB string `json:"database"`
|
DB string `json:"database"`
|
||||||
}{DB: "ok"}
|
Mail string `json:"mail"`
|
||||||
|
}{DB: ok, Mail: ok}
|
||||||
|
|
||||||
enc := json.NewEncoder(w)
|
enc := json.NewEncoder(w)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Refresh", "10")
|
||||||
|
w.Header().Add("Cache-Control", "no-store")
|
||||||
|
|
||||||
err := app.motions.DB.Ping()
|
var err error
|
||||||
if err != nil {
|
var hasErrors = false
|
||||||
|
|
||||||
|
if err = app.mailNotifier.Ping(); err != nil {
|
||||||
|
hasErrors = true
|
||||||
|
|
||||||
|
response.Mail = failed
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = app.motions.DB.Ping(); err != nil {
|
||||||
|
hasErrors = true
|
||||||
|
|
||||||
|
response.DB = failed
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasErrors {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
response.DB = "FAILED"
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = enc.Encode(response)
|
_ = enc.Encode(response)
|
||||||
|
|
|
@ -19,12 +19,16 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path"
|
"path"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/lestrrat-go/tcputil"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
@ -44,7 +48,41 @@ func prepareTestDb(t *testing.T) *sqlx.DB {
|
||||||
return dbx
|
return dbx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func StartTestTcpServer(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
port, err := tcputil.EmptyPort()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
go func(port int) {
|
||||||
|
l, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not run test TCP listener: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func(l net.Listener) {
|
||||||
|
_ = l.Close()
|
||||||
|
}(l)
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := l.Accept()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not accept connection: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = conn.Close(); err != nil {
|
||||||
|
t.Errorf("could not close connection: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(port)
|
||||||
|
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplication_healthCheck(t *testing.T) {
|
func TestApplication_healthCheck(t *testing.T) {
|
||||||
|
port := StartTestTcpServer(t)
|
||||||
|
|
||||||
t.Run("check with valid DB", func(t *testing.T) {
|
t.Run("check with valid DB", func(t *testing.T) {
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
@ -55,8 +93,11 @@ func TestApplication_healthCheck(t *testing.T) {
|
||||||
|
|
||||||
app := &application{
|
app := &application{
|
||||||
motions: &models.MotionModel{DB: testDB},
|
motions: &models.MotionModel{DB: testDB},
|
||||||
|
mailConfig: &mailConfig{SMTPHost: "localhost", SMTPPort: port, SMTPTimeOut: 1 * time.Second},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.NewMailNotifier()
|
||||||
|
|
||||||
app.healthCheck(rr, r)
|
app.healthCheck(rr, r)
|
||||||
|
|
||||||
rs := rr.Result()
|
rs := rr.Result()
|
||||||
|
@ -81,8 +122,11 @@ func TestApplication_healthCheck(t *testing.T) {
|
||||||
|
|
||||||
app := &application{
|
app := &application{
|
||||||
motions: &models.MotionModel{DB: testDB},
|
motions: &models.MotionModel{DB: testDB},
|
||||||
|
mailConfig: &mailConfig{SMTPHost: "localhost", SMTPPort: port, SMTPTimeOut: 1 * time.Second},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.NewMailNotifier()
|
||||||
|
|
||||||
app.healthCheck(rr, r)
|
app.healthCheck(rr, r)
|
||||||
|
|
||||||
rs := rr.Result()
|
rs := rr.Result()
|
||||||
|
|
|
@ -89,13 +89,13 @@ func newTemplateCache() (map[string]*template.Template, error) {
|
||||||
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>"))
|
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>"))
|
||||||
}
|
}
|
||||||
funcMaps["canManageUsers"] = func(v *models.User) (bool, error) {
|
funcMaps["canManageUsers"] = func(v *models.User) (bool, error) {
|
||||||
return checkRole(v, []string{models.RoleSecretary, models.RoleAdmin})
|
return checkRole(v, models.RoleSecretary, models.RoleAdmin)
|
||||||
}
|
}
|
||||||
funcMaps["canVote"] = func(v *models.User) (bool, error) {
|
funcMaps["canVote"] = func(v *models.User) (bool, error) {
|
||||||
return checkRole(v, []string{models.RoleVoter})
|
return checkRole(v, models.RoleVoter)
|
||||||
}
|
}
|
||||||
funcMaps["canStartVote"] = func(v *models.User) (bool, error) {
|
funcMaps["canStartVote"] = func(v *models.User) (bool, error) {
|
||||||
return checkRole(v, []string{models.RoleVoter})
|
return checkRole(v, models.RoleVoter)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, page := range pages {
|
for _, page := range pages {
|
||||||
|
@ -180,7 +180,7 @@ func (app *application) motionFromRequestParam(
|
||||||
|
|
||||||
withVotes := r.URL.Query().Has("showvotes")
|
withVotes := r.URL.Query().Has("showvotes")
|
||||||
|
|
||||||
motion, err := app.motions.GetMotionByTag(r.Context(), tag, withVotes)
|
motion, err := app.motions.ByTag(r.Context(), tag, withVotes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.serverError(w, err)
|
app.serverError(w, err)
|
||||||
|
|
||||||
|
|
|
@ -36,11 +36,11 @@ type RemindVotersJob struct {
|
||||||
timer *time.Timer
|
timer *time.Timer
|
||||||
voters *models.UserModel
|
voters *models.UserModel
|
||||||
decisions *models.MotionModel
|
decisions *models.MotionModel
|
||||||
notify chan NotificationMail
|
notifier *MailNotifier
|
||||||
reschedule chan Job
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RemindVotersJob) Schedule() {
|
func (r *RemindVotersJob) Schedule() {
|
||||||
|
// TODO: check logic. It would make more sense to remind at a specific interval before the next pending decision is closed
|
||||||
const reminderDays = 3
|
const reminderDays = 3
|
||||||
|
|
||||||
year, month, day := time.Now().UTC().Date()
|
year, month, day := time.Now().UTC().Date()
|
||||||
|
@ -65,7 +65,7 @@ func (r *RemindVotersJob) Schedule() {
|
||||||
func (r *RemindVotersJob) Run() {
|
func (r *RemindVotersJob) Run() {
|
||||||
r.infoLog.Print("running RemindVotersJob")
|
r.infoLog.Print("running RemindVotersJob")
|
||||||
|
|
||||||
defer func(r *RemindVotersJob) { r.reschedule <- r }(r)
|
defer func(r *RemindVotersJob) { r.Schedule() }(r)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
voters []*models.User
|
voters []*models.User
|
||||||
|
@ -75,7 +75,7 @@ func (r *RemindVotersJob) Run() {
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
voters, err = r.voters.GetReminderVoters(ctx)
|
voters, err = r.voters.ReminderVoters(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.errorLog.Printf("problem getting voters: %v", err)
|
r.errorLog.Printf("problem getting voters: %v", err)
|
||||||
|
|
||||||
|
@ -85,7 +85,7 @@ func (r *RemindVotersJob) Run() {
|
||||||
for _, voter := range voters {
|
for _, voter := range voters {
|
||||||
v := voter
|
v := voter
|
||||||
|
|
||||||
decisions, err = r.decisions.UnVotedDecisionsForVoter(ctx, v)
|
decisions, err = r.decisions.UnvotedForVoter(ctx, v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.errorLog.Printf("problem getting unvoted decisions: %v", err)
|
r.errorLog.Printf("problem getting unvoted decisions: %v", err)
|
||||||
|
|
||||||
|
@ -93,7 +93,7 @@ func (r *RemindVotersJob) Run() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(decisions) > 0 {
|
if len(decisions) > 0 {
|
||||||
r.notify <- &RemindVoterNotification{voter: voter, decisions: decisions}
|
r.notifier.Notify(&RemindVoterNotification{voter: voter, decisions: decisions})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -106,16 +106,13 @@ func (r *RemindVotersJob) Stop() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) NewRemindVotersJob(
|
func (app *application) NewRemindVotersJob() Job {
|
||||||
rescheduleChannel chan Job,
|
|
||||||
) Job {
|
|
||||||
return &RemindVotersJob{
|
return &RemindVotersJob{
|
||||||
infoLog: app.infoLog,
|
infoLog: app.infoLog,
|
||||||
errorLog: app.errorLog,
|
errorLog: app.errorLog,
|
||||||
voters: app.users,
|
voters: app.users,
|
||||||
decisions: app.motions,
|
decisions: app.motions,
|
||||||
reschedule: rescheduleChannel,
|
notifier: app.mailNotifier,
|
||||||
notify: app.mailNotifier.notifyChannel,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,8 +121,7 @@ type CloseDecisionsJob struct {
|
||||||
infoLog *log.Logger
|
infoLog *log.Logger
|
||||||
errorLog *log.Logger
|
errorLog *log.Logger
|
||||||
decisions *models.MotionModel
|
decisions *models.MotionModel
|
||||||
reschedule chan Job
|
notifier *MailNotifier
|
||||||
notify chan NotificationMail
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CloseDecisionsJob) Schedule() {
|
func (c *CloseDecisionsJob) Schedule() {
|
||||||
|
@ -136,7 +132,7 @@ func (c *CloseDecisionsJob) Schedule() {
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
nextDue, err = c.decisions.NextPendingDecisionDue(ctx)
|
nextDue, err = c.decisions.NextPendingDue(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.errorLog.Printf("could not get next pending due date")
|
c.errorLog.Printf("could not get next pending due date")
|
||||||
|
|
||||||
|
@ -167,16 +163,23 @@ func (c *CloseDecisionsJob) Schedule() {
|
||||||
func (c *CloseDecisionsJob) Run() {
|
func (c *CloseDecisionsJob) Run() {
|
||||||
c.infoLog.Printf("running CloseDecisionsJob")
|
c.infoLog.Printf("running CloseDecisionsJob")
|
||||||
|
|
||||||
|
defer func(c *CloseDecisionsJob) { c.Schedule() }(c)
|
||||||
|
|
||||||
results, err := c.decisions.CloseDecisions(context.Background())
|
results, err := c.decisions.CloseDecisions(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.errorLog.Printf("closing decisions failed: %v", err)
|
c.errorLog.Printf("closing decisions failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, res := range results {
|
for _, res := range results {
|
||||||
c.notify <- &ClosedDecisionNotification{Decision: res}
|
c.infoLog.Printf(
|
||||||
}
|
"decision %s closed with result %s: reasoning '%s'",
|
||||||
|
res.Tag,
|
||||||
|
res.Status,
|
||||||
|
res.Reasoning,
|
||||||
|
)
|
||||||
|
|
||||||
c.reschedule <- c
|
c.notifier.Notify(&ClosedDecisionNotification{Decision: res})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CloseDecisionsJob) Stop() {
|
func (c *CloseDecisionsJob) Stop() {
|
||||||
|
@ -186,39 +189,43 @@ func (c *CloseDecisionsJob) Stop() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) NewCloseDecisionsJob(
|
func (app *application) NewCloseDecisionsJob() Job {
|
||||||
rescheduleChannel chan Job,
|
|
||||||
) Job {
|
|
||||||
return &CloseDecisionsJob{
|
return &CloseDecisionsJob{
|
||||||
infoLog: app.infoLog,
|
infoLog: app.infoLog,
|
||||||
errorLog: app.errorLog,
|
errorLog: app.errorLog,
|
||||||
decisions: app.motions,
|
decisions: app.motions,
|
||||||
reschedule: rescheduleChannel,
|
notifier: app.mailNotifier,
|
||||||
notify: app.mailNotifier.notifyChannel,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type JobIdentifier int
|
||||||
|
|
||||||
|
const (
|
||||||
|
JobIDCloseDecisions JobIdentifier = iota
|
||||||
|
JobIDRemindVoters
|
||||||
|
)
|
||||||
|
|
||||||
type JobScheduler struct {
|
type JobScheduler struct {
|
||||||
infoLogger *log.Logger
|
infoLogger *log.Logger
|
||||||
errorLogger *log.Logger
|
errorLogger *log.Logger
|
||||||
jobs []Job
|
jobs map[JobIdentifier]Job
|
||||||
rescheduleChannel chan Job
|
rescheduleChannel chan JobIdentifier
|
||||||
quitChannel chan struct{}
|
quitChannel chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) NewJobScheduler() {
|
func (app *application) NewJobScheduler() {
|
||||||
rescheduleChannel := make(chan Job, 1)
|
rescheduleChannel := make(chan JobIdentifier, 1)
|
||||||
|
|
||||||
app.jobScheduler = &JobScheduler{
|
app.jobScheduler = &JobScheduler{
|
||||||
infoLogger: app.infoLog,
|
infoLogger: app.infoLog,
|
||||||
errorLogger: app.errorLog,
|
errorLogger: app.errorLog,
|
||||||
jobs: make([]Job, 0, 2),
|
jobs: make(map[JobIdentifier]Job, 2),
|
||||||
rescheduleChannel: rescheduleChannel,
|
rescheduleChannel: rescheduleChannel,
|
||||||
quitChannel: make(chan struct{}),
|
quitChannel: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
app.jobScheduler.addJob(app.NewCloseDecisionsJob(rescheduleChannel))
|
app.jobScheduler.addJob(JobIDCloseDecisions, app.NewCloseDecisionsJob())
|
||||||
app.jobScheduler.addJob(app.NewRemindVotersJob(rescheduleChannel))
|
app.jobScheduler.addJob(JobIDRemindVoters, app.NewRemindVotersJob())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (js *JobScheduler) Schedule() {
|
func (js *JobScheduler) Schedule() {
|
||||||
|
@ -228,8 +235,8 @@ func (js *JobScheduler) Schedule() {
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case job := <-js.rescheduleChannel:
|
case jobId := <-js.rescheduleChannel:
|
||||||
job.Schedule()
|
js.jobs[jobId].Schedule()
|
||||||
case <-js.quitChannel:
|
case <-js.quitChannel:
|
||||||
for _, job := range js.jobs {
|
for _, job := range js.jobs {
|
||||||
job.Stop()
|
job.Stop()
|
||||||
|
@ -242,10 +249,16 @@ func (js *JobScheduler) Schedule() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (js *JobScheduler) addJob(job Job) {
|
func (js *JobScheduler) addJob(jobID JobIdentifier, job Job) {
|
||||||
js.jobs = append(js.jobs, job)
|
js.jobs[jobID] = job
|
||||||
}
|
}
|
||||||
|
|
||||||
func (js *JobScheduler) Quit() {
|
func (js *JobScheduler) Quit() {
|
||||||
js.quitChannel <- struct{}{}
|
js.quitChannel <- struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (js *JobScheduler) Reschedule(jobIDs ...JobIdentifier) {
|
||||||
|
for i := range jobIDs {
|
||||||
|
js.rescheduleChannel <- jobIDs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ limitations under the License.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
@ -30,6 +31,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -104,12 +106,11 @@ func main() {
|
||||||
app := &application{
|
app := &application{
|
||||||
errorLog: errorLog,
|
errorLog: errorLog,
|
||||||
infoLog: infoLog,
|
infoLog: infoLog,
|
||||||
motions: &models.MotionModel{DB: db, InfoLog: infoLog},
|
motions: &models.MotionModel{DB: db},
|
||||||
users: &models.UserModel{DB: db},
|
users: &models.UserModel{DB: db},
|
||||||
mailConfig: config.MailConfig,
|
mailConfig: config.MailConfig,
|
||||||
templateCache: templateCache,
|
templateCache: templateCache,
|
||||||
sessionManager: sessionManager,
|
sessionManager: sessionManager,
|
||||||
formDecoder: setupFormDecoder(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = internal.InitializeDb(db.DB, infoLog)
|
err = internal.InitializeDb(db.DB, infoLog)
|
||||||
|
@ -117,15 +118,18 @@ func main() {
|
||||||
errorLog.Fatal(err)
|
errorLog.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.setupFormDecoder()
|
||||||
|
|
||||||
app.NewMailNotifier()
|
app.NewMailNotifier()
|
||||||
defer app.mailNotifier.Quit()
|
defer app.mailNotifier.Quit()
|
||||||
|
|
||||||
go app.StartMailNotifier()
|
go app.mailNotifier.Start()
|
||||||
|
|
||||||
app.NewJobScheduler()
|
app.NewJobScheduler()
|
||||||
defer app.jobScheduler.Quit()
|
defer app.jobScheduler.Quit()
|
||||||
|
|
||||||
go app.jobScheduler.Schedule()
|
go app.jobScheduler.Schedule()
|
||||||
|
|
||||||
infoLog.Printf("Starting server on %s", config.HTTPAddress)
|
infoLog.Printf("Starting server on %s", config.HTTPAddress)
|
||||||
|
|
||||||
errChan := make(chan error, 1)
|
errChan := make(chan error, 1)
|
||||||
|
@ -144,7 +148,7 @@ func main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupFormDecoder() *form.Decoder {
|
func (app *application) setupFormDecoder() {
|
||||||
decoder := form.NewDecoder()
|
decoder := form.NewDecoder()
|
||||||
|
|
||||||
decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) {
|
decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) {
|
||||||
|
@ -163,8 +167,21 @@ func setupFormDecoder() *form.Decoder {
|
||||||
|
|
||||||
return v, nil
|
return v, nil
|
||||||
}, new(models.VoteChoice))
|
}, new(models.VoteChoice))
|
||||||
|
decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) {
|
||||||
|
userID, err := strconv.Atoi(values[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not convert value %s to user ID: %w", values[0], err)
|
||||||
|
}
|
||||||
|
|
||||||
return decoder
|
u, err := app.users.ByID(context.Background(), int64(userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not convert value %s to user: %w", values[0], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}, new(models.User))
|
||||||
|
|
||||||
|
app.formDecoder = decoder
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) startHTTPSServer(config *Config) error {
|
func (app *application) startHTTPSServer(config *Config) error {
|
||||||
|
|
|
@ -68,9 +68,24 @@ func (app *application) authenticateRequest(r *http.Request) (*models.User, *x50
|
||||||
}
|
}
|
||||||
|
|
||||||
clientCert := r.TLS.PeerCertificates[0]
|
clientCert := r.TLS.PeerCertificates[0]
|
||||||
|
|
||||||
|
allowClientAuth := false
|
||||||
|
for _, eku := range clientCert.ExtKeyUsage {
|
||||||
|
if eku == x509.ExtKeyUsageClientAuth {
|
||||||
|
allowClientAuth = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowClientAuth {
|
||||||
|
// presented certificate is not valid for client authentication
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
emails := clientCert.EmailAddresses
|
emails := clientCert.EmailAddresses
|
||||||
|
|
||||||
user, err := app.users.GetUser(r.Context(), emails)
|
user, err := app.users.ByEmails(r.Context(), emails)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("could not get user information from database: %w", err)
|
return nil, nil, fmt.Errorf("could not get user information from database: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -116,7 +131,7 @@ func (app *application) GetUser(r *http.Request) (*models.User, error) {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) HasRole(r *http.Request, roles []string) (bool, bool, error) {
|
func (app *application) HasRole(r *http.Request, roles ...models.RoleName) (bool, bool, error) {
|
||||||
user, err := app.GetUser(r)
|
user, err := app.GetUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, err
|
return false, false, err
|
||||||
|
@ -126,16 +141,21 @@ func (app *application) HasRole(r *http.Request, roles []string) (bool, bool, er
|
||||||
return false, false, nil
|
return false, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
roleMatched, err := user.HasRole(roles)
|
roleMatched, err := user.HasRole(roles...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, true, fmt.Errorf("could not determin user role assignment: %w", err)
|
return false, true, fmt.Errorf("could not determin user role assignment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !roleMatched {
|
if !roleMatched {
|
||||||
|
roleNames := make([]string, len(roles))
|
||||||
|
for idx := range roles {
|
||||||
|
roleNames[idx] = string(roles[idx])
|
||||||
|
}
|
||||||
|
|
||||||
app.errorLog.Printf(
|
app.errorLog.Printf(
|
||||||
"user %s does not have any of the required role(s) %s assigned",
|
"user %s does not have any of the required role(s) %s assigned",
|
||||||
user.Name,
|
user.Name,
|
||||||
strings.Join(roles, ", "),
|
strings.Join(roleNames, ", "),
|
||||||
)
|
)
|
||||||
|
|
||||||
return false, true, nil
|
return false, true, nil
|
||||||
|
@ -144,9 +164,9 @@ func (app *application) HasRole(r *http.Request, roles []string) (bool, bool, er
|
||||||
return true, true, nil
|
return true, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) requireRole(next http.Handler, roles []string) http.Handler {
|
func (app *application) requireRole(next http.Handler, roles ...models.RoleName) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
hasRole, hasUser, err := app.HasRole(r, roles)
|
hasRole, hasUser, err := app.HasRole(r, roles...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.serverError(w, err)
|
app.serverError(w, err)
|
||||||
|
|
||||||
|
@ -170,15 +190,15 @@ func (app *application) requireRole(next http.Handler, roles []string) http.Hand
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) userCanVote(next http.Handler) http.Handler {
|
func (app *application) userCanVote(next http.Handler) http.Handler {
|
||||||
return app.requireRole(next, []string{models.RoleVoter})
|
return app.requireRole(next, models.RoleVoter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) userCanEditVote(next http.Handler) http.Handler {
|
func (app *application) userCanEditVote(next http.Handler) http.Handler {
|
||||||
return app.requireRole(next, []string{models.RoleVoter})
|
return app.requireRole(next, models.RoleVoter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) userCanChangeVoters(next http.Handler) http.Handler {
|
func (app *application) userCanChangeVoters(next http.Handler) http.Handler {
|
||||||
return app.requireRole(next, []string{models.RoleSecretary, models.RoleAdmin})
|
return app.requireRole(next, models.RoleSecretary, models.RoleAdmin)
|
||||||
}
|
}
|
||||||
|
|
||||||
func noSurf(next http.Handler) http.Handler {
|
func noSurf(next http.Handler) http.Handler {
|
||||||
|
|
|
@ -96,7 +96,7 @@ func TestApplication_tryAuthenticate(t *testing.T) {
|
||||||
|
|
||||||
users := &models.UserModel{DB: db}
|
users := &models.UserModel{DB: db}
|
||||||
|
|
||||||
_, err = users.CreateUser(
|
_, err = users.Create(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
"Test User",
|
"Test User",
|
||||||
"test@example.org",
|
"test@example.org",
|
||||||
|
@ -151,7 +151,10 @@ func TestApplication_tryAuthenticate(t *testing.T) {
|
||||||
r, err := http.NewRequest(http.MethodGet, "/", nil)
|
r, err := http.NewRequest(http.MethodGet, "/", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{EmailAddresses: []string{"test@example.org"}}}}
|
r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{
|
||||||
|
EmailAddresses: []string{"test@example.org"},
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||||
|
}}}
|
||||||
|
|
||||||
app.tryAuthenticate(next).ServeHTTP(rr, r)
|
app.tryAuthenticate(next).ServeHTTP(rr, r)
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,8 @@ package main
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
"path"
|
"path"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
|
@ -51,6 +53,8 @@ type MailNotifier struct {
|
||||||
senderAddress string
|
senderAddress string
|
||||||
dialer *mail.Dialer
|
dialer *mail.Dialer
|
||||||
quitChannel chan struct{}
|
quitChannel chan struct{}
|
||||||
|
infoLog, errorLog *log.Logger
|
||||||
|
mailConfig *mailConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) NewMailNotifier() {
|
func (app *application) NewMailNotifier() {
|
||||||
|
@ -59,20 +63,23 @@ func (app *application) NewMailNotifier() {
|
||||||
senderAddress: app.mailConfig.NotificationSenderAddress,
|
senderAddress: app.mailConfig.NotificationSenderAddress,
|
||||||
dialer: mail.NewDialer(app.mailConfig.SMTPHost, app.mailConfig.SMTPPort, "", ""),
|
dialer: mail.NewDialer(app.mailConfig.SMTPHost, app.mailConfig.SMTPPort, "", ""),
|
||||||
quitChannel: make(chan struct{}),
|
quitChannel: make(chan struct{}),
|
||||||
|
infoLog: app.infoLog,
|
||||||
|
errorLog: app.errorLog,
|
||||||
|
mailConfig: app.mailConfig,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) StartMailNotifier() {
|
func (mn *MailNotifier) Start() {
|
||||||
app.infoLog.Print("Launching mail notifier")
|
mn.infoLog.Print("Launching mail notifier")
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case notification := <-app.mailNotifier.notifyChannel:
|
case notification := <-mn.notifyChannel:
|
||||||
content := notification.GetNotificationContent(app.mailConfig)
|
content := notification.GetNotificationContent(mn.mailConfig)
|
||||||
|
|
||||||
mailText, err := content.buildMail(app.mailConfig.BaseURL)
|
mailText, err := content.buildMail(mn.mailConfig.BaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.errorLog.Printf("building mail failed: %v", err)
|
mn.errorLog.Printf("building mail failed: %v", err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -80,7 +87,7 @@ func (app *application) StartMailNotifier() {
|
||||||
m := mail.NewMessage()
|
m := mail.NewMessage()
|
||||||
|
|
||||||
m.SetHeaders(content.headers)
|
m.SetHeaders(content.headers)
|
||||||
m.SetAddressHeader("From", app.mailNotifier.senderAddress, "CAcert board voting system")
|
m.SetAddressHeader("From", mn.senderAddress, "CAcert board voting system")
|
||||||
|
|
||||||
for _, recipient := range content.recipients {
|
for _, recipient := range content.recipients {
|
||||||
m.SetAddressHeader(recipient.field, recipient.address, recipient.name)
|
m.SetAddressHeader(recipient.field, recipient.address, recipient.name)
|
||||||
|
@ -90,22 +97,44 @@ func (app *application) StartMailNotifier() {
|
||||||
|
|
||||||
m.SetBody("text/plain", mailText.String())
|
m.SetBody("text/plain", mailText.String())
|
||||||
|
|
||||||
if err = app.mailNotifier.dialer.DialAndSend(m); err != nil {
|
if err = mn.dialer.DialAndSend(m); err != nil {
|
||||||
app.errorLog.Printf("sending mail failed: %v", err)
|
mn.errorLog.Printf("sending mail failed: %v", err)
|
||||||
}
|
}
|
||||||
case <-app.mailNotifier.quitChannel:
|
case <-mn.quitChannel:
|
||||||
app.infoLog.Print("ending mail notifier")
|
mn.infoLog.Print("ending mail notifier")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MailNotifier) Quit() {
|
func (mn *MailNotifier) Quit() {
|
||||||
m.quitChannel <- struct{}{}
|
mn.quitChannel <- struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mn *MailNotifier) Notify(w NotificationMail) {
|
||||||
|
mn.notifyChannel <- w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mn *MailNotifier) Ping() error {
|
||||||
|
conn, err := net.DialTimeout(
|
||||||
|
"tcp",
|
||||||
|
fmt.Sprintf("%s:%d", mn.mailConfig.SMTPHost, mn.mailConfig.SMTPPort),
|
||||||
|
mn.mailConfig.SMTPTimeOut,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not connect to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func(conn net.Conn) {
|
||||||
|
_ = conn.Close()
|
||||||
|
}(conn)
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) {
|
func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) {
|
||||||
|
// TODO: implement a template cache for mail templates too
|
||||||
b, err := internal.MailTemplates.ReadFile(path.Join("mailtemplates", n.template))
|
b, err := internal.MailTemplates.ReadFile(path.Join("mailtemplates", n.template))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not read mail template %s: %w", n.template, err)
|
return nil, fmt.Errorf("could not read mail template %s: %w", n.template, err)
|
||||||
|
@ -130,27 +159,6 @@ func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) {
|
||||||
return mailText, nil
|
return mailText, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type RemindVoterNotification struct {
|
|
||||||
voter *models.User
|
|
||||||
decisions []*models.Motion
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *NotificationContent {
|
|
||||||
return &NotificationContent{
|
|
||||||
template: "remind_voter_mail.txt",
|
|
||||||
data: struct {
|
|
||||||
Decisions []*models.Motion
|
|
||||||
Name string
|
|
||||||
}{Decisions: r.decisions, Name: r.voter.Name},
|
|
||||||
subject: "Outstanding CAcert board votes",
|
|
||||||
recipients: []recipientData{{
|
|
||||||
field: "To",
|
|
||||||
address: r.voter.Reminder,
|
|
||||||
name: r.voter.Name,
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultRecipient(mc *mailConfig) recipientData {
|
func defaultRecipient(mc *mailConfig) recipientData {
|
||||||
return recipientData{
|
return recipientData{
|
||||||
field: "To",
|
field: "To",
|
||||||
|
@ -174,6 +182,27 @@ func motionReplyHeaders(m *models.Motion) map[string][]string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RemindVoterNotification struct {
|
||||||
|
voter *models.User
|
||||||
|
decisions []*models.Motion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *NotificationContent {
|
||||||
|
return &NotificationContent{
|
||||||
|
template: "remind_voter_mail.txt",
|
||||||
|
data: struct {
|
||||||
|
Decisions []*models.Motion
|
||||||
|
Name string
|
||||||
|
}{Decisions: r.decisions, Name: r.voter.Name},
|
||||||
|
subject: "Outstanding CAcert board votes",
|
||||||
|
recipients: []recipientData{{
|
||||||
|
field: "To",
|
||||||
|
address: r.voter.Reminder,
|
||||||
|
name: r.voter.Name,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ClosedDecisionNotification struct {
|
type ClosedDecisionNotification struct {
|
||||||
Decision *models.Motion
|
Decision *models.Motion
|
||||||
}
|
}
|
||||||
|
|
171
forms.go
171
forms.go
|
@ -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
|
|
||||||
}
|
|
7
go.mod
7
go.mod
|
@ -14,10 +14,8 @@ require (
|
||||||
github.com/johejo/golang-migrate-extra v0.0.0-20211005021153-c17dd75f8b4a
|
github.com/johejo/golang-migrate-extra v0.0.0-20211005021153-c17dd75f8b4a
|
||||||
github.com/mattn/go-sqlite3 v1.14.12
|
github.com/mattn/go-sqlite3 v1.14.12
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
github.com/sirupsen/logrus v1.8.1
|
|
||||||
github.com/vearutop/statigz v1.1.8
|
github.com/vearutop/statigz v1.1.8
|
||||||
golang.org/x/crypto v0.0.0-20220507011949-2cf3adece122 // indirect
|
golang.org/x/crypto v0.0.0-20220507011949-2cf3adece122 // indirect
|
||||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 // indirect
|
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
gopkg.in/mail.v2 v2.3.1
|
gopkg.in/mail.v2 v2.3.1
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
|
@ -27,11 +25,10 @@ require (
|
||||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20220216073957-c252878bcf5a
|
github.com/alexedwards/scs/sqlite3store v0.0.0-20220216073957-c252878bcf5a
|
||||||
github.com/alexedwards/scs/v2 v2.5.0
|
github.com/alexedwards/scs/v2 v2.5.0
|
||||||
github.com/go-playground/form/v4 v4.2.0
|
github.com/go-playground/form/v4 v4.2.0
|
||||||
github.com/gorilla/csrf v1.7.1
|
|
||||||
github.com/gorilla/sessions v1.2.1
|
|
||||||
github.com/julienschmidt/httprouter v1.3.0
|
github.com/julienschmidt/httprouter v1.3.0
|
||||||
github.com/justinas/alice v1.2.0
|
github.com/justinas/alice v1.2.0
|
||||||
github.com/justinas/nosurf v1.1.1
|
github.com/justinas/nosurf v1.1.1
|
||||||
|
github.com/lestrrat-go/tcputil v0.0.0-20180223003554-d3c7f98154fb
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.7.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -40,9 +37,7 @@ require (
|
||||||
github.com/Masterminds/semver/v3 v3.1.1 // indirect
|
github.com/Masterminds/semver/v3 v3.1.1 // indirect
|
||||||
github.com/andybalholm/brotli v1.0.4 // indirect
|
github.com/andybalholm/brotli v1.0.4 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/shopspring/decimal v1.3.1 // indirect
|
github.com/shopspring/decimal v1.3.1 // indirect
|
||||||
github.com/spf13/cast v1.4.1 // indirect
|
github.com/spf13/cast v1.4.1 // indirect
|
||||||
|
|
12
go.sum
12
go.sum
|
@ -617,18 +617,12 @@ github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2c
|
||||||
github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA=
|
github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA=
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||||
github.com/gorilla/csrf v1.7.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE=
|
|
||||||
github.com/gorilla/csrf v1.7.1/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA=
|
|
||||||
github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
|
github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
|
||||||
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
|
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
|
||||||
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||||
github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||||
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||||
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
|
||||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
|
||||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
|
||||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
|
||||||
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
@ -797,6 +791,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/ktrysmt/go-bitbucket v0.6.4/go.mod h1:9u0v3hsd2rqCHRIpbir1oP7F58uo5dq19sBYvuMoyQ4=
|
github.com/ktrysmt/go-bitbucket v0.6.4/go.mod h1:9u0v3hsd2rqCHRIpbir1oP7F58uo5dq19sBYvuMoyQ4=
|
||||||
|
github.com/lestrrat-go/tcputil v0.0.0-20180223003554-d3c7f98154fb h1:sb9NxqWoS17VT3aZd4mlBm48bsaHB1Fvwro3H/uiuZM=
|
||||||
|
github.com/lestrrat-go/tcputil v0.0.0-20180223003554-d3c7f98154fb/go.mod h1:bBamYL9/WjNn0b2CS4v4F8cHmWRpClSxrpEoAY+maJo=
|
||||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
|
@ -959,7 +955,6 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzL
|
||||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
@ -1034,7 +1029,6 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
|
||||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
|
||||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||||
github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||||
|
@ -1485,8 +1479,6 @@ golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220317061510-51cd9980dadf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220317061510-51cd9980dadf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 h1:nonptSpoQ4vQjyraW20DXPAglgQfVnM9ZC6MmNLMR60=
|
|
||||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
|
|
@ -78,6 +78,7 @@ func (f *EditMotionForm) Validate() {
|
||||||
"content",
|
"content",
|
||||||
fmt.Sprintf("This field must be at most %d characters long", maximumContentLength),
|
fmt.Sprintf("This field must be at most %d characters long", maximumContentLength),
|
||||||
)
|
)
|
||||||
|
f.CheckField(validator.NotNil(f.Type), "type", "You must choose a valid vote type")
|
||||||
|
|
||||||
f.CheckField(validator.PermittedInt(
|
f.CheckField(validator.PermittedInt(
|
||||||
f.Due, threeDays, oneWeek, twoWeeks, threeWeeks), "due", "invalid duration choice",
|
f.Due, threeDays, oneWeek, twoWeeks, threeWeeks), "due", "invalid duration choice",
|
||||||
|
@ -94,7 +95,7 @@ type DirectVoteForm struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProxyVoteForm struct {
|
type ProxyVoteForm struct {
|
||||||
VoterID int64 `form:"voter"`
|
Voter *models.User `form:"voter"`
|
||||||
Choice *models.VoteChoice `form:"choice"`
|
Choice *models.VoteChoice `form:"choice"`
|
||||||
Justification string `form:"justification"`
|
Justification string `form:"justification"`
|
||||||
Voters []*models.User `form:"-"`
|
Voters []*models.User `form:"-"`
|
||||||
|
@ -102,6 +103,7 @@ type ProxyVoteForm struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *ProxyVoteForm) Validate() {
|
func (f *ProxyVoteForm) Validate() {
|
||||||
|
f.CheckField(validator.NotNil(f.Voter), "voter", "Please choose a valid voter")
|
||||||
f.CheckField(validator.NotBlank(f.Justification), "justification", "This field cannot be blank")
|
f.CheckField(validator.NotBlank(f.Justification), "justification", "This field cannot be blank")
|
||||||
f.CheckField(
|
f.CheckField(
|
||||||
validator.MinChars(
|
validator.MinChars(
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
Dear Board,
|
Dear Board,
|
||||||
|
|
||||||
{{ with .Decision }}The motion with the identifier {{.Tag}} has been {{.Status}}.{{ end }}
|
The motion with the identifier {{ .Data.Tag }} has been closed.
|
||||||
|
|
||||||
The reasoning for this result is: {{ .Reasoning }}
|
The reasoning for this result is: {{ .Data.Reasoning }}
|
||||||
|
|
||||||
{{ with .Decision }}Motion:
|
{{ with .Data }}Motion:
|
||||||
{{ .Title}}
|
{{ .Title}}
|
||||||
{{ .Content}}
|
{{ .Content}}
|
||||||
|
|
||||||
Vote type: {{.VoteType}}{{end}}
|
Vote type: {{ .Type}}{{end}}
|
||||||
|
|
||||||
{{ with .VoteSums }} Ayes: {{ .Ayes }}
|
{{ with .Data.Sums }} Ayes: {{ .Ayes }}
|
||||||
Nayes: {{ .Nayes }}
|
Nayes: {{ .Nayes }}
|
||||||
Abstentions: {{ .Abstains }}
|
Abstentions: {{ .Abstains }}
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,6 @@ import (
|
||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -108,11 +107,11 @@ var (
|
||||||
voteStatusDeclined = &VoteStatus{Label: "declined", ID: -1}
|
voteStatusDeclined = &VoteStatus{Label: "declined", ID: -1}
|
||||||
voteStatusPending = &VoteStatus{Label: "pending", ID: 0}
|
voteStatusPending = &VoteStatus{Label: "pending", ID: 0}
|
||||||
voteStatusApproved = &VoteStatus{Label: "approved", ID: 1}
|
voteStatusApproved = &VoteStatus{Label: "approved", ID: 1}
|
||||||
VoteStatusWithdrawn = &VoteStatus{Label: "withdrawn", ID: -2}
|
voteStatusWithdrawn = &VoteStatus{Label: "withdrawn", ID: -2}
|
||||||
)
|
)
|
||||||
|
|
||||||
func VoteStatusFromInt(id int64) (*VoteStatus, error) {
|
func VoteStatusFromInt(id int64) (*VoteStatus, error) {
|
||||||
for _, vs := range []*VoteStatus{voteStatusPending, voteStatusApproved, VoteStatusWithdrawn, voteStatusDeclined} {
|
for _, vs := range []*VoteStatus{voteStatusPending, voteStatusApproved, voteStatusWithdrawn, voteStatusDeclined} {
|
||||||
if int64(vs.ID) == id {
|
if int64(vs.ID) == id {
|
||||||
return vs, nil
|
return vs, nil
|
||||||
}
|
}
|
||||||
|
@ -216,6 +215,15 @@ func (v *VoteSums) TotalVotes() int {
|
||||||
return v.Ayes + v.Nayes
|
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) {
|
func (v *VoteSums) CalculateResult(quorum int, majority float32) (*VoteStatus, string) {
|
||||||
if v.VoteCount() < quorum {
|
if v.VoteCount() < quorum {
|
||||||
return voteStatusDeclined, fmt.Sprintf("Needed quorum of %d has not been reached.", quorum)
|
return voteStatusDeclined, fmt.Sprintf("Needed quorum of %d has not been reached.", quorum)
|
||||||
|
@ -230,15 +238,15 @@ func (v *VoteSums) CalculateResult(quorum int, majority float32) (*VoteStatus, s
|
||||||
|
|
||||||
type Motion struct {
|
type Motion struct {
|
||||||
ID int64 `db:"id"`
|
ID int64 `db:"id"`
|
||||||
Proposed time.Time
|
Proposed time.Time `db:"proposed"`
|
||||||
Proponent int64 `db:"proponent"`
|
Proponent int64 `db:"proponent"`
|
||||||
Proposer string `db:"proposer"`
|
Proposer string `db:"proposer"`
|
||||||
Title string
|
Title string `db:"title"`
|
||||||
Content string
|
Content string `db:"content"`
|
||||||
Status *VoteStatus
|
Status *VoteStatus `db:"status"`
|
||||||
Due time.Time
|
Due time.Time `db:"due"`
|
||||||
Modified time.Time
|
Modified time.Time `db:"modified"`
|
||||||
Tag string
|
Tag string `db:"tag"`
|
||||||
Type *VoteType `db:"votetype"`
|
Type *VoteType `db:"votetype"`
|
||||||
Sums *VoteSums `db:"-"`
|
Sums *VoteSums `db:"-"`
|
||||||
Votes []*Vote `db:"-"`
|
Votes []*Vote `db:"-"`
|
||||||
|
@ -247,7 +255,6 @@ type Motion struct {
|
||||||
|
|
||||||
type MotionModel struct {
|
type MotionModel struct {
|
||||||
DB *sqlx.DB
|
DB *sqlx.DB
|
||||||
InfoLog *log.Logger
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new decision.
|
// Create a new decision.
|
||||||
|
@ -335,9 +342,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now
|
||||||
var decisionResult *Motion
|
var decisionResult *Motion
|
||||||
|
|
||||||
for _, decision := range decisions {
|
for _, decision := range decisions {
|
||||||
m.InfoLog.Printf("found closable decision %s", decision.Tag)
|
if decisionResult, err = closeDecision(ctx, tx, decision); err != nil {
|
||||||
|
|
||||||
if decisionResult, err = m.CloseDecision(ctx, tx, decision); err != nil {
|
|
||||||
return nil, fmt.Errorf("closing decision %s failed: %w", decision.Tag, err)
|
return nil, fmt.Errorf("closing decision %s failed: %w", decision.Tag, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -351,7 +356,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*Motion, error) {
|
func closeDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*Motion, error) {
|
||||||
quorum, majority := d.Type.QuorumAndMajority()
|
quorum, majority := d.Type.QuorumAndMajority()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -360,13 +365,14 @@ func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion)
|
||||||
reasoning string
|
reasoning string
|
||||||
)
|
)
|
||||||
|
|
||||||
if voteSums, err = m.SumsForDecision(ctx, tx, d); err != nil {
|
// TODO: implement prefetching in CloseDecisions
|
||||||
|
if voteSums, err = sumsForDecision(ctx, tx, d); err != nil {
|
||||||
return nil, fmt.Errorf("getting vote sums failed: %w", err)
|
return nil, fmt.Errorf("getting vote sums failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
d.Status, reasoning = voteSums.CalculateResult(quorum, majority)
|
d.Status, reasoning = voteSums.CalculateResult(quorum, majority)
|
||||||
|
|
||||||
result, err := m.DB.NamedExecContext(
|
result, err := tx.NamedExecContext(
|
||||||
ctx,
|
ctx,
|
||||||
`UPDATE decisions SET status=:status, modified=CURRENT_TIMESTAMP WHERE id=:id`,
|
`UPDATE decisions SET status=:status, modified=CURRENT_TIMESTAMP WHERE id=:id`,
|
||||||
d,
|
d,
|
||||||
|
@ -385,20 +391,21 @@ func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion)
|
||||||
return nil, fmt.Errorf("unexpected number of rows %d instead of 1", affectedRows)
|
return nil, fmt.Errorf("unexpected number of rows %d instead of 1", affectedRows)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.InfoLog.Printf("decision %s closed with result %s: reasoning '%s'", d.Tag, d.Status, reasoning)
|
|
||||||
|
|
||||||
d.Sums = voteSums
|
d.Sums = voteSums
|
||||||
d.Reasoning = reasoning
|
d.Reasoning = reasoning
|
||||||
|
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MotionModel) UnVotedDecisionsForVoter(ctx context.Context, voter *User) ([]*Motion, error) {
|
func (m *MotionModel) UnvotedForVoter(ctx context.Context, voter *User) ([]*Motion, error) {
|
||||||
|
// TODO: implement more efficient variant that fetches unvoted votes for a slice of voters
|
||||||
rows, err := m.DB.QueryxContext(
|
rows, err := m.DB.QueryxContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT decisions.*
|
`SELECT decisions.*
|
||||||
FROM decisions
|
FROM decisions
|
||||||
WHERE NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?)`,
|
WHERE due < ? AND status=? AND NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?)`,
|
||||||
|
time.Now().UTC(),
|
||||||
|
voteStatusPending,
|
||||||
voter.ID)
|
voter.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(errCouldNotExecuteQuery, err)
|
return nil, fmt.Errorf(errCouldNotExecuteQuery, err)
|
||||||
|
@ -427,7 +434,7 @@ WHERE NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MotionModel) SumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*VoteSums, error) {
|
func sumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*VoteSums, error) {
|
||||||
voteRows, err := tx.QueryxContext(
|
voteRows, err := tx.QueryxContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT vote, COUNT(vote) FROM votes WHERE decision=$1 GROUP BY vote`,
|
`SELECT vote, COUNT(vote) FROM votes WHERE decision=$1 GROUP BY vote`,
|
||||||
|
@ -470,7 +477,7 @@ func (m *MotionModel) SumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Motio
|
||||||
return sums, nil
|
return sums, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MotionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, error) {
|
func (m *MotionModel) NextPendingDue(ctx context.Context) (*time.Time, error) {
|
||||||
row := m.DB.QueryRowContext(
|
row := m.DB.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT due FROM decisions WHERE status=0 ORDER BY due LIMIT 1`,
|
`SELECT due FROM decisions WHERE status=0 ORDER BY due LIMIT 1`,
|
||||||
|
@ -489,8 +496,6 @@ func (m *MotionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, e
|
||||||
|
|
||||||
if err := row.Scan(&due); err != nil {
|
if err := row.Scan(&due); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
m.InfoLog.Print("no pending decisions")
|
|
||||||
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -500,11 +505,6 @@ func (m *MotionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, e
|
||||||
return &due, nil
|
return &due, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type VoteForDisplay struct {
|
|
||||||
Name string
|
|
||||||
Vote *VoteChoice
|
|
||||||
}
|
|
||||||
|
|
||||||
type MotionListOptions struct {
|
type MotionListOptions struct {
|
||||||
Limit int
|
Limit int
|
||||||
UnvotedOnly bool
|
UnvotedOnly bool
|
||||||
|
@ -561,7 +561,7 @@ WHERE due >= ?
|
||||||
return firstTs, lastTs, nil
|
return firstTs, lastTs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions) ([]*Motion, error) {
|
func (m *MotionModel) List(ctx context.Context, options *MotionListOptions) ([]*Motion, error) {
|
||||||
var (
|
var (
|
||||||
rows *sqlx.Rows
|
rows *sqlx.Rows
|
||||||
err error
|
err error
|
||||||
|
@ -569,11 +569,11 @@ func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case options.Before != nil:
|
case options.Before != nil:
|
||||||
rows, err = m.GetMotionRowsBefore(ctx, options)
|
rows, err = m.rowsBefore(ctx, options)
|
||||||
case options.After != nil:
|
case options.After != nil:
|
||||||
rows, err = m.GetMotionRowsAfter(ctx, options)
|
rows, err = m.rowsAfter(ctx, options)
|
||||||
default:
|
default:
|
||||||
rows, err = m.GetFirstMotionRows(ctx, options)
|
rows, err = m.rowsFirst(ctx, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -621,7 +621,10 @@ func (m *MotionModel) FillVoteSums(ctx context.Context, decisions []*Motion) err
|
||||||
}
|
}
|
||||||
|
|
||||||
query, args, err := sqlx.In(
|
query, args, err := sqlx.In(
|
||||||
`SELECT v.decision, v.vote, COUNT(*) FROM votes v WHERE v.decision IN (?) GROUP BY v.decision, v.vote`,
|
`SELECT v.decision, v.vote, COUNT(*)
|
||||||
|
FROM votes v
|
||||||
|
WHERE v.decision IN (?)
|
||||||
|
GROUP BY v.decision, v.vote`,
|
||||||
decisionIds,
|
decisionIds,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -666,7 +669,8 @@ func (m *MotionModel) FillVoteSums(ctx context.Context, decisions []*Motion) err
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MotionModel) GetMotionRowsBefore(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
|
func (m *MotionModel) rowsBefore(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
|
||||||
|
// TODO: implement variant for options.UnvotedOnly
|
||||||
rows, err := m.DB.QueryxContext(
|
rows, err := m.DB.QueryxContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT decisions.id,
|
`SELECT decisions.id,
|
||||||
|
@ -695,7 +699,8 @@ LIMIT $2`,
|
||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MotionModel) GetMotionRowsAfter(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
|
func (m *MotionModel) rowsAfter(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
|
||||||
|
// TODO: implement variant for options.UnvotedOnly
|
||||||
rows, err := m.DB.QueryxContext(
|
rows, err := m.DB.QueryxContext(
|
||||||
ctx,
|
ctx,
|
||||||
`WITH display_decision AS (SELECT decisions.id,
|
`WITH display_decision AS (SELECT decisions.id,
|
||||||
|
@ -727,7 +732,7 @@ ORDER BY proposed DESC`,
|
||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MotionModel) GetFirstMotionRows(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
|
func (m *MotionModel) rowsFirst(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
|
||||||
var (
|
var (
|
||||||
rows *sqlx.Rows
|
rows *sqlx.Rows
|
||||||
err error
|
err error
|
||||||
|
@ -749,12 +754,12 @@ func (m *MotionModel) GetFirstMotionRows(ctx context.Context, options *MotionLis
|
||||||
decisions.modified
|
decisions.modified
|
||||||
FROM decisions
|
FROM decisions
|
||||||
JOIN voters ON decisions.proponent = voters.id
|
JOIN voters ON decisions.proponent = voters.id
|
||||||
WHERE NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?)
|
WHERE status=? AND due >= ? AND NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?)
|
||||||
AND due >= ?
|
|
||||||
ORDER BY decisions.proposed DESC
|
ORDER BY decisions.proposed DESC
|
||||||
LIMIT ?`,
|
LIMIT ?`,
|
||||||
options.VoterID,
|
voteStatusPending,
|
||||||
time.Now().UTC(),
|
time.Now().UTC(),
|
||||||
|
options.VoterID,
|
||||||
options.Limit,
|
options.Limit,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
@ -786,7 +791,7 @@ LIMIT ?`,
|
||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MotionModel) GetMotionByTag(ctx context.Context, tag string, withVotes bool) (*Motion, error) {
|
func (m *MotionModel) ByTag(ctx context.Context, tag string, withVotes bool) (*Motion, error) {
|
||||||
row := m.DB.QueryRowxContext(
|
row := m.DB.QueryRowxContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT decisions.id,
|
`SELECT decisions.id,
|
||||||
|
@ -865,7 +870,7 @@ ORDER BY voters.name`,
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MotionModel) GetByID(ctx context.Context, id int64) (*Motion, error) {
|
func (m *MotionModel) ByID(ctx context.Context, id int64) (*Motion, error) {
|
||||||
row := m.DB.QueryRowxContext(ctx, `SELECT * FROM decisions WHERE id=?`, id)
|
row := m.DB.QueryRowxContext(ctx, `SELECT * FROM decisions WHERE id=?`, id)
|
||||||
|
|
||||||
if err := row.Err(); err != nil {
|
if err := row.Err(); err != nil {
|
||||||
|
@ -986,3 +991,9 @@ WHERE decision = :decision
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MotionModel) Withdraw(ctx context.Context, id int64) error {
|
||||||
|
return m.Update(ctx, id, func(m *Motion) {
|
||||||
|
m.Status = voteStatusWithdrawn
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -34,7 +34,7 @@ import (
|
||||||
"git.cacert.org/cacert-boardvoting/internal/models"
|
"git.cacert.org/cacert-boardvoting/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func prepareTestDb(t *testing.T) (*sqlx.DB, *log.Logger) {
|
func prepareTestDb(t *testing.T) *sqlx.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
testDir := t.TempDir()
|
testDir := t.TempDir()
|
||||||
|
@ -49,13 +49,13 @@ func prepareTestDb(t *testing.T) (*sqlx.DB, *log.Logger) {
|
||||||
err = internal.InitializeDb(dbx.DB, logger)
|
err = internal.InitializeDb(dbx.DB, logger)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return dbx, logger
|
return dbx
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDecisionModel_Create(t *testing.T) {
|
func TestDecisionModel_Create(t *testing.T) {
|
||||||
dbx, logger := prepareTestDb(t)
|
dbx := prepareTestDb(t)
|
||||||
|
|
||||||
dm := models.MotionModel{DB: dbx, InfoLog: logger}
|
dm := models.MotionModel{DB: dbx}
|
||||||
|
|
||||||
v := &models.User{
|
v := &models.User{
|
||||||
ID: 1, // sqlite does not check referential integrity. Might fail with a foreign key index.
|
ID: 1, // sqlite does not check referential integrity. Might fail with a foreign key index.
|
||||||
|
@ -77,16 +77,16 @@ func TestDecisionModel_Create(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDecisionModel_GetNextPendingDecisionDue(t *testing.T) {
|
func TestDecisionModel_GetNextPendingDecisionDue(t *testing.T) {
|
||||||
dbx, logger := prepareTestDb(t)
|
dbx := prepareTestDb(t)
|
||||||
|
|
||||||
dm := models.MotionModel{DB: dbx, InfoLog: logger}
|
dm := models.MotionModel{DB: dbx}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
nextDue *time.Time
|
nextDue *time.Time
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
|
|
||||||
nextDue, err = dm.NextPendingDecisionDue(context.Background())
|
nextDue, err = dm.NextPendingDue(context.Background())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Empty(t, nextDue)
|
assert.Empty(t, nextDue)
|
||||||
|
|
||||||
|
@ -103,7 +103,7 @@ func TestDecisionModel_GetNextPendingDecisionDue(t *testing.T) {
|
||||||
_, err = dm.Create(ctx, v, models.VoteTypeMotion, "test motion", "I move that we should test more", time.Now(), due)
|
_, err = dm.Create(ctx, v, models.VoteTypeMotion, "test motion", "I move that we should test more", time.Now(), due)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
nextDue, err = dm.NextPendingDecisionDue(ctx)
|
nextDue, err = dm.NextPendingDue(ctx)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotEmpty(t, nextDue)
|
assert.NotEmpty(t, nextDue)
|
||||||
|
|
||||||
|
|
|
@ -26,10 +26,16 @@ import (
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Role struct {
|
||||||
|
Name string `db:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoleName string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RoleAdmin string = "ADMIN"
|
RoleAdmin RoleName = "ADMIN"
|
||||||
RoleSecretary string = "SECRETARY"
|
RoleSecretary RoleName = "SECRETARY"
|
||||||
RoleVoter string = "VOTER"
|
RoleVoter RoleName = "VOTER"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The User type is used for mapping users from the voters table. The table
|
// The User type is used for mapping users from the voters table. The table
|
||||||
|
@ -47,16 +53,16 @@ type User struct {
|
||||||
roles []*Role `db:"-"`
|
roles []*Role `db:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *User) Roles() ([]*Role, error) {
|
func (u *User) Roles() ([]*Role, error) {
|
||||||
if v.roles != nil {
|
if u.roles != nil {
|
||||||
return v.roles, nil
|
return u.roles, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errors.New("call to GetRoles required")
|
return nil, errors.New("call to Roles required")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *User) HasRole(roles []string) (bool, error) {
|
func (u *User) HasRole(roles ...RoleName) (bool, error) {
|
||||||
userRoles, err := v.Roles()
|
userRoles, err := u.Roles()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
@ -66,7 +72,7 @@ func (v *User) HasRole(roles []string) (bool, error) {
|
||||||
outer:
|
outer:
|
||||||
for _, role := range userRoles {
|
for _, role := range userRoles {
|
||||||
for _, checkRole := range roles {
|
for _, checkRole := range roles {
|
||||||
if role.Name == checkRole {
|
if role.Name == string(checkRole) {
|
||||||
roleMatched = true
|
roleMatched = true
|
||||||
|
|
||||||
break outer
|
break outer
|
||||||
|
@ -81,7 +87,7 @@ type UserModel struct {
|
||||||
DB *sqlx.DB
|
DB *sqlx.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *UserModel) GetReminderVoters(ctx context.Context) ([]*User, error) {
|
func (m *UserModel) ReminderVoters(ctx context.Context) ([]*User, error) {
|
||||||
rows, err := m.DB.QueryxContext(
|
rows, err := m.DB.QueryxContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT v.id, v.name, e.address AS reminder
|
`SELECT v.id, v.name, e.address AS reminder
|
||||||
|
@ -119,7 +125,11 @@ WHERE ur.role = ?
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *UserModel) GetUser(ctx context.Context, emails []string) (*User, error) {
|
func (m *UserModel) ByEmails(ctx context.Context, emails []string) (*User, error) {
|
||||||
|
for i := range emails {
|
||||||
|
emails[i] = strings.ToLower(emails[i])
|
||||||
|
}
|
||||||
|
|
||||||
query, args, err := sqlx.In(
|
query, args, err := sqlx.In(
|
||||||
`WITH reminders AS (SELECT voter, address
|
`WITH reminders AS (SELECT voter, address
|
||||||
FROM emails
|
FROM emails
|
||||||
|
@ -166,18 +176,14 @@ WHERE e.address IN (?)`, emails)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.roles, err = m.GetRoles(ctx, &user); err != nil {
|
if user.roles, err = m.Roles(ctx, &user); err != nil {
|
||||||
return nil, fmt.Errorf("could not retrieve roles for user %s: %w", user.Name, err)
|
return nil, fmt.Errorf("could not retrieve roles for user %s: %w", user.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type Role struct {
|
func (m *UserModel) Roles(ctx context.Context, user *User) ([]*Role, error) {
|
||||||
Name string `db:"role"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *UserModel) GetRoles(ctx context.Context, user *User) ([]*Role, error) {
|
|
||||||
rows, err := m.DB.QueryxContext(ctx, `SELECT role FROM user_roles WHERE voter_id=?`, user.ID)
|
rows, err := m.DB.QueryxContext(ctx, `SELECT role FROM user_roles WHERE voter_id=?`, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not query roles for %s: %w", user.Name, err)
|
return nil, fmt.Errorf("could not query roles for %s: %w", user.Name, err)
|
||||||
|
@ -206,7 +212,7 @@ func (m *UserModel) GetRoles(ctx context.Context, user *User) ([]*Role, error) {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *UserModel) CreateUser(ctx context.Context, name string, reminder string, emails []string) (int64, error) {
|
func (m *UserModel) Create(ctx context.Context, name string, reminder string, emails []string) (int64, error) {
|
||||||
tx, err := m.DB.BeginTxx(ctx, nil)
|
tx, err := m.DB.BeginTxx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("could not start transaction: %w", err)
|
return 0, fmt.Errorf("could not start transaction: %w", err)
|
||||||
|
@ -250,14 +256,15 @@ VALUES (?, ?, ?)`,
|
||||||
return userID, nil
|
return userID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *UserModel) PotentialVoters(ctx context.Context) ([]*User, error) {
|
func (m *UserModel) InRole(ctx context.Context, role RoleName) ([]*User, error) {
|
||||||
rows, err := m.DB.QueryxContext(
|
rows, err := m.DB.QueryxContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT voters.id, voters.name
|
`SELECT voters.id, voters.name
|
||||||
FROM voters
|
FROM voters
|
||||||
JOIN user_roles ur ON voters.id = ur.voter_id
|
JOIN user_roles ur ON voters.id = ur.voter_id
|
||||||
WHERE ur.role = 'VOTER'
|
WHERE ur.role = ?
|
||||||
ORDER BY voters.name`,
|
ORDER BY voters.name`,
|
||||||
|
role,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -287,7 +294,11 @@ ORDER BY voters.name`,
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *UserModel) LoadVoter(ctx context.Context, voterID int64) (*User, error) {
|
func (m *UserModel) Voters(ctx context.Context) ([]*User, error) {
|
||||||
|
return m.InRole(ctx, RoleVoter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) ByID(ctx context.Context, voterID int64) (*User, error) {
|
||||||
row := m.DB.QueryRowxContext(
|
row := m.DB.QueryRowxContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT DISTINCT v.id, v.name
|
`SELECT DISTINCT v.id, v.name
|
199
jobs.go
199
jobs.go
|
@ -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}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
876
models.go
876
models.go
|
@ -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
|
|
||||||
}
|
|
347
notifications.go
347
notifications.go
|
@ -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()},
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
{{ define "base" }}
|
{{ define "base" -}}
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
|
Loading…
Reference in a new issue