📝 README and inline docs

This commit is contained in:
Brian Wiborg 2024-05-14 10:26:48 +02:00
parent d533a879ef
commit c61300c6b8
No known key found for this signature in database
GPG Key ID: BE53FA9286B719D6
14 changed files with 139 additions and 24 deletions

13
README.md Normal file
View File

@ -0,0 +1,13 @@
# GoVote
> Referendums & Consensus
A voting tool for the web, written in Go.
## Develop
```
yarn install
go generate
go run ./main
```

View File

@ -1,3 +1,5 @@
// cmd implements the CLI using urfav/cli.
package cmd package cmd
import ( import (
@ -6,7 +8,7 @@ import (
var App = cli.App{ var App = cli.App{
Name: "govote", Name: "govote",
Usage: "🌈 Referendums and concensus.", Usage: "🌈 Referendums and consensus.",
Commands: []*cli.Command{ Commands: []*cli.Command{
newCmd, newCmd,
showCmd, showCmd,

View File

@ -18,6 +18,7 @@ import (
"github.com/labstack/echo/middleware" "github.com/labstack/echo/middleware"
) )
// Serve takes a bind-address and starts the HTTP server.
func Serve(bindAddr string) error { func Serve(bindAddr string) error {
e := echo.New() e := echo.New()
e.Pre(middleware.RemoveTrailingSlash()) e.Pre(middleware.RemoveTrailingSlash())
@ -75,6 +76,7 @@ func handleNewVoting(ctx echo.Context) error {
case "d": case "d":
d = time.Now().UTC().Add(time.Duration(deadlineInt) * time.Hour * 24).Round(time.Second) d = time.Now().UTC().Add(time.Duration(deadlineInt) * time.Hour * 24).Round(time.Second)
default: default:
// TODO: this needs better handling!
panic("this code should never be reached") panic("this code should never be reached")
} }

View File

@ -1,3 +1,6 @@
// labstack/echo requires a custom TemplateRenderer.
// This module implements it.
package http package http
import ( import (
@ -7,14 +10,17 @@ import (
"github.com/labstack/echo" "github.com/labstack/echo"
) )
// Template is a data-container for a template.
type Template struct { type Template struct {
Templates *template.Template Templates *template.Template
} }
// Render injects the echo.Context into the template renderer.
func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
return t.Templates.ExecuteTemplate(w, name, data) return t.Templates.ExecuteTemplate(w, name, data)
} }
// NewTemplateRenderer returns an initialized Template and injects the patched Renderer.
func NewTemplateRenderer(e *echo.Echo, paths ...string) { func NewTemplateRenderer(e *echo.Echo, paths ...string) {
tmpl := &template.Template{} tmpl := &template.Template{}
for i := range paths { for i := range paths {

View File

@ -5,16 +5,22 @@ import (
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
// file points the the sqlite file.
const file = "./govote.db" const file = "./govote.db"
// db references the initialized sqlite connection.
var db *sql.DB var db *sql.DB
// init initialized the database layer
func init() { func init() {
var err error var err error
if db, err = sql.Open("sqlite3", file); err != nil { if db, err = sql.Open("sqlite3", file); err != nil {
panic(err) panic(err)
} }
// initialize the sql schema
initCreateTables(db) initCreateTables(db)
// initialize all prepared statements
initStmts(db) initStmts(db)
} }

View File

@ -11,6 +11,7 @@ import (
"code.c-base.org/baccenfutter/govote/voting/vote" "code.c-base.org/baccenfutter/govote/voting/vote"
) )
// NewVoting writes a new voting into the store.
func NewVoting( func NewVoting(
id string, id string,
r string, r string,
@ -27,6 +28,7 @@ func NewVoting(
return nil return nil
} }
// GetVoting takes an id and reads and returns the voting with that ID from the store.
func GetVoting(id string) (*voting.Voting, error) { func GetVoting(id string) (*voting.Voting, error) {
result := votingSelect.QueryRow(id) result := votingSelect.QueryRow(id)
if result == nil { if result == nil {
@ -73,6 +75,7 @@ func GetVoting(id string) (*voting.Voting, error) {
return v, nil return v, nil
} }
// PlaceVote writes an individual vote to the store.
func PlaceVote(id, votingID, elector string, choice vote.Choice) error { func PlaceVote(id, votingID, elector string, choice vote.Choice) error {
if _, err := voteInsert.Exec(id, votingID, elector, choice.String()); err != nil { if _, err := voteInsert.Exec(id, votingID, elector, choice.String()); err != nil {
return err return err

View File

@ -2,6 +2,7 @@ package store
import "database/sql" import "database/sql"
// References to all available prepared statements.
var ( var (
votingInsert *sql.Stmt votingInsert *sql.Stmt
votingSelect *sql.Stmt votingSelect *sql.Stmt
@ -10,6 +11,8 @@ var (
voteSelect *sql.Stmt voteSelect *sql.Stmt
) )
// initCreateTables takes an sql connection and creates all tables if they
// don't yet exist.
func initCreateTables(db *sql.DB) { func initCreateTables(db *sql.DB) {
var err error var err error
createTables := ` createTables := `
@ -41,6 +44,8 @@ func initCreateTables(db *sql.DB) {
} }
} }
// initStmts takes an initialized sql connection and initializes all prepared
// statements.
func initStmts(db *sql.DB) { func initStmts(db *sql.DB) {
initStmtVotingInsert(db) initStmtVotingInsert(db)
initStmtVotingSelect(db) initStmtVotingSelect(db)

View File

@ -6,6 +6,8 @@ import (
const charSet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const charSet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// GenerateRandomString takes a length and returns a random string of that length.
// This function is used for generating random IDs for the votings.
func GenerateRandomString(length int) string { func GenerateRandomString(length int) string {
result := make([]byte, length) result := make([]byte, length)
for i := 0; i < length; i++ { for i := 0; i < length; i++ {

View File

@ -5,8 +5,10 @@ import (
"strings" "strings"
) )
// Quorum defines a custom uint8 type for storing a quorum.
type Quorum uint8 type Quorum uint8
// Supported quorums are:
const ( const (
Simple Quorum = iota Simple Quorum = iota
OneFifth OneFifth
@ -21,6 +23,7 @@ const (
Unanimous Unanimous
) )
// ValidQuorums returns a []Quorum of all supported quorums.
func ValidQuorums() []Quorum { func ValidQuorums() []Quorum {
return []Quorum{ return []Quorum{
Simple, Unanimous, Simple, Unanimous,
@ -31,6 +34,8 @@ func ValidQuorums() []Quorum {
} }
} }
// FromString takes a string and returns an initialized Quorum or an error if
// the string could not be parsed.
func FromString(s string) (Quorum, error) { func FromString(s string) (Quorum, error) {
for _, q := range ValidQuorums() { for _, q := range ValidQuorums() {
if strings.ToUpper(q.String()) == strings.ToUpper(s) { if strings.ToUpper(q.String()) == strings.ToUpper(s) {
@ -40,6 +45,7 @@ func FromString(s string) (Quorum, error) {
return Simple, fmt.Errorf("inalid quorum: %s", s) return Simple, fmt.Errorf("inalid quorum: %s", s)
} }
// String implements fmt.Stringer
func (q Quorum) String() string { func (q Quorum) String() string {
switch q { switch q {
case Simple: case Simple:
@ -69,6 +75,8 @@ func (q Quorum) String() string {
} }
} }
// IsSatisfied takes the number of eligible and actual votes and returns a bool
// indicating if the quroum is satisfied or not.
func (q Quorum) IsSatisfied(possibleVotes, totalVotes int) bool { func (q Quorum) IsSatisfied(possibleVotes, totalVotes int) bool {
if totalVotes == 0 { if totalVotes == 0 {
return false return false

View File

@ -2,6 +2,8 @@ package voting
import "code.c-base.org/baccenfutter/govote/voting/vote" import "code.c-base.org/baccenfutter/govote/voting/vote"
// Result is a data-container for the results of a voting.
// It can easily be accessed in templates.
type Result struct { type Result struct {
Quorum bool Quorum bool
Threshold bool Threshold bool

View File

@ -5,8 +5,10 @@ import (
"strings" "strings"
) )
// Threshold defines a custom uint8 type for representing a threshold.
type Threshold uint8 type Threshold uint8
// Supported thresholds are:
const ( const (
Simple Threshold = iota Simple Threshold = iota
OneFifth OneFifth
@ -21,16 +23,19 @@ const (
Unanimous Unanimous
) )
// ValidThresholds returns a []Threshold of all supported thresholds.
func ValidThresholds() []Threshold { func ValidThresholds() []Threshold {
return []Threshold{ return []Threshold{
Simple, Unanimous, Simple, Unanimous,
OneFifth, OneQuarter, OneThird, OneHalf, OneFifth, OneQuarter, OneThird, OneHalf,
TwoFifths, TwoThirds, TwoFifths, TwoThirds,
ThreeFifths, ThreeQuarters, ThreeFifths, ThreeQuarters, ThreeFifths,
FourFifths, FourFifths,
} }
} }
// FromString takes a string and returns an initialized Threshold or an error
// if the string could not be parsed.
func FromString(s string) (Threshold, error) { func FromString(s string) (Threshold, error) {
for _, t := range ValidThresholds() { for _, t := range ValidThresholds() {
if strings.ToUpper(t.String()) == strings.ToUpper(s) { if strings.ToUpper(t.String()) == strings.ToUpper(s) {
@ -40,6 +45,7 @@ func FromString(s string) (Threshold, error) {
return Simple, fmt.Errorf("invalid threshold: %s", s) return Simple, fmt.Errorf("invalid threshold: %s", s)
} }
// String implements fmt.Stringer
func (t Threshold) String() string { func (t Threshold) String() string {
switch t { switch t {
case Simple: case Simple:
@ -69,6 +75,8 @@ func (t Threshold) String() string {
} }
} }
// IsSatisfied takes the number of all votes, yes votes and no votes and
// returns a bool indicating if the threshold is satisfied.
func (t Threshold) IsSatisfied(totalVotes, yesVotes, noVotes int) bool { func (t Threshold) IsSatisfied(totalVotes, yesVotes, noVotes int) bool {
if totalVotes == 0 { if totalVotes == 0 {
return false return false

View File

@ -5,8 +5,10 @@ import (
"strings" "strings"
) )
// Choice defines a custom int8 type for use as choice in a vote.
type Choice int8 type Choice int8
// String implements fmt.Stringer
func (choice Choice) String() string { func (choice Choice) String() string {
switch choice { switch choice {
case Yes: case Yes:
@ -20,16 +22,20 @@ func (choice Choice) String() string {
} }
} }
// Available choices are:
const ( const (
Abstain Choice = 0 Abstain Choice = 0
Yes Choice = 1 Yes Choice = 1
No Choice = -1 No Choice = -1
) )
// ValidChoices returns a []Choice with all available choices.
func ValidChoices() []Choice { func ValidChoices() []Choice {
return []Choice{Yes, No, Abstain} return []Choice{Yes, No, Abstain}
} }
// ChoiceFromString takes a string and returns an initialized Choice or an
// error if the string couldn't be parsed.
func ChoiceFromString(s string) (Choice, error) { func ChoiceFromString(s string) (Choice, error) {
for _, c := range ValidChoices() { for _, c := range ValidChoices() {
if strings.ToUpper(c.String()) == strings.ToUpper(s) { if strings.ToUpper(c.String()) == strings.ToUpper(s) {

View File

@ -5,13 +5,19 @@ import (
"time" "time"
) )
// Vote represents an individual vote in a voting.
type Vote struct { type Vote struct {
// Id contains the unique ID of the vote.
Id string Id string
// Elector contains the name of whoever placed the vote.
Elector string Elector string
// Choice contains the choice of the vote.
Choice Choice Choice Choice
// timestamp contains the UTC timestamp of when the vote was placed.
timestamp time.Time timestamp time.Time
} }
// NewVote returns an initialized Vote.
func NewVote(id, elector string, choice Choice) Vote { func NewVote(id, elector string, choice Choice) Vote {
return Vote{ return Vote{
Id: id, Id: id,
@ -21,6 +27,8 @@ func NewVote(id, elector string, choice Choice) Vote {
} }
} }
// NewVoteWithTimestamp returns an initialized Vote with a predefined
// timestamp. This can be useful when loading a vote from the store.
func NewVoteWithTimestamp(id, elector string, choice Choice, timestamp time.Time) Vote { func NewVoteWithTimestamp(id, elector string, choice Choice, timestamp time.Time) Vote {
return Vote{ return Vote{
Id: id, Id: id,
@ -30,6 +38,7 @@ func NewVoteWithTimestamp(id, elector string, choice Choice, timestamp time.Time
} }
} }
// String implements fmt.Stringer
func (vote Vote) String() string { func (vote Vote) String() string {
return fmt.Sprintf("%s %s %s %s", vote.Id, vote.timestamp.Format(time.DateTime), vote.Choice, vote.Elector) return fmt.Sprintf("%s %s %s %s", vote.Id, vote.timestamp.Format(time.DateTime), vote.Choice, vote.Elector)
} }

View File

@ -12,18 +12,28 @@ import (
) )
type ( type (
// Voting represents a voting with all its data and meta-data.
Voting struct { Voting struct {
// id is the unique ID of the vote.
id string id string
// referendum contains the subject or title of the voting
referendum string referendum string
// deadline defines the point in time when the voting closes
deadline time.Time deadline time.Time
// quorum defines the mininimum required eligible votes
quorum quorum.Quorum quorum quorum.Quorum
// threshold defines the minimum required YES votes
threshold threshold.Threshold threshold threshold.Threshold
// electors contains the list of eligible voters (empty if anyone can vote)
electors []string electors []string
// annonymous defines if the voting is anonymous or public
annonymous bool annonymous bool
// votes holds all votes associated with this voting
votes []vote.Vote votes []vote.Vote
} }
) )
// NewVoting returns an initialized Voting.
func NewVoting(id, r string, d time.Time, q quorum.Quorum, t threshold.Threshold, e []string, a bool) *Voting { func NewVoting(id, r string, d time.Time, q quorum.Quorum, t threshold.Threshold, e []string, a bool) *Voting {
return &Voting{ return &Voting{
id: id, id: id,
@ -37,6 +47,8 @@ func NewVoting(id, r string, d time.Time, q quorum.Quorum, t threshold.Threshold
} }
} }
// NewVotingWithVotes returns an initialized Voting with pre-defined votes.
// This is convenient when loading a voting from the store and populating it in one call.
func NewVotingWithVotes(id, r string, d time.Time, q quorum.Quorum, t threshold.Threshold, e []string, a bool, v []vote.Vote) *Voting { func NewVotingWithVotes(id, r string, d time.Time, q quorum.Quorum, t threshold.Threshold, e []string, a bool, v []vote.Vote) *Voting {
voting := NewVoting(id, r, d, q, t, e, a) voting := NewVoting(id, r, d, q, t, e, a)
for i := range v { for i := range v {
@ -45,7 +57,9 @@ func NewVotingWithVotes(id, r string, d time.Time, q quorum.Quorum, t threshold.
return voting return voting
} }
// String implements fmt.Stringer.
func (v Voting) String() string { func (v Voting) String() string {
// initialize vars with all the metadata
var ( var (
possibleVotes int = len(v.electors) possibleVotes int = len(v.electors)
totalVotes int = len(v.Votes()) totalVotes int = len(v.Votes())
@ -55,12 +69,12 @@ func (v Voting) String() string {
quorumStatus string = "❌ FAIL" quorumStatus string = "❌ FAIL"
thresholdStatus string = "❌ FAIL" thresholdStatus string = "❌ FAIL"
out string = "" out string = ""
quorumSatisfied = v.quorum.IsSatisfied(possibleVotes, totalVotes)
thresholdSatisfied = v.threshold.IsSatisfied(totalVotes, yesVotes, noVotes)
votingSatisfied = quorumSatisfied && thresholdSatisfied
) )
quorumSatisfied := v.quorum.IsSatisfied(possibleVotes, totalVotes) // check the deadline
thresholdSatisfied := v.threshold.IsSatisfied(totalVotes, yesVotes, noVotes)
votingSatisfied := quorumSatisfied && thresholdSatisfied
if time.Now().UTC().After(v.deadline) { if time.Now().UTC().After(v.deadline) {
if votingSatisfied { if votingSatisfied {
deadlineStatus = "✅ APROVED ✅" deadlineStatus = "✅ APROVED ✅"
@ -68,6 +82,8 @@ func (v Voting) String() string {
deadlineStatus = "❌ REJECTED ❌" deadlineStatus = "❌ REJECTED ❌"
} }
} }
// check quorum and threshold
if v.quorum.IsSatisfied(possibleVotes, totalVotes) { if v.quorum.IsSatisfied(possibleVotes, totalVotes) {
quorumStatus = "✅ PASS" quorumStatus = "✅ PASS"
} }
@ -75,6 +91,7 @@ func (v Voting) String() string {
thresholdStatus = "✅ PASS" thresholdStatus = "✅ PASS"
} }
// assemble output string
out += fmt.Sprintf("Referendum: %s\n", strings.ToUpper(v.referendum)) out += fmt.Sprintf("Referendum: %s\n", strings.ToUpper(v.referendum))
out += fmt.Sprintf("Deadline : %s UTC\n", v.deadline.Format(time.DateTime)) out += fmt.Sprintf("Deadline : %s UTC\n", v.deadline.Format(time.DateTime))
out += fmt.Sprintf("Quorum : %s (%d/%d) (required: %s)\n", quorumStatus, totalVotes, possibleVotes, v.quorum) out += fmt.Sprintf("Quorum : %s (%d/%d) (required: %s)\n", quorumStatus, totalVotes, possibleVotes, v.quorum)
@ -88,6 +105,7 @@ func (v Voting) String() string {
) )
out += fmt.Sprintf("Status : %s\n", deadlineStatus) out += fmt.Sprintf("Status : %s\n", deadlineStatus)
// only show votes when not anonymous
if !v.annonymous { if !v.annonymous {
out += "\n" out += "\n"
if v.votes != nil && len(v.votes) > 0 { if v.votes != nil && len(v.votes) > 0 {
@ -97,29 +115,36 @@ func (v Voting) String() string {
} }
} }
// return the output string
return out return out
} }
// ID returns the unique ID of this voting.
func (v Voting) ID() string { func (v Voting) ID() string {
return v.id return v.id
} }
// Referendum returns the referendum of this voting.
func (v Voting) Referendum() string { func (v Voting) Referendum() string {
return v.referendum return v.referendum
} }
// Deadline returns the deadline of this voting.
func (v Voting) Deadline() time.Time { func (v Voting) Deadline() time.Time {
return v.deadline return v.deadline
} }
// Quorum returns the quorum of this voting.
func (v Voting) Quorum() string { func (v Voting) Quorum() string {
return v.quorum.String() return v.quorum.String()
} }
// Threshold returns the threshold of this voting.
func (v Voting) Threshold() string { func (v Voting) Threshold() string {
return v.threshold.String() return v.threshold.String()
} }
// Electors returns the list of eligible voters of this voting.
func (v Voting) Electors() []string { func (v Voting) Electors() []string {
electors := make([]string, len(v.electors)) electors := make([]string, len(v.electors))
for i := range v.electors { for i := range v.electors {
@ -128,14 +153,17 @@ func (v Voting) Electors() []string {
return electors return electors
} }
// Anonymous returns the anonymous setting of this voting.
func (v Voting) Anonymous() bool { func (v Voting) Anonymous() bool {
return v.annonymous return v.annonymous
} }
// IsOpen returns true while the deadline has not yet been reached.
func (v Voting) IsOpen() bool { func (v Voting) IsOpen() bool {
return v.deadline.After(time.Now().UTC()) return v.deadline.After(time.Now().UTC())
} }
// Result returns a Result based on this voting.
func (v Voting) Result() Result { func (v Voting) Result() Result {
var ( var (
possibleVotes = len(v.electors) possibleVotes = len(v.electors)
@ -162,22 +190,33 @@ func (v Voting) Result() Result {
} }
} }
// Vote takes a vote.Vote and places it.
// Placing a vote after the deadline has passed will raise an error. Placing a
// vote with an elector not in the list of eligible electors will raise an
// error.
func (v *Voting) Vote(vote vote.Vote) error { func (v *Voting) Vote(vote vote.Vote) error {
// check if deadline has passed
if time.Now().UTC().After(v.deadline) { if time.Now().UTC().After(v.deadline) {
return fmt.Errorf("deadline has passed") return fmt.Errorf("deadline has passed")
} }
// place vote if elector is eligible
for _, elector := range v.electors { for _, elector := range v.electors {
if elector == vote.Elector { if elector == vote.Elector {
v.votes = append(v.votes, vote) v.votes = append(v.votes, vote)
return nil return nil
} }
} }
// raise an error if not eligible
return fmt.Errorf("not eligable to vote: %s", vote.Elector) return fmt.Errorf("not eligable to vote: %s", vote.Elector)
} }
// Votes returns the normalized list of effective votes.
// Only the last vote of each elector before the deadline counts.
func (v Voting) Votes() []vote.Vote { func (v Voting) Votes() []vote.Vote {
votes := []vote.Vote{} votes := []vote.Vote{}
nextVote: nextVote:
// iterate over all placed votes in reverse order only respecting the first
// vote of each elector (effectively the final vote of each elector).
for i := len(v.votes) - 1; i >= 0; i-- { for i := len(v.votes) - 1; i >= 0; i-- {
elector := v.votes[i].Elector elector := v.votes[i].Elector
for _, e := range votes { for _, e := range votes {
@ -188,25 +227,29 @@ nextVote:
votes = append(votes, v.votes[i]) votes = append(votes, v.votes[i])
} }
// anonymize // optionally, anonymize the votes before returning them
if v.annonymous { if v.annonymous {
for i := range votes { for i := range votes {
votes[i].Elector = "" votes[i].Elector = ""
} }
} }
return votes return votes
} }
// yesVotes returns a []vote.Vote of all yes votes.
func (v Voting) yesVotes() []vote.Vote { func (v Voting) yesVotes() []vote.Vote {
filterFunc := func(_v vote.Vote) bool { return _v.Choice == vote.Yes } filterFunc := func(_v vote.Vote) bool { return _v.Choice == vote.Yes }
return filter.Choose(v.Votes(), filterFunc).([]vote.Vote) return filter.Choose(v.Votes(), filterFunc).([]vote.Vote)
} }
// noVotes returns a []vote.Vote of all no votes.
func (v Voting) noVotes() []vote.Vote { func (v Voting) noVotes() []vote.Vote {
filterFunc := func(_v vote.Vote) bool { return _v.Choice == vote.No } filterFunc := func(_v vote.Vote) bool { return _v.Choice == vote.No }
return filter.Choose(v.Votes(), filterFunc).([]vote.Vote) return filter.Choose(v.Votes(), filterFunc).([]vote.Vote)
} }
// abstainVotes returns a []vote.Vote of all abstain votes.
func (v Voting) abstainVotes() []vote.Vote { func (v Voting) abstainVotes() []vote.Vote {
filterFunc := func(_v vote.Vote) bool { return _v.Choice == vote.Abstain } filterFunc := func(_v vote.Vote) bool { return _v.Choice == vote.Abstain }
return filter.Choose(v.Votes(), filterFunc).([]vote.Vote) return filter.Choose(v.Votes(), filterFunc).([]vote.Vote)