From c61300c6b897529c390809ee36e150d6c7bc6360 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Tue, 14 May 2024 10:26:48 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20README=20and=20inline=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 ++++++ cmd/main.go | 4 +- http/main.go | 2 + http/template_renderer.go | 6 +++ store/db.go | 6 +++ store/handler.go | 3 ++ store/prepared.go | 5 +++ utils/random.go | 2 + voting/quorum/quorum.go | 8 ++++ voting/result.go | 2 + voting/threshold/threshold.go | 10 ++++- voting/vote/choice.go | 6 +++ voting/vote/vote.go | 15 +++++-- voting/voting.go | 81 +++++++++++++++++++++++++++-------- 14 files changed, 139 insertions(+), 24 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3e8c7f --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# GoVote + +> Referendums & Consensus + +A voting tool for the web, written in Go. + +## Develop + +``` +yarn install +go generate +go run ./main +``` diff --git a/cmd/main.go b/cmd/main.go index 9b556be..b1be246 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,3 +1,5 @@ +// cmd implements the CLI using urfav/cli. + package cmd import ( @@ -6,7 +8,7 @@ import ( var App = cli.App{ Name: "govote", - Usage: "🌈 Referendums and concensus.", + Usage: "🌈 Referendums and consensus.", Commands: []*cli.Command{ newCmd, showCmd, diff --git a/http/main.go b/http/main.go index bb46e38..5857b74 100644 --- a/http/main.go +++ b/http/main.go @@ -18,6 +18,7 @@ import ( "github.com/labstack/echo/middleware" ) +// Serve takes a bind-address and starts the HTTP server. func Serve(bindAddr string) error { e := echo.New() e.Pre(middleware.RemoveTrailingSlash()) @@ -75,6 +76,7 @@ func handleNewVoting(ctx echo.Context) error { case "d": d = time.Now().UTC().Add(time.Duration(deadlineInt) * time.Hour * 24).Round(time.Second) default: + // TODO: this needs better handling! panic("this code should never be reached") } diff --git a/http/template_renderer.go b/http/template_renderer.go index 707306b..28a9e63 100644 --- a/http/template_renderer.go +++ b/http/template_renderer.go @@ -1,3 +1,6 @@ +// labstack/echo requires a custom TemplateRenderer. +// This module implements it. + package http import ( @@ -7,14 +10,17 @@ import ( "github.com/labstack/echo" ) +// Template is a data-container for a template. type Template struct { 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 { return t.Templates.ExecuteTemplate(w, name, data) } +// NewTemplateRenderer returns an initialized Template and injects the patched Renderer. func NewTemplateRenderer(e *echo.Echo, paths ...string) { tmpl := &template.Template{} for i := range paths { diff --git a/store/db.go b/store/db.go index fdf4e97..b77d4f1 100644 --- a/store/db.go +++ b/store/db.go @@ -5,16 +5,22 @@ import ( _ "github.com/mattn/go-sqlite3" ) +// file points the the sqlite file. const file = "./govote.db" +// db references the initialized sqlite connection. var db *sql.DB +// init initialized the database layer func init() { var err error if db, err = sql.Open("sqlite3", file); err != nil { panic(err) } + // initialize the sql schema initCreateTables(db) + + // initialize all prepared statements initStmts(db) } diff --git a/store/handler.go b/store/handler.go index 73fbf6d..f5b618b 100644 --- a/store/handler.go +++ b/store/handler.go @@ -11,6 +11,7 @@ import ( "code.c-base.org/baccenfutter/govote/voting/vote" ) +// NewVoting writes a new voting into the store. func NewVoting( id string, r string, @@ -27,6 +28,7 @@ func NewVoting( 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) { result := votingSelect.QueryRow(id) if result == nil { @@ -73,6 +75,7 @@ func GetVoting(id string) (*voting.Voting, error) { return v, nil } +// PlaceVote writes an individual vote to the store. func PlaceVote(id, votingID, elector string, choice vote.Choice) error { if _, err := voteInsert.Exec(id, votingID, elector, choice.String()); err != nil { return err diff --git a/store/prepared.go b/store/prepared.go index b7a00e8..8d7b478 100644 --- a/store/prepared.go +++ b/store/prepared.go @@ -2,6 +2,7 @@ package store import "database/sql" +// References to all available prepared statements. var ( votingInsert *sql.Stmt votingSelect *sql.Stmt @@ -10,6 +11,8 @@ var ( voteSelect *sql.Stmt ) +// initCreateTables takes an sql connection and creates all tables if they +// don't yet exist. func initCreateTables(db *sql.DB) { var err error 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) { initStmtVotingInsert(db) initStmtVotingSelect(db) diff --git a/utils/random.go b/utils/random.go index 22a7c0d..d2425a9 100644 --- a/utils/random.go +++ b/utils/random.go @@ -6,6 +6,8 @@ import ( 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 { result := make([]byte, length) for i := 0; i < length; i++ { diff --git a/voting/quorum/quorum.go b/voting/quorum/quorum.go index c0acb08..08d23a1 100644 --- a/voting/quorum/quorum.go +++ b/voting/quorum/quorum.go @@ -5,8 +5,10 @@ import ( "strings" ) +// Quorum defines a custom uint8 type for storing a quorum. type Quorum uint8 +// Supported quorums are: const ( Simple Quorum = iota OneFifth @@ -21,6 +23,7 @@ const ( Unanimous ) +// ValidQuorums returns a []Quorum of all supported quorums. func ValidQuorums() []Quorum { return []Quorum{ 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) { for _, q := range ValidQuorums() { 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) } +// String implements fmt.Stringer func (q Quorum) String() string { switch q { 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 { if totalVotes == 0 { return false diff --git a/voting/result.go b/voting/result.go index 75b14f6..ae8fc85 100644 --- a/voting/result.go +++ b/voting/result.go @@ -2,6 +2,8 @@ package voting 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 { Quorum bool Threshold bool diff --git a/voting/threshold/threshold.go b/voting/threshold/threshold.go index 2f21e68..3cb083c 100644 --- a/voting/threshold/threshold.go +++ b/voting/threshold/threshold.go @@ -5,8 +5,10 @@ import ( "strings" ) +// Threshold defines a custom uint8 type for representing a threshold. type Threshold uint8 +// Supported thresholds are: const ( Simple Threshold = iota OneFifth @@ -21,16 +23,19 @@ const ( Unanimous ) +// ValidThresholds returns a []Threshold of all supported thresholds. func ValidThresholds() []Threshold { return []Threshold{ Simple, Unanimous, OneFifth, OneQuarter, OneThird, OneHalf, TwoFifths, TwoThirds, - ThreeFifths, ThreeQuarters, ThreeFifths, + ThreeQuarters, ThreeFifths, 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) { for _, t := range ValidThresholds() { 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) } +// String implements fmt.Stringer func (t Threshold) String() string { switch t { 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 { if totalVotes == 0 { return false diff --git a/voting/vote/choice.go b/voting/vote/choice.go index e87b991..60151d6 100644 --- a/voting/vote/choice.go +++ b/voting/vote/choice.go @@ -5,8 +5,10 @@ import ( "strings" ) +// Choice defines a custom int8 type for use as choice in a vote. type Choice int8 +// String implements fmt.Stringer func (choice Choice) String() string { switch choice { case Yes: @@ -20,16 +22,20 @@ func (choice Choice) String() string { } } +// Available choices are: const ( Abstain Choice = 0 Yes Choice = 1 No Choice = -1 ) +// ValidChoices returns a []Choice with all available choices. func ValidChoices() []Choice { 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) { for _, c := range ValidChoices() { if strings.ToUpper(c.String()) == strings.ToUpper(s) { diff --git a/voting/vote/vote.go b/voting/vote/vote.go index 94c18b2..9437d5f 100644 --- a/voting/vote/vote.go +++ b/voting/vote/vote.go @@ -5,13 +5,19 @@ import ( "time" ) +// Vote represents an individual vote in a voting. type Vote struct { - Id string - Elector string - Choice Choice + // Id contains the unique ID of the vote. + Id string + // Elector contains the name of whoever placed the vote. + Elector string + // Choice contains the choice of the vote. + Choice Choice + // timestamp contains the UTC timestamp of when the vote was placed. timestamp time.Time } +// NewVote returns an initialized Vote. func NewVote(id, elector string, choice Choice) Vote { return Vote{ 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 { return Vote{ 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 { return fmt.Sprintf("%s %s %s %s", vote.Id, vote.timestamp.Format(time.DateTime), vote.Choice, vote.Elector) } diff --git a/voting/voting.go b/voting/voting.go index d26926b..8bdf9bd 100644 --- a/voting/voting.go +++ b/voting/voting.go @@ -12,18 +12,28 @@ import ( ) type ( + // Voting represents a voting with all its data and meta-data. Voting struct { - id string + // id is the unique ID of the vote. + id string + // referendum contains the subject or title of the voting referendum string - deadline time.Time - quorum quorum.Quorum - threshold threshold.Threshold - electors []string + // deadline defines the point in time when the voting closes + deadline time.Time + // quorum defines the mininimum required eligible votes + quorum quorum.Quorum + // threshold defines the minimum required YES votes + threshold threshold.Threshold + // electors contains the list of eligible voters (empty if anyone can vote) + electors []string + // annonymous defines if the voting is anonymous or public annonymous bool - votes []vote.Vote + // votes holds all votes associated with this voting + 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 { return &Voting{ 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 { voting := NewVoting(id, r, d, q, t, e, a) for i := range v { @@ -45,22 +57,24 @@ func NewVotingWithVotes(id, r string, d time.Time, q quorum.Quorum, t threshold. return voting } +// String implements fmt.Stringer. func (v Voting) String() string { + // initialize vars with all the metadata var ( - possibleVotes int = len(v.electors) - totalVotes int = len(v.Votes()) - yesVotes int = len(v.yesVotes()) - noVotes int = len(v.noVotes()) - deadlineStatus string = "🎭 ONGOING 🎭" - quorumStatus string = "❌ FAIL" - thresholdStatus string = "❌ FAIL" - out string = "" + possibleVotes int = len(v.electors) + totalVotes int = len(v.Votes()) + yesVotes int = len(v.yesVotes()) + noVotes int = len(v.noVotes()) + deadlineStatus string = "🎭 ONGOING 🎭" + quorumStatus string = "❌ FAIL" + thresholdStatus string = "❌ FAIL" + out string = "" + quorumSatisfied = v.quorum.IsSatisfied(possibleVotes, totalVotes) + thresholdSatisfied = v.threshold.IsSatisfied(totalVotes, yesVotes, noVotes) + votingSatisfied = quorumSatisfied && thresholdSatisfied ) - quorumSatisfied := v.quorum.IsSatisfied(possibleVotes, totalVotes) - thresholdSatisfied := v.threshold.IsSatisfied(totalVotes, yesVotes, noVotes) - votingSatisfied := quorumSatisfied && thresholdSatisfied - + // check the deadline if time.Now().UTC().After(v.deadline) { if votingSatisfied { deadlineStatus = "✅ APROVED ✅" @@ -68,6 +82,8 @@ func (v Voting) String() string { deadlineStatus = "❌ REJECTED ❌" } } + + // check quorum and threshold if v.quorum.IsSatisfied(possibleVotes, totalVotes) { quorumStatus = "✅ PASS" } @@ -75,6 +91,7 @@ func (v Voting) String() string { thresholdStatus = "✅ PASS" } + // assemble output string 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("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) + // only show votes when not anonymous if !v.annonymous { out += "\n" if v.votes != nil && len(v.votes) > 0 { @@ -97,29 +115,36 @@ func (v Voting) String() string { } } + // return the output string return out } +// ID returns the unique ID of this voting. func (v Voting) ID() string { return v.id } +// Referendum returns the referendum of this voting. func (v Voting) Referendum() string { return v.referendum } +// Deadline returns the deadline of this voting. func (v Voting) Deadline() time.Time { return v.deadline } +// Quorum returns the quorum of this voting. func (v Voting) Quorum() string { return v.quorum.String() } +// Threshold returns the threshold of this voting. func (v Voting) Threshold() string { return v.threshold.String() } +// Electors returns the list of eligible voters of this voting. func (v Voting) Electors() []string { electors := make([]string, len(v.electors)) for i := range v.electors { @@ -128,14 +153,17 @@ func (v Voting) Electors() []string { return electors } +// Anonymous returns the anonymous setting of this voting. func (v Voting) Anonymous() bool { return v.annonymous } +// IsOpen returns true while the deadline has not yet been reached. func (v Voting) IsOpen() bool { return v.deadline.After(time.Now().UTC()) } +// Result returns a Result based on this voting. func (v Voting) Result() Result { var ( 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 { + // check if deadline has passed if time.Now().UTC().After(v.deadline) { return fmt.Errorf("deadline has passed") } + // place vote if elector is eligible for _, elector := range v.electors { if elector == vote.Elector { v.votes = append(v.votes, vote) return nil } } + // raise an error if not eligible 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 { votes := []vote.Vote{} 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-- { elector := v.votes[i].Elector for _, e := range votes { @@ -188,25 +227,29 @@ nextVote: votes = append(votes, v.votes[i]) } - // anonymize + // optionally, anonymize the votes before returning them if v.annonymous { for i := range votes { votes[i].Elector = "" } } + return votes } +// yesVotes returns a []vote.Vote of all yes votes. func (v Voting) yesVotes() []vote.Vote { filterFunc := func(_v vote.Vote) bool { return _v.Choice == vote.Yes } return filter.Choose(v.Votes(), filterFunc).([]vote.Vote) } +// noVotes returns a []vote.Vote of all no votes. func (v Voting) noVotes() []vote.Vote { filterFunc := func(_v vote.Vote) bool { return _v.Choice == vote.No } return filter.Choose(v.Votes(), filterFunc).([]vote.Vote) } +// abstainVotes returns a []vote.Vote of all abstain votes. func (v Voting) abstainVotes() []vote.Vote { filterFunc := func(_v vote.Vote) bool { return _v.Choice == vote.Abstain } return filter.Choose(v.Votes(), filterFunc).([]vote.Vote)