Compare commits

...

46 Commits

Author SHA1 Message Date
f519b94392 Use realIP
All checks were successful
Build and Push Docker Image on Tag / Build and Push Docker Image (push) Successful in 15m31s
Code was copied from https://github.com/go-chi/chi/blob/master/middleware/realip.go
2025-02-01 17:07:24 +01:00
5a0da6560d Fix env var in image-build.yaml
All checks were successful
Build and Push Docker Image on Tag / Build and Push Docker Image (push) Successful in 15m25s
2025-01-30 09:09:21 +01:00
6aed7cb7fa Add Gitea workflow
Some checks failed
Build and Push Docker Image on Tag / Build and Push Docker Image (push) Failing after 10m54s
2025-01-30 08:56:30 +01:00
5ed16bdab3 Remove allow_different_binary_count 2025-01-23 14:15:34 +01:00
7939a6fde1 Fix deprecated property 2025-01-23 14:12:59 +01:00
e78d0a7276 Add version in archive name 2025-01-23 14:12:16 +01:00
e544ea8426 Fix Makefile 2025-01-23 14:09:18 +01:00
56adeaf1b8 Configure for goreleaser 2025-01-23 14:03:58 +01:00
a72eead432 Fix TODOs in openapi.yml 2025-01-22 20:12:52 +01:00
6e0375c666 Use alpine as base image in Dockerfile. Image size went from 372MB to 17.9MB 2025-01-22 08:13:10 +01:00
ff2a90ed3d Add links for election results after creating 2025-01-22 08:08:28 +01:00
6d92358f79 Fix Makefile and add VOLUME in Dockerfile 2025-01-21 22:24:01 +01:00
e7387be995 Make Chart.js work as local file 2025-01-21 11:38:21 +01:00
a1af7a48b2 Fix maxVoters check and add unit tests 2025-01-21 09:24:51 +01:00
5570dca6c9 Use cgo-free SQL driver and add limit of 100 to maxVoters 2025-01-21 09:17:40 +01:00
1b6fc173d3 Add page for results 2025-01-20 18:08:19 +01:00
37b06cfb9e Use makefile instead 2025-01-20 11:53:48 +01:00
8792b05cca Fix footer link and tailwind source 2025-01-20 10:39:15 +01:00
9a03a9cebd Add script to build 2025-01-20 10:39:00 +01:00
bddae031cc Add index page and link to share more easily 2025-01-20 10:13:59 +01:00
5668b1cd6a Insert election with uuid instead of auto-generated id 2025-01-20 10:04:15 +01:00
729fbecae6 Fix content-type header 2025-01-17 19:34:49 +01:00
fde07b74fa Display error message from JSON to end user 2025-01-17 17:26:24 +01:00
f94e08fc7f Add integration tests for createVotes 2025-01-17 17:11:16 +01:00
5e4a089b89 Fix integration test URI 2025-01-17 16:50:16 +01:00
27d166dad6 Implement page to vote and fix trigger 2025-01-17 16:48:38 +01:00
62562272f1 Add tests for GetElection 2025-01-17 14:55:54 +01:00
410e8f39d3 Add some more tests and implement getElection 2025-01-17 14:49:51 +01:00
60d1bb382c Write TestGetElectionResults_NotFound 2025-01-17 13:59:37 +01:00
afac0f8f91 Change copyAllCodes to async function 2025-01-16 16:36:25 +01:00
70a0cd7d3b Add voterIdentities to copy again and fix issue if there was no body in the response (in the case of unknown voters election) 2025-01-14 20:07:02 +01:00
f784eb474f Fix maxVoters minimum value and value of boolean areVotersKnown 2025-01-14 19:49:12 +01:00
96133f00de Add variable for baseUri 2025-01-14 17:32:44 +01:00
1428534dc3 Write test for getElectionResults 2025-01-14 17:28:45 +01:00
66cdd0086c Fix tests 2025-01-14 17:00:29 +01:00
c095e9ae0b Implement getElectionResults 2025-01-14 16:52:20 +01:00
13c2693bdb Add caching middlware for static files 2025-01-14 13:43:16 +01:00
82aa5ef57d Remove unused CSS files 2025-01-14 13:31:55 +01:00
07ceec4db2 Use TailwindCSS 2025-01-14 13:31:33 +01:00
e102d01bff Remove comments 2025-01-14 12:51:53 +01:00
c878b33649 Add error returned from the request 2025-01-14 12:51:29 +01:00
2847c53bca Add doc for HTTP 400 on vote creation 2025-01-14 12:44:06 +01:00
3c2d45083f Put API behind /api 2025-01-14 12:40:30 +01:00
88790bdec2 Convert to Alpine.js frontend 2025-01-14 12:35:32 +01:00
f22710464b Add TODO 2025-01-13 22:10:12 +01:00
22a6593e3a Add busy_timeout 2025-01-13 22:00:00 +01:00
34 changed files with 1495 additions and 840 deletions

View File

@ -0,0 +1,35 @@
name: Build and Push Docker Image on Tag
on:
push:
tags:
- "v*"
jobs:
build:
name: Build and Push Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
registry: code.dlmw.ch
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
push: true
tags: code.dlmw.ch/dlmw/qv:${{ env.GITHUB_REF_NAME }}

1
.gitignore vendored
View File

@ -30,3 +30,4 @@ go.work.sum
# exclude built binary # exclude built binary
web web
dist/

43
.goreleaser.yaml Normal file
View File

@ -0,0 +1,43 @@
version: 2
before:
hooks:
- go mod tidy
builds:
- id: qv
main: ./cmd/web
binary: qv
env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
goamd64:
- v1
- v2
- v3
- v4
goarm64:
- v8.0
ldflags:
- -s -w
archives:
- id: qv-archive
name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
{{- .Os }}_
{{- .Arch }}_
{{- if eq .Arch "amd64" }}{{ .Amd64 }}
{{- else if eq .Arch "arm64" }}{{ .Arm64 }}
{{- end }}
formats: [ "tar.gz" ]
format_overrides:
- goos: windows
formats: [ "zip" ]

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM golang:1.23-alpine AS build
WORKDIR /usr/src/qv
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
RUN go build -ldflags "-s -w" -v -o /usr/local/bin/qv ./cmd/web/
FROM alpine
RUN mkdir /qv
ENV QV_DATABASE_PATH="/qv/qv.sqlite"
VOLUME /qv
COPY --from=build /usr/local/bin/qv /usr/local/bin/
ENTRYPOINT ["qv"]

10
Makefile Normal file
View File

@ -0,0 +1,10 @@
.PHONY: compile compile-snapshot clean
compile:
@goreleaser release --clean --skip=publish
compile-snapshot:
@goreleaser release --clean --snapshot --skip=publish
clean:
@rm -r dist/

View File

@ -2,6 +2,7 @@ package main
import ( import (
api "code.dlmw.ch/dlmw/qv/internal" api "code.dlmw.ch/dlmw/qv/internal"
"code.dlmw.ch/dlmw/qv/internal/mappers"
"code.dlmw.ch/dlmw/qv/internal/models" "code.dlmw.ch/dlmw/qv/internal/models"
"code.dlmw.ch/dlmw/qv/internal/validator" "code.dlmw.ch/dlmw/qv/internal/validator"
"code.dlmw.ch/dlmw/qv/ui" "code.dlmw.ch/dlmw/qv/ui"
@ -9,16 +10,50 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"math"
"math/rand" "math/rand"
"net/http" "net/http"
"slices" "slices"
"time" "time"
) )
func (app *application) indexPage(w http.ResponseWriter, r *http.Request) {
content, err := ui.Files.ReadFile("index.html")
if err != nil {
app.serverError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(content)
}
func (app *application) createElectionPage(w http.ResponseWriter, r *http.Request) { func (app *application) createElectionPage(w http.ResponseWriter, r *http.Request) {
content, err := ui.Files.ReadFile("create-election.html") content, err := ui.Files.ReadFile("create-election.html")
if err != nil { if err != nil {
app.serverError(w, r, fmt.Errorf("couldn't read create-election.html")) app.serverError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(content)
}
func (app *application) getElectionResultsPage(w http.ResponseWriter, r *http.Request) {
content, err := ui.Files.ReadFile("election-results.html")
if err != nil {
app.serverError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(content)
}
func (app *application) getElectionPage(w http.ResponseWriter, r *http.Request) {
content, err := ui.Files.ReadFile("election.html")
if err != nil {
app.serverError(w, r, err)
return return
} }
@ -43,6 +78,10 @@ func (r *createElectionRequestWithValidator) isValid() bool {
} }
if r.AreVotersKnown { if r.AreVotersKnown {
r.CheckField(validator.LesserThan(r.MaxVoters, 101),
"maxVoters",
"cannot create a known-voters election with more than 100 voters",
)
r.CheckField( r.CheckField(
validator.GreaterThan(r.MaxVoters, 0), validator.GreaterThan(r.MaxVoters, 0),
"maxVoters", "maxVoters",
@ -103,6 +142,7 @@ func (app *application) CreateElection(w http.ResponseWriter, r *http.Request) {
app.serverError(w, r, err) app.serverError(w, r, err)
return return
} }
w.Header().Set("Content-Type", "application/json")
} }
w.Header().Set("Location", fmt.Sprintf("/election/%v", electionId)) w.Header().Set("Location", fmt.Sprintf("/election/%v", electionId))
@ -131,7 +171,7 @@ func (r *createVotesRequestWithValidator) isValid() bool {
return r.Valid() return r.Valid()
} }
func (app *application) CreateVotes(w http.ResponseWriter, r *http.Request, id int) { func (app *application) CreateVotes(w http.ResponseWriter, r *http.Request, id string) {
var request createVotesRequestWithValidator var request createVotesRequestWithValidator
if err := app.unmarshalRequest(r, &request); err != nil { if err := app.unmarshalRequest(r, &request); err != nil {
@ -237,7 +277,7 @@ func (app *application) createVotesHandleKnownVotersElection(w http.ResponseWrit
} }
func (app *application) createVotesHandleUnknownVotersElection(w http.ResponseWriter, r *http.Request, election *models.Election) (string, error) { func (app *application) createVotesHandleUnknownVotersElection(w http.ResponseWriter, r *http.Request, election *models.Election) (string, error) {
voterIdentity := r.RemoteAddr voterIdentity := realIP(r)
voterExists, err := app.voters.Exists(voterIdentity, election.ID) voterExists, err := app.voters.Exists(voterIdentity, election.ID)
if err != nil { if err != nil {
@ -250,26 +290,84 @@ func (app *application) createVotesHandleUnknownVotersElection(w http.ResponseWr
return "", fmt.Errorf(message) return "", fmt.Errorf(message)
} }
voterCount, err := app.voters.CountByElection(election.ID)
if err != nil && !errors.Is(sql.ErrNoRows, err) {
app.serverError(w, r, err)
return "", err
}
if voterCount != 0 && voterCount == election.MaxVoters {
message := "maximum voters reached"
app.clientError(w, http.StatusUnprocessableEntity, message)
return "", fmt.Errorf(message)
}
_, err = app.voters.InsertMultiple([]string{voterIdentity}, election.ID) _, err = app.voters.InsertMultiple([]string{voterIdentity}, election.ID)
if err != nil { if err != nil {
app.serverError(w, r, err) app.serverError(w, r, err)
return "", err return "", err
} }
voterCount, err := app.voters.CountByElection(election.ID)
if err != nil && !errors.Is(sql.ErrNoRows, err) {
app.serverError(w, r, err)
return "", err
}
if voterCount == election.MaxVoters {
message := "maximum voters reached"
app.clientError(w, http.StatusUnprocessableEntity, message)
return "", fmt.Errorf(message)
}
return voterIdentity, nil return voterIdentity, nil
} }
func (app *application) GetElectionResults(w http.ResponseWriter, r *http.Request, id int) { func (app *application) GetElectionResults(w http.ResponseWriter, r *http.Request, id string) {
votes, err := app.votes.GetByElection(id)
if err != nil {
if errors.Is(sql.ErrNoRows, err) {
app.clientError(w, http.StatusNotFound, fmt.Sprintf("couldn't find votes for an election with id %v", id))
return
}
app.serverError(w, r, err)
return
}
results := getResultsFromVotes(votes)
response, err := json.Marshal(api.ElectionResultsResponse{Results: &results})
if err != nil {
app.serverError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(response)
}
func getResultsFromVotes(votes *[]models.Vote) []api.VotesForChoice {
votesForChoice := make(map[string]int)
for _, v := range *votes {
votesForChoice[v.ChoiceText] += int(math.Floor(math.Sqrt(float64(v.Tokens))))
}
results := make([]api.VotesForChoice, 0)
for choice, votes := range votesForChoice {
result := api.VotesForChoice{
Choice: choice,
Votes: votes,
}
results = append(results, result)
}
return results
}
func (app *application) GetElection(w http.ResponseWriter, r *http.Request, id string) {
election, err := app.elections.GetById(id)
if err != nil {
if errors.Is(sql.ErrNoRows, err) {
app.clientError(w, http.StatusNotFound, fmt.Sprintf("couldn't find election with id %v", id))
return
}
app.serverError(w, r, err)
return
}
response, err := json.Marshal(mappers.ElectionResponse(election))
if err != nil {
app.serverError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(response)
} }

View File

@ -7,6 +7,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"net/http" "net/http"
@ -14,17 +15,20 @@ import (
"time" "time"
) )
const baseUri = "/api/"
func TestCreateElection(t *testing.T) { func TestCreateElection(t *testing.T) {
app := newTestApplication(t) app := newTestApplication(t)
server := newTestServer(t, app.routes()) server := newTestServer(t, app.routes())
defer server.Close() defer server.Close()
id, _ := uuid.NewV7()
mockElections := app.elections.(*mockElectionModel) mockElections := app.elections.(*mockElectionModel)
mockElections. mockElections.
On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(1, nil) Return(id.String(), nil)
path := "/election" path := baseUri + "election"
tests := []struct { tests := []struct {
name string name string
@ -78,7 +82,7 @@ func TestCreateElection(t *testing.T) {
Choices: []string{"Gandhi", "Buddha"}, Choices: []string{"Gandhi", "Buddha"},
ExpiresAt: time.Now().Add(24 * time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour),
AreVotersKnown: false, AreVotersKnown: false,
MaxVoters: 10, MaxVoters: 1000,
Name: "Guy of the year", Name: "Guy of the year",
Tokens: 100, Tokens: 100,
}, },
@ -175,6 +179,19 @@ func TestCreateElection(t *testing.T) {
}, },
expectedCode: http.StatusUnprocessableEntity, expectedCode: http.StatusUnprocessableEntity,
}, },
{
name: "Invalid request for known voters election (max voters greater than 100)",
urlPath: path,
body: api.CreateElectionRequest{
Choices: []string{"Gandhi", "Buddha", ""},
ExpiresAt: time.Now().Add(24 * time.Hour),
AreVotersKnown: true,
MaxVoters: 101,
Name: "Guy of the year",
Tokens: 100,
},
expectedCode: http.StatusUnprocessableEntity,
},
} }
for _, tt := range tests { for _, tt := range tests {
@ -190,6 +207,51 @@ func TestCreateElection(t *testing.T) {
} }
} }
func TestCreateElection_KnownVoters(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
electionId, _ := uuid.NewV7()
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(electionId.String(), nil)
mockVoters := app.voters.(*mockVoterModel)
mockVoters.
On("InsertMultiple", mock.Anything, mock.Anything).
Return([]int{1}, nil)
path := baseUri + "election"
requestBody := api.CreateElectionRequest{
Choices: []string{"宮本武蔵", "伊東一刀斎"},
ExpiresAt: time.Now().Add(24 * time.Hour),
AreVotersKnown: true,
MaxVoters: 100,
Name: "強",
Tokens: 100,
}
requestBodyJson, err := json.Marshal(requestBody)
if err != nil {
t.Fatal(err)
}
code, _, body := server.post(t, path, bytes.NewReader(requestBodyJson))
assert.Equal(t, http.StatusOK, code)
var voterIdentities struct {
VoterIdentities []string
}
err = json.Unmarshal([]byte(body), &voterIdentities)
if err != nil {
t.Fatal(err)
}
assert.Len(t, voterIdentities.VoterIdentities, 100)
}
func TestCreateElection_ServerError(t *testing.T) { func TestCreateElection_ServerError(t *testing.T) {
app := newTestApplication(t) app := newTestApplication(t)
server := newTestServer(t, app.routes()) server := newTestServer(t, app.routes())
@ -200,7 +262,7 @@ func TestCreateElection_ServerError(t *testing.T) {
On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(0, fmt.Errorf("")) Return(0, fmt.Errorf(""))
path := "/election" path := baseUri + "election"
requestBody := api.CreateElectionRequest{ requestBody := api.CreateElectionRequest{
Choices: []string{"宮本武蔵", "伊東一刀斎"}, Choices: []string{"宮本武蔵", "伊東一刀斎"},
ExpiresAt: time.Now().Add(24 * time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour),
@ -224,11 +286,12 @@ func TestCreateVotes_UnknownVotersElection(t *testing.T) {
server := newTestServer(t, app.routes()) server := newTestServer(t, app.routes())
defer server.Close() defer server.Close()
id, _ := uuid.NewV7()
mockElections := app.elections.(*mockElectionModel) mockElections := app.elections.(*mockElectionModel)
mockElections. mockElections.
On("GetById", mock.Anything). On("GetById", mock.Anything).
Return(&models.Election{ Return(&models.Election{
ID: 1, ID: id.String(),
Name: "Guy of the year", Name: "Guy of the year",
Tokens: 100, Tokens: 100,
AreVotersKnown: false, AreVotersKnown: false,
@ -254,7 +317,7 @@ func TestCreateVotes_UnknownVotersElection(t *testing.T) {
On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything). On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(1, nil) Return(1, nil)
path := "/election/1/votes" path := baseUri + "election/1/votes"
tests := []struct { tests := []struct {
name string name string
@ -327,11 +390,12 @@ func TestCreateVotes_KnownVotersElection(t *testing.T) {
server := newTestServer(t, app.routes()) server := newTestServer(t, app.routes())
defer server.Close() defer server.Close()
id, _ := uuid.NewV7()
mockElections := app.elections.(*mockElectionModel) mockElections := app.elections.(*mockElectionModel)
mockElections. mockElections.
On("GetById", mock.Anything). On("GetById", mock.Anything).
Return(&models.Election{ Return(&models.Election{
ID: 1, ID: id.String(),
Name: "Guy of the year", Name: "Guy of the year",
Tokens: 100, Tokens: 100,
AreVotersKnown: true, AreVotersKnown: true,
@ -359,7 +423,7 @@ func TestCreateVotes_KnownVotersElection(t *testing.T) {
On("Exists", mock.Anything, mock.Anything). On("Exists", mock.Anything, mock.Anything).
Return(false, nil) Return(false, nil)
path := "/election/1/votes" path := baseUri + "election/1/votes"
tests := []struct { tests := []struct {
name string name string
@ -467,7 +531,7 @@ func TestCreateVotes_NonExistingElection(t *testing.T) {
On("GetById", mock.Anything). On("GetById", mock.Anything).
Return((*models.Election)(nil), sql.ErrNoRows) Return((*models.Election)(nil), sql.ErrNoRows)
path := "/election/1/votes" path := baseUri + "election/1/votes"
requestBody := api.CreateVotesRequest{ requestBody := api.CreateVotesRequest{
Choices: []struct { Choices: []struct {
ChoiceText string `json:"choiceText"` ChoiceText string `json:"choiceText"`
@ -488,7 +552,7 @@ func TestCreateVotes_NonExistingElection(t *testing.T) {
assert.Equal(t, http.StatusNotFound, code) assert.Equal(t, http.StatusNotFound, code)
} }
func TestCreateVotes_NonNumberElectionID(t *testing.T) { func TestCreateVotes_NonUuidElectionID(t *testing.T) {
app := newTestApplication(t) app := newTestApplication(t)
server := newTestServer(t, app.routes()) server := newTestServer(t, app.routes())
defer server.Close() defer server.Close()
@ -498,7 +562,7 @@ func TestCreateVotes_NonNumberElectionID(t *testing.T) {
On("GetById", mock.Anything). On("GetById", mock.Anything).
Return((*models.Election)(nil), sql.ErrNoRows) Return((*models.Election)(nil), sql.ErrNoRows)
path := "/election/1a/votes" path := baseUri + "election/1a/votes"
requestBody := api.CreateVotesRequest{ requestBody := api.CreateVotesRequest{
Choices: []struct { Choices: []struct {
ChoiceText string `json:"choiceText"` ChoiceText string `json:"choiceText"`
@ -516,7 +580,7 @@ func TestCreateVotes_NonNumberElectionID(t *testing.T) {
code, _, _ := server.post(t, path, bytes.NewReader(requestBodyJson)) code, _, _ := server.post(t, path, bytes.NewReader(requestBodyJson))
assert.Equal(t, http.StatusBadRequest, code) assert.Equal(t, http.StatusNotFound, code)
} }
func TestCreateVotes_AlreadyVoted(t *testing.T) { func TestCreateVotes_AlreadyVoted(t *testing.T) {
@ -524,8 +588,9 @@ func TestCreateVotes_AlreadyVoted(t *testing.T) {
server := newTestServer(t, app.routes()) server := newTestServer(t, app.routes())
defer server.Close() defer server.Close()
unknownVotersElectionId, _ := uuid.NewV7()
unknownVotersElection := models.Election{ unknownVotersElection := models.Election{
ID: 1, ID: unknownVotersElectionId.String(),
Name: "Guy of the year", Name: "Guy of the year",
Tokens: 100, Tokens: 100,
AreVotersKnown: false, AreVotersKnown: false,
@ -534,15 +599,18 @@ func TestCreateVotes_AlreadyVoted(t *testing.T) {
ExpiresAt: time.Now().Add(24 * time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour),
Choices: []string{"Gandhi", "Buddha"}, Choices: []string{"Gandhi", "Buddha"},
} }
knownVotersElection := unknownVotersElection knownVotersElection := unknownVotersElection
knownVotersElectionId, _ := uuid.NewV7()
knownVotersElection.ID = knownVotersElectionId.String()
knownVotersElection.AreVotersKnown = true knownVotersElection.AreVotersKnown = true
mockElections := app.elections.(*mockElectionModel) mockElections := app.elections.(*mockElectionModel)
mockElections. mockElections.
On("GetById", 1). On("GetById", knownVotersElectionId.String()).
Return(&knownVotersElection, nil) Return(&knownVotersElection, nil)
mockElections. mockElections.
On("GetById", 2). On("GetById", unknownVotersElectionId.String()).
Return(&unknownVotersElection, nil) Return(&unknownVotersElection, nil)
mockVotes := app.votes.(*mockVoteModel) mockVotes := app.votes.(*mockVoteModel)
@ -555,8 +623,9 @@ func TestCreateVotes_AlreadyVoted(t *testing.T) {
On("Exists", mock.Anything, mock.Anything). On("Exists", mock.Anything, mock.Anything).
Return(true, nil) Return(true, nil)
knownVotersElectionPath := "/election/1/votes" layout := baseUri + "election/%v/votes"
unknownVotersElectionPath := "/election/2/votes" knownVotersElectionPath := fmt.Sprintf(layout, unknownVotersElectionId)
unknownVotersElectionPath := fmt.Sprintf(layout, knownVotersElectionId)
voterIdentity := "anything" voterIdentity := "anything"
tests := []struct { tests := []struct {
@ -615,11 +684,12 @@ func TestCreateVotes_UnknownVotersElectionMaxVotersReached(t *testing.T) {
server := newTestServer(t, app.routes()) server := newTestServer(t, app.routes())
defer server.Close() defer server.Close()
id, _ := uuid.NewV7()
mockElections := app.elections.(*mockElectionModel) mockElections := app.elections.(*mockElectionModel)
mockElections. mockElections.
On("GetById", mock.Anything). On("GetById", mock.Anything).
Return(&models.Election{ Return(&models.Election{
ID: 1, ID: id.String(),
Name: "Guy of the year", Name: "Guy of the year",
Tokens: 100, Tokens: 100,
AreVotersKnown: false, AreVotersKnown: false,
@ -640,7 +710,7 @@ func TestCreateVotes_UnknownVotersElectionMaxVotersReached(t *testing.T) {
On("CountByElection", mock.Anything). On("CountByElection", mock.Anything).
Return(10, nil) Return(10, nil)
path := "/election/1/votes" path := baseUri + "election/1/votes"
requestBody := api.CreateVotesRequest{ requestBody := api.CreateVotesRequest{
Choices: []struct { Choices: []struct {
ChoiceText string `json:"choiceText"` ChoiceText string `json:"choiceText"`
@ -666,11 +736,12 @@ func TestCreateVotes_ExpiredElection(t *testing.T) {
server := newTestServer(t, app.routes()) server := newTestServer(t, app.routes())
defer server.Close() defer server.Close()
id, _ := uuid.NewV7()
mockElections := app.elections.(*mockElectionModel) mockElections := app.elections.(*mockElectionModel)
mockElections. mockElections.
On("GetById", mock.Anything). On("GetById", mock.Anything).
Return(&models.Election{ Return(&models.Election{
ID: 1, ID: id.String(),
Name: "Guy of the year", Name: "Guy of the year",
Tokens: 100, Tokens: 100,
AreVotersKnown: false, AreVotersKnown: false,
@ -691,7 +762,7 @@ func TestCreateVotes_ExpiredElection(t *testing.T) {
On("CountByElection", mock.Anything). On("CountByElection", mock.Anything).
Return(10, nil) Return(10, nil)
path := "/election/1/votes" path := baseUri + "election/1/votes"
requestBody := api.CreateVotesRequest{ requestBody := api.CreateVotesRequest{
Choices: []struct { Choices: []struct {
ChoiceText string `json:"choiceText"` ChoiceText string `json:"choiceText"`
@ -711,3 +782,140 @@ func TestCreateVotes_ExpiredElection(t *testing.T) {
assert.Equal(t, http.StatusUnprocessableEntity, code) assert.Equal(t, http.StatusUnprocessableEntity, code)
} }
func TestGetElectionResults(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
electionID, _ := uuid.NewV7()
votes := []models.Vote{
{
VoterIdentity: "Voter1",
ElectionID: electionID.String(),
ChoiceText: "Choice1",
Tokens: 2,
CreatedAt: time.Now(),
},
{
VoterIdentity: "Voter2",
ElectionID: electionID.String(),
ChoiceText: "Choice2",
Tokens: 4,
CreatedAt: time.Now(),
},
{
VoterIdentity: "Voter3",
ElectionID: electionID.String(),
ChoiceText: "Choice3",
Tokens: 6,
CreatedAt: time.Now(),
},
{
VoterIdentity: "Voter4",
ElectionID: electionID.String(),
ChoiceText: "Choice1",
Tokens: 8,
CreatedAt: time.Now(),
},
{
VoterIdentity: "Voter5",
ElectionID: electionID.String(),
ChoiceText: "Choice2",
Tokens: 10,
CreatedAt: time.Now(),
},
}
mockVotes := app.votes.(*mockVoteModel)
mockVotes.
On("GetByElection", electionID.String()).
Return(&votes, nil)
path := baseUri + fmt.Sprintf("election/%v/results", electionID)
code, _, body := server.get(t, path)
assert.Equal(t, http.StatusOK, code)
var response api.ElectionResultsResponse
err := json.Unmarshal([]byte(body), &response)
if err != nil {
t.Fatal(err)
}
for _, result := range *response.Results {
switch result.Choice {
case "Choice1":
assert.Equal(t, 3, result.Votes)
case "Choice2":
assert.Equal(t, 5, result.Votes)
case "Choice3":
assert.Equal(t, 2, result.Votes)
default:
t.Fatalf("Unexpected choice: %s", result.Choice)
}
}
}
func TestGetElectionResults_NotFound(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
mockVotes := app.votes.(*mockVoteModel)
mockVotes.
On("GetByElection", mock.Anything).
Return(&[]models.Vote{}, sql.ErrNoRows)
path := baseUri + "election/1/results"
code, _, body := server.get(t, path)
assert.Equal(t, http.StatusNotFound, code)
var response api.ElectionResultsResponse
err := json.Unmarshal([]byte(body), &response)
if err != nil {
t.Fatal(err)
}
}
func TestGetElection_Found(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
id, _ := uuid.NewV7()
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("GetById", mock.Anything).
Return(&models.Election{
ID: id.String(),
Name: "Guy of the year",
Tokens: 100,
AreVotersKnown: false,
MaxVoters: 10,
CreatedAt: time.UnixMilli(1),
ExpiresAt: time.UnixMilli(100000),
Choices: []string{"Gandhi", "Buddha"},
}, nil)
path := baseUri + "election/1"
code, _, _ := server.get(t, path)
assert.Equal(t, http.StatusOK, code)
}
func TestGetElection_NotFound(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("GetById", mock.Anything).
Return((*models.Election)(nil), sql.ErrNoRows)
path := baseUri + "election/1"
code, _, _ := server.get(t, path)
assert.Equal(t, http.StatusNotFound, code)
}

View File

@ -5,11 +5,14 @@ import (
"code.dlmw.ch/dlmw/qv/internal/validator" "code.dlmw.ch/dlmw/qv/internal/validator"
"encoding/json" "encoding/json"
"io" "io"
"net"
"net/http" "net/http"
"strings"
) )
func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error) { func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error) {
app.logger.Error(err.Error()) app.logger.Error(err.Error())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
var response = api.ErrorResponse{ var response = api.ErrorResponse{
Code: http.StatusInternalServerError, Code: http.StatusInternalServerError,
@ -20,6 +23,7 @@ func (app *application) serverError(w http.ResponseWriter, r *http.Request, err
} }
func (app *application) badRequestError(w http.ResponseWriter, r *http.Request, err error) { func (app *application) badRequestError(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
var response = api.ErrorResponse{ var response = api.ErrorResponse{
Code: http.StatusBadRequest, Code: http.StatusBadRequest,
@ -29,6 +33,7 @@ func (app *application) badRequestError(w http.ResponseWriter, r *http.Request,
} }
func (app *application) clientError(w http.ResponseWriter, status int, message string) { func (app *application) clientError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
var response = api.ErrorResponse{ var response = api.ErrorResponse{
Code: status, Code: status,
@ -39,6 +44,7 @@ func (app *application) clientError(w http.ResponseWriter, status int, message s
} }
func (app *application) unprocessableEntityError(w http.ResponseWriter, v validator.Validator) { func (app *application) unprocessableEntityError(w http.ResponseWriter, v validator.Validator) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
var response = api.ErrorResponse{ var response = api.ErrorResponse{
Code: http.StatusUnprocessableEntity, Code: http.StatusUnprocessableEntity,
@ -63,3 +69,23 @@ func (app *application) unmarshalRequest(r *http.Request, dst any) error {
return nil return nil
} }
func realIP(r *http.Request) string {
var ip string
if tcip := r.Header.Get(http.CanonicalHeaderKey("True-Client-IP")); tcip != "" {
ip = tcip
} else if xrip := r.Header.Get(http.CanonicalHeaderKey("X-Real-IP")); xrip != "" {
ip = xrip
} else if xff := r.Header.Get(http.CanonicalHeaderKey("X-Forwarded-For")); xff != "" {
i := strings.Index(xff, ",")
if i == -1 {
i = len(xff)
}
ip = xff[:i]
}
if ip == "" || net.ParseIP(ip) == nil {
return ""
}
return ip
}

View File

@ -1,5 +1,3 @@
//go:build !integration
//go:generate oapi-codegen --config=oapi-codegen.yml openapi.yml //go:generate oapi-codegen --config=oapi-codegen.yml openapi.yml
package main package main
@ -9,8 +7,8 @@ import (
"code.dlmw.ch/dlmw/qv/internal/models" "code.dlmw.ch/dlmw/qv/internal/models"
"context" "context"
"database/sql" "database/sql"
_ "github.com/mattn/go-sqlite3"
"log/slog" "log/slog"
_ "modernc.org/sqlite"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@ -19,6 +17,15 @@ import (
) )
var addr = ":8080" var addr = ":8080"
var databasePath string
func init() {
if os.Getenv("QV_DATABASE_PATH") == "" {
databasePath = "./qv.sqlite"
} else {
databasePath = os.Getenv("QV_DATABASE_PATH")
}
}
func main() { func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
@ -48,7 +55,7 @@ func main() {
Addr: addr, Addr: addr,
Handler: app.routes(), Handler: app.routes(),
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
IdleTimeout: time.Minute, IdleTimeout: 30 * time.Second,
ReadTimeout: 5 * time.Second, ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second,
} }
@ -62,7 +69,7 @@ func main() {
} }
func openDb() (*sql.DB, error) { func openDb() (*sql.DB, error) {
db, err := sql.Open("sqlite3", "./qv.sqlite?_foreign_keys=on") db, err := sql.Open("sqlite", databasePath+"?_foreign_keys=on&_busy_timeout=5000")
if err == nil { if err == nil {
err = db.Ping() err = db.Ping()
} }

View File

@ -1,196 +0,0 @@
//go:build integration
package main
import (
"code.dlmw.ch/dlmw/qv/internal/migrations"
"code.dlmw.ch/dlmw/qv/internal/models"
"context"
"database/sql"
"errors"
. "github.com/Eun/go-hit"
"log/slog"
"net/http"
"os"
"time"
)
var addr = ":8080"
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
err := os.Remove("./qv.integration.sqlite")
if err != nil {
logger.Error("couldn't delete qv.integration.sqlite")
}
db, err := openDb()
if err != nil {
logger.Error(err.Error())
os.Exit(1)
}
defer db.Close()
err = migrations.Run(db)
if err != nil {
logger.Error(err.Error())
os.Exit(1)
}
app := &application{
logger: logger,
elections: &models.ElectionModel{DB: db},
voters: &models.VoterModel{DB: db},
}
logger.Info("Starting integration tests server", "addr", addr)
srv := &http.Server{
Addr: addr,
Handler: app.routes(),
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
IdleTimeout: time.Minute,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
go func() {
logger.Info("Starting integration test server", "addr", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("Server error", "error", err)
os.Exit(1)
}
}()
time.Sleep(1 * time.Second) // wait until srv starts
runTests()
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
srv.Shutdown(ctx)
}
func openDb() (*sql.DB, error) {
db, err := sql.Open("sqlite3", "./qv.integration.sqlite?_foreign_keys=on")
if err == nil {
err = db.Ping()
}
return db, err
}
func runTests() {
runCreateElectionTests()
}
func runCreateElectionTests() {
template := CombineSteps(
Post("http://127.0.0.1:8080/election"),
)
MustDo(
template,
Send().Body().String(`
{
"name": "Guy of the year",
"tokens": 100,
"areVotersKnown": false,
"maxVoters": 10,
"expiresAt": "2124-12-31T14:15:22Z",
"choices": [
"Gandhi",
"Buddha"
]
}`),
Expect().Status().Equal(http.StatusOK),
Expect().Headers("Location").Contains("/election/1"),
)
MustDo(
template,
Send().Body().String(`
{
"name": "Guy of the year",
"tokens": 100,
"areVotersKnown": false,
"maxVoters": 0,
"expiresAt": "2124-12-31T14:15:22Z",
"choices": [
"Gandhi",
"Buddha"
]
}`),
Expect().Status().Equal(http.StatusOK),
Expect().Headers("Location").Contains("/election/2"),
)
MustDo(
template,
Send().Body().String(`
{
"name": "Guy of the year",
"tokens": 100,
"areVotersKnown": true,
"maxVoters": 10,
"expiresAt": "2124-12-31T14:15:22Z",
"choices": [
"Gandhi",
"Buddha"
]
}`),
Expect().Status().Equal(http.StatusOK),
Expect().Headers("Location").Contains("/election/3"),
Expect().Body().JSON().JQ(".voterIdentities").Len().Equal(10),
)
MustDo(
template,
Send().Body().String(`
{
"name": "Guy of the year",
"tokens": 100,
"areVotersKnown": true,
"maxVoters": -1,
"expiresAt": "2124-12-31T14:15:22Z",
"choices": [
"Gandhi",
"Buddha"
]
}`),
Expect().Status().Equal(http.StatusUnprocessableEntity),
)
MustDo(
template,
Send().Body().String(`
{
"name": "Guy of the year",
"tokens": 100,
"areVotersKnown": false,
"maxVoters": -1,
"expiresAt": "2124-12-31T14:15:22Z",
"choices": [
"Gandhi"
]
}`),
Expect().Status().Equal(http.StatusUnprocessableEntity),
)
MustDo(
template,
Send().Body().String(`
{
"name": "Guy of the year",
"tokens": 100,
"areVotersKnown": false,
"maxVoters": 10,
"expiresAt": "2018-12-31T14:15:22Z",
"choices": [
"Gandhi",
"Buddha"
]
}`),
Expect().Status().Equal(http.StatusUnprocessableEntity),
)
}

View File

@ -20,7 +20,7 @@ func (app *application) recoverPanic(next http.Handler) http.Handler {
func (app *application) logRequest(next http.Handler) http.Handler { func (app *application) logRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ( var (
ip = r.RemoteAddr ip = realIP(r)
proto = r.Proto proto = r.Proto
method = r.Method method = r.Method
uri = r.URL.RequestURI() uri = r.URL.RequestURI()
@ -29,3 +29,10 @@ func (app *application) logRequest(next http.Handler) http.Handler {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
func (app *application) cacheStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=86400")
next.ServeHTTP(w, r)
})
}

View File

@ -3,7 +3,7 @@ info:
title: qv - dlmw title: qv - dlmw
description: |- description: |-
This is the documentation for the qv (Quadratic Voting) API. This is the documentation for the qv (Quadratic Voting) API.
termsOfService: http://swagger.io/terms/ termsOfService:
contact: contact:
email: dylan@dlmw.ch email: dylan@dlmw.ch
license: license:
@ -11,10 +11,10 @@ info:
url: https://www.gnu.org/licenses/gpl-3.0.txt url: https://www.gnu.org/licenses/gpl-3.0.txt
version: 0.0.1 version: 0.0.1
externalDocs: externalDocs:
description: Find out more about qv # todo description: Get the code
url: http://swagger.io # todo url: https://code.dlmw.ch/dlmw/qv
servers: servers:
- url: https://petstore3.swagger.io/api/v3 # todo - url: https://qv.dlmw.ch/api
tags: tags:
- name: election - name: election
description: Retrieve data related to elections description: Retrieve data related to elections
@ -22,6 +22,38 @@ tags:
description: Retrieve data related to votes description: Retrieve data related to votes
paths: paths:
/election/{id}:
get:
tags:
- election
summary: Get an election
operationId: getElection
parameters:
- name: id
in: path
required: true
description: The ID of the election
schema:
type: string
responses:
200:
description: Election returned
content:
application/json:
schema:
$ref: "#/components/schemas/Election"
400:
description: Request malformed
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
404:
description: Election not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/election: /election:
post: post:
tags: tags:
@ -70,15 +102,21 @@ paths:
required: true required: true
description: The ID of the election description: The ID of the election
schema: schema:
type: integer type: string
requestBody: requestBody:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/CreateVotesRequest" $ref: "#/components/schemas/CreateVotesRequest"
responses: responses:
200: 201:
description: Votes cast description: Votes cast
400:
description: Request malformed
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
404: 404:
description: Election not found description: Election not found
content: content:
@ -110,7 +148,7 @@ paths:
required: true required: true
description: The ID of the election description: The ID of the election
schema: schema:
type: integer type: string
responses: responses:
200: 200:
description: Election results returned description: Election results returned
@ -118,6 +156,12 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/ElectionResultsResponse" $ref: "#/components/schemas/ElectionResultsResponse"
400:
description: Request malformed
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
404: 404:
description: Election doesn't exist description: Election doesn't exist
content: content:
@ -133,6 +177,37 @@ paths:
components: components:
schemas: schemas:
Election:
type: object
required:
- id
- name
- tokens
- areVotersKnown
- maxVoters
- createdAt
- expiresAt
- choices
properties:
id:
type: string
name:
type: string
tokens:
type: integer
areVotersKnown:
type: boolean
maxVoters:
type: integer
createdAt:
type: string
expiresAt:
type: string
choices:
type: array
items:
type: string
CreateElectionRequest: CreateElectionRequest:
type: object type: object
required: required:
@ -199,6 +274,22 @@ components:
ElectionResultsResponse: ElectionResultsResponse:
type: object type: object
properties:
results:
type: array
items:
$ref: "#/components/schemas/VotesForChoice"
VotesForChoice:
type: object
required:
- choice
- votes
properties:
choice:
type: string
votes:
type: integer
ErrorResponse: ErrorResponse:
type: object type: object

View File

@ -19,11 +19,17 @@ type application struct {
func (app *application) routes() http.Handler { func (app *application) routes() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(ui.Files)) cached := alice.New(app.cacheStatic)
mux.Handle("GET /static/", cached.Then(http.FileServerFS(ui.Files)))
mux.HandleFunc("GET /", app.indexPage)
mux.HandleFunc("GET /election/create", app.createElectionPage) mux.HandleFunc("GET /election/create", app.createElectionPage)
mux.HandleFunc("GET /election/{id}/results", app.getElectionResultsPage)
mux.HandleFunc("GET /election/{id}", app.getElectionPage)
api.HandlerWithOptions(app, api.StdHTTPServerOptions{ api.HandlerWithOptions(app, api.StdHTTPServerOptions{
BaseRouter: mux, BaseRouter: mux,
BaseURL: "/api",
ErrorHandlerFunc: app.badRequestError, ErrorHandlerFunc: app.badRequestError,
}) })

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"code.dlmw.ch/dlmw/qv/internal/models" "code.dlmw.ch/dlmw/qv/internal/models"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"io" "io"
@ -29,6 +30,22 @@ func newTestServer(t *testing.T, h http.Handler) *testServer {
return &testServer{server} return &testServer{server}
} }
func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, string) {
rs, err := ts.Client().Get(ts.URL + urlPath)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
body = bytes.TrimSpace(body)
return rs.StatusCode, rs.Header, string(body)
}
func (ts *testServer) post(t *testing.T, urlPath string, body io.Reader) (int, http.Header, string) { func (ts *testServer) post(t *testing.T, urlPath string, body io.Reader) (int, http.Header, string) {
res, err := ts.Client().Post(ts.URL+urlPath, "application/json", body) res, err := ts.Client().Post(ts.URL+urlPath, "application/json", body)
if err != nil { if err != nil {
@ -48,12 +65,12 @@ type mockElectionModel struct {
mock.Mock mock.Mock
} }
func (e *mockElectionModel) Insert(name string, tokens int, areVotersKnown bool, maxVoters int, choices []string, expiresAt time.Time) (int, error) { func (e *mockElectionModel) Insert(name string, tokens int, areVotersKnown bool, maxVoters int, choices []string, expiresAt time.Time) (string, error) {
args := e.Called(name, tokens, areVotersKnown, maxVoters, choices, expiresAt) args := e.Called(name, tokens, areVotersKnown, maxVoters, choices, expiresAt)
return args.Int(0), args.Error(1) return args.String(0), args.Error(1)
} }
func (e *mockElectionModel) GetById(id int) (*models.Election, error) { func (e *mockElectionModel) GetById(id string) (*models.Election, error) {
args := e.Called(id) args := e.Called(id)
return args.Get(0).(*models.Election), args.Error(1) return args.Get(0).(*models.Election), args.Error(1)
} }
@ -62,17 +79,17 @@ type mockVoterModel struct {
mock.Mock mock.Mock
} }
func (v *mockVoterModel) InsertMultiple(identities []string, electionID int) ([]int, error) { func (v *mockVoterModel) InsertMultiple(identities []string, electionID string) ([]int, error) {
args := v.Called(identities, electionID) args := v.Called(identities, electionID)
return args.Get(0).([]int), args.Error(1) return args.Get(0).([]int), args.Error(1)
} }
func (v *mockVoterModel) CountByElection(electionID int) (int, error) { func (v *mockVoterModel) CountByElection(electionID string) (int, error) {
args := v.Called(electionID) args := v.Called(electionID)
return args.Int(0), args.Error(1) return args.Int(0), args.Error(1)
} }
func (v *mockVoterModel) Exists(voterIdentity string, electionID int) (bool, error) { func (v *mockVoterModel) Exists(voterIdentity string, electionID string) (bool, error) {
args := v.Called(voterIdentity, electionID) args := v.Called(voterIdentity, electionID)
return args.Bool(0), args.Error(1) return args.Bool(0), args.Error(1)
} }
@ -81,12 +98,17 @@ type mockVoteModel struct {
mock.Mock mock.Mock
} }
func (v *mockVoteModel) Insert(voterIdentity string, electionId int, choiceText string, tokens int) (int, error) { func (v *mockVoteModel) Insert(voterIdentity string, electionId string, choiceText string, tokens int) (int, error) {
args := v.Called(voterIdentity, electionId, choiceText, tokens) args := v.Called(voterIdentity, electionId, choiceText, tokens)
return args.Int(0), args.Error(1) return args.Int(0), args.Error(1)
} }
func (v *mockVoteModel) Exists(voterIdentity string, electionID int) (bool, error) { func (v *mockVoteModel) Exists(voterIdentity string, electionID string) (bool, error) {
args := v.Called(voterIdentity, electionID) args := v.Called(voterIdentity, electionID)
return args.Bool(0), args.Error(1) return args.Bool(0), args.Error(1)
} }
func (v *mockVoteModel) GetByElection(electionID string) (*[]models.Vote, error) {
args := v.Called(electionID)
return args.Get(0).(*[]models.Vote), args.Error(1)
}

37
go.mod
View File

@ -3,41 +3,40 @@ module code.dlmw.ch/dlmw/qv
go 1.23.4 go 1.23.4
require ( require (
github.com/Eun/go-hit v0.5.23
github.com/golang-migrate/migrate/v4 v4.18.1 github.com/golang-migrate/migrate/v4 v4.18.1
github.com/google/uuid v1.6.0
github.com/justinas/alice v1.2.0 github.com/justinas/alice v1.2.0
github.com/mattn/go-sqlite3 v1.14.24
github.com/oapi-codegen/runtime v1.1.1 github.com/oapi-codegen/runtime v1.1.1
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
modernc.org/sqlite v1.18.1
) )
require ( require (
github.com/Eun/go-convert v0.0.0-20200421145326-bef6c56666ee // indirect
github.com/Eun/go-doppelgangerreader v0.0.0-20190911075941-30f1527f16b2 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/araddon/dateparse v0.0.0-20200409225146-d820a6159ab1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.5.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gookit/color v1.4.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/itchyny/gojq v0.12.5 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/itchyny/timefmt-go v0.1.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/k0kubun/pp v3.0.1+incompatible // indirect
github.com/lunixbochs/vtclean v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
go.uber.org/atomic v1.7.0 // indirect go.uber.org/atomic v1.7.0 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect golang.org/x/sys v0.25.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect golang.org/x/tools v0.24.0 // indirect
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.36.3 // indirect
modernc.org/ccgo/v3 v3.16.9 // indirect
modernc.org/libc v1.17.1 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.2.1 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.0 // indirect
) )

153
go.sum
View File

@ -1,160 +1,131 @@
github.com/Eun/go-convert v0.0.0-20200421145326-bef6c56666ee h1:9oCc9EfVVSuy2WoHLAEYppJ5zX45+MQhAU1W30Uu3SI=
github.com/Eun/go-convert v0.0.0-20200421145326-bef6c56666ee/go.mod h1:cMqWKb0SQrV+L1Zve08CI1NQGPeRAjXuYTxYE/y6gcU=
github.com/Eun/go-doppelgangerreader v0.0.0-20190911075941-30f1527f16b2 h1:RfkLLL7sQdxTMWRLo//6CZcAN3j5/laO8BooS9ctG2g=
github.com/Eun/go-doppelgangerreader v0.0.0-20190911075941-30f1527f16b2/go.mod h1:+o+i8cYK1XYOQo4ocUKNV4R9D5Y7MIAPJk2l5SEh93M=
github.com/Eun/go-hit v0.5.23 h1:ezifQcvEh4qW/1/NdG59h0H7vTVJWVZWkXILaJBav4c=
github.com/Eun/go-hit v0.5.23/go.mod h1:LCHZ6WSPFDXlTQkFUSLe0VsrOhzzEEzbPzCGc6FYTXQ=
github.com/Eun/go-testdoc v0.0.1/go.mod h1:uT+GeDi7TpqQx6MBkcfXD9nF15Q8IX+kTNEnUUPbuUo=
github.com/Eun/yaegi-template v1.5.16/go.mod h1:eyFQ1QHbKLNHKpUvdjt8+99ZR1ji7lVVbduSK1M5N/U=
github.com/Eun/yaegi-template v1.5.18/go.mod h1:iVHjge496SWL7hLf1euBZIO40Bk0R38g6lu8iyvpc30=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/aaw/maybe_tls v0.0.0-20160803104303-89c499bcc6aa h1:6yJyU8MlPBB2enGJdPciPlr8P+PC0nhCFHnSHYMirZI=
github.com/aaw/maybe_tls v0.0.0-20160803104303-89c499bcc6aa/go.mod h1:I0wzMZvViQzmJjxK+AtfFAnqDCkQV/+r17PO1CCSYnU=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/araddon/dateparse v0.0.0-20190622164848-0fb0a474d195/go.mod h1:SLqhdZcd+dF3TEVL2RMoob5bBP5R1P1qkox+HtCBgGI=
github.com/araddon/dateparse v0.0.0-20200409225146-d820a6159ab1 h1:TEBmxO80TM04L8IuMWk77SGL1HomBmKTdzdJLLWznxI=
github.com/araddon/dateparse v0.0.0-20200409225146-d820a6159ab1/go.mod h1:SLqhdZcd+dF3TEVL2RMoob5bBP5R1P1qkox+HtCBgGI=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/dave/jennifer v1.4.1/go.mod h1:7jEdnm+qBcxl8PC0zyp7vxcpSRnzXSt9r39tpTVGlwA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/color v1.4.2 h1:tXy44JFSFkKnELV6WaMo/lLfu/meqITX3iAV52do7lk=
github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/itchyny/go-flags v1.5.0/go.mod h1:lenkYuCobuxLBAd/HGFE4LRoW8D3B6iXRQfWYJ+MNbA=
github.com/itchyny/gojq v0.12.5 h1:6SJ1BQ1VAwJAlIvLSIZmqHP/RUEq3qfVWvsRxrqhsD0=
github.com/itchyny/gojq v0.12.5/go.mod h1:3e1hZXv+Kwvdp6V9HXpVrvddiHVApi5EDZwS+zLFeiE=
github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU=
github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo= github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA= github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 h1:uC1QfSlInpQF+M0ao65imhwqKnz3Q2z/d8PWZRMQvDM= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/k0kubun/pp v3.0.1+incompatible h1:3tqvf7QgUnZ5tXO6pNAZlrvHgl6DvifjDrd9g2S9Z40=
github.com/k0kubun/pp v3.0.1+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8=
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/traefik/yaegi v0.9.8/go.mod h1:FAYnRlZyuVlEkvnkHq3bvJ1lW5be6XuwgLdkYgYG6Lk=
github.com/traefik/yaegi v0.9.10/go.mod h1:FAYnRlZyuVlEkvnkHq3bvJ1lW5be6XuwgLdkYgYG6Lk=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg=
modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/ccgo/v3 v3.16.9 h1:AXquSwg7GuMk11pIdw7fmO1Y/ybgazVkMhsZWCV0mHM=
modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
modernc.org/libc v1.17.1 h1:Q8/Cpi36V/QBfuQaFVeisEBs3WqoGAJprZzmf7TfEYI=
modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.2.1 h1:dkRh86wgmq/bJu2cAS2oqBCz/KsMZU7TUM4CibQ7eBs=
modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.18.1 h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8=
modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao=
modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw=
modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM=
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=

View File

@ -41,8 +41,22 @@ type CreateVotesRequest struct {
VoterIdentity *string `json:"voterIdentity,omitempty"` VoterIdentity *string `json:"voterIdentity,omitempty"`
} }
// Election defines model for Election.
type Election struct {
AreVotersKnown bool `json:"areVotersKnown"`
Choices []string `json:"choices"`
CreatedAt string `json:"createdAt"`
ExpiresAt string `json:"expiresAt"`
Id string `json:"id"`
MaxVoters int `json:"maxVoters"`
Name string `json:"name"`
Tokens int `json:"tokens"`
}
// ElectionResultsResponse defines model for ElectionResultsResponse. // ElectionResultsResponse defines model for ElectionResultsResponse.
type ElectionResultsResponse = map[string]interface{} type ElectionResultsResponse struct {
Results *[]VotesForChoice `json:"results,omitempty"`
}
// ErrorResponse defines model for ErrorResponse. // ErrorResponse defines model for ErrorResponse.
type ErrorResponse struct { type ErrorResponse struct {
@ -56,6 +70,12 @@ type ErrorResponse struct {
Message string `json:"message"` Message string `json:"message"`
} }
// VotesForChoice defines model for VotesForChoice.
type VotesForChoice struct {
Choice string `json:"choice"`
Votes int `json:"votes"`
}
// CreateElectionJSONRequestBody defines body for CreateElection for application/json ContentType. // CreateElectionJSONRequestBody defines body for CreateElection for application/json ContentType.
type CreateElectionJSONRequestBody = CreateElectionRequest type CreateElectionJSONRequestBody = CreateElectionRequest
@ -67,12 +87,15 @@ type ServerInterface interface {
// Create a new election // Create a new election
// (POST /election) // (POST /election)
CreateElection(w http.ResponseWriter, r *http.Request) CreateElection(w http.ResponseWriter, r *http.Request)
// Get an election
// (GET /election/{id})
GetElection(w http.ResponseWriter, r *http.Request, id string)
// Get the results of an election // Get the results of an election
// (GET /election/{id}/results) // (GET /election/{id}/results)
GetElectionResults(w http.ResponseWriter, r *http.Request, id int) GetElectionResults(w http.ResponseWriter, r *http.Request, id string)
// Cast your votes for an election // Cast your votes for an election
// (POST /election/{id}/votes) // (POST /election/{id}/votes)
CreateVotes(w http.ResponseWriter, r *http.Request, id int) CreateVotes(w http.ResponseWriter, r *http.Request, id string)
} }
// ServerInterfaceWrapper converts contexts to parameters. // ServerInterfaceWrapper converts contexts to parameters.
@ -99,6 +122,32 @@ func (siw *ServerInterfaceWrapper) CreateElection(w http.ResponseWriter, r *http
handler.ServeHTTP(w, r.WithContext(ctx)) handler.ServeHTTP(w, r.WithContext(ctx))
} }
// GetElection operation middleware
func (siw *ServerInterfaceWrapper) GetElection(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
// ------------- Path parameter "id" -------------
var id string
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetElection(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r.WithContext(ctx))
}
// GetElectionResults operation middleware // GetElectionResults operation middleware
func (siw *ServerInterfaceWrapper) GetElectionResults(w http.ResponseWriter, r *http.Request) { func (siw *ServerInterfaceWrapper) GetElectionResults(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@ -106,7 +155,7 @@ func (siw *ServerInterfaceWrapper) GetElectionResults(w http.ResponseWriter, r *
var err error var err error
// ------------- Path parameter "id" ------------- // ------------- Path parameter "id" -------------
var id int var id string
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil { if err != nil {
@ -132,7 +181,7 @@ func (siw *ServerInterfaceWrapper) CreateVotes(w http.ResponseWriter, r *http.Re
var err error var err error
// ------------- Path parameter "id" ------------- // ------------- Path parameter "id" -------------
var id int var id string
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil { if err != nil {
@ -266,6 +315,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
} }
m.HandleFunc("POST "+options.BaseURL+"/election", wrapper.CreateElection) m.HandleFunc("POST "+options.BaseURL+"/election", wrapper.CreateElection)
m.HandleFunc("GET "+options.BaseURL+"/election/{id}", wrapper.GetElection)
m.HandleFunc("GET "+options.BaseURL+"/election/{id}/results", wrapper.GetElectionResults) m.HandleFunc("GET "+options.BaseURL+"/election/{id}/results", wrapper.GetElectionResults)
m.HandleFunc("POST "+options.BaseURL+"/election/{id}/votes", wrapper.CreateVotes) m.HandleFunc("POST "+options.BaseURL+"/election/{id}/votes", wrapper.CreateVotes)

View File

@ -0,0 +1,19 @@
package mappers
import (
api "code.dlmw.ch/dlmw/qv/internal"
"code.dlmw.ch/dlmw/qv/internal/models"
)
func ElectionResponse(election *models.Election) *api.Election {
return &api.Election{
Id: election.ID,
Name: election.Name,
Tokens: election.Tokens,
AreVotersKnown: election.AreVotersKnown,
MaxVoters: election.MaxVoters,
CreatedAt: election.CreatedAt.String(),
ExpiresAt: election.ExpiresAt.String(),
Choices: election.Choices,
}
}

View File

@ -0,0 +1,48 @@
package mappers
import (
"code.dlmw.ch/dlmw/qv/internal/models"
uuid2 "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestElectionResponse(t *testing.T) {
layout := "2006-01-02 15:04:05 -0700"
parsedCreatedAtTime, err := time.Parse(layout, "2025-01-14 15:13:37 +0000")
if err != nil {
t.Fatal(err.Error())
}
parsedExpiresAtTime, err := time.Parse(layout, "2025-01-15 15:13:37 +0000")
if err != nil {
t.Fatal(err.Error())
}
uuid, _ := uuid2.NewV7()
election := models.Election{
ID: uuid.String(),
Name: "The best",
Tokens: 100,
AreVotersKnown: true,
MaxVoters: 150,
CreatedAt: parsedCreatedAtTime.In(time.UTC),
ExpiresAt: parsedExpiresAtTime.In(time.UTC),
Choices: []string{"You", "Me"},
}
response := ElectionResponse(&election)
assert.Equal(t, uuid.String(), response.Id)
assert.Equal(t, "The best", response.Name)
assert.Equal(t, 100, response.Tokens)
assert.Equal(t, true, response.AreVotersKnown)
assert.Equal(t, 150, response.MaxVoters)
assert.Equal(t, "2025-01-14 15:13:37 +0000 UTC", response.CreatedAt)
assert.Equal(t, "2025-01-15 15:13:37 +0000 UTC", response.ExpiresAt)
for _, choice := range election.Choices {
assert.Contains(t, election.Choices, choice)
}
}

View File

@ -1,5 +1,5 @@
CREATE TABLE elections ( CREATE TABLE elections (
id INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
tokens INTEGER NOT NULL, tokens INTEGER NOT NULL,
are_voters_known INTEGER NOT NULL, are_voters_known INTEGER NOT NULL,
@ -35,7 +35,7 @@ CREATE TRIGGER enforce_max_voters
SELECT 1 SELECT 1
FROM elections e FROM elections e
WHERE e.id = NEW.election_id WHERE e.id = NEW.election_id
AND e.max_voters IS NOT NULL AND e.max_voters != 0
) )
BEGIN BEGIN
SELECT CASE SELECT CASE
@ -57,7 +57,6 @@ CREATE TABLE votes (
election_id INTEGER NOT NULL, election_id INTEGER NOT NULL,
choice_text TEXT NOT NULL, choice_text TEXT NOT NULL,
tokens INTEGER NOT NULL, tokens INTEGER NOT NULL,
-- calculated_vote_count GENERATED ALWAYS AS (floor(sqrt(tokens))) VIRTUAL, TODO: Cannot use math functions
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (voter_identity, choice_text), PRIMARY KEY (voter_identity, choice_text),
FOREIGN KEY (voter_identity, election_id) REFERENCES voters (identity, election_id), FOREIGN KEY (voter_identity, election_id) REFERENCES voters (identity, election_id),

View File

@ -2,12 +2,13 @@ package models
import ( import (
"database/sql" "database/sql"
"github.com/google/uuid"
"time" "time"
) )
type ElectionModelInterface interface { type ElectionModelInterface interface {
Insert(name string, tokens int, areVotersKnown bool, maxVoters int, Choices []string, ExpiresAt time.Time) (int, error) Insert(name string, tokens int, areVotersKnown bool, maxVoters int, Choices []string, ExpiresAt time.Time) (string, error)
GetById(id int) (*Election, error) GetById(id string) (*Election, error)
} }
type ElectionModel struct { type ElectionModel struct {
@ -15,7 +16,7 @@ type ElectionModel struct {
} }
type Election struct { type Election struct {
ID int ID string
Name string Name string
Tokens int Tokens int
AreVotersKnown bool AreVotersKnown bool
@ -25,48 +26,48 @@ type Election struct {
Choices []string Choices []string
} }
func (e *ElectionModel) Insert(name string, tokens int, areVotersKnown bool, maxVoters int, choices []string, expiresAt time.Time) (int, error) { func (e *ElectionModel) Insert(name string, tokens int, areVotersKnown bool, maxVoters int, choices []string, expiresAt time.Time) (string, error) {
tx, err := e.DB.Begin() tx, err := e.DB.Begin()
if err != nil { if err != nil {
return 0, err return "", err
} }
defer tx.Rollback() defer tx.Rollback()
result, err := tx.Exec(` electionID, err := uuid.NewV7()
INSERT INTO elections (name, tokens, are_voters_known, max_voters, expires_at)
VALUES (?, ?, ?, ?, ?)`, name, tokens, areVotersKnown, maxVoters, expiresAt)
if err != nil { if err != nil {
return 0, err return "", err
} }
electionID, err := result.LastInsertId() _, err = tx.Exec(`
INSERT INTO elections (id, name, tokens, are_voters_known, max_voters, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`, electionID, name, tokens, areVotersKnown, maxVoters, expiresAt)
if err != nil { if err != nil {
return 0, err return "", err
} }
stmt, err := tx.Prepare(` stmt, err := tx.Prepare(`
INSERT INTO choices (text, election_id) INSERT INTO choices (text, election_id)
VALUES (?, ?)`) VALUES (?, ?)`)
if err != nil { if err != nil {
return 0, err return "", err
} }
defer stmt.Close() defer stmt.Close()
for _, choice := range choices { for _, choice := range choices {
_, err = stmt.Exec(choice, electionID) _, err = stmt.Exec(choice, electionID)
if err != nil { if err != nil {
return 0, err return "", err
} }
} }
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return 0, err return "0", err
} }
return int(electionID), nil return electionID.String(), nil
} }
func (e *ElectionModel) GetById(id int) (*Election, error) { func (e *ElectionModel) GetById(id string) (*Election, error) {
query := ` query := `
SELECT id, name, tokens, are_voters_known, max_voters, created_at, expires_at SELECT id, name, tokens, are_voters_known, max_voters, created_at, expires_at
FROM elections FROM elections

View File

@ -6,16 +6,16 @@ import (
) )
type VoterModelInterface interface { type VoterModelInterface interface {
InsertMultiple(identities []string, electionID int) ([]int, error) InsertMultiple(identities []string, electionID string) ([]int, error)
CountByElection(electionID int) (int, error) CountByElection(electionID string) (int, error)
Exists(voterIdentity string, electionID int) (bool, error) Exists(voterIdentity string, electionID string) (bool, error)
} }
type VoterModel struct { type VoterModel struct {
DB *sql.DB DB *sql.DB
} }
func (v *VoterModel) InsertMultiple(identities []string, electionID int) ([]int, error) { func (v *VoterModel) InsertMultiple(identities []string, electionID string) ([]int, error) {
tx, err := v.DB.Begin() tx, err := v.DB.Begin()
if err != nil { if err != nil {
return nil, err return nil, err
@ -53,7 +53,7 @@ func (v *VoterModel) InsertMultiple(identities []string, electionID int) ([]int,
return voterIDs, nil return voterIDs, nil
} }
func (v *VoterModel) CountByElection(electionID int) (int, error) { func (v *VoterModel) CountByElection(electionID string) (int, error) {
// use a transaction to prevent race conditions // use a transaction to prevent race conditions
tx, err := v.DB.Begin() tx, err := v.DB.Begin()
if err != nil { if err != nil {
@ -82,7 +82,7 @@ func (v *VoterModel) CountByElection(electionID int) (int, error) {
return voterCount, nil return voterCount, nil
} }
func (v *VoterModel) Exists(voterIdentity string, electionID int) (bool, error) { func (v *VoterModel) Exists(voterIdentity string, electionID string) (bool, error) {
query := ` query := `
SELECT EXISTS ( SELECT EXISTS (
SELECT 1 SELECT 1

View File

@ -3,18 +3,28 @@ package models
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"time"
) )
type VoteModelInterface interface { type VoteModelInterface interface {
Insert(voterIdentity string, electionId int, choiceText string, tokens int) (int, error) Insert(voterIdentity string, electionId string, choiceText string, tokens int) (int, error)
Exists(voterIdentity string, electionID int) (bool, error) Exists(voterIdentity string, electionID string) (bool, error)
GetByElection(electionID string) (*[]Vote, error)
} }
type VoteModel struct { type VoteModel struct {
DB *sql.DB DB *sql.DB
} }
func (v *VoteModel) Insert(voterIdentity string, electionId int, choiceText string, tokens int) (int, error) { type Vote struct {
VoterIdentity string
ElectionID string
ChoiceText string
Tokens int
CreatedAt time.Time
}
func (v *VoteModel) Insert(voterIdentity string, electionId string, choiceText string, tokens int) (int, error) {
tx, err := v.DB.Begin() tx, err := v.DB.Begin()
if err != nil { if err != nil {
return 0, err return 0, err
@ -37,7 +47,7 @@ func (v *VoteModel) Insert(voterIdentity string, electionId int, choiceText stri
return int(voteId), nil return int(voteId), nil
} }
func (v *VoteModel) Exists(voterIdentity string, electionID int) (bool, error) { func (v *VoteModel) Exists(voterIdentity string, electionID string) (bool, error) {
var exists bool var exists bool
query := ` query := `
SELECT EXISTS ( SELECT EXISTS (
@ -55,3 +65,43 @@ func (v *VoteModel) Exists(voterIdentity string, electionID int) (bool, error) {
} }
return exists, nil return exists, nil
} }
func (v *VoteModel) GetByElection(electionID string) (*[]Vote, error) {
query := `
SELECT voter_identity, election_id, choice_text, tokens, created_at
FROM votes
WHERE election_id = ?
`
rows, err := v.DB.Query(query, electionID)
if err != nil {
return nil, err
}
defer rows.Close()
var votes []Vote
for rows.Next() {
var vote Vote
err := rows.Scan(
&vote.VoterIdentity,
&vote.ElectionID,
&vote.ChoiceText,
&vote.Tokens,
&vote.CreatedAt,
)
if err != nil {
return nil, err
}
votes = append(votes, vote)
}
if err = rows.Err(); err != nil {
return nil, err
}
if len(votes) == 0 {
return nil, sql.ErrNoRows
}
return &votes, nil
}

View File

@ -64,6 +64,10 @@ func GreaterThan(value int, n int) bool {
return value > n return value > n
} }
func LesserThan(value int, n int) bool {
return value < n
}
func GreaterThanOrEquals(value int, n int) bool { func GreaterThanOrEquals(value int, n int) bool {
return value >= n return value >= n
} }

View File

@ -3,170 +3,222 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create New Election</title> <title>Create New Election - qv</title>
<link rel="stylesheet" href="/static/css/styles.css"> <script src="/static/js/tailwind.min.js"></script>
<script src="https://unpkg.com/vue@3"></script> <script src="/static/js/alpine.min.js" defer></script>
</head> </head>
<body> <body class="bg-gray-100 font-sans leading-normal tracking-normal">
<div id="app" class="container"> <div x-data="electionForm" class="container mx-auto p-6 bg-white rounded-lg shadow-lg">
<main> <main>
<h1>Create New Election</h1> <h1 class="text-3xl font-bold mb-6">Create New Election</h1>
<form @submit.prevent="createElection" class="form"> <form @submit.prevent="createElection" class="space-y-6">
<div class="form-group">
<label for="name">Election Name</label> <div>
<input type="text" id="name" v-model="election.name" required> <label for="name" class="block text-sm font-medium text-gray-700">Election Name</label>
<input type="text" id="name" x-model="election.name" required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
</div> </div>
<div class="form-row"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="form-group"> <div>
<label for="tokens">Tokens per Voter</label> <label for="tokens" class="block text-sm font-medium text-gray-700">Tokens per Voter</label>
<input type="number" id="tokens" v-model.number="election.tokens" min="1" required> <input type="number" id="tokens" x-model.number="election.tokens" min="1" required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
</div> </div>
<div class="form-group"> <div>
<label for="areVotersKnown">Voter Access</label> <label for="areVotersKnown" class="block text-sm font-medium text-gray-700">Voter Access</label>
<select id="areVotersKnown" v-model="election.areVotersKnown" required> <select id="areVotersKnown" x-model="election.areVotersKnown" required
<option :value="true">Known voters only</option> @change="election.areVotersKnown = JSON.parse(election.areVotersKnown)"
<option :value="false">Open to anyone</option> class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
<option :value=true>Known voters only</option>
<option :value=false>Open to anyone</option>
</select> </select>
<small>Known voters only = codes will be generated and you must give those to your voters</small> <p class="text-sm text-gray-500 mt-1">Known voters only = codes will be generated for distribution.</p>
</div> </div>
</div> </div>
<div class="form-group"> <div>
<label for="maxVoters">Maximum Number of Voters</label> <label for="maxVoters" class="block text-sm font-medium text-gray-700">Maximum Number of Voters</label>
<input type="number" id="maxVoters" v-model.number="election.maxVoters" min="1"> <input type="number" id="maxVoters" x-model.number="election.maxVoters"
<small>0 = unlimited</small> class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
<span v-if="election.areVotersKnown && election.maxVoters <= 0" class="error-text">Maximum number of voters must be greater than 0 if voters are known</span> <p class="text-sm text-gray-500">0 = unlimited</p>
<template x-if="election.areVotersKnown && election.maxVoters <= 0">
<span class="text-red-500 text-sm">Maximum number of voters must be greater than 0 if voters are known.</span>
</template>
</div> </div>
<div class="form-group"> <div>
<label for="expiresAt">Expiration Date</label> <label for="expiresAt" class="block text-sm font-medium text-gray-700">Expiration Date</label>
<input type="datetime-local" id="expiresAt" v-model="election.expiresAt" required> <input type="datetime-local" id="expiresAt" x-model="election.expiresAt" required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
</div> </div>
<div class="form-group"> <div>
<label>Choices</label> <label class="block text-sm font-medium text-gray-700">Choices</label>
<div id="choices-container"> <div class="space-y-2 mt-2">
<div v-for="(choice, index) in election.choices" :key="index" class="choice-input"> <template x-for="(choice, index) in election.choices" :key="index">
<input type="text" v-model="election.choices[index]" required> <div class="flex space-x-2 items-center">
<button type="button" class="remove-choice" @click="removeChoice(index)" v-show="election.choices.length > 2">×</button> <input type="text" x-model="election.choices[index]" required
</div> class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
<button type="button" class="text-red-500 hover:text-red-700"
@click="removeChoice(index)" x-show="election.choices.length > 2">×</button>
</div>
</template>
</div> </div>
<button type="button" id="add-choice" @click="addChoice">Add Another Choice</button> <button type="button" @click="addChoice"
class="mt-3 inline-flex items-center px-4 py-2 bg-indigo-600 border border-transparent rounded-md font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Add Another Choice
</button>
</div> </div>
<div class="form-actions"> <div>
<button type="submit">Create Election</button> <button type="submit"
class="w-full inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Create Election
</button>
</div> </div>
</form> </form>
<div v-if="createdElectionId > 0" class="election-info"> <div x-show="errorMessage || Object.keys(errorDetails).length > 0" class="mt-6 bg-red-100 p-4 rounded-md">
<h2>Election Created Successfully</h2> <h2 class="text-red-700 font-bold" x-text="errorMessage"></h2>
<div class="info-container"> <ul class="text-red-600 mt-2 space-y-1">
<span class="info-label">Election ID:</span> <template x-for="(message, field) in errorDetails" :key="field">
<span class="info-value">{{ createdElectionId }}</span> <li><strong x-text="field"></strong>: <span x-text="message"></span></li>
<button @click="copyElectionId" class="copy-btn"> </template>
Copy </ul>
</button>
</div>
</div> </div>
<div v-if="voterIdentities.length > 0" class="voter-codes"> <template x-if="createdElectionId != ''">
<h2>Voter Access Codes</h2> <div class="mt-6 bg-green-100 p-4 rounded-md">
<div class="codes-container"> <h2 class="text-green-700 font-bold">Election Created Successfully</h2>
<button @click="copyAllCodes" class="copy-all-btn"> <p class="mt-2">
Copy All Codes <a
</button> :href="`/election/${createdElectionId}`"
<div class="codes-list"> class="text-blue-600 underline hover:text-blue-800"
<div v-for="(code, index) in voterIdentities" target="_blank">
:key="code" View election
class="code-item"> </a>
<span class="code-number">{{ index + 1 }}.</span> </p>
<span class="code-text">{{ code }}</span> <p>
<button @click="copyCode(code)" class="copy-btn"> <a
Copy :href="`/election/${createdElectionId}/results`"
</button> class="text-blue-600 underline hover:text-blue-800"
target="_blank">
View results
</a>
</p>
</div>
</template>
<template x-if="voterIdentities.length > 0">
<div class="voter-codes">
<h2 class="text-2xl font-semibold mb-4">Voter Access Codes</h2>
<div class="codes-container">
<button @click="copyAllCodes" class="copy-all-btn bg-indigo-600 text-white py-2 px-4 rounded-md font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 mb-4">
Copy All Codes
</button>
<div class="codes-list">
<template x-for="(code, index) in voterIdentities" :key="index">
<div class="code-item flex items-center space-x-3 mb-2">
<span class="code-number text-gray-700 font-medium" x-text="index + 1"></span>.
<span class="code-text text-gray-900" x-text="code"></span>
<button @click="copyCode(code)" class="copy-btn bg-indigo-600 text-white text-sm py-1 px-3 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500">
Copy
</button>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
</div> </template>
</main> </main>
</div> </div>
<script> <script>
const app = Vue.createApp({ document.addEventListener("alpine:init", () => {
data() { Alpine.data("electionForm", () => ({
return { election: {
election: { name: "",
name: "", tokens: 100,
tokens: 100, areVotersKnown: true,
areVotersKnown: true, maxVoters: 0,
maxVoters: 0, expiresAt: "",
expiresAt: "", choices: ["", ""]
choices: ["", ""] // Start with two empty choices },
}, createdElectionId: "",
createdElectionId: 0, errorMessage: "",
voterIdentities: [] errorDetails: {},
}; voterIdentities: [],
},
methods: {
addChoice() { addChoice() {
this.election.choices.push(""); // Add a new empty choice this.election.choices.push("");
}, },
removeChoice(index) { removeChoice(index) {
this.election.choices.splice(index, 1); // Remove choice by index this.election.choices.splice(index, 1);
}, },
async copyCode(code) {
try { async createElection() {
await navigator.clipboard.writeText(code); this.errorMessage = "";
// Optional: Add visual feedback that copy succeeded this.errorDetails = {};
} catch (err) { this.createdElectionId = "";
console.error('Failed to copy code:', err);
}
},
async copyAllCodes() {
try {
const allCodes = this.voterIdentities.join('\n');
await navigator.clipboard.writeText(allCodes);
// Optional: Add visual feedback that copy succeeded
} catch (err) {
console.error('Failed to copy codes:', err);
}
},
createElection() {
this.voterIdentities = []; this.voterIdentities = [];
const payload = { const payload = {
...this.election, ...this.election,
expiresAt: this.election.expiresAt + ":00Z", // Add timezone if necessary expiresAt: this.election.expiresAt + ":00Z",
choices: this.election.choices.filter(choice => choice.trim() !== "") // Filter out empty choices choices: this.election.choices.filter(choice => choice.trim() !== "")
}; };
fetch("/election", { try {
method: "POST", const response = await fetch("/api/election", {
headers: { method: "POST",
"Content-Type": "application/json" headers: {
}, "Content-Type": "application/json"
body: JSON.stringify(payload) },
}) body: JSON.stringify(payload)
.then(response => {
const locationHeader = response.headers.get('Location');
this.createdElectionId = locationHeader.replace('/election/', '');
return response.json();
})
.then(data => {
this.voterIdentities = data.voterIdentities;
})
.catch(error => {
alert("Failed to create election.");
}); });
}
}
});
app.mount("#app"); if (!response.ok) {
const errorData = await response.json();
this.errorMessage = errorData.message || "An error occurred.";
this.errorDetails = errorData.details?.fields || {};
return;
}
const locationHeader = response.headers.get("Location");
this.createdElectionId = locationHeader.replace("/election/", "");
const contentType = response.headers.get("Content-Type") ?? "";
if (contentType.includes("application/json")) {
const data = await response.json();
this.voterIdentities = data.voterIdentities;
}
} catch (error) {
console.log(error);
this.errorMessage = "Failed to create election.";
}
},
copyCode(code) {
navigator.clipboard.writeText(code).then(() => {
alert("Code copied to clipboard!");
}).catch(err => {
console.error("Failed to copy code: ", err);
});
},
async copyAllCodes() {
const allCodes = this.voterIdentities.join("\n");
await navigator.clipboard.writeText(allCodes)
.catch(err => {
console.error("Failed to copy all codes: ", err);
});
}
}));
});
</script> </script>
</body> </body>
</html> </html>

145
ui/election-results.html Normal file
View File

@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Election Results - qv</title>
<script src="/static/js/tailwind.min.js"></script>
<script src="/static/js/chart.umd.js"></script>
<script src="/static/js/alpine.min.js" defer></script>
</head>
<body class="bg-gray-100 font-sans leading-normal tracking-normal">
<div x-data="resultsPage" class="container mx-auto p-6 bg-white rounded-lg shadow-lg">
<main>
<h1 class="text-3xl font-bold mb-6">Election Results</h1>
<div x-show="error" class="bg-red-100 p-4 rounded-md text-red-700">
<p x-text="error"></p>
</div>
<template x-if="!error">
<div class="space-y-6">
<!-- Chart Container -->
<div class="bg-white p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4">Vote Distribution</h2>
<div class="h-96 w-full">
<canvas id="resultsChart"></canvas>
</div>
</div>
<!-- Detailed Results -->
<div class="bg-white p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">Detailed Results</h3>
<div class="space-y-2">
<template x-for="(result, index) in results" :key="index">
<div class="flex justify-between border-b pb-2">
<div class="flex items-center">
<div class="w-4 h-4 rounded mr-2" :style="{ backgroundColor: getColor(index) }"></div>
<span class="font-medium" x-text="result.choice"></span>
</div>
<span class="text-gray-600">
<span x-text="result.votes"></span> votes
(<span x-text="getPercentage(result.votes)"></span>%)
</span>
</div>
</template>
</div>
</div>
</div>
</template>
</main>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('resultsPage', () => ({
results: [],
error: '',
chart: null,
colors: [
'#4f46e5', // Indigo (primary)
'#2563eb', // Blue
'#7c3aed', // Violet
'#db2777', // Pink
'#dc2626', // Red
],
getColor(index) {
return this.colors[index % this.colors.length];
},
getPercentage(votes) {
const total = this.results.reduce((sum, result) => sum + result.votes, 0);
if (total === 0) return '0.0';
return ((votes / total) * 100).toFixed(1);
},
async init() {
await this.fetchResults();
this.initChart();
},
async fetchResults() {
const electionId = window.location.pathname.split("/")[2];
try {
const response = await fetch(`/api/election/${electionId}/results`);
if (!response.ok) throw new Error("Failed to load election results.");
const data = await response.json();
this.results = data.results;
} catch (error) {
console.error(error);
this.error = error.message;
}
},
initChart() {
if (this.error) return;
const ctx = document.getElementById('resultsChart').getContext('2d');
// Destroy existing chart if it exists
if (this.chart) {
this.chart.destroy();
}
this.chart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: this.results.map(r => r.choice),
datasets: [{
data: this.results.map(r => r.votes),
backgroundColor: this.results.map((_, index) => this.getColor(index)),
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
padding: 20,
boxWidth: 12,
boxHeight: 12
}
},
tooltip: {
callbacks: {
label: (context) => {
const total = context.dataset.data.reduce((sum, count) => sum + count, 0);
const percentage = ((context.raw / total) * 100).toFixed(1);
return `${context.raw} votes (${percentage}%)`;
}
}
}
},
cutout: '60%'
}
});
}
}));
});
</script>
</body>
</html>

126
ui/election.html Normal file
View File

@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vote in Election - qv</title>
<script src="/static/js/tailwind.min.js"></script>
<script src="/static/js/alpine.min.js" defer></script>
</head>
<body class="bg-gray-100 font-sans leading-normal tracking-normal">
<div x-data="votePage" class="container mx-auto p-6 bg-white rounded-lg shadow-lg">
<main>
<h1 class="text-3xl font-bold mb-6" x-text="election.name || 'Loading...'"></h1>
<form @submit.prevent="submitVote" class="space-y-6">
<!-- Code Field -->
<template x-if="election.areVotersKnown">
<div>
<label for="code" class="block text-sm font-medium text-gray-700">Access Code</label>
<input type="text" id="code" x-model="code" required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
</template>
<!-- Voting Choices -->
<template x-if="election.choices.length > 0">
<div>
<label class="block text-sm font-medium text-gray-700">Distribute Your Tokens</label>
<div class="space-y-2 mt-2">
<template x-for="(choice, index) in election.choices" :key="index">
<div>
<label class="block text-gray-700" x-text="choice"></label>
<input type="number" min="0"
x-model.number="votes[index]"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
</template>
<p class="text-sm text-gray-500 mt-2">Tokens remaining: <span x-text="remainingTokens"></span></p>
</div>
</div>
</template>
<!-- No Choices Message -->
<template x-if="!election.choices.length">
<p class="text-gray-700">No choices available for this election.</p>
</template>
<button type="submit"
:disabled="remainingTokens < 0"
class="w-full inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50">
Submit Vote
</button>
</form>
<div x-show="message" class="mt-6 p-4 rounded-md" :class="{'bg-green-100 text-green-700': success, 'bg-red-100 text-red-700': !success}">
<p x-text="message"></p>
</div>
</main>
</div>
<script>
document.addEventListener("alpine:init", () => {
Alpine.data("votePage", () => ({
election: { name: "", choices: [], tokens: 0, areVotersKnown: false },
code: "",
votes: [],
message: "",
success: false,
get remainingTokens() {
return this.election.tokens - this.votes.reduce((sum, v) => sum + v, 0);
},
async init() {
const electionId = window.location.pathname.split("/").pop();
try {
const response = await fetch(`/api/election/${electionId}`);
if (!response.ok) throw new Error("Failed to load election details.");
const data = await response.json();
this.election = data;
this.votes = Array(data.choices.length).fill(0);
} catch (error) {
console.error(error);
this.message = "Failed to load election details.";
this.success = false;
}
},
async submitVote() {
if (this.remainingTokens < 0) {
this.message = "You have exceeded the number of available tokens.";
this.success = false;
return;
}
const electionId = window.location.pathname.split("/").pop();
const payload = {
voterIdentity: this.election.areVotersKnown ? this.code : null,
choices: this.election.choices.map((choice, index) => ({
choiceText: choice,
tokens: this.votes[index] || 0,
})),
};
try {
const response = await fetch(`/api/election/${electionId}/votes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
res = await response.json();
throw new Error(res.message);
}
this.message = "Vote submitted successfully!";
this.success = true;
} catch (error) {
this.message = error.message;
this.success = false;
}
}
}));
});
</script>
</body>
</html>

28
ui/index.html Normal file
View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>qv</title>
<script src="/static/js/tailwind.min.js"></script>
</head>
<body class="bg-gray-100 text-gray-900">
<div class="min-h-screen flex flex-col items-center justify-center">
<header class="text-center mb-8">
<h1 class="text-4xl font-bold mb-4">Quadratic Voting</h1>
<p class="text-lg text-gray-600">Start your journey by creating a new election.</p>
</header>
<main class="text-center">
<a href="/election/create"
class="bg-blue-600 text-white text-lg font-semibold px-6 py-3 rounded-lg hover:bg-blue-700">
Create Election
</a>
</main>
<footer class="mt-12 text-center text-gray-600">
<a href="https://code.dlmw.ch/dlmw/qv" target="_blank">See the source code</a>
</footer>
</div>
</body>
</html>

View File

@ -1,197 +0,0 @@
:root {
--primary-color: #2563eb;
--error-color: #dc2626;
--background-color: #f8fafc;
--border-color: #e2e8f0;
--text-color: #1e293b;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--background-color);
min-height: 100vh;
}
.container {
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 2rem;
}
main {
width: 100%;
max-width: 600px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 2rem;
}
.form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.form-row .form-group {
flex: 1 1 200px;
min-width: 0;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
h1 {
margin-bottom: 2rem;
color: var(--text-color);
font-size: 1.5rem;
}
label {
font-weight: 500;
}
input, select {
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 1rem;
}
input:focus, select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
#choices-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.choice-input {
display: flex;
gap: 0.5rem;
}
.choice-input input {
flex: 1;
}
.error-text {
color: red;
font-size: 12px;
}
button {
background: var(--primary-color);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
button:hover {
background-color: #1d4ed8;
}
#add-choice {
background: transparent;
color: var(--primary-color);
border: 1px solid var(--primary-color);
margin-top: 0.5rem;
}
#add-choice:hover {
background: rgba(37, 99, 235, 0.1);
}
.remove-choice {
padding: 0.5rem 1rem;
background: transparent;
color: var(--error-color);
border: 1px solid var(--error-color);
flex-shrink: 0;
}
.remove-choice:hover {
background: rgba(220, 38, 38, 0.1);
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
}
.election-info {
margin-top: 2rem;
}
.election-info h2 {
margin-bottom: 1.5rem;
color: var(--text-color);
font-size: 1.5rem;
}
.info-container {
display: flex;
align-items: center;
padding: 0.75rem;
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.info-label {
font-weight: 500;
margin-right: 1rem;
}
.info-value {
font-family: monospace;
flex: 1;
}
@media (max-width: 640px) {
.container {
padding: 0;
}
main {
border-radius: 0;
box-shadow: none;
padding: 1rem;
}
.form-actions {
flex-direction: column;
}
button {
width: 100%;
}
}

6
ui/static/js/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

14
ui/static/js/chart.umd.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,120 +0,0 @@
htmx.defineExtension('json-enc-custom', {
onEvent: function (name, evt) {
if (name === "htmx:configRequest") {
evt.detail.headers['Content-Type'] = "application/json";
}
},
encodeParameters: function (xhr, parameters, elt) {
xhr.overrideMimeType('text/json');
let encoded_parameters = encodingAlgorithm(parameters);
return encoded_parameters;
}
});
function encodingAlgorithm(parameters) {
let resultingObject = Object.create(null);
const PARAM_NAMES = Object.keys(parameters);
const PARAM_VALUES = Object.values(parameters);
const PARAM_LENGHT = PARAM_NAMES.length;
for (let param_index = 0; param_index < PARAM_LENGHT; param_index++) {
let name = PARAM_NAMES[param_index];
let value = PARAM_VALUES[param_index];
let steps = JSONEncodingPath(name);
let context = resultingObject;
for (let step_index = 0; step_index < steps.length; step_index++) {
let step = steps[step_index];
context = setValueFromPath(context, step, value);
}
}
let result = JSON.stringify(resultingObject);
return result
}
function JSONEncodingPath(name) {
let path = name;
let original = path;
const FAILURE = [{ "type": "object", "key": original, "last": true, "next_type": null }];
let steps = Array();
let first_key = String();
for (let i = 0; i < path.length; i++) {
if (path[i] !== "[") first_key += path[i];
else break;
}
if (first_key === "") return FAILURE;
path = path.slice(first_key.length);
steps.push({ "type": "object", "key": first_key, "last": false, "next_type": null });
while (path.length) {
// [123...]
if (/^\[\d+\]/.test(path)) {
path = path.slice(1);
let collected_digits = path.match(/\d+/)[0]
path = path.slice(collected_digits.length);
let numeric_key = parseInt(collected_digits, 10);
path = path.slice(1);
steps.push({ "type": "array", "key": numeric_key, "last": false, "next_type": null });
continue
}
// [abc...]
if (/^\[[^\]]+\]/.test(path)) {
path = path.slice(1);
let collected_characters = path.match(/[^\]]+/)[0];
path = path.slice(collected_characters.length);
let object_key = collected_characters;
path = path.slice(1);
steps.push({ "type": "object", "key": object_key, "last": false, "next_type": null });
continue;
}
return FAILURE;
}
for (let step_index = 0; step_index < steps.length; step_index++) {
if (step_index === steps.length - 1) {
let tmp_step = steps[step_index];
tmp_step["last"] = true;
steps[step_index] = tmp_step;
}
else {
let tmp_step = steps[step_index];
tmp_step["next_type"] = steps[step_index + 1]["type"];
steps[step_index] = tmp_step;
}
}
return steps;
}
function setValueFromPath(context, step, value) {
if (step.last) {
context[step.key] = value;
}
//TODO: make merge functionality and file suport.
//check if the context value already exists
if (context[step.key] === undefined) {
if (step.type === "object") {
if (step.next_type === "object") {
context[step.key] = {};
return context[step.key];
}
if (step.next_type === "array") {
context[step.key] = [];
return context[step.key];
}
}
if (step.type === "array") {
if (step.next_type === "object") {
context[step.key] = {};
return context[step.key];
}
if (step.next_type === "array") {
context[step.key] = [];
return context[step.key];
}
}
}
else {
return context[step.key];
}
}

84
ui/static/js/tailwind.min.js vendored Normal file

File diff suppressed because one or more lines are too long