From 471daf12ea98aa2a7fcb11deff9697403c5dd6a5 Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Tue, 18 Apr 2017 02:34:21 +0200 Subject: [PATCH] Partialy add new motion creation --- actions.go | 12 ++++ boardvoting.go | 117 ++++++++++++++++++++++++++-------- config.yaml.example | 3 +- forms.go | 54 ++++++++++++++++ models.go | 37 ++++++++++- templates/header.html | 7 ++ templates/newmotion_form.html | 73 +++++++++++++++++++++ 7 files changed, 272 insertions(+), 31 deletions(-) create mode 100644 forms.go create mode 100644 templates/newmotion_form.html diff --git a/actions.go b/actions.go index f41e1ff..190314c 100644 --- a/actions.go +++ b/actions.go @@ -6,6 +6,18 @@ import ( "text/template" ) +func CreateMotion(decision *Decision, voter *Voter) (err error) { + decision.ProponentId = voter.Id + err = decision.Save() + if err != nil { + logger.Println("Error saving motion:", err) + return + } + + // TODO: implement fetching new decision, implement mail + return +} + func WithdrawMotion(decision *Decision, voter *Voter) (err error) { // load template, fill name, tag, title, content type mailContext struct { diff --git a/boardvoting.go b/boardvoting.go index 1fe031e..c467b40 100644 --- a/boardvoting.go +++ b/boardvoting.go @@ -4,8 +4,10 @@ import ( "context" "crypto/tls" "crypto/x509" + "encoding/base64" "fmt" "github.com/Masterminds/sprig" + "github.com/gorilla/sessions" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" "gopkg.in/yaml.v2" @@ -20,17 +22,20 @@ import ( var logger *log.Logger var config *Config +var store *sessions.CookieStore -func getTemplateFilenames(tmpl []string) (result []string) { - result = make([]string, len(tmpl)) - for i := range tmpl { - result[i] = fmt.Sprintf("templates/%s", tmpl[i]) +const sessionCookieName = "votesession" + +func getTemplateFilenames(templates []string) (result []string) { + result = make([]string, len(templates)) + for i := range templates { + result[i] = fmt.Sprintf("templates/%s", templates[i]) } return result } -func renderTemplate(w http.ResponseWriter, tmpl []string, context interface{}) { - t := template.Must(template.New(tmpl[0]).Funcs(sprig.FuncMap()).ParseFiles(getTemplateFilenames(tmpl)...)) +func renderTemplate(w http.ResponseWriter, templates []string, context interface{}) { + t := template.Must(template.New(templates[0]).Funcs(sprig.FuncMap()).ParseFiles(getTemplateFilenames(templates)...)) if err := t.Execute(w, context); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } @@ -76,7 +81,7 @@ type motionParameters struct { } type motionListParameters struct { - Page int64 + Page int64 Flags struct { Confirmed, Withdraw, Unvoted bool } @@ -85,7 +90,6 @@ type motionListParameters struct { func parseMotionParameters(r *http.Request) motionParameters { var m = motionParameters{} m.ShowVotes, _ = strconv.ParseBool(r.URL.Query().Get("showvotes")) - logger.Printf("parsed parameters: %+v\n", m) return m } @@ -102,12 +106,16 @@ func parseMotionListParameters(r *http.Request) motionListParameters { if r.Method == http.MethodPost { m.Flags.Confirmed, _ = strconv.ParseBool(r.PostFormValue("confirm")) } - logger.Printf("parsed parameters: %+v\n", m) return m } func motionListHandler(w http.ResponseWriter, r *http.Request) { params := parseMotionListParameters(r) + session, err := store.Get(r, sessionCookieName) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } var templateContext struct { Decisions []*DecisionForDisplay @@ -115,12 +123,16 @@ func motionListHandler(w http.ResponseWriter, r *http.Request) { Params *motionListParameters PrevPage, NextPage int64 PageTitle string + Flashes interface{} } if voter, ok := r.Context().Value(ctxVoter).(*Voter); ok { templateContext.Voter = voter } + if flashes := session.Flashes(); len(flashes) > 0 { + templateContext.Flashes = flashes + } + session.Save(r, w) templateContext.Params = ¶ms - var err error if params.Flags.Unvoted && templateContext.Voter != nil { if templateContext.Decisions, err = FindVotersUnvotedDecisionsForDisplayOnPage( @@ -168,6 +180,7 @@ func motionHandler(w http.ResponseWriter, r *http.Request) { Params *motionParameters PrevPage, NextPage int64 PageTitle string + Flashes interface{} } voter, ok := getVoterFromRequest(r) if ok { @@ -304,16 +317,61 @@ func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { }) return default: - fmt.Fprintf(w, "No handler for '%s'", subURL) + http.NotFound(w, r) return } } func newMotionHandler(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "New motion") - voter, _ := getVoterFromRequest(r) - fmt.Fprintf(w, "%+v\n", voter) - // TODO: implement + voter, ok := getVoterFromRequest(r) + if !ok { + http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) + } + + templates := []string{"newmotion_form.html", "header.html", "footer.html"} + var templateContext struct { + Form NewDecisionForm + PageTitle string + Voter *Voter + Flashes interface{} + } + switch r.Method { + case http.MethodPost: + form := NewDecisionForm{ + Title: r.FormValue("Title"), + Content: r.FormValue("Content"), + VoteType: r.FormValue("VoteType"), + Due: r.FormValue("Due"), + } + + if valid, data := form.Validate(); !valid { + templateContext.Voter = voter + templateContext.Form = form + renderTemplate(w, templates, templateContext) + } else { + if err := CreateMotion(data, voter); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + session, err := store.Get(r, sessionCookieName) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + session.AddFlash("The motion has been proposed!") + session.Save(r, w) + + http.Redirect(w, r, "/motions/", http.StatusTemporaryRedirect) + } + + return + default: + templateContext.Voter = voter + templateContext.Form = NewDecisionForm{ + VoteType: strconv.FormatInt(voteTypeMotion, 10), + } + renderTemplate(w, templates, templateContext) + } } type Config struct { @@ -323,30 +381,33 @@ type Config struct { ClientCACertificates string `yaml:"client_ca_certificates"` ServerCert string `yaml:"server_certificate"` ServerKey string `yaml:"server_key"` + CookieSecret string `yaml:"cookie_secret"` } -func main() { +func init() { logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags|log.LUTC|log.Lshortfile) - var filename = "config.yaml" - if len(os.Args) == 2 { - filename = os.Args[1] - } - - var err error - - var source []byte - - source, err = ioutil.ReadFile(filename) + source, err := ioutil.ReadFile("config.yaml") if err != nil { logger.Fatal(err) } - err = yaml.Unmarshal(source, &config) + if err := yaml.Unmarshal(source, &config); err != nil { + logger.Fatal(err) + } + + cookieSecret, err := base64.StdEncoding.DecodeString(config.CookieSecret) if err != nil { logger.Fatal(err) } - logger.Printf("read configuration %v", config) + if len(cookieSecret) < 32 { + logger.Fatalln("Cookie secret is less than 32 bytes long") + } + store = sessions.NewCookieStore(cookieSecret) + logger.Println("read configuration") +} +func main() { + var err error db, err = sqlx.Open("sqlite3", config.DatabaseFile) if err != nil { logger.Fatal(err) diff --git a/config.yaml.example b/config.yaml.example index d24977e..fac503d 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -4,4 +4,5 @@ notice_sender_address: cacert-board-votes@lists.cacert.org database_file: database.sqlite client_ca_certificates: cacert_class3.pem server_certificate: server.crt -server_key: server.key \ No newline at end of file +server_key: server.key +cookie_secret: base64encoded_random_byte_value_of_at_least_32_bytes \ No newline at end of file diff --git a/forms.go b/forms.go new file mode 100644 index 0000000..d5359ff --- /dev/null +++ b/forms.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +var validDueDurations = map[string]time.Duration{ + "+3 days": time.Hour * 24 * 3, + "+7 days": time.Hour * 24 * 7, + "+14 days": time.Hour * 24 * 14, + "+28 days": time.Hour * 24 * 28, +} + +type NewDecisionForm struct { + Title string + Content string + VoteType string + Due string + Errors map[string]string +} + +func (f *NewDecisionForm) Validate() (bool, *Decision) { + f.Errors = make(map[string]string) + + data := &Decision{} + + data.Title = strings.TrimSpace(f.Title) + if len(data.Title) < 3 { + f.Errors["Title"] = "Please enter at least 3 characters." + } + + data.Content = strings.TrimSpace(f.Content) + if len(strings.Fields(data.Content)) < 3 { + f.Errors["Content"] = "Please enter at least 3 words." + } + + if voteType, err := strconv.ParseUint(f.VoteType, 10, 8); err != nil || (voteType != 0 && voteType != 1) { + f.Errors["VoteType"] = fmt.Sprint("Please choose a valid vote type", err) + } else { + data.VoteType = VoteType(uint8(voteType)) + } + + if dueDuration, ok := validDueDurations[f.Due]; !ok { + f.Errors["Due"] = "Please choose a valid due date" + } else { + year, month, day := time.Now().UTC().Add(dueDuration).Date() + data.Due = time.Date(year, month, day, 23,59,59,0, time.UTC) + } + + return len(f.Errors) == 0, data +} diff --git a/models.go b/models.go index 46d669e..410ed3e 100644 --- a/models.go +++ b/models.go @@ -51,12 +51,26 @@ WHERE decisions.status=0 AND decisions.id NOT IN ( SELECT decision FROM votes WHERE votes.voter=$2) ORDER BY proposed DESC LIMIT 10 OFFSET 10 * $1` + sqlCreateDecision = ` +INSERT INTO decisions ( + proposed, proponent, title, content, votetype, status, due, modified,tag +) VALUES ( + datetime('now','utc'), :proponent, :title, :content, :votetype, 0, + :due, + datetime('now','utc'), + 'm' || strftime('%Y%m%d','now') || '.' || ( + SELECT COUNT(*)+1 AS num + FROM decisions + WHERE proposed BETWEEN date('now') AND date('now','1 day') + ) +) +` ) var db *sqlx.DB -type VoteType int -type VoteStatus int +type VoteType uint8 +type VoteStatus int8 type Decision struct { Id int @@ -332,6 +346,25 @@ func (d *Decision) OlderExists() (result bool, err error) { return } +func (d *Decision) Save() (err error) { + insertDecisionStmt, err := db.PrepareNamed(sqlCreateDecision) + if err != nil { + logger.Println("Error preparing statement:", err) + return + } + defer insertDecisionStmt.Close() + + result, err := insertDecisionStmt.Exec(d) + if err != nil { + logger.Println("Error creating motion:", err) + return + } + logger.Println(result) + // TODO: implement fetch last id from result + // TODO: load decision from DB + return +} + func FindVoterByAddress(emailAddress string) (voter *Voter, err error) { findVoterStmt, err := db.Preparex(sqlGetVoter) if err != nil { diff --git a/templates/header.html b/templates/header.html index 5f777bb..a6fc69e 100644 --- a/templates/header.html +++ b/templates/header.html @@ -12,4 +12,11 @@ CAcert Board Decisions{{ if .PageTitle }} - {{ .PageTitle }}{{ end}}

{{ template "pagetitle" . }}

+{{ with .Flashes }} + +{{ end }} {{ end }} \ No newline at end of file diff --git a/templates/newmotion_form.html b/templates/newmotion_form.html new file mode 100644 index 0000000..b7573d4 --- /dev/null +++ b/templates/newmotion_form.html @@ -0,0 +1,73 @@ +{{ template "header" . }} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID:(generated on submit)
Proponent:{{ .Voter.Name }}
Proposed date/time:(auto filled to current date/time)
Title: + {{ with .Form.Errors.Title }} + {{ . }} + {{ end }} +
Text: + {{ with .Form.Errors.Content }} + {{ . }} + {{ end }} +
Vote type: + + {{ with .Form.Errors.VoteType }} + {{ . }} + {{ end }} +
Due:(autofilled from option below)
+ + {{ with .Form.Errors.Due }} + {{ . }} + {{ end }} +
 
+
+Back to motions +{{ template "footer" . }} \ No newline at end of file