Implement motion detail view

- add httprouter for parameterized routing
- improve styling
- add routes and handlers
- implement motion detail handler
main
Jan Dittberner 2 years ago
parent 44a6180a09
commit 0ad88fe5f4

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

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

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

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

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save