Implement timestamp pagination for motion list
This commit is contained in:
parent
2b8beadb77
commit
ec7623a51a
14 changed files with 501 additions and 37 deletions
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
50
internal/models/models.go
Normal 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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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 }}
|
||||
|
|
3
ui/html/partials/motion_actions.html
Normal file
3
ui/html/partials/motion_actions.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
{{ define "motion_actions" }}
|
||||
<p>TODO: MOTION_ACTIONS PLACEHOLDER</p>
|
||||
{{ end }}
|
50
ui/html/partials/motion_display.html
Normal file
50
ui/html/partials/motion_display.html
Normal 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 }}
|
3
ui/html/partials/motion_status_class.html
Normal file
3
ui/html/partials/motion_status_class.html
Normal 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 }}
|
|
@ -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 }}
|
12
ui/html/partials/pagination.html
Normal file
12
ui/html/partials/pagination.html
Normal 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 }}
|
Loading…
Reference in a new issue