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