diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go index 4ad02dd..5c19fc0 100644 --- a/cmd/boardvoting/handlers.go +++ b/cmd/boardvoting/handlers.go @@ -18,17 +18,112 @@ limitations under the License. package main import ( + "fmt" "html/template" "net/http" + "path/filepath" "strings" "time" - "git.cacert.org/cacert-boardvoting/internal/models" "github.com/Masterminds/sprig/v3" "github.com/gorilla/csrf" + + "git.cacert.org/cacert-boardvoting/internal/models" ) -const motionsPerPage = 10 +func newTemplateCache() (map[string]*template.Template, error) { + cache := map[string]*template.Template{} + + pages, err := filepath.Glob("./ui/html/pages/*.html") + if err != nil { + return nil, fmt.Errorf("could not find page templates: %w", err) + } + + for _, page := range pages { + name := filepath.Base(page) + + files := []string{ + "./ui/html/base.html", + "./ui/html/partials/motion_actions.html", + "./ui/html/partials/motion_display.html", + "./ui/html/partials/motion_status_class.html", + "./ui/html/partials/nav.html", + "./ui/html/partials/pagination.html", + page, + } + + 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["canManageUsers"] = func(*models.Voter) bool { + return false + } + funcMaps[csrf.TemplateTag] = csrf.TemplateField + + ts, err := template.New("").Funcs(funcMaps).ParseFiles(files...) + if err != nil { + return nil, fmt.Errorf("could not parse templates: %w", err) + } + + cache[name] = ts + } + + return cache, nil +} + +func (app *application) render(w http.ResponseWriter, status int, page string, data interface{}) { + ts, ok := app.templateCache[page] + if !ok { + app.serverError(w, fmt.Errorf("the template %s does not exist", page)) + + return + } + + w.WriteHeader(status) + + err := ts.ExecuteTemplate(w, "base", data) + if err != nil { + app.serverError(w, err) + } +} + +type motionListTemplateData struct { + Voter *models.Voter + Flashes []string + Params struct { + Flags struct { + Unvoted bool + } + } + PrevPage, NextPage string + Motions []*models.MotionForDisplay +} + +func (m *motionListTemplateData) setPaginationParameters(first, last *time.Time) error { + motions := m.Motions + + if len(motions) > 0 && first.Before(motions[len(motions)-1].Proposed) { + marshalled, err := motions[len(motions)-1].Proposed.MarshalText() + if err != nil { + return fmt.Errorf("could not serialize timestamp: %w", err) + } + + m.NextPage = string(marshalled) + } + + if len(motions) > 0 && last.After(motions[0].Proposed) { + marshalled, err := motions[0].Proposed.MarshalText() + if err != nil { + return fmt.Errorf("could not serialize timestamp: %w", err) + } + + m.PrevPage = string(marshalled) + } + + return nil +} func (app *application) motionList(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/motions/" { @@ -37,39 +132,20 @@ func (app *application) motionList(w http.ResponseWriter, r *http.Request) { return } - var err error + var ( + listOptions *models.MotionListOptions + err error + ) + + listOptions, err = calculateMotionListOptions(r) + if err != nil { + app.clientError(w, http.StatusBadRequest) + + return + } 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) @@ -84,72 +160,48 @@ func (app *application) motionList(w http.ResponseWriter, r *http.Request) { 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", - } + templateData := &motionListTemplateData{Motions: motions} - 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...) + err = templateData.setPaginationParameters(first, last) if err != nil { app.serverError(w, err) return } - templateCtx := struct { - Voter *models.Voter - Flashes []string - Params struct { - Flags struct { - Unvoted bool - } - } - PrevPage, NextPage string - Motions []*models.MotionForDisplay - }{Motions: motions} + app.render(w, http.StatusOK, "motions.html", &templateData) +} - if len(motions) > 0 && first.Before(motions[len(motions)-1].Proposed) { - marshalled, err := motions[len(motions)-1].Proposed.MarshalText() +func calculateMotionListOptions(r *http.Request) (*models.MotionListOptions, error) { + const ( + queryParamBefore = "before" + queryParamAfter = "after" + motionsPerPage = 10 + ) + + listOptions := &models.MotionListOptions{Limit: motionsPerPage} + + if r.URL.Query().Has(queryParamAfter) { + var after time.Time + + err := after.UnmarshalText([]byte(r.URL.Query().Get(queryParamAfter))) if err != nil { - app.serverError(w, err) - - return + return nil, fmt.Errorf("could not unmarshal timestamp: %w", err) } - templateCtx.NextPage = string(marshalled) - } - if len(motions) > 0 && last.After(motions[0].Proposed) { - marshalled, err := motions[0].Proposed.MarshalText() + 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.serverError(w, err) - - return + return nil, fmt.Errorf("could not unmarshal timestamp: %w", err) } - templateCtx.PrevPage = string(marshalled) + listOptions.Before = &before } - err = ts.ExecuteTemplate(w, "base", templateCtx) - if err != nil { - app.serverError(w, err) - } + return listOptions, nil } func (app *application) home(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/boardvoting/main.go b/cmd/boardvoting/main.go index 9ed5114..8ba6e23 100644 --- a/cmd/boardvoting/main.go +++ b/cmd/boardvoting/main.go @@ -23,6 +23,7 @@ import ( "database/sql" "flag" "fmt" + "html/template" "log" "net/http" "os" @@ -48,6 +49,7 @@ type application struct { mailNotifier *MailNotifier mailConfig *mailConfig baseURL string + templateCache map[string]*template.Template } func main() { @@ -82,13 +84,19 @@ func main() { errorLog.Fatalf("could not setup decision model: %v", err) } + templateCache, err := newTemplateCache() + if err != nil { + errorLog.Fatal(err) + } + app := &application{ - errorLog: errorLog, - infoLog: infoLog, - motions: &models.MotionModel{DB: db, InfoLog: infoLog}, - voters: &models.VoterModel{DB: db}, - mailConfig: config.MailConfig, - baseURL: config.BaseURL, + errorLog: errorLog, + infoLog: infoLog, + motions: &models.MotionModel{DB: db, InfoLog: infoLog}, + voters: &models.VoterModel{DB: db}, + mailConfig: config.MailConfig, + baseURL: config.BaseURL, + templateCache: templateCache, } app.NewMailNotifier() diff --git a/internal/models/motions.go b/internal/models/motions.go index a026bed..65e207d 100644 --- a/internal/models/motions.go +++ b/internal/models/motions.go @@ -436,62 +436,22 @@ func (m *MotionModel) TimestampRange(ctx context.Context) (*time.Time, *time.Tim } func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions) ([]*MotionForDisplay, error) { - var rows *sqlx.Rows - var err error + var ( + rows *sqlx.Rows + 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, - ) + switch { + case options.Before != nil: + rows, err = m.GetMotionRowsBefore(ctx, options) + case options.After != nil: + rows, err = m.GetMotionRowsAfter(ctx, options) + default: + rows, err = m.GetFirstMotionRows(ctx, options) } if err != nil { - return nil, fmt.Errorf("could not execute query: %w", err) + return nil, err } defer func(rows *sqlx.Rows) { @@ -578,3 +538,91 @@ func (m *MotionModel) FillVoteSums(ctx context.Context, decisions []*MotionForDi return nil } + +func (m *MotionModel) GetMotionRowsBefore(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) { + 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, + ) + if err != nil { + return nil, fmt.Errorf("could not query motions before %s: %w", options.Before, err) + } + + return rows, nil +} + +func (m *MotionModel) GetMotionRowsAfter(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) { + 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, + ) + if err != nil { + return nil, fmt.Errorf("could not query motions after %s: %w", options.After, err) + } + + return rows, nil +} + +func (m *MotionModel) GetFirstMotionRows(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) { + 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 query motions: %w", err) + } + + return rows, nil +} diff --git a/ui/html/partials/nav.html b/ui/html/partials/nav.html index d8e732b..cec8f83 100644 --- a/ui/html/partials/nav.html +++ b/ui/html/partials/nav.html @@ -1,5 +1,5 @@ {{ define "nav" }} -{{ if .Voter | canMangageUsers }} +{{ if .Voter | canManageUsers }}