Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/go-lint.yml → .github/workflows/go.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Golang Linting
name: Golang Validation

on:
push:
Expand Down Expand Up @@ -29,3 +29,10 @@ jobs:
- name: Check Format
run: |
gofmt -s -l database logging sse *.go
- name: Run Tests
run: |
go test ./database
- name: Run vet
run: |
go vet ./database/ ./logging/ ./sse/
go vet *.go
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ go mod tidy

# format all code according to go standards
gofmt -w -s *.go logging sse database

# run tests (database is the first place we've defined tests)
go test ./database

# run heuristic validation
go vet ./database/ ./logging/ ./sse/
go vet *.go
```

## To-Dos
Expand Down
9 changes: 8 additions & 1 deletion database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"os"
"strings"
"testing"
"time"

"github.com/computersciencehouse/vote/logging"
Expand All @@ -20,10 +21,16 @@ const (
Updated UpsertResult = 1
)

var Client = Connect()
var Client *mongo.Client = Connect()
var db = ""

func Connect() *mongo.Client {
// This always gets invoked on initialisation. bad! it'd be nice if we only did this setup in main rather than components under test. for now we just skip if testing
if testing.Testing() {
logging.Logger.WithFields(logrus.Fields{"module": "database", "method": "Connect"}).Info("testing, not doing db connection, someone should mock this someday")
return nil
}

logging.Logger.WithFields(logrus.Fields{"module": "database", "method": "Connect"}).Info("beginning database connection")

ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second)
Expand Down
267 changes: 156 additions & 111 deletions database/poll.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ package database

import (
"context"
"sort"
"time"

"github.com/sirupsen/logrus"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"

"github.com/computersciencehouse/vote/logging"
)

type Poll struct {
Expand Down Expand Up @@ -139,32 +143,32 @@ func GetClosedVotedPolls(ctx context.Context, userId string) ([]*Poll, error) {

cursor, err := Client.Database(db).Collection("votes").Aggregate(ctx, mongo.Pipeline{
{{
"$match", bson.D{
{"userId", userId},
Key: "$match", Value: bson.D{
{Key: "userId", Value: userId},
},
}},
{{
"$lookup", bson.D{
{"from", "polls"},
{"localField", "pollId"},
{"foreignField", "_id"},
{"as", "polls"},
Key: "$lookup", Value: bson.D{
{Key: "from", Value: "polls"},
{Key: "localField", Value: "pollId"},
{Key: "foreignField", Value: "_id"},
{Key: "as", Value: "polls"},
},
}},
{{
"$unwind", bson.D{
{"path", "$polls"},
{"preserveNullAndEmptyArrays", false},
Key: "$unwind", Value: bson.D{
{Key: "path", Value: "$polls"},
{Key: "preserveNullAndEmptyArrays", Value: false},
},
}},
{{
"$replaceRoot", bson.D{
{"newRoot", "$polls"},
Key: "$replaceRoot", Value: bson.D{
{Key: "newRoot", Value: "$polls"},
},
}},
{{
"$match", bson.D{
{"open", false},
Key: "$match", Value: bson.D{
{Key: "open", Value: false},
},
}},
})
Expand All @@ -178,6 +182,105 @@ func GetClosedVotedPolls(ctx context.Context, userId string) ([]*Poll, error) {
return polls, nil
}

// calculateRankedResult determines a result for a ranked choice vote
// votesRaw is the RankedVote entries that are returned directly from the database
// The algorithm defined in the Constitution as of 26 Nov 2025 is as follows:
//
// > The winning option is selected outright if it gains more than half the votes
// > cast as a first preference. If not, the option with the fewest number of first
// > preference votes is eliminated and their votes move to the second preference
// > marked on the ballots. This process continues until one option has half of the
// > votes cast and is elected.
//
// The return value consists of a list of voting rounds. Each round contains a
// mapping of the vote options to their vote share for that round. If the vote
// is not decided in a given round, there will be a subsequent round with the
// option that had the fewest votes eliminated, and its votes redistributed.
//
// The last entry in this list is the final round, and the option with the most
// votes in this round is the winner. If all options have the same, then it is
// unfortunately a tie, and the vote is not resolvable, as there is no lowest
// option to eliminate.
func calculateRankedResult(ctx context.Context, votesRaw []RankedVote) ([]map[string]int, error) {
// We want to store those that were eliminated so we don't accidentally reinclude them
eliminated := make([]string, 0)
votes := make([][]string, 0)
finalResult := make([]map[string]int, 0)

//change ranked votes from a map (which is unordered) to a slice of votes (which is ordered)
//order is from first preference to last preference
for _, vote := range votesRaw {
optionList := orderOptions(ctx, vote.Options)
votes = append(votes, optionList)
}

round := 0
// Iterate until we have a winner
for {
round = round + 1
// Contains candidates to number of votes in this round
tallied := make(map[string]int)
voteCount := 0
for _, picks := range votes {
// Go over picks until we find a non-eliminated candidate
for _, candidate := range picks {
if !containsValue(eliminated, candidate) {
if _, ok := tallied[candidate]; ok {
tallied[candidate]++
} else {
tallied[candidate] = 1
}
voteCount += 1
break
}
}
}
// Eliminate lowest vote getter
minVote := 1000000 //the smallest number of votes received thus far (to find who is in last)
minPerson := make([]string, 0) //the person(s) with the least votes that need removed
for person, vote := range tallied {
if vote < minVote { // this should always be true round one, to set a true "who is in last"
minVote = vote
minPerson = make([]string, 0)
minPerson = append(minPerson, person)
} else if vote == minVote {
minPerson = append(minPerson, person)
}
}
eliminated = append(eliminated, minPerson...)
finalResult = append(finalResult, tallied)

// TODO this should probably include some poll identifier
logging.Logger.WithFields(logrus.Fields{"round": round, "tallies": tallied, "threshold": voteCount / 2}).Debug("round report")

// If one person has all the votes, they win
if len(tallied) == 1 {
break
}

end := true
for str, val := range tallied {
// if any particular entry is above half remaining votes, they win and it ends
if val > (voteCount / 2) {
finalResult = append(finalResult, map[string]int{str: val})
end = true
break
}
// Check if all values in tallied are the same
// In that case, it's a tie?
if val != minVote {
end = false
break
}
}
if end {
break
}
}
return finalResult, nil

}

func (poll *Poll) GetResult(ctx context.Context) ([]map[string]int, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
Expand All @@ -191,15 +294,15 @@ func (poll *Poll) GetResult(ctx context.Context) ([]map[string]int, error) {
pollResult := make(map[string]int)
cursor, err := Client.Database(db).Collection("votes").Aggregate(ctx, mongo.Pipeline{
{{
"$match", bson.D{
{"pollId", pollId},
Key: "$match", Value: bson.D{
{Key: "pollId", Value: pollId},
},
}},
{{
"$group", bson.D{
{"_id", "$option"},
{"count", bson.D{
{"$sum", 1},
Key: "$group", Value: bson.D{
{Key: "_id", Value: "$option"},
{Key: "count", Value: bson.D{
{Key: "$sum", Value: 1},
}},
},
}},
Expand All @@ -223,14 +326,11 @@ func (poll *Poll) GetResult(ctx context.Context) ([]map[string]int, error) {
return finalResult, nil

case POLL_TYPE_RANKED:
// We want to store those that were eliminated
eliminated := make([]string, 0)

// Get all votes
cursor, err := Client.Database(db).Collection("votes").Aggregate(ctx, mongo.Pipeline{
{{
"$match", bson.D{
{"pollId", pollId},
Key: "$match", Value: bson.D{
{Key: "pollId", Value: pollId},
},
}},
})
Expand All @@ -239,76 +339,7 @@ func (poll *Poll) GetResult(ctx context.Context) ([]map[string]int, error) {
}
var votesRaw []RankedVote
cursor.All(ctx, &votesRaw)

votes := make([][]string, 0)

//change ranked votes from a map (which is unordered) to a slice of votes (which is ordered)
//order is from first preference to last preference
for _, vote := range votesRaw {
temp, cf := context.WithTimeout(context.Background(), 1*time.Second)
optionList := orderOptions(vote.Options, temp)
cf()
votes = append(votes, optionList)
}

// Iterate until we have a winner
for {
// Contains candidates to number of votes in this round
tallied := make(map[string]int)
voteCount := 0
for _, picks := range votes {
// Go over picks until we find a non-eliminated candidate
for _, candidate := range picks {
if !containsValue(eliminated, candidate) {
if _, ok := tallied[candidate]; ok {
tallied[candidate]++
} else {
tallied[candidate] = 1
}
voteCount += 1
break
}
}
}
// Eliminate lowest vote getter
minVote := 1000000 //the smallest number of votes received thus far (to find who is in last)
minPerson := make([]string, 0) //the person(s) with the least votes that need removed
for person, vote := range tallied {
if vote < minVote { // this should always be true round one, to set a true "who is in last"
minVote = vote
minPerson = make([]string, 0)
minPerson = append(minPerson, person)
} else if vote == minVote {
minPerson = append(minPerson, person)
}
}
eliminated = append(eliminated, minPerson...)
finalResult = append(finalResult, tallied)
// If one person has all the votes, they win
if len(tallied) == 1 {
break
}

end := true
for str, val := range tallied {
// if any particular entry is above half remaining votes, they win and it ends
if val > (voteCount / 2) {
finalResult = append(finalResult, map[string]int{str: val})
end = true
break
}
// Check if all values in tallied are the same
// In that case, it's a tie?
if val != minVote {
end = false
break
}
}
if end {
break
}
}
return finalResult, nil
return calculateRankedResult(ctx, votesRaw)
}
return nil, nil
}
Expand All @@ -322,21 +353,35 @@ func containsValue(slice []string, value string) bool {
return false
}

func orderOptions(options map[string]int, ctx context.Context) []string {
result := make([]string, 0, len(options))
order := 1
for order <= len(options) {
for option, preference := range options {
select {
case <-ctx.Done():
return make([]string, 0)
default:
if preference == order {
result = append(result, option)
order += 1
}
}
}
// orderOptions takes a RankedVote's options, and returns an ordered list of
// their choices
//
// it's invalid for a vote to list the same number multiple times, the output
// will vary based on the map ordering of the options, and so is not guaranteed
// to be deterministic
//
// ctx is no longer used, as this function is not expected to hang, but remains
// an argument per golang standards
//
// the return values is the option keys, ordered from lowest to highest
func orderOptions(ctx context.Context, options map[string]int) []string {
// Figure out all the ranks they've listed
var ranks []int = make([]int, len(options))
reverse_map := make(map[int]string)
i := 0
for option, rank := range options {
ranks[i] = rank
reverse_map[rank] = option
i += 1
}

sort.Ints(ranks)

// normalise the ranks for counts that don't start at 1
var choices []string = make([]string, len(ranks))
for idx, rank := range ranks {
choices[idx] = reverse_map[rank]
}
return result

return choices
}
Loading