Partialy add new motion creation
authorJan Dittberner <jan@dittberner.info>
Tue, 18 Apr 2017 00:34:21 +0000 (02:34 +0200)
committerJan Dittberner <jan@dittberner.info>
Fri, 21 Apr 2017 22:12:32 +0000 (00:12 +0200)
actions.go
boardvoting.go
config.yaml.example
forms.go [new file with mode: 0644]
models.go
templates/header.html
templates/newmotion_form.html [new file with mode: 0644]

index f41e1ff..190314c 100644 (file)
@@ -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 {
index 1fe031e..c467b40 100644 (file)
@@ -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 = &params
-       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)
index d24977e..fac503d 100644 (file)
@@ -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 (file)
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
+}
index 46d669e..410ed3e 100644 (file)
--- 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 {
index 5f777bb..a6fc69e 100644 (file)
@@ -12,4 +12,11 @@ CAcert Board Decisions{{ if .PageTitle }} - {{ .PageTitle }}{{ end}}
 </head>
 <body>
 <h1>{{ template "pagetitle" . }}</h1>
+{{ with .Flashes }}
+<ul class="flash-messages">
+{{ range . }}
+    <li>{{ . }}</li>
+{{ end }}
+</ul>
+{{ end }}
 {{ end }}
\ No newline at end of file
diff --git a/templates/newmotion_form.html b/templates/newmotion_form.html
new file mode 100644 (file)
index 0000000..b7573d4
--- /dev/null
@@ -0,0 +1,73 @@
+{{ template "header" . }}
+<form action="/newmotion/" method="post">
+    <table>
+        <tr>
+            <td>ID:</td>
+            <td>(generated on submit)</td>
+        </tr>
+        <tr>
+            <td>Proponent:</td>
+            <td>{{ .Voter.Name }}</td>
+        </tr>
+        <tr>
+            <td>Proposed date/time:</td>
+            <td>(auto filled to current date/time)</td>
+        </tr>
+        <tr>
+            <td>Title:</td>
+            <td><input name="Title" value="{{ .Form.Title }}"/>
+                {{ with .Form.Errors.Title }}
+                <span class="error">{{ . }}</span>
+                {{ end }}
+            </td>
+        </tr>
+        <tr>
+            <td>Text:</td>
+            <td><textarea name="Content">{{ .Form.Content }}</textarea>
+                {{ with .Form.Errors.Content }}
+                <span class="error">{{ . }}</span>
+                {{ end }}
+            </td>
+        </tr>
+        <tr>
+            <td>Vote type:</td>
+            <td>
+                <select name="VoteType">
+                    <option value="0"
+                            {{ if eq "0" .Form.VoteType }}selected{{ end }}>
+                        Motion
+                    </option>
+                    <option value="1"
+                            {{ if eq "1" .Form.VoteType }}selected{{ end }}>Veto
+                    </option>
+                </select>
+                {{ with .Form.Errors.VoteType }}
+                <span class="error">{{ . }}</span>
+                {{ end }}
+            </td>
+        </tr>
+        <tr>
+            <td rowspan="2">Due:</td>
+            <td>(autofilled from option below)</td>
+        </tr>
+        <tr>
+            <td>
+                <select name="Due">
+                    <option value="+3 days">In 3 Days</option>
+                    <option value="+7 days">In 1 Week</option>
+                    <option value="+14 days">In 2 Weeks</option>
+                    <option value="+28 days">In 4 Weeks</option>
+                </select>
+                {{ with .Form.Errors.Due }}
+                <span class="error">{{ . }}</span>
+                {{ end }}
+            </td>
+        </tr>
+        <tr>
+            <td>&nbsp;</td>
+            <td><input type="submit" value="Propose"/></td>
+        </tr>
+    </table>
+</form>
+<a href="/motions/">Back to motions</a>
+{{ template "footer" . }}
\ No newline at end of file