diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go
index 65d00b4..4ad02dd 100644
--- a/cmd/boardvoting/handlers.go
+++ b/cmd/boardvoting/handlers.go
@@ -20,29 +20,133 @@ package main
import (
"html/template"
"net/http"
+ "strings"
+ "time"
+
+ "git.cacert.org/cacert-boardvoting/internal/models"
+ "github.com/Masterminds/sprig/v3"
+ "github.com/gorilla/csrf"
)
-func (app *application) motions(w http.ResponseWriter, r *http.Request) {
+const motionsPerPage = 10
+
+func (app *application) motionList(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/motions/" {
app.notFound(w)
return
}
+ var err error
+
+ ctx := r.Context()
+
+ listOptions := &models.MotionListOptions{Limit: motionsPerPage}
+
+ const queryParamBefore = "before"
+ const queryParamAfter = "after"
+
+ if r.URL.Query().Has(queryParamAfter) {
+ var after time.Time
+
+ err := after.UnmarshalText([]byte(r.URL.Query().Get(queryParamAfter)))
+ if err != nil {
+ app.clientError(w, http.StatusBadRequest)
+
+ return
+ }
+
+ listOptions.After = &after
+ } else if r.URL.Query().Has(queryParamBefore) {
+ var before time.Time
+
+ err := before.UnmarshalText([]byte(r.URL.Query().Get(queryParamBefore)))
+ if err != nil {
+ app.clientError(w, http.StatusBadRequest)
+
+ return
+ }
+
+ listOptions.Before = &before
+ }
+
+ motions, err := app.motions.GetMotions(ctx, listOptions)
+ if err != nil {
+ app.serverError(w, err)
+
+ return
+ }
+
+ first, last, err := app.motions.TimestampRange(ctx)
+ if err != nil {
+ app.serverError(w, err)
+
+ return
+ }
+
files := []string{
"./ui/html/base.html",
"./ui/html/partials/nav.html",
+ "./ui/html/partials/pagination.html",
+ "./ui/html/partials/motion_actions.html",
+ "./ui/html/partials/motion_display.html",
+ "./ui/html/partials/motion_status_class.html",
"./ui/html/pages/motions.html",
}
- ts, err := template.ParseFiles(files...)
+ funcMaps := sprig.FuncMap()
+ funcMaps["nl2br"] = func(text string) template.HTML {
+ // #nosec G203 input is sanitized
+ return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "
"))
+ }
+ funcMaps["canMangageUsers"] = func(*models.Voter) bool {
+ return false
+ }
+ funcMaps[csrf.TemplateTag] = func() template.HTML {
+ return csrf.TemplateField(r)
+ }
+
+ ts, err := template.New("").Funcs(funcMaps).ParseFiles(files...)
if err != nil {
app.serverError(w, err)
return
}
- err = ts.ExecuteTemplate(w, "base", nil)
+ templateCtx := struct {
+ Voter *models.Voter
+ Flashes []string
+ Params struct {
+ Flags struct {
+ Unvoted bool
+ }
+ }
+ PrevPage, NextPage string
+ Motions []*models.MotionForDisplay
+ }{Motions: motions}
+
+ if len(motions) > 0 && first.Before(motions[len(motions)-1].Proposed) {
+ marshalled, err := motions[len(motions)-1].Proposed.MarshalText()
+ if err != nil {
+ app.serverError(w, err)
+
+ return
+ }
+
+ templateCtx.NextPage = string(marshalled)
+ }
+ if len(motions) > 0 && last.After(motions[0].Proposed) {
+ marshalled, err := motions[0].Proposed.MarshalText()
+ if err != nil {
+ app.serverError(w, err)
+
+ return
+ }
+
+ templateCtx.PrevPage = string(marshalled)
+ }
+
+ err = ts.ExecuteTemplate(w, "base", templateCtx)
if err != nil {
app.serverError(w, err)
}
diff --git a/cmd/boardvoting/jobs.go b/cmd/boardvoting/jobs.go
index a732db7..90b1666 100644
--- a/cmd/boardvoting/jobs.go
+++ b/cmd/boardvoting/jobs.go
@@ -35,7 +35,7 @@ type RemindVotersJob struct {
infoLog, errorLog *log.Logger
timer *time.Timer
voters *models.VoterModel
- decisions *models.DecisionModel
+ decisions *models.MotionModel
notify chan NotificationMail
reschedule chan Job
}
@@ -69,7 +69,7 @@ func (r *RemindVotersJob) Run() {
var (
voters []models.Voter
- decisions []models.Decision
+ decisions []models.Motion
err error
)
@@ -113,7 +113,7 @@ func (app *application) NewRemindVotersJob(
infoLog: app.infoLog,
errorLog: app.errorLog,
voters: app.voters,
- decisions: app.decisions,
+ decisions: app.motions,
reschedule: rescheduleChannel,
notify: app.mailNotifier.notifyChannel,
}
@@ -123,7 +123,7 @@ type CloseDecisionsJob struct {
timer *time.Timer
infoLog *log.Logger
errorLog *log.Logger
- decisions *models.DecisionModel
+ decisions *models.MotionModel
reschedule chan Job
notify chan NotificationMail
}
@@ -192,7 +192,7 @@ func (app *application) NewCloseDecisionsJob(
return &CloseDecisionsJob{
infoLog: app.infoLog,
errorLog: app.errorLog,
- decisions: app.decisions,
+ decisions: app.motions,
reschedule: rescheduleChannel,
notify: app.mailNotifier.notifyChannel,
}
diff --git a/cmd/boardvoting/main.go b/cmd/boardvoting/main.go
index 497218d..9ed5114 100644
--- a/cmd/boardvoting/main.go
+++ b/cmd/boardvoting/main.go
@@ -43,7 +43,7 @@ var (
type application struct {
errorLog, infoLog *log.Logger
voters *models.VoterModel
- decisions *models.DecisionModel
+ motions *models.MotionModel
jobScheduler *JobScheduler
mailNotifier *MailNotifier
mailConfig *mailConfig
@@ -85,7 +85,7 @@ func main() {
app := &application{
errorLog: errorLog,
infoLog: infoLog,
- decisions: &models.DecisionModel{DB: db, InfoLog: infoLog},
+ motions: &models.MotionModel{DB: db, InfoLog: infoLog},
voters: &models.VoterModel{DB: db},
mailConfig: config.MailConfig,
baseURL: config.BaseURL,
diff --git a/cmd/boardvoting/notifications.go b/cmd/boardvoting/notifications.go
index b73a7ff..f0295fd 100644
--- a/cmd/boardvoting/notifications.go
+++ b/cmd/boardvoting/notifications.go
@@ -132,14 +132,14 @@ func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) {
type RemindVoterNotification struct {
voter models.Voter
- decisions []models.Decision
+ decisions []models.Motion
}
func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *NotificationContent {
return &NotificationContent{
template: "remind_voter_mail.txt",
data: struct {
- Decisions []models.Decision
+ Decisions []models.Motion
Name string
}{Decisions: r.decisions, Name: r.voter.Name},
subject: "Outstanding CAcert board votes",
@@ -148,7 +148,7 @@ func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *Notificati
}
type ClosedDecisionNotification struct {
- decision *models.ClosedDecision
+ decision *models.ClosedMotion
}
func (c *ClosedDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent {
diff --git a/cmd/boardvoting/routes.go b/cmd/boardvoting/routes.go
index e6f8857..da11703 100644
--- a/cmd/boardvoting/routes.go
+++ b/cmd/boardvoting/routes.go
@@ -42,7 +42,7 @@ func (app *application) routes() *http.ServeMux {
mux.Handle("/static/", http.StripPrefix("/static", fileServer))
mux.HandleFunc("/", app.home)
- mux.HandleFunc("/motions/", app.motions)
+ mux.HandleFunc("/motions/", app.motionList)
return mux
}
diff --git a/internal/models/models.go b/internal/models/models.go
new file mode 100644
index 0000000..5e16392
--- /dev/null
+++ b/internal/models/models.go
@@ -0,0 +1,50 @@
+/*
+Copyright 2022 CAcert Inc.
+SPDX-License-Identifier: Apache-2.0
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package models defines data models and database access types.
+package models
+
+import (
+ "fmt"
+ "time"
+)
+
+func parseSqlite3TimeStamp(timeStamp string) (*time.Time, error) {
+ const (
+ sqlite3TsStringFormat = "2006-01-02 15:04:05.999999999-07:00"
+ sqlite3ShortTsStringFormat = "2006-01-02 15:04:05"
+ )
+
+ var (
+ result time.Time
+ err error
+ )
+
+ if result, err = time.Parse(sqlite3TsStringFormat, timeStamp); err == nil {
+ result = result.UTC()
+
+ return &result, nil
+ }
+
+ if result, err = time.ParseInLocation(sqlite3ShortTsStringFormat, timeStamp, time.UTC); err == nil {
+ result = result.UTC()
+
+ return &result, nil
+ }
+
+ return nil, fmt.Errorf("could not parse timestamp: %w", err)
+}
diff --git a/internal/models/decisions.go b/internal/models/motions.go
similarity index 55%
rename from internal/models/decisions.go
rename to internal/models/motions.go
index 5f5e447..a026bed 100644
--- a/internal/models/decisions.go
+++ b/internal/models/motions.go
@@ -135,7 +135,7 @@ func (v *VoteSums) CalculateResult(quorum int, majority float32) (VoteStatus, st
return voteStatusApproved, "Quorum and majority have been reached"
}
-type Decision struct {
+type Motion struct {
ID int64 `db:"id"`
Proposed time.Time
Proponent int64 `db:"proponent"`
@@ -150,26 +150,26 @@ type Decision struct {
VoteType VoteType
}
-type ClosedDecision struct {
- Decision *Decision
+type ClosedMotion struct {
+ Decision *Motion
VoteSums *VoteSums
Reasoning string
}
-type DecisionModel struct {
+type MotionModel struct {
DB *sqlx.DB
InfoLog *log.Logger
}
// Create a new decision.
-func (m *DecisionModel) Create(
+func (m *MotionModel) Create(
ctx context.Context,
proponent *Voter,
voteType VoteType,
title, content string,
proposed, due time.Time,
) (int64, error) {
- d := &Decision{
+ d := &Motion{
Proposed: proposed.UTC(),
Proponent: proponent.ID,
Title: title,
@@ -183,10 +183,10 @@ func (m *DecisionModel) Create(
`INSERT INTO decisions
(proposed, proponent, title, content, votetype, status, due, modified, tag)
VALUES (:proposed, :proponent, :title, :content, :votetype, :status, :due, :proposed,
- 'm' || strftime('%Y%m%d', :proposed) || '.' || (
+ 'm' || STRFTIME('%Y%m%d', :proposed) || '.' || (
SELECT COUNT(*)+1 AS num
FROM decisions
- WHERE proposed BETWEEN date(:proposed) AND date(:proposed, '1 day')
+ WHERE proposed BETWEEN DATE(:proposed) AND DATE(:proposed, '1 day')
))`,
d,
)
@@ -202,7 +202,7 @@ VALUES (:proposed, :proponent, :title, :content, :votetype, :status, :due, :prop
return id, nil
}
-func (m *DecisionModel) CloseDecisions(ctx context.Context) ([]*ClosedDecision, error) {
+func (m *MotionModel) CloseDecisions(ctx context.Context) ([]*ClosedMotion, error) {
tx, err := m.DB.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("could not start transaction: %w", err)
@@ -225,10 +225,10 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now
_ = rows.Close()
}(rows)
- decisions := make([]*Decision, 0)
+ decisions := make([]*Motion, 0)
for rows.Next() {
- decision := &Decision{}
+ decision := &Motion{}
if err = rows.StructScan(decision); err != nil {
return nil, fmt.Errorf("scanning row failed: %w", err)
}
@@ -240,9 +240,9 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now
decisions = append(decisions, decision)
}
- results := make([]*ClosedDecision, 0, len(decisions))
+ results := make([]*ClosedMotion, 0, len(decisions))
- var decisionResult *ClosedDecision
+ var decisionResult *ClosedMotion
for _, decision := range decisions {
m.InfoLog.Printf("found closable decision %s", decision.Tag)
@@ -261,7 +261,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now
return results, nil
}
-func (m *DecisionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Decision) (*ClosedDecision, error) {
+func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*ClosedMotion, error) {
quorum, majority := d.VoteType.QuorumAndMajority()
var (
@@ -297,14 +297,14 @@ func (m *DecisionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Decis
m.InfoLog.Printf("decision %s closed with result %s: reasoning '%s'", d.Tag, d.Status, reasoning)
- return &ClosedDecision{d, voteSums, reasoning}, nil
+ return &ClosedMotion{d, voteSums, reasoning}, nil
}
-func (m *DecisionModel) UnVotedDecisionsForVoter(_ context.Context, _ *Voter) ([]Decision, error) {
+func (m *MotionModel) UnVotedDecisionsForVoter(_ context.Context, _ *Voter) ([]Motion, error) {
panic("not implemented")
}
-func (m *DecisionModel) SumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Decision) (*VoteSums, error) {
+func (m *MotionModel) SumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*VoteSums, error) {
voteRows, err := tx.QueryxContext(
ctx,
`SELECT vote, COUNT(vote) FROM votes WHERE decision=$1 GROUP BY vote`,
@@ -347,7 +347,7 @@ func (m *DecisionModel) SumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Dec
return sums, nil
}
-func (m *DecisionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, error) {
+func (m *MotionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, error) {
row := m.DB.QueryRowContext(
ctx,
`SELECT due FROM decisions WHERE status=0 ORDER BY due LIMIT 1`,
@@ -376,3 +376,205 @@ func (m *DecisionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time,
return &due, nil
}
+
+type MotionForDisplay struct {
+ ID int64 `db:"id"`
+ Tag string
+ Proponent int64
+ Proposer string
+ Proposed time.Time
+ Title string
+ Content string
+ Type VoteType `db:"votetype"`
+ Status VoteStatus
+ Due time.Time
+ Modified time.Time
+ Sums VoteSums
+ Votes []VoteForDisplay
+}
+
+type VoteForDisplay struct {
+ Name string
+ Vote VoteChoice
+}
+
+type MotionListOptions struct {
+ Limit int
+ Before, After *time.Time
+}
+
+func (m *MotionModel) TimestampRange(ctx context.Context) (*time.Time, *time.Time, error) {
+ row := m.DB.QueryRowxContext(ctx, `SELECT MIN(proposed), MAX(proposed) FROM decisions`)
+
+ if err := row.Err(); err != nil {
+ return nil, nil, fmt.Errorf("could not query for motion timestamps: %w", err)
+ }
+
+ var (
+ first, last sql.NullString
+ firstTs, lastTs *time.Time
+ err error
+ )
+
+ if err := row.Scan(&first, &last); err != nil {
+ return nil, nil, fmt.Errorf("could not scan timestamps: %w", err)
+ }
+
+ if !first.Valid || !last.Valid {
+ return nil, nil, nil
+ }
+
+ if firstTs, err = parseSqlite3TimeStamp(first.String); err != nil {
+ return nil, nil, err
+ }
+
+ if lastTs, err = parseSqlite3TimeStamp(last.String); err != nil {
+ return nil, nil, err
+ }
+
+ return firstTs, lastTs, nil
+}
+
+func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions) ([]*MotionForDisplay, error) {
+ var rows *sqlx.Rows
+ var err error
+
+ if options.Before != nil {
+ rows, err = m.DB.QueryxContext(
+ ctx,
+ `SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title,
+ decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified
+FROM decisions
+JOIN voters ON decisions.proponent=voters.id
+WHERE decisions.proposed < $1
+ORDER BY proposed DESC
+LIMIT $2`,
+ options.Before,
+ options.Limit,
+ )
+ } else if options.After != nil {
+ rows, err = m.DB.QueryxContext(
+ ctx,
+ `WITH display_decision AS (SELECT decisions.id,
+ decisions.tag,
+ decisions.proponent,
+ voters.name AS proposer,
+ decisions.proposed,
+ decisions.title,
+ decisions.content,
+ decisions.votetype,
+ decisions.status,
+ decisions.due,
+ decisions.modified
+ FROM decisions
+ JOIN voters ON decisions.proponent = voters.id
+ WHERE decisions.proposed > $1
+ ORDER BY proposed
+ LIMIT $2)
+SELECT *
+FROM display_decision
+ORDER BY proposed DESC`,
+ options.After,
+ options.Limit,
+ )
+ } else {
+ rows, err = m.DB.QueryxContext(
+ ctx,
+ `SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title,
+ decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified
+FROM decisions
+JOIN voters ON decisions.proponent=voters.id
+ORDER BY proposed DESC
+LIMIT $1`,
+ options.Limit,
+ )
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("could not execute query: %w", err)
+ }
+
+ defer func(rows *sqlx.Rows) {
+ _ = rows.Close()
+ }(rows)
+
+ motions := make([]*MotionForDisplay, 0, options.Limit)
+
+ for rows.Next() {
+ var decision MotionForDisplay
+
+ if err = rows.Err(); err != nil {
+ return nil, fmt.Errorf("could not fetch row: %w", err)
+ }
+
+ if err = rows.StructScan(&decision); err != nil {
+ return nil, fmt.Errorf("could not scan result: %w", err)
+ }
+
+ motions = append(motions, &decision)
+ }
+
+ if len(motions) > 0 {
+ err = m.FillVoteSums(ctx, motions)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return motions, nil
+}
+
+func (m *MotionModel) FillVoteSums(ctx context.Context, decisions []*MotionForDisplay) error {
+ decisionIds := make([]int64, len(decisions))
+ decisionMap := make(map[int64]*MotionForDisplay, len(decisions))
+
+ for idx, decision := range decisions {
+ decisionIds[idx] = decision.ID
+ decisionMap[decision.ID] = decision
+ }
+
+ query, args, err := sqlx.In(
+ `SELECT v.decision, v.vote, COUNT(*) FROM votes v WHERE v.decision IN (?) GROUP BY v.decision, v.vote`,
+ decisionIds,
+ )
+ if err != nil {
+ return fmt.Errorf("could not create IN query: %w", err)
+ }
+
+ rows, err := m.DB.QueryContext(ctx, query, args...)
+ if err != nil {
+ return fmt.Errorf("could not execute query: %w", err)
+ }
+
+ defer func(rows *sql.Rows) {
+ _ = rows.Close()
+ }(rows)
+
+ for rows.Next() {
+ if err = rows.Err(); err != nil {
+ return fmt.Errorf("could not fetch row: %w", err)
+ }
+
+ var (
+ decisionID int64
+ vote VoteChoice
+ count int
+ )
+
+ err = rows.Scan(&decisionID, &vote, &count)
+ if err != nil {
+ return fmt.Errorf("could not scan row: %w", err)
+ }
+
+ switch vote {
+ case voteAye:
+ decisionMap[decisionID].Sums.Ayes = count
+ case voteNaye:
+ decisionMap[decisionID].Sums.Nayes = count
+ case voteAbstain:
+ decisionMap[decisionID].Sums.Abstains = count
+ }
+ }
+
+ return nil
+}
diff --git a/internal/models/decisions_test.go b/internal/models/motions_test.go
similarity index 95%
rename from internal/models/decisions_test.go
rename to internal/models/motions_test.go
index 3b8b623..4be4cbd 100644
--- a/internal/models/decisions_test.go
+++ b/internal/models/motions_test.go
@@ -55,7 +55,7 @@ func prepareTestDb(t *testing.T) (*sqlx.DB, *log.Logger) {
func TestDecisionModel_Create(t *testing.T) {
dbx, logger := prepareTestDb(t)
- dm := models.DecisionModel{DB: dbx, InfoLog: logger}
+ dm := models.MotionModel{DB: dbx, InfoLog: logger}
v := &models.Voter{
ID: 1, // sqlite does not check referential integrity. Might fail with a foreign key index.
@@ -79,7 +79,7 @@ func TestDecisionModel_Create(t *testing.T) {
func TestDecisionModel_GetNextPendingDecisionDue(t *testing.T) {
dbx, logger := prepareTestDb(t)
- dm := models.DecisionModel{DB: dbx, InfoLog: logger}
+ dm := models.MotionModel{DB: dbx, InfoLog: logger}
var (
nextDue *time.Time
diff --git a/ui/html/pages/motions.html b/ui/html/pages/motions.html
index 5759ce8..209f9b3 100644
--- a/ui/html/pages/motions.html
+++ b/ui/html/pages/motions.html
@@ -1,6 +1,43 @@
{{ define "title" }}Motions{{ end }}
{{ define "main" }}
-
Nothing to see yet.
{{ end }} +{{ end }} diff --git a/ui/html/partials/motion_actions.html b/ui/html/partials/motion_actions.html new file mode 100644 index 0000000..dc46365 --- /dev/null +++ b/ui/html/partials/motion_actions.html @@ -0,0 +1,3 @@ +{{ define "motion_actions" }} +TODO: MOTION_ACTIONS PLACEHOLDER
+{{ end }} \ No newline at end of file diff --git a/ui/html/partials/motion_display.html b/ui/html/partials/motion_display.html new file mode 100644 index 0000000..1c38a52 --- /dev/null +++ b/ui/html/partials/motion_display.html @@ -0,0 +1,50 @@ +{{ define "motion_display" }} +{{ .Status|toString|title }} +{{ wrap 76 .Content | nl2br }}
+Due | +{{ dateInZone "2006-01-02 15:04:05 UTC" .Due "UTC" }} | +
Proposed | +{{ dateInZone "2006-01-02 15:04:05 UTC" .Proposed "UTC" }} | +
Vote type: | +{{ .Type|toString|title }} | +
Votes: | +
+
+
+ {{ if .Votes }}
+ Aye
+
+ {{.Sums.Ayes}}
+ Naye
+
+ {{.Sums.Nayes}}
+ Abstain
+
+ {{.Sums.Abstains}}
+
+ {{ range .Votes }}
+
+ Hide Votes
+ {{ else if or (ne 0 .Sums.Ayes) (ne 0 .Sums.Nayes) (ne 0 .Sums.Abstains) }}
+ Show Votes
+ {{ end }}
+ {{ .Name }}: {{ .Vote.Vote }}
+ {{ end }}
+ |
+