website/pkg/models/poll.go
Noah Petherbridge 90d0d10ee5 Adopt a Forum
* Forums are disowned on user account deletion (their owner_id=0)
* A forum without an owner shows a notice at the bottom with a link to petition
  to adopt the forum. It goes to the Contact form with a special subject.
  * Note: there is no easy way to re-assign ownership yet other than a direct
    database query.
* Code cleanup
  * Alphabetize the DB.AutoMigrate tables.
  * Delete more things on user deletion: forum_memberships, admin_group_users
  * Vacuum worker to clean up orphaned polls after the threads are removed
2024-08-23 22:56:40 -07:00

173 lines
4.1 KiB
Go

package models
import (
"fmt"
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
)
// Poll table for user surveys posted in the forums.
type Poll struct {
ID uint64 `gorm:"primaryKey"`
// Poll options
Choices string // line-separated choices
MultipleChoice bool // User can vote multiple choices
CustomAnswers bool // Users can contribute a custom response
Expires bool // if it stops accepting new votes
ExpiresAt time.Time // when it stops accepting new votes
CreatedAt time.Time
UpdatedAt time.Time
}
// CreatePoll initializes a poll.
//
// expires is in days (0 = doesn't expire)
func CreatePoll(choices []string, expires int) *Poll {
return &Poll{
Choices: strings.Join(choices, "\n"),
Expires: expires > 0,
ExpiresAt: time.Now().Add(time.Duration(expires) * 24 * time.Hour),
}
}
// GetPoll by ID.
func GetPoll(id uint64) (*Poll, error) {
m := &Poll{}
result := DB.First(&m, id)
return m, result.Error
}
// Options returns a conveniently formatted listing of the options.
func (p *Poll) Options() []string {
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.
func (p *Poll) Save() error {
result := DB.Save(p)
return result.Error
}
// Delete Poll, which also deletes its PollVotes.
func (p *Poll) Delete() error {
// Delete votes first.
if result := DB.Exec(
"DELETE FROM poll_votes WHERE poll_id = ?",
p.ID,
); result.Error != nil {
return fmt.Errorf("deleting votes: %s", result.Error)
}
result := DB.Delete(p)
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]
}
// GetOrphanedPolls gets all (up to 500) polls that don't have Threads pointing to them.
func GetOrphanedPolls() ([]*Poll, int64, error) {
var (
count int64
ps = []*Poll{}
)
query := DB.Model(&Poll{}).Where(`
NOT EXISTS (
SELECT 1 FROM threads
WHERE threads.poll_id = polls.id
)
`)
query.Count(&count)
res := query.Limit(500).Find(&ps)
if res.Error != nil {
return nil, 0, res.Error
}
return ps, count, res.Error
}