Web polls #12

Merged
noah merged 2 commits from polls into main 2022-12-21 03:19:24 +00:00
13 changed files with 356 additions and 22 deletions
Showing only changes of commit 1cc2306cbf - Show all commits

View File

@ -96,11 +96,26 @@ const (
// rapidly it does not increment the view counter more. // rapidly it does not increment the view counter more.
ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d" ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d"
ThreadViewDebounceCooldown = 1 * time.Hour ThreadViewDebounceCooldown = 1 * time.Hour
)
// Poll settings
var (
// Max number of responses to accept for a poll (how many form // Max number of responses to accept for a poll (how many form
// values the app will read in). NOTE: also enforced in frontend // values the app will read in). NOTE: also enforced in frontend
// UX in new_post.html, update there if you change this. // UX in new_post.html, update there if you change this.
PollMaxAnswers = 100 PollMaxAnswers = 100
// Poll color CSS classes (Bulma). Plugged in to templates like:
// <progress class="$CLASS">
// Values will wrap around for long polls.
PollProgressBarClasses = []string{
"progress is-success",
"progress is-link",
"progress is-warning",
"progress is-danger",
"progress is-primary",
"progress is-info",
}
) )
// Variables set by main.go to make them readily available. // Variables set by main.go to make them readily available.

View File

@ -47,9 +47,10 @@ func NewPost() http.HandlerFunc {
isOriginalComment bool isOriginalComment bool
// Polls // Polls
pollOptions = []string{} pollOptions = []string{}
pollExpires = 3 pollExpires = 3
isPoll bool pollMultipleChoice = r.FormValue("poll_multiple_choice") == "true"
isPoll bool
// Attached photo object. // Attached photo object.
commentPhoto *models.CommentPhoto commentPhoto *models.CommentPhoto
@ -179,13 +180,27 @@ func NewPost() http.HandlerFunc {
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
// Polls: parse form parameters into a neat list of answers. // Polls: parse form parameters into a neat list of answers.
pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires")) pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires"))
var distinctPollChoices = map[string]interface{}{}
for i := 0; i < config.PollMaxAnswers; i++ { for i := 0; i < config.PollMaxAnswers; i++ {
if value := r.FormValue(fmt.Sprintf("answer%d", i)); value != "" { if value := r.FormValue(fmt.Sprintf("answer%d", i)); value != "" {
pollOptions = append(pollOptions, value) pollOptions = append(pollOptions, value)
isPoll = len(pollOptions) >= 2 isPoll = len(pollOptions) >= 2
// Make sure every option is distinct!
if _, ok := distinctPollChoices[value]; ok {
session.FlashError(w, r, "Your poll options must all be unique! Duplicate option '%s' seen in your post!", value)
intent = "preview" // do not continue to submit
}
distinctPollChoices[value] = nil
} }
} }
// If only one poll option, warn about it.
if len(pollOptions) == 1 {
session.FlashError(w, r, "Your poll should have at least two choices.")
intent = "preview" // do not continue to submit
}
// Is a photo coming along? // Is a photo coming along?
if forum.PermitPhotos { if forum.PermitPhotos {
// Removing or replacing? // Removing or replacing?
@ -375,6 +390,7 @@ func NewPost() http.HandlerFunc {
if isPoll { if isPoll {
log.Info("It's a Poll! Options: %+v", pollOptions) log.Info("It's a Poll! Options: %+v", pollOptions)
poll := models.CreatePoll(pollOptions, pollExpires) poll := models.CreatePoll(pollOptions, pollExpires)
poll.MultipleChoice = pollMultipleChoice
if err := poll.Save(); err != nil { if err := poll.Save(); err != nil {
session.FlashError(w, r, "Error creating poll: %s", err) session.FlashError(w, r, "Error creating poll: %s", err)
} }

View File

@ -0,0 +1,91 @@
package poll
import (
"fmt"
"net/http"
"strconv"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Vote controller for polls.
func Vote() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
// Form parameters
pollID uint64
fromThreadID uint64
answers = r.Form["answer"] // a slice in case of MultipleChoice
nextURL string
)
// Parse integer params.
if value, err := strconv.Atoi(r.FormValue("poll_id")); err != nil {
session.FlashError(w, r, "Invalid poll ID")
templates.Redirect(w, "/")
return
} else {
pollID = uint64(value)
}
// Currently polls only exist in forum threads, require the thread ID.
if value, err := strconv.Atoi(r.FormValue("from_thread_id")); err != nil {
session.FlashError(w, r, "Invalid thread ID")
templates.Redirect(w, "/")
return
} else {
fromThreadID = uint64(value)
nextURL = fmt.Sprintf("/forum/thread/%d", fromThreadID)
}
// POST request only.
if r.Method != http.MethodPost {
session.FlashError(w, r, "POST requests only.")
templates.Redirect(w, nextURL)
return
}
// An answer is required.
if len(answers) == 0 || len(answers) == 1 && answers[0] == "" {
session.FlashError(w, r, "An answer to this poll is required for voting.")
templates.Redirect(w, nextURL)
return
}
// Get the current user.
user, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
templates.Redirect(w, nextURL)
return
}
// Look up the poll.
poll, err := models.GetPoll(pollID)
if err != nil {
session.FlashError(w, r, "Poll not found.")
templates.Redirect(w, nextURL)
return
}
// Is it accepting responses?
result := poll.Result(user)
if !result.AcceptingVotes {
session.FlashError(w, r, "This poll is not accepting your vote at this time.")
templates.Redirect(w, nextURL)
return
}
// Cast the vote!
if err := poll.CastVote(user, answers); err != nil {
session.FlashError(w, r, "Couldn't cast the vote: %s", err)
templates.Redirect(w, nextURL)
return
}
session.Flash(w, r, "Your vote has been recorded!")
templates.Redirect(w, nextURL)
})
}

View File

@ -35,7 +35,7 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
// Hide explicit forum if user hasn't opted into it. // Hide explicit forum if user hasn't opted into it.
if !user.Explicit && !user.IsAdmin { if !user.Explicit && !user.IsAdmin {
wheres = append(wheres, "explicit = false") wheres = append(wheres, "forums.explicit = false")
} }
// Get the page of recent forum comment IDs of all time. // Get the page of recent forum comment IDs of all time.

View File

@ -25,4 +25,5 @@ func AutoMigrate() {
DB.AutoMigrate(&Subscription{}) DB.AutoMigrate(&Subscription{})
DB.AutoMigrate(&CommentPhoto{}) DB.AutoMigrate(&CommentPhoto{})
DB.AutoMigrate(&Poll{}) DB.AutoMigrate(&Poll{})
DB.AutoMigrate(&PollVote{})
} }

View File

@ -1,8 +1,11 @@
package models package models
import ( import (
"fmt"
"strings" "strings"
"time" "time"
"code.nonshy.com/nonshy/website/pkg/config"
) )
// Poll table for user surveys posted in the forums. // Poll table for user surveys posted in the forums.
@ -27,6 +30,7 @@ type Poll struct {
func CreatePoll(choices []string, expires int) *Poll { func CreatePoll(choices []string, expires int) *Poll {
return &Poll{ return &Poll{
Choices: strings.Join(choices, "\n"), Choices: strings.Join(choices, "\n"),
Expires: expires > 0,
ExpiresAt: time.Now().Add(time.Duration(expires) * 24 * time.Hour), ExpiresAt: time.Now().Add(time.Duration(expires) * 24 * time.Hour),
} }
} }
@ -43,8 +47,89 @@ func (p *Poll) Options() []string {
return strings.Split(p.Choices, "\n") return strings.Split(p.Choices, "\n")
} }
// IsExpired returns if the poll has ended.
func (p *Poll) IsExpired() bool {
return p.Expires && time.Now().After(p.ExpiresAt)
}
// InputType returns "radio" or "checkbox" for multiple choice polls.
func (p *Poll) InputType() string {
if p.MultipleChoice {
return "checkbox"
}
return "radio"
}
// Result returns metadata about a poll's status and results, for frontend assist.
func (p *Poll) Result(currentUser *User) PollResult {
var (
result = PollResult{
AcceptingVotes: true,
CurrentUserVote: []string{},
Results: map[string]int{},
ResultsPercent: map[string]float64{},
ResultsClass: map[string]string{},
}
votes = p.GetAllVotes()
distinctAnswers int
)
// Populate the CSS classes.
for i, answer := range p.Options() {
result.ResultsClass[answer] = config.PollProgressBarClasses[i%len(config.PollProgressBarClasses)]
}
result.TotalVotes = len(votes)
for _, res := range votes {
if res.UserID == currentUser.ID {
result.CurrentUserVote = append(result.CurrentUserVote, res.Answer)
result.AcceptingVotes = false
}
if _, ok := result.Results[res.Answer]; !ok {
distinctAnswers++
result.Results[res.Answer] = 0
result.ResultsPercent[res.Answer] = 0
}
result.Results[res.Answer]++
}
// Compute the percent splits.
if result.TotalVotes > 0 {
for answer, count := range result.Results {
result.ResultsPercent[answer] = float64(count) / float64(result.TotalVotes)
}
}
// Expired polls don't accept answers.
if p.IsExpired() {
result.AcceptingVotes = false
}
return result
}
// Save Poll. // Save Poll.
func (p *Poll) Save() error { func (p *Poll) Save() error {
result := DB.Save(p) result := DB.Save(p)
return result.Error return result.Error
} }
// PollResult holds metadata about the poll result for frontend display.
type PollResult struct {
AcceptingVotes bool // user voted or it expired
CurrentUserVote []string // current user's selection, if any
Results map[string]int // answers and their %
ResultsPercent map[string]float64
ResultsClass map[string]string // progress bar classes
TotalVotes int
}
func (pr PollResult) GetPercent(answer string) string {
value := pr.ResultsPercent[answer]
return fmt.Sprintf("%.1f", value*100)
}
func (pr PollResult) GetClass(answer string) string {
return pr.ResultsClass[answer]
}

View File

@ -1,6 +1,11 @@
package models package models
import "time" import (
"errors"
"time"
"code.nonshy.com/nonshy/website/pkg/log"
)
// PollVote table records answers to polls. // PollVote table records answers to polls.
type PollVote struct { type PollVote struct {
@ -12,3 +17,52 @@ type PollVote struct {
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
// CastVote on a poll. Multiple answers OK for multiple choice polls.
func (p *Poll) CastVote(user *User, answers []string) error {
if len(answers) > 1 && !p.MultipleChoice {
return errors.New("multiple answers not accepted for this poll")
}
// If this user has already voted, remove their vote.
result := DB.Where(
"poll_id = ? AND user_id = ?",
p.ID, user.ID,
).Delete(&PollVote{})
if result.Error != nil {
return result.Error
}
// Insert their votes.
var err error
for _, answer := range answers {
vote := &PollVote{
PollID: p.ID,
UserID: user.ID,
Answer: answer,
}
err = vote.Save()
}
return err
}
// GetAllVotes for a poll.
func (p *Poll) GetAllVotes() []*PollVote {
var pv = []*PollVote{}
result := DB.Where(
"poll_id = ?", p.ID,
).Find(&pv)
if result.Error != nil {
log.Error("Poll(%d).GetAllVotes(): %s", p.ID, result.Error)
}
return pv
}
// Save Poll.
func (v *PollVote) Save() error {
result := DB.Save(v)
return result.Error
}

View File

@ -15,6 +15,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/controller/inbox" "code.nonshy.com/nonshy/website/pkg/controller/inbox"
"code.nonshy.com/nonshy/website/pkg/controller/index" "code.nonshy.com/nonshy/website/pkg/controller/index"
"code.nonshy.com/nonshy/website/pkg/controller/photo" "code.nonshy.com/nonshy/website/pkg/controller/photo"
"code.nonshy.com/nonshy/website/pkg/controller/poll"
"code.nonshy.com/nonshy/website/pkg/middleware" "code.nonshy.com/nonshy/website/pkg/middleware"
) )
@ -67,6 +68,7 @@ func New() http.Handler {
mux.Handle("/forum/thread/", middleware.CertRequired(forum.Thread())) mux.Handle("/forum/thread/", middleware.CertRequired(forum.Thread()))
mux.Handle("/forum/newest", middleware.CertRequired(forum.Newest())) mux.Handle("/forum/newest", middleware.CertRequired(forum.Newest()))
mux.Handle("/f/", middleware.CertRequired(forum.Forum())) mux.Handle("/f/", middleware.CertRequired(forum.Forum()))
mux.Handle("/poll/vote", middleware.CertRequired(poll.Vote()))
// Admin endpoints. // Admin endpoints.
mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard())) mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard()))

View File

@ -8,6 +8,11 @@ import (
// FormatDurationCoarse returns a pretty printed duration with coarse granularity. // FormatDurationCoarse returns a pretty printed duration with coarse granularity.
func FormatDurationCoarse(duration time.Duration) string { func FormatDurationCoarse(duration time.Duration) string {
// Negative durations (e.g. future dates) should work too.
if duration < 0 {
duration *= -1
}
var result = func(text string, v int64) string { var result = func(text string, v int64) string {
if v == 1 { if v == 1 {
text = strings.TrimSuffix(text, "s") text = strings.TrimSuffix(text, "s")

View File

@ -105,6 +105,13 @@
</span> </span>
{{end}} {{end}}
{{if .PollID}}
<span class="tag is-info is-light mr-2">
<span class="icon"><i class="fa fa-poll"></i></span>
<span>Poll</span>
</span>
{{end}}
{{if .Explicit}} {{if .Explicit}}
<span class="tag is-danger is-light mr-2"> <span class="tag is-danger is-light mr-2">
<span class="icon"><i class="fa fa-fire"></i></span> <span class="icon"><i class="fa fa-fire"></i></span>

View File

@ -81,10 +81,20 @@
</p> </p>
</div> </div>
{{if not .EditCommentID}} {{if and (not .EditCommentID) (not .Thread)}}
<div class="field block"> <div class="field block">
<label for="message" class="label">Poll <span class="tag is-success">NEW!</span></label> <label for="message" class="label">Poll <span class="tag is-success">NEW!</span></label>
<div v-if="pollVisible"> <div v-if="pollVisible">
<!-- MultipleChoice option -->
<div class="mb-2">
<label class="checkbox">
<input type="checkbox"
name="poll_multiple_choice"
value="true">
<small class="ml-2">Allow multiple selections per vote</small>
</label>
</div>
<!-- Answer rows --> <!-- Answer rows -->
<div v-for="(answer, i) in answers" <div v-for="(answer, i) in answers"
v-bind:key="i" v-bind:key="i"
@ -116,9 +126,9 @@
</div> </div>
<!-- Settings --> <!-- Settings -->
<div class="columns mt-2 is-mobile"> <div class="columns mt-2 mb-0 is-mobile">
<div class="column is-narrow"> <div class="column is-narrow">
Poll expires: <small>Poll expires:</small>
</div> </div>
<div class="column"> <div class="column">
<div class="select is-small is-fullwidth"> <div class="select is-small is-fullwidth">
@ -355,7 +365,7 @@
delimiters: ['[[', ']]'], delimiters: ['[[', ']]'],
data() { data() {
return { return {
pollVisible: true, pollVisible: pollOptions.length > 0,
answers: [ answers: [
{value: ""}, {value: ""},
{value: ""}, {value: ""},

View File

@ -81,6 +81,10 @@
<sup class="has-text-success fa fa-thumbtack is-size-6 ml-1"></sup> <sup class="has-text-success fa fa-thumbtack is-size-6 ml-1"></sup>
{{end}} {{end}}
{{if .Thread.PollID}}
<sup class="has-text-info fa fa-poll is-size-6 ml-1"></sup>
{{end}}
{{if .Thread.Explicit}} {{if .Thread.Explicit}}
<sup class="has-text-danger fa fa-fire is-size-6 ml-1"></sup> <sup class="has-text-danger fa fa-fire is-size-6 ml-1"></sup>
{{end}} {{end}}

View File

@ -153,27 +153,71 @@
{{if and (eq $i 0) $Root.Thread.PollID}} {{if and (eq $i 0) $Root.Thread.PollID}}
<h2>Poll</h2> <h2>Poll</h2>
<!-- Get the results -->
{{$Poll := $Root.Thread.Poll}}
{{$PollResult := $Poll.Result $Root.CurrentUser}}
<form name="ballot" action="/poll/vote" method="POST"> <form name="ballot" action="/poll/vote" method="POST">
{{InputCSRF}} {{InputCSRF}}
<input type="hidden" name="poll_id" value="{{$Root.Thread.PollID}}"> <input type="hidden" name="poll_id" value="{{$Root.Thread.PollID}}">
<input type="hidden" name="from_thread_id" value="{{$Root.Thread.ID}}"> <input type="hidden" name="from_thread_id" value="{{$Root.Thread.ID}}">
<!-- Poll is open? -->
{{if $PollResult.AcceptingVotes}}
{{range $Poll.Options}}
<div class="control">
<label class="{{$Poll.InputType}} box nonshy-fullwidth p-3 mb-3">
<input type="{{$Poll.InputType}}"
name="answer"
value="{{.}}">
{{.}}
</label>
</div>
{{end}}
{{range $Root.Thread.Poll.Options}} {{if $Poll.MultipleChoice}}
<div class="control"> <div class="mt-2 mb-4">
<label class="radio box nonshy-fullwidth p-3 mb-3"> <strong>Multiple choice:</strong> select all the answers you want before casting your vote!
<input type="radio" </div>
name="answer" {{end}}
value="{{.}}">
{{.}} <div class="mb-4">
</label> {{if and ($Poll.Expires) (not $Poll.IsExpired)}}
</div> Poll expires in about <span title="{{$Poll.ExpiresAt.Format "2006-01-02 15:04:05"}}">{{SincePrettyCoarse $Root.Thread.Poll.ExpiresAt}}</span>.
Vote or wait to see the responses.
{{else}}
Poll doesn't expire. Vote to see the responses.
{{end}}
</div>
<button type="submit"
class="button is-primary is-outline">
Submit response
</button>
{{else}}
{{range $Poll.Options}}
<div class="columns mb-0">
<div class="column is-one-quarter">
{{.}}
</div>
<div class="column">
<progress class="{{$PollResult.GetClass .}}" value="{{$PollResult.GetPercent .}}" max="100">{{$PollResult.GetPercent .}}%</progress>
</div>
<div class="column is-1">
{{$PollResult.GetPercent .}}%
</div>
</div>
{{end}}
<em>
{{$PollResult.TotalVotes}} vote{{Pluralize $PollResult.TotalVotes}}.
{{if $Poll.IsExpired}}
Poll ended <span title="{{$Poll.ExpiresAt.Format "2006-01-02 15:04:05"}}">{{SincePrettyCoarse $Root.Thread.Poll.ExpiresAt}} ago</span>.
{{end}}
</em>
{{end}} {{end}}
<button type="submit"
class="button is-primary is-outline">
Submit response
</button>
</form> </form>
{{end}} {{end}}