Implement motion detail view
- add httprouter for parameterized routing - improve styling - add routes and handlers - implement motion detail handler
This commit is contained in:
parent
44a6180a09
commit
0ad88fe5f4
12 changed files with 207 additions and 37 deletions
|
@ -19,6 +19,8 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
|
@ -27,11 +29,11 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
"github.com/gorilla/csrf"
|
||||
|
||||
"git.cacert.org/cacert-boardvoting/internal/models"
|
||||
"git.cacert.org/cacert-boardvoting/ui"
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
func newTemplateCache() (map[string]*template.Template, error) {
|
||||
|
@ -81,6 +83,11 @@ type templateData struct {
|
|||
}
|
||||
PrevPage, NextPage string
|
||||
Motions []*models.MotionForDisplay
|
||||
Motion *models.MotionForDisplay
|
||||
}
|
||||
|
||||
func (app *application) newTemplateData(r *http.Request) *templateData {
|
||||
return &templateData{}
|
||||
}
|
||||
|
||||
func (app *application) render(w http.ResponseWriter, status int, page string, data *templateData) {
|
||||
|
@ -164,7 +171,9 @@ func (app *application) motionList(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
templateData := &templateData{Motions: motions}
|
||||
templateData := app.newTemplateData(r)
|
||||
|
||||
templateData.Motions = motions
|
||||
|
||||
err = templateData.setPaginationParameters(first, last)
|
||||
if err != nil {
|
||||
|
@ -208,19 +217,53 @@ func calculateMotionListOptions(r *http.Request) (*models.MotionListOptions, err
|
|||
return listOptions, nil
|
||||
}
|
||||
|
||||
func (app *application) home(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
func (app *application) motionDetails(w http.ResponseWriter, r *http.Request) {
|
||||
params := httprouter.ParamsFromContext(r.Context())
|
||||
|
||||
tag := params.ByName("tag")
|
||||
|
||||
showVotes := r.URL.Query().Has("showvotes")
|
||||
|
||||
motion, err := app.motions.GetMotionByTag(r.Context(), tag, showVotes)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
app.serverError(w, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != "GET" && r.Method != "HEAD" {
|
||||
w.Header().Set("Allow", "GET,HEAD")
|
||||
app.clientError(w, http.StatusMethodNotAllowed)
|
||||
if motion == nil {
|
||||
app.notFound(w)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/motions/", http.StatusMovedPermanently)
|
||||
data := app.newTemplateData(r)
|
||||
|
||||
data.Motion = motion
|
||||
|
||||
app.render(w, http.StatusOK, "motion.html", data)
|
||||
}
|
||||
|
||||
func (app *application) newMotionForm(writer http.ResponseWriter, request *http.Request) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (app *application) newMotionSubmit(writer http.ResponseWriter, request *http.Request) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (app *application) voteForm(writer http.ResponseWriter, request *http.Request) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (app *application) voteSubmit(writer http.ResponseWriter, request *http.Request) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (app *application) proxyVoteForm(writer http.ResponseWriter, request *http.Request) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (app *application) proxyVoteSubmit(writer http.ResponseWriter, request *http.Request) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ import (
|
|||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/justinas/alice"
|
||||
"github.com/vearutop/statigz"
|
||||
"github.com/vearutop/statigz/brotli"
|
||||
|
@ -29,7 +30,7 @@ import (
|
|||
)
|
||||
|
||||
func (app *application) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
router := httprouter.New()
|
||||
|
||||
staticDir, _ := fs.Sub(ui.Files, "static")
|
||||
|
||||
|
@ -40,13 +41,23 @@ func (app *application) routes() http.Handler {
|
|||
|
||||
fileServer := statigz.FileServer(staticData, brotli.AddEncoding, statigz.EncodeOnInit)
|
||||
|
||||
mux.Handle("/favicon.ico", http.RedirectHandler("/static/images/favicon.ico", http.StatusMovedPermanently))
|
||||
mux.Handle("/static/", http.StripPrefix("/static", fileServer))
|
||||
|
||||
mux.HandleFunc("/", app.home)
|
||||
mux.HandleFunc("/motions/", app.motionList)
|
||||
router.Handler(http.MethodGet, "/", http.RedirectHandler("/motions/", http.StatusMovedPermanently))
|
||||
router.Handler(
|
||||
http.MethodGet,
|
||||
"/favicon.ico",
|
||||
http.RedirectHandler("/static/images/favicon.ico", http.StatusMovedPermanently),
|
||||
)
|
||||
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", fileServer))
|
||||
router.HandlerFunc(http.MethodGet, "/motions/", app.motionList)
|
||||
router.HandlerFunc(http.MethodGet, "/motions/:tag", app.motionDetails)
|
||||
router.HandlerFunc(http.MethodGet, "/newmotion/", app.newMotionForm)
|
||||
router.HandlerFunc(http.MethodPost, "/newmotion/", app.newMotionSubmit)
|
||||
router.HandlerFunc(http.MethodGet, "/vote/", app.voteForm)
|
||||
router.HandlerFunc(http.MethodPost, "/vote/", app.voteSubmit)
|
||||
router.HandlerFunc(http.MethodGet, "/proxy/", app.proxyVoteForm)
|
||||
router.HandlerFunc(http.MethodPost, "/proxy/", app.proxyVoteSubmit)
|
||||
|
||||
standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
|
||||
|
||||
return standard.Then(mux)
|
||||
return standard.Then(router)
|
||||
}
|
||||
|
|
1
go.mod
1
go.mod
|
@ -36,6 +36,7 @@ require (
|
|||
github.com/andybalholm/brotli v1.0.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/julienschmidt/httprouter v1.3.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
|
|
1
go.sum
1
go.sum
|
@ -749,6 +749,7 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
|
|||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||
|
|
|
@ -390,7 +390,7 @@ type MotionForDisplay struct {
|
|||
Due time.Time
|
||||
Modified time.Time
|
||||
Sums VoteSums
|
||||
Votes []VoteForDisplay
|
||||
Votes []*VoteForDisplay
|
||||
}
|
||||
|
||||
type VoteForDisplay struct {
|
||||
|
@ -626,3 +626,82 @@ LIMIT $1`,
|
|||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (m *MotionModel) GetMotionByTag(ctx context.Context, tag string, withVotes bool) (*MotionForDisplay, error) {
|
||||
row := m.DB.QueryRowxContext(
|
||||
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.tag = ?`,
|
||||
tag,
|
||||
)
|
||||
if err := row.Err(); err != nil {
|
||||
return nil, fmt.Errorf("could not query motion: %w", err)
|
||||
}
|
||||
|
||||
var result MotionForDisplay
|
||||
|
||||
if err := row.StructScan(&result); err != nil {
|
||||
return nil, fmt.Errorf("could not fill motion from query result: %w", err)
|
||||
}
|
||||
|
||||
if err := m.FillVoteSums(ctx, []*MotionForDisplay{&result}); err != nil {
|
||||
return nil, fmt.Errorf("could not get vote sums: %w", err)
|
||||
}
|
||||
|
||||
if withVotes {
|
||||
if err := m.FillVotes(ctx, &result); err != nil {
|
||||
return nil, fmt.Errorf("could not get votes for %s: %w", result.Tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (m *MotionModel) FillVotes(ctx context.Context, md *MotionForDisplay) error {
|
||||
rows, err := m.DB.QueryxContext(ctx,
|
||||
`SELECT voters.name, votes.vote
|
||||
FROM voters
|
||||
JOIN votes ON votes.voter = voters.id
|
||||
WHERE votes.decision = ?
|
||||
ORDER BY voters.name`,
|
||||
md.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("could not fetch rows: %w", err)
|
||||
}
|
||||
|
||||
defer func(rows *sqlx.Rows) {
|
||||
_ = rows.Close()
|
||||
}(rows)
|
||||
|
||||
for rows.Next() {
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("could not get row: %w", err)
|
||||
}
|
||||
|
||||
var voteDisplay VoteForDisplay
|
||||
|
||||
if err := rows.StructScan(&voteDisplay); err != nil {
|
||||
return fmt.Errorf("could not scan row: %w", err)
|
||||
}
|
||||
|
||||
md.Votes = append(md.Votes, &voteDisplay)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ template "title" . }} - CAcert Board Voting System</title>
|
||||
<link rel="stylesheet" type="text/css" href="/static/lato-fonts.css">
|
||||
<link rel="stylesheet" type="text/css" href="/static/semantic.min.css">
|
||||
<link rel="icon" href="/static/images/favicon.ico">
|
||||
</head>
|
||||
|
@ -38,9 +39,27 @@
|
|||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ $voter := .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>
|
||||
{{ if $voter }}
|
||||
<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>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ template "main" . }}
|
||||
</main>
|
||||
<footer class="ui container"><span class="ui small text">© 2017-2022 CAcert Inc.</span></footer>
|
||||
<footer class="ui vertical footer segment">
|
||||
<div class="ui container">
|
||||
<span class="ui small text">© 2017-2022 CAcert Inc.</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
<script src="/static/jquery.min.js"></script>
|
||||
<script src="/static/semantic.min.js"></script>
|
||||
|
|
11
ui/html/pages/motion.html
Normal file
11
ui/html/pages/motion.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
{{ define "title" }}CAcert Board Decisions: {{ .Motion.Tag }}{{ end }}
|
||||
|
||||
{{ define "main" }}
|
||||
{{ $voter := .Voter }}
|
||||
{{ with .Motion }}
|
||||
<div class="ui raised segment">
|
||||
{{ template "motion_display" . }}
|
||||
{{ if $voter }}{{ template "motion_actions" . }}{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
|
@ -1,21 +1,8 @@
|
|||
{{ define "title" }}Motions{{ end }}
|
||||
{{ define "title" }}CAcert Board Decisions{{ end }}
|
||||
|
||||
{{ define "main" }}
|
||||
{{ $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 }}
|
||||
{{ $voter := .Voter }}
|
||||
{{ if .Motions }}
|
||||
<div class="ui labeled icon menu">
|
||||
{{ template "pagination" $page }}
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
{{ if .Votes }}
|
||||
<div class="list">
|
||||
{{ range .Votes }}
|
||||
<div class="item">{{ .Name }}: {{ .Vote.Vote }}</div>
|
||||
<div class="item">{{ .Name }}: {{ .Vote }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<a href="/motions/{{ .Tag }}">Hide Votes</a>
|
||||
|
|
|
@ -1,3 +1,12 @@
|
|||
/*******************************
|
||||
Site Overrides
|
||||
*******************************/
|
||||
|
||||
footer.ui.vertical.segment {
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
.ui.footer.segment {
|
||||
margin: 5em 0em 0em;
|
||||
padding: 5em 0em;
|
||||
}
|
|
@ -658,6 +658,15 @@ body .ui.inverted:not(.dimmer)::-webkit-scrollbar-thumb:hover {
|
|||
/*******************************
|
||||
Site Overrides
|
||||
*******************************/
|
||||
|
||||
footer.ui.vertical.segment {
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
.ui.footer.segment {
|
||||
margin: 5em 0em 0em;
|
||||
padding: 5em 0em;
|
||||
}
|
||||
/*!
|
||||
* # Fomantic-UI 2.8.8 - Button
|
||||
* http://github.com/fomantic/Fomantic-UI/
|
||||
|
|
2
ui/static/semantic.min.css
vendored
2
ui/static/semantic.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue