Implement timestamp pagination for motion list

This commit is contained in:
Jan Dittberner 2022-05-21 19:18:17 +02:00
parent 2b8beadb77
commit ec7623a51a
14 changed files with 501 additions and 37 deletions

View file

@ -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", "<br>"))
}
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)
}

View file

@ -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,
}

View file

@ -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,

View file

@ -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 {

View file

@ -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
}

50
internal/models/models.go Normal file
View file

@ -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)
}

View file

@ -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
}

View file

@ -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

View file

@ -1,6 +1,43 @@
{{ define "title" }}Motions{{ end }}
{{ define "main" }}
<h2>All the motions</h2>
{{ $voter := .Voter }}
{{ $page := . }}
{{ if $voter }}
<div class="ui basic segment">
<div class="ui secondary pointing menu">
<a href="/motions/" class="{{ if not .Params.Flags.Unvoted }}active {{ end }}item" title="Show all motions">All
motions</a>
<a href="/motions/?unvoted=1" class="{{ if .Params.Flags.Unvoted }}active {{ end}}item"
title="My unvoted motions">My unvoted motions</a>
<div class="right item">
<a class="ui primary button" href="/newmotion/">New motion</a>
</div>
</div>
</div>
{{ end }}
{{ if .Motions }}
<div class="ui labeled icon menu">
{{ template "pagination" $page }}
</div>
{{ range .Motions }}
<div class="ui raised segment">
{{ template "motion_display" . }}
{{ if $voter }}{{ template "motion_actions" . }}{{ end }}
</div>
{{ end }}
<div class="ui labeled icon menu">
{{ template "pagination" $page }}
</div>
{{ else }}
<div class="ui basic segment">
<div class="ui icon message">
<i class="inbox icon"></i>
<div class="content">
<div class="header">No motions available</div>
</div>
</div>
</div>
<p>Nothing to see yet.</p>
{{ end }}
{{ end }}

View file

@ -0,0 +1,3 @@
{{ define "motion_actions" }}
<p>TODO: MOTION_ACTIONS PLACEHOLDER</p>
{{ end }}

View file

@ -0,0 +1,50 @@
{{ define "motion_display" }}
<span class="ui {{ template "motion_status_class" .Status }} ribbon label">{{ .Status|toString|title }}</span>
<div class="ui label"><i class="ui icon calendar"></i> {{ dateInZone "2006-01-02 15:04:05 UTC" .Modified "UTC" }}</div>
<h3 class="ui header"><a href="/motions/{{ .Tag }}" title="Details for motion {{ .Tag }}: {{ .Title }}">{{ .Tag }}: {{ .Title }}</a></h3>
<p>{{ wrap 76 .Content | nl2br }}</p>
<table class="ui small definition table">
<tbody>
<tr>
<td>Due</td>
<td>{{ dateInZone "2006-01-02 15:04:05 UTC" .Due "UTC" }}</td>
</tr>
<tr>
<td>Proposed</td>
<td>{{ dateInZone "2006-01-02 15:04:05 UTC" .Proposed "UTC" }}</td>
</tr>
<tr>
<td>Vote type:</td>
<td>{{ .Type|toString|title }}</td>
</tr>
<tr>
<td>Votes:</td>
<td>
<div class="ui labels">
<div class="ui basic label green"><i
class="check circle icon"></i>Aye
<div class="detail">{{.Sums.Ayes}}</div>
</div>
<div class="ui basic label red"><i
class="minus circle icon"></i>Naye
<div class="detail">{{.Sums.Nayes}}</div>
</div>
<div class="ui basic label grey"><i class="circle icon"></i>Abstain
<div class="detail">{{.Sums.Abstains}}</div>
</div>
</div>
{{ if .Votes }}
<div class="list">
{{ range .Votes }}
<div class="item">{{ .Name }}: {{ .Vote.Vote }}</div>
{{ end }}
</div>
<a href="/motions/{{ .Tag }}">Hide Votes</a>
{{ else if or (ne 0 .Sums.Ayes) (ne 0 .Sums.Nayes) (ne 0 .Sums.Abstains) }}
<a href="/motions/{{ .Tag }}?showvotes=1">Show Votes</a>
{{ end }}
</td>
</tr>
</tbody>
</table>
{{ end }}

View file

@ -0,0 +1,3 @@
{{ define "motion_status_class" -}}
{{ if eq . 0 }}blue{{ else if eq . 1 }}green{{ else if eq . -1 }}red{{ else if eq . -2 }}grey{{ end }}
{{- end }}

View file

@ -1,5 +1,8 @@
{{ define "nav" }}
<nav class="ui container">
<a href="/motions/">Motions</a>
{{ if .Voter | canMangageUsers }}
<nav class="ui top attached tabular menu">
<a class="active item" href="/motions/">Motions</a>
<a class="item" href="/users/">User management</a>
</nav>
{{ end }}
{{ end }}

View file

@ -0,0 +1,12 @@
{{ define "pagination" }}
{{ if .PrevPage -}}
<a class="item" href="?after={{ .PrevPage }}{{ if .Params.Flags.Unvoted }}&unvoted=1{{ end }}" title="newer motions">
<i class="left arrow icon"></i> newer
</a>
{{- end }}
{{ if .NextPage -}}
<a class="right item" href="?before={{ .NextPage }}{{ if .Params.Flags.Unvoted }}&unvoted=1{{ end }}" title="older motions">
<i class="right arrow icon"></i> older
</a>
{{- end }}
{{ end }}