Polls use issues of a single repository as Candidates. Models: - Poll - Judgment Features: - Basic CRUD of Polls - Judging issues - Keyboard navigation & nojs support Each Judge may emit one Judgment per Candidate of a Poll. For Gitea 1.18.3 fix: update Go files to Gitea 1.18.3 (not the templates) fix: update Go files to Gitea 1.18.3 (the templates) feat: improve the repository's polls indexmj-v1.18.3
parent
f6cb7860a2
commit
b3250a061d
@ -0,0 +1,342 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A Poll on Subject with the issues of a repository as candidates.
|
||||
type Poll struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX"`
|
||||
Repo *repo_model.Repository `xorm:"-"`
|
||||
AuthorID int64 `xorm:"INDEX"`
|
||||
Author *user_model.User `xorm:"-"`
|
||||
//Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository.
|
||||
// When the poll is applied to all the issues, the subject should be an issue's trait.
|
||||
// eg: Quality, Importance, Urgency, Wholeness, Relevance…
|
||||
Subject string `xorm:"name"`
|
||||
// Description may be used to describe at length the constitutive details of that poll.
|
||||
// Eg: Rationale, Deliberation Consequences, Schedule, Modus Operandi…
|
||||
// It can be written in the usual gitea-flavored markdown.
|
||||
Description string `xorm:"TEXT"`
|
||||
RenderedDescription string `xorm:"-"`
|
||||
Ref string // Do we need this? Are we even using it? WHat is it?
|
||||
|
||||
Gradation string `xorm:"-"`
|
||||
AreCandidatesIssues bool // unused
|
||||
|
||||
DeadlineUnix timeutil.TimeStamp `xorm:"INDEX"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||
ClosedUnix timeutil.TimeStamp `xorm:"INDEX"`
|
||||
|
||||
// No idea how xorm works -- help!
|
||||
//Judgments []*PollJudgment `xorm:"-"`
|
||||
//Judgments JudgmentList `xorm:"-"`
|
||||
}
|
||||
|
||||
// PollList is a list of polls offering additional functionality (perhaps)
|
||||
type PollList []*Poll
|
||||
|
||||
func (poll *Poll) GetGradationList() []string {
|
||||
list := make([]string, 0, 6)
|
||||
|
||||
// Placeholder until user customization somehow (poll.Gradation?)
|
||||
// - 🤮😒😐🙂😀🤩
|
||||
// - 😫😒😐😌😀😍 (more support, apparently)
|
||||
// - …
|
||||
list = append(list, "😫")
|
||||
list = append(list, "😒")
|
||||
list = append(list, "😐")
|
||||
list = append(list, "😌")
|
||||
list = append(list, "😀")
|
||||
list = append(list, "😍")
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
func (poll *Poll) GetGradeColorWord(grade uint8) (_ string) {
|
||||
// Another placeholder, bypassing the dragon for now
|
||||
switch grade {
|
||||
case 0:
|
||||
return "red"
|
||||
case 1:
|
||||
return "red"
|
||||
case 2:
|
||||
return "orange"
|
||||
case 3:
|
||||
return "yellow"
|
||||
case 4:
|
||||
return "olive"
|
||||
case 5:
|
||||
return "green"
|
||||
default:
|
||||
return "green"
|
||||
}
|
||||
}
|
||||
|
||||
func (poll *Poll) GetGradeColorCode(grade uint8) (_ string) {
|
||||
// Another placeholder, bypassing the dragon for now
|
||||
switch grade {
|
||||
case 0:
|
||||
return "#E0361C"
|
||||
case 1:
|
||||
return "#EE6E00"
|
||||
case 2:
|
||||
return "#FDB200"
|
||||
case 3:
|
||||
return "#C6D700"
|
||||
case 4:
|
||||
return "#7EC239"
|
||||
case 5:
|
||||
return "#02AB58"
|
||||
case 6:
|
||||
return "#007E3D"
|
||||
default:
|
||||
return "#007E3D"
|
||||
}
|
||||
}
|
||||
|
||||
func (poll *Poll) GetCandidatesIDs() (_ []int64, err error) {
|
||||
ids := make([]int64, 0, 10)
|
||||
x := db.GetEngine(db.DefaultContext)
|
||||
if err := x.Table("poll_judgment").
|
||||
Select("DISTINCT `poll_judgment`.`candidate_id`").
|
||||
Where("`poll_judgment`.`poll_id` = ?", poll.ID).
|
||||
OrderBy("`poll_judgment`.`candidate_id` ASC").
|
||||
Find(&ids); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (poll *Poll) GetJudgmentOnCandidate(judge *user_model.User, candidateID int64) (judgment *PollJudgment) {
|
||||
x := db.GetEngine(db.DefaultContext)
|
||||
judgment, err := getJudgmentOfJudgeOnPollCandidate(x, judge.ID, poll.ID, candidateID)
|
||||
if nil != err {
|
||||
return nil
|
||||
}
|
||||
|
||||
return judgment
|
||||
}
|
||||
|
||||
func (poll *Poll) GetResult() (results *PollResult) {
|
||||
// The deliberator should probably be a parameter of this function,
|
||||
// and upstream we could fetch it from context or settings.
|
||||
deliberator := &PollNaiveDeliberator{
|
||||
UseHighMean: false,
|
||||
}
|
||||
|
||||
results, err := deliberator.Deliberate(poll)
|
||||
if nil != err {
|
||||
return nil // What should we do here?
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (poll *Poll) CountGrades(candidateID int64, grade uint8) (_ uint64, err error) {
|
||||
rows := make([]int64, 0, 2)
|
||||
x := db.GetEngine(db.DefaultContext)
|
||||
if err := x.Table("poll_judgment").
|
||||
Select("COUNT(*) as amount").
|
||||
Where("`poll_judgment`.`poll_id` = ?", poll.ID).
|
||||
And("`poll_judgment`.`candidate_id` = ?", candidateID).
|
||||
And("`poll_judgment`.`grade` = ?", grade).
|
||||
// Use Get() perhaps?
|
||||
Find(&rows); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if 1 != len(rows) {
|
||||
return 0, fmt.Errorf("wrong amount of COUNT()")
|
||||
}
|
||||
|
||||
amount := uint64(rows[0])
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
// $ figlet -w 120 "Create"
|
||||
// ____ _
|
||||
// / ___|_ __ ___ __ _| |_ ___
|
||||
// | | | '__/ _ \/ _` | __/ _ \
|
||||
// | |___| | | __/ (_| | || __/
|
||||
// \____|_| \___|\__,_|\__\___|
|
||||
//
|
||||
|
||||
type CreatePollOptions struct {
|
||||
//Type PollType // for inline polls with their own candidates?
|
||||
Author *user_model.User
|
||||
Repo *repo_model.Repository
|
||||
Subject string
|
||||
Description string
|
||||
//Grades string
|
||||
}
|
||||
|
||||
func CreatePoll(opts *CreatePollOptions) (poll *Poll, err error) {
|
||||
ctx, committer, err := db.TxContext()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer committer.Close()
|
||||
x := db.GetEngine(ctx)
|
||||
|
||||
poll, err = createPoll(x, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = committer.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return poll, nil
|
||||
}
|
||||
|
||||
func createPoll(e db.Engine, opts *CreatePollOptions) (_ *Poll, err error) {
|
||||
poll := &Poll{
|
||||
AuthorID: opts.Author.ID,
|
||||
Author: opts.Author,
|
||||
RepoID: opts.Repo.ID,
|
||||
Repo: opts.Repo,
|
||||
Subject: opts.Subject,
|
||||
Description: opts.Description,
|
||||
AreCandidatesIssues: true,
|
||||
}
|
||||
if _, err = e.Insert(poll); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// FIXME: check if we're the repo owner
|
||||
//if err = opts.Repo.getOwner(e); err != nil {
|
||||
// return nil, err
|
||||
//}
|
||||
|
||||
//if err = updatePollInfos(e, opts, poll); err != nil {
|
||||
// return nil, err
|
||||
//}
|
||||
|
||||
return poll, nil
|
||||
}
|
||||
|
||||
// ____ _
|
||||
// | _ \ ___ __ _ __| |
|
||||
// | |_) / _ \/ _` |/ _` |
|
||||
// | _ < __/ (_| | (_| |
|
||||
// |_| \_\___|\__,_|\__,_|
|
||||
//
|
||||
|
||||
// GetPolls returns the (paginated) list of polls of a given repository and status.
|
||||
func GetPolls(repoID int64, page int) (PollList, error) {
|
||||
polls := make([]*Poll, 0, setting.UI.IssuePagingNum)
|
||||
x := db.GetEngine(db.DefaultContext)
|
||||
sess := x.Where("repo_id = ?", repoID)
|
||||
//sess := x.Where("repo_id = ? AND is_closed = ?", repoID, isClosed)
|
||||
if page > 0 {
|
||||
sess = sess.Limit(setting.UI.IssuePagingNum, (page-1)*setting.UI.IssuePagingNum)
|
||||
}
|
||||
|
||||
return polls, sess.Find(&polls)
|
||||
}
|
||||
|
||||
func getPollByRepoID(e db.Engine, repoID, id int64) (*Poll, error) {
|
||||
m := new(Poll)
|
||||
has, err := e.ID(id).Where("repo_id = ?", repoID).Get(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrPollNotFound{ID: id, RepoID: repoID}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// GetPollByRepoID returns the poll in a repository.
|
||||
func GetPollByRepoID(repoID, id int64) (*Poll, error) {
|
||||
x := db.GetEngine(db.DefaultContext)
|
||||
return getPollByRepoID(x, repoID, id)
|
||||
}
|
||||
|
||||
// _ _ _ _
|
||||
// | | | |_ __ __| | __ _| |_ ___
|
||||
// | | | | '_ \ / _` |/ _` | __/ _ \
|
||||
// | |_| | |_) | (_| | (_| | || __/
|
||||
// \___/| .__/ \__,_|\__,_|\__\___|
|
||||
// |_|
|
||||
|
||||
func updatePoll(e db.Engine, m *Poll) error {
|
||||
m.Subject = strings.TrimSpace(m.Subject)
|
||||
_, err := e.ID(m.ID).AllCols().
|
||||
// Do some extra work here, like updating stats?
|
||||
//SetExpr("num_closed_issues", builder.Select("count(*)").From("issue").Where(
|
||||
// builder.Eq{
|
||||
// "poll_id": m.ID,
|
||||
// "is_closed": true,
|
||||
// },
|
||||
//)).
|
||||
Update(m)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdatePoll updates information of given poll.
|
||||
func UpdatePoll(poll *Poll) error {
|
||||
ctx, committer, err := db.TxContext()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
engine := db.GetEngine(ctx)
|
||||
|
||||
if err = updatePoll(engine, poll); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = committer.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ____ _ _
|
||||
// | _ \ ___| | ___| |_ ___
|
||||
// | | | |/ _ \ |/ _ \ __/ _ \
|
||||
// | |_| | __/ | __/ || __/
|
||||
// |____/ \___|_|\___|\__\___|
|
||||
//
|
||||
|
||||
// DeletePollByRepoID deletes a poll from a repository.
|
||||
func DeletePollByRepoID(repoID, id int64) error {
|
||||
m, err := GetPollByRepoID(repoID, id)
|
||||
if err != nil {
|
||||
if IsErrPollNotFound(err) {
|
||||
return nil // not very confident in this ; yelling is best?
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, committer, err := db.TxContext()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
engine := db.GetEngine(ctx)
|
||||
|
||||
if _, err = engine.ID(m.ID).Delete(new(Poll)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = committer.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"math"
|
||||
)
|
||||
|
||||
type PollCandidateMeritProfile struct {
|
||||
Poll *Poll
|
||||
CandidateID int64 // Issue Index (or internal candidate index, later on)
|
||||
Position uint64 // Two Candidates may share the same Position (perfect equality)
|
||||
Grades []uint64 // Amount of Judgments per Grade
|
||||
JudgmentsAmount uint64
|
||||
//HalfJudgmentsAmount uint64 // Hack for the template, since we can't do arithmetic
|
||||
CreatedUnix timeutil.TimeStamp
|
||||
}
|
||||
|
||||
// ____
|
||||
// / ___|_ ____ _
|
||||
// \___ \ \ / / _` |
|
||||
// ___) \ V / (_| |
|
||||
// |____/ \_/ \__, |
|
||||
// |___/
|
||||
//
|
||||
|
||||
type CartesianVector2 struct {
|
||||
X float64
|
||||
Y float64
|
||||
}
|
||||
|
||||
type TwoCirclePoints struct {
|
||||
Start *CartesianVector2
|
||||
End *CartesianVector2
|
||||
}
|
||||
|
||||
func (merit *PollCandidateMeritProfile) GetGradeAngle(gradeID int, gapHalfAngle float64) (_ float64) {
|
||||
TAU := 6.283185307179586 // Sigh ; how is this not in Golang yet?
|
||||
totalAngle := TAU - gapHalfAngle*2.0
|
||||
return totalAngle * float64(merit.Grades[gradeID]) / float64(merit.JudgmentsAmount)
|
||||
}
|
||||
|
||||
func (merit *PollCandidateMeritProfile) GetCirclePoints(gradeID int, radius float64, halfGap float64) (_ *TwoCirclePoints) {
|
||||
TAU := 6.283185307179586 // Sigh ; how is this not in Golang yet?
|
||||
gapHalfAngle := halfGap
|
||||
//gapHalfAngle = 0.39192267544687825 * 0.96 // asin(GOLDEN_RATIO-1)
|
||||
//gapHalfAngle = TAU / 4.0 // Hemicycle
|
||||
//gapHalfAngle = 0.0 // Camembert (du fromage!)
|
||||
totalAngle := TAU - gapHalfAngle*2.0
|
||||
lowerGradeJudgmentsAmount := uint64(0)
|
||||
for i := 0; i < gradeID; i++ {
|
||||
lowerGradeJudgmentsAmount += merit.Grades[i]
|
||||
}
|
||||
totalJudgments := float64(merit.JudgmentsAmount)
|
||||
startingAngle := gapHalfAngle + totalAngle*float64(lowerGradeJudgmentsAmount)/totalJudgments
|
||||
angle := totalAngle * float64(merit.Grades[gradeID]) / totalJudgments
|
||||
endingAngle := startingAngle + angle
|
||||
|
||||
//println("Angles", gradeID, merit.Grades[gradeID], totalJudgments, startingAngle, endingAngle)
|
||||
return &TwoCirclePoints{
|
||||
Start: &CartesianVector2{
|
||||
X: radius * math.Cos(startingAngle),
|
||||
Y: radius * math.Sin(startingAngle),
|
||||
},
|
||||
End: &CartesianVector2{
|
||||
X: radius * math.Cos(endingAngle),
|
||||
Y: radius * math.Sin(endingAngle),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (merit *PollCandidateMeritProfile) GetColorWord(gradeID int) (_ string) {
|
||||
return merit.Poll.GetGradeColorWord(uint8(gradeID))
|
||||
}
|
||||
|
||||
func (merit *PollCandidateMeritProfile) GetColorCode(gradeID int) (_ string) {
|
||||
return merit.Poll.GetGradeColorCode(uint8(gradeID))
|
||||
}
|
@ -0,0 +1,167 @@
|
||||
// Copyright 2020 The Macronavirus. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type PollDeliberator interface {
|
||||
Deliberate(poll *Poll) (result *PollResult, err error)
|
||||
}
|
||||
|
||||
// _ _ _
|
||||
// | \ | | __ _(_)_ _____
|
||||
// | \| |/ _` | \ \ / / _ \
|
||||
// | |\ | (_| | |\ V / __/
|
||||
// |_| \_|\__,_|_| \_/ \___|
|
||||
//
|
||||
|
||||
type PollNaiveDeliberator struct {
|
||||
UseHighMean bool // should default to false ; strategy for even number of judgments
|
||||
}
|
||||
|
||||
func (deli *PollNaiveDeliberator) Deliberate(poll *Poll) (_ *PollResult, err error) {
|
||||
|
||||
naiveTallier := &PollNaiveTallier{}
|
||||
pollTally, err := naiveTallier.Tally(poll)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Consider missing judgments as TO_REJECT judgments
|
||||
// One reason why we need many algorithms, and options on each
|
||||
for _, candidateTally := range pollTally.Candidates {
|
||||
missing := pollTally.MaxJudgmentsAmount - candidateTally.JudgmentsAmount
|
||||
if 0 < missing {
|
||||
candidateTally.JudgmentsAmount = pollTally.MaxJudgmentsAmount
|
||||
candidateTally.Grades[0].Amount += missing
|
||||
//println("Added the missing TO REJECT judgments", missing)
|
||||
}
|
||||
}
|
||||
|
||||
amountOfGrades := len(poll.GetGradationList())
|
||||
candidates := make(PollCandidateResults, 0, 64) // /!. 5k issues repos exist
|
||||
creationTime := timeutil.TimeStampNow()
|
||||
|
||||
for _, candidateTally := range pollTally.Candidates {
|
||||
|
||||
medianGrade := candidateTally.GetMedian()
|
||||
candidateScore := deli.GetScore(candidateTally)
|
||||
|
||||
gradesProfile := make([]uint64, 0, amountOfGrades)
|
||||
for i := 0; i < amountOfGrades; i++ {
|
||||
gradesProfile = append(gradesProfile, candidateTally.Grades[i].Amount)
|
||||
//gradesProfile = append(gradesProfile, uint64(i+1))
|
||||
}
|
||||
|
||||
meritProfile := &PollCandidateMeritProfile{
|
||||
Poll: poll,
|
||||
CandidateID: candidateTally.CandidateID,
|
||||
Position: 0, // see below
|
||||
Grades: gradesProfile,
|
||||
JudgmentsAmount: candidateTally.JudgmentsAmount,
|
||||
//JudgmentsAmount: (6+1)*3,
|
||||
CreatedUnix: creationTime,
|
||||
}
|
||||
|
||||
candidates = append(candidates, &PollCandidateResult{
|
||||
Poll: poll,
|
||||
CandidateID: candidateTally.CandidateID,
|
||||
Position: 0, // We set it below after the Sort on Score
|
||||
MedianGrade: medianGrade,
|
||||
Score: candidateScore,
|
||||
Tally: candidateTally,
|
||||
MeritProfile: meritProfile,
|
||||
CreatedUnix: creationTime,
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
sort.Sort(sort.Reverse(candidates))
|
||||
|
||||
// Rule: Multiple Candidates may have the same Position in case of perfect equality.
|
||||
// or (for Randomized Condorcet evangelists)
|
||||
// Rule: Multiple Candidates at perfect equality are shuffled.
|
||||
previousScore := ""
|
||||
for key, candidate := range candidates {
|
||||
position := uint64(key + 1)
|
||||
if (previousScore == candidate.Score) && (key > 0) {
|
||||
position = candidates[key-1].Position
|
||||
}
|
||||
candidate.Position = position
|
||||
candidate.MeritProfile.Position = position
|
||||
previousScore = candidate.Score
|
||||
}
|
||||
|
||||
result := &PollResult{
|
||||
Poll: poll,
|
||||
Tally: pollTally,
|
||||
Candidates: candidates,
|
||||
CreatedUnix: timeutil.TimeStampNow(),
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ____ _
|
||||
// / ___| ___ ___ _ __(_)_ __ __ _
|
||||
// \___ \ / __/ _ \| '__| | '_ \ / _` |
|
||||
// ___) | (_| (_) | | | | | | | (_| |
|
||||
// |____/ \___\___/|_| |_|_| |_|\__, |
|
||||
// |___/
|
||||
// String scoring is not the fastest but it was within reach of a Go newbie.
|
||||
|
||||
/*
|
||||
This method follows the following algorithm:
|
||||
|
||||
// Assume that each candidate has the same amount of judgments = MAX_JUDGES.
|
||||
// (best fill with 0=REJECT to allow posterior candidate addition, cf. <paper>)
|
||||
|
||||
for each Candidate
|
||||
tally = CandidateTally(Candidate) // sums of judgments, per grade, basically
|
||||
score = "" // score is a string but could be raw bits
|
||||
// When we append integers to score below,
|
||||
// consider that we concatenate the string representation including leading zeroes
|
||||
// up to the amount of digits we need to store 2 * MAX_JUDGES,
|
||||
// or the raw bits (unsigned and led as well) in case of a byte array.
|
||||
|
||||
for i in range(MAX_GRADES)
|
||||
grade = tally.median()
|
||||
score.append(grade) // three digits will suffice for int8
|
||||
// Collect biggest of the two groups of grades outside of the median.
|
||||
// Group Grade is the group's grade adjacent to the median group
|
||||
// Group Sign is:
|
||||
// - +1 if the group promotes higher grades (adhesion)
|
||||
// - -1 if the group promotes lower grades (contestation)
|
||||
// - ±0 if there is no spoon
|
||||
group_size, group_sign, group_grade = tally.get_biggest_group()
|
||||
// MAX_JUDGES offset to deal with negative values lexicographically
|
||||
score.append(MAX_JUDGES + groups_sign * group_size)
|
||||
// Move the median grades into the group grades
|
||||
tally.regrade_judgments(grade, groups_grade)
|
||||
|
||||
// Use it later in a bubble sort or whatever
|
||||
Candidate.score = score
|
||||
|
||||
*/
|
||||
func (deli *PollNaiveDeliberator) GetScore(pct *PollCandidateTally) (_ string) {
|
||||
score := ""
|
||||
|
||||
ct := pct.Copy() // /!. Poll is not a copy (nor does it have to be)
|
||||
|
||||
for _, _ = range pct.Poll.GetGradationList() {
|
||||
medianGrade := ct.GetMedian()
|
||||
score += fmt.Sprintf("%03d", medianGrade)
|
||||
groupSize, groupSign, groupGrade := ct.GetBiggestGroup(medianGrade)
|
||||
score += fmt.Sprintf("%012d",
|
||||
int(ct.JudgmentsAmount)+groupSize*groupSign)
|
||||
ct.RegradeJudgments(medianGrade, groupGrade)
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
@ -0,0 +1,159 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
//"sort"
|
||||
"testing"
|
||||
//"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNaiveDeliberator(t *testing.T) {
|
||||
assert.NoError(t, PrepareTestDatabase())
|
||||
|
||||
repo := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)
|
||||
userAli := AssertExistsAndLoadBean(t, &User{ID: 1}).(*User)
|
||||
userBob := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
|
||||
userCho := AssertExistsAndLoadBean(t, &User{ID: 3}).(*User)
|
||||
|
||||
poll, err := CreatePoll(&CreatePollOptions{
|
||||
Repo: repo,
|
||||
Author: userAli,
|
||||
Subject: "Demand",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, poll)
|
||||
|
||||
pnd := &PollNaiveDeliberator{}
|
||||
|
||||
// No judgments yet
|
||||
|
||||
result, err := pnd.Deliberate(poll)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.NotNil(t, result.Poll)
|
||||
assert.Equal(t, poll, result.Poll)
|
||||
assert.NotNil(t, result.Tally)
|
||||
assert.NotNil(t, result.Candidates)
|
||||
assert.Len(t, result.Candidates, 0)
|
||||
|
||||
// Add a Judgment from Ali
|
||||
// Candidate 1 : SOMEWHAT_GOOD
|
||||
// Candidate 2 :
|
||||
|
||||
judgment, err := CreateJudgment(&CreateJudgmentOptions{
|
||||
Judge: userAli,
|
||||
Poll: poll,
|
||||
Grade: 3,
|
||||
CandidateID: 1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, judgment)
|
||||
|
||||
result, err = pnd.Deliberate(poll)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.NotNil(t, result.Tally)
|
||||
assert.NotNil(t, result.Candidates)
|
||||
assert.Len(t, result.Candidates, 1)
|
||||
|
||||
// Add a judgment from Bob
|
||||
// Candidate 1 : PASSABLE SOMEWHAT_GOOD
|
||||
// Candidate 2 :
|
||||
|
||||
judgment, err = CreateJudgment(&CreateJudgmentOptions{
|
||||
Judge: userBob,
|
||||
Poll: poll,
|
||||
Grade: 2,
|
||||
CandidateID: 1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, judgment)
|
||||
|
||||
result, err = pnd.Deliberate(poll)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.NotNil(t, result.Tally)
|
||||
assert.NotNil(t, result.Candidates)
|
||||
assert.Len(t, result.Candidates, 1)
|
||||
assert.Equal(t, uint64(1), result.Candidates[0].Position)
|
||||
|
||||
// Add another judgment from Bob
|
||||
// Candidate 1 : PASSABLE SOMEWHAT_GOOD
|
||||
// Candidate 2 : GOOD
|
||||
|
||||
judgment, err = CreateJudgment(&CreateJudgmentOptions{
|
||||
Judge: userBob,
|
||||
Poll: poll,
|
||||
Grade: 4,
|
||||
CandidateID: 2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, judgment)
|
||||
|
||||
result, err = pnd.Deliberate(poll)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.NotNil(t, result.Candidates)
|
||||
assert.Len(t, result.Candidates, 2)
|
||||
assert.Equal(t, uint64(1), result.Candidates[0].Position)
|
||||
assert.Equal(t, uint64(2), result.Candidates[1].Position)
|
||||
assert.Equal(t, int64(1), result.Candidates[0].CandidateID)
|
||||
assert.Equal(t, int64(2), result.Candidates[1].CandidateID)
|
||||
assert.Equal(t, uint8(2), result.Candidates[0].MedianGrade)
|
||||
assert.Equal(t, uint8(0), result.Candidates[1].MedianGrade)
|
||||
|
||||
// Add another 2 judgments from Cho and one from Ali
|
||||
// Candidate 1 : PASSABLE SOMEWHAT_GOOD GOOD
|
||||
// Candidate 2 : SOMEWHAT_GOOD SOMEWHAT_GOOD GOOD
|
||||
|
||||
judgment, err = CreateJudgment(&CreateJudgmentOptions{
|
||||
Judge: userCho,
|
||||
Poll: poll,
|
||||
Grade: 4,
|
||||
CandidateID: 1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, judgment)
|
||||
|
||||
judgment, err = CreateJudgment(&CreateJudgmentOptions{
|
||||
Judge: userCho,
|
||||
Poll: poll,
|
||||
Grade: 3,
|
||||
CandidateID: 2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, judgment)
|
||||
|
||||
judgment, err = CreateJudgment(&CreateJudgmentOptions{
|
||||
Judge: userAli,
|
||||
Poll: poll,
|
||||
Grade: 3,
|
||||
CandidateID: 2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, judgment)
|
||||
|
||||
result, err = pnd.Deliberate(poll)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.NotNil(t, result.Candidates)
|
||||
assert.Len(t, result.Candidates, 2)
|
||||
assert.Equal(t, uint64(1), result.Candidates[0].Position)
|
||||
assert.Equal(t, uint64(2), result.Candidates[1].Position)
|
||||
assert.Equal(t, int64(2), result.Candidates[0].CandidateID)
|
||||
assert.Equal(t, int64(1), result.Candidates[1].CandidateID)
|
||||
//println("C1", result.Candidates[1].MedianGrade)
|
||||
assert.Equal(t, uint8(3), result.Candidates[0].MedianGrade)
|
||||
assert.Equal(t, uint8(3), result.Candidates[1].MedianGrade)
|
||||
|
||||
assert.Equal(t, uint64(1), result.Candidates[0].Tally.Grades[4].Amount)
|
||||
assert.Equal(t, uint64(2), result.Candidates[0].Tally.Grades[3].Amount)
|
||||
assert.Equal(t, uint64(1), result.Candidates[1].Tally.Grades[2].Amount)
|
||||
assert.Equal(t, uint64(1), result.Candidates[1].Tally.Grades[3].Amount)
|
||||
assert.Equal(t, uint64(1), result.Candidates[1].Tally.Grades[4].Amount)
|
||||
}
|
@ -0,0 +1,188 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
//"code.gitea.io/gitea/modules/references"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// PollJudgment represents a single Judgment on a single Candidate of a Poll
|
||||
type PollJudgment struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
PollID int64 `xorm:"INDEX UNIQUE(poll_judge_candidate)"`
|
||||
Poll *Poll `xorm:"-"`
|
||||
JudgeID int64 `xorm:"INDEX UNIQUE(poll_judge_candidate)"`
|
||||
Judge *user_model.User `xorm:"-"`
|
||||
// Either an Issue ID or an index in the list of Candidates (for inline polls)
|
||||
CandidateID int64 `xorm:"UNIQUE(poll_judge_candidate)"`
|
||||
// There may be other graduations
|
||||
// 0 = to reject
|
||||
// 1 = poor
|
||||
// 2 = passable
|
||||
// 3 = good
|
||||
// 4 = very good
|
||||
// 5 = excellent
|
||||
// Make sure 0 always means *something* in your graduation
|
||||
// Various graduations are provided <???>.
|
||||
Grade uint8
|
||||
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||
}
|
||||
|
||||
type CreateJudgmentOptions struct {
|
||||
Poll *Poll
|
||||
Judge *user_model.User
|
||||
Grade uint8
|
||||
CandidateID int64
|
||||
}
|
||||
|
||||
type UpdateJudgmentOptions struct {
|
||||
Poll *Poll
|
||||
Judge *user_model.User
|
||||
Grade uint8
|
||||
CandidateID int64
|
||||
}
|
||||
|
||||
type DeleteJudgmentOptions struct {
|
||||
Poll *Poll
|
||||
Judge *user_model.User
|
||||
CandidateID int64
|
||||
}
|
||||
|
||||
func CreateJudgment(opts *CreateJudgmentOptions) (judgment *PollJudgment, err error) {
|
||||
ctx, committer, err := db.TxContext()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer committer.Close()
|
||||
x := db.GetEngine(ctx)
|
||||
|
||||
judgment, err = createJudgment(x, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = committer.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return judgment, nil
|
||||
}
|
||||
|
||||
func createJudgment(e db.Engine, opts *CreateJudgmentOptions) (_ *PollJudgment, err error) {
|
||||
judgment := &PollJudgment{
|
||||
PollID: opts.Poll.ID,
|
||||
Poll: opts.Poll,
|
||||
JudgeID: opts.Judge.ID,
|
||||
Judge: opts.Judge,
|
||||
CandidateID: opts.CandidateID,
|
||||
Grade: opts.Grade,
|
||||
}
|
||||
if _, err = e.Insert(judgment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//if err = updatePollInfos(e, opts, poll); err != nil {
|
||||
// return nil, err
|
||||
//}
|
||||
|
||||
return judgment, nil
|
||||
}
|
||||
|
||||
func getJudgmentByID(e db.Engine, id int64) (*PollJudgment, error) {
|
||||
repo := new(PollJudgment)
|
||||
has, err := e.ID(id).Get(repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrJudgmentNotFound{}
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
func getJudgmentOfJudgeOnPollCandidate(e db.Engine, judgeID int64, pollID int64, candidateID int64) (judgment *PollJudgment, err error) {
|
||||
// We could probably use only one SQL query instead of two here.
|
||||
// No idea how this ORM works, and sprinting past it with snippet copy-pasting.
|
||||
judgmentsIds := make([]int64, 0, 1)
|
||||
if err = e.Table("poll_judgment").
|
||||
Cols("id").
|
||||
Where("`poll_judgment`.`judge_id` = ?", judgeID).
|
||||
And("`poll_judgment`.`poll_id` = ?", pollID).
|
||||
And("`poll_judgment`.`candidate_id` = ?", candidateID).
|
||||
Limit(1). // perhaps .Get() is what we need here?
|
||||
Find(&judgmentsIds); err != nil {
|
||||
return nil, fmt.Errorf("find judgment: %v", err)
|
||||
}
|
||||
|
||||
if 0 == len(judgmentsIds) {
|
||||
return nil, ErrJudgmentNotFound{}
|
||||
}
|
||||
|
||||
judgment, errj := getJudgmentByID(e, judgmentsIds[0])
|
||||
if errj != nil {
|
||||
return nil, errj
|
||||
}
|
||||
|
||||
return judgment, nil
|
||||
}
|
||||
|
||||
func UpdateJudgment(opts *UpdateJudgmentOptions) (judgment *PollJudgment, err error) {
|
||||
ctx, committer, err := db.TxContext()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer committer.Close()
|
||||
x := db.GetEngine(ctx)
|
||||
|
||||
judgment, errJ := getJudgmentOfJudgeOnPollCandidate(x, opts.Judge.ID, opts.Poll.ID, opts.CandidateID)
|
||||
if nil != errJ {
|
||||
return nil, errJ
|
||||
}
|
||||
|
||||
judgment.Grade = opts.Grade
|
||||
|
||||
_, err = x.ID(judgment.ID).
|
||||
Cols("grade", "updated_unix").
|
||||
Update(judgment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = committer.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return judgment, nil
|
||||
}
|
||||
|
||||
func DeleteJudgment(opts *DeleteJudgmentOptions) (err error) {
|
||||
ctx, committer, err := db.TxContext()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
x := db.GetEngine(ctx)
|
||||
|
||||
judgment, err := getJudgmentOfJudgeOnPollCandidate(x, opts.Judge.ID, opts.Poll.ID, opts.CandidateID)
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = x.Delete(judgment); nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = committer.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
//"sort"
|
||||
"testing"
|
||||
//"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPollJudgment_Create(t *testing.T) {
|
||||
assert.NoError(t, PrepareTestDatabase())
|
||||
repo := AssertExistsAndLoadBean(t, &Repository{ID: 2}).(*Repository)
|
||||
user := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
|
||||
//issue := AssertExistsAndLoadBean(t, &Issue{ID: 2}).(*Issue)
|
||||
//assert.Equal(t, repo, issue.Repo)
|
||||
|
||||
poll, errp := CreatePoll(&CreatePollOptions{
|
||||
Repo: repo,
|
||||
Author: user,
|
||||
Subject: "Quality",
|
||||
})
|
||||
|
||||
assert.NoError(t, errp)
|
||||
assert.NotNil(t, poll)
|
||||
|
||||
judgment, err := CreateJudgment(&CreateJudgmentOptions{
|
||||
Judge: user,
|
||||
Poll: poll,
|
||||
Grade: 3,
|
||||
CandidateID: 1,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, judgment)
|
||||
|
||||
// Emit another Judgment, on another Candidate
|
||||
|
||||
judgment, err = CreateJudgment(&CreateJudgmentOptions{
|
||||
Judge: user,
|
||||
Poll: poll,
|
||||
Grade: 1,
|
||||
CandidateID: 2,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, judgment)
|
||||
|
||||
// Cannot create another judgment on the same poll and candidate
|
||||
|
||||
judgment, err = CreateJudgment(&CreateJudgmentOptions{
|
||||
Judge: user,
|
||||
Poll: poll,
|
||||
Grade: 0,
|
||||
CandidateID: 2,
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, judgment)
|
||||
|
||||
// … you have to update it
|
||||
|
||||
judgment, err = UpdateJudgment(&UpdateJudgmentOptions{
|
||||
Judge: user,
|
||||
Poll: poll,
|
||||
Grade: 0,
|
||||
CandidateID: 2,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, judgment)
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ____ _ _ _ _ ____ _ _
|
||||
// / ___|__ _ _ __ __| (_) __| | __ _| |_ ___| _ \ ___ ___ _ _| | |_
|
||||
// | | / _` | '_ \ / _` | |/ _` |/ _` | __/ _ \ |_) / _ \/ __| | | | | __|
|
||||
// | |__| (_| | | | | (_| | | (_| | (_| | || __/ _ < __/\__ \ |_| | | |_
|
||||
// \____\__,_|_| |_|\__,_|_|\__,_|\__,_|\__\___|_| \_\___||___/\__,_|_|\__|
|
||||
//
|
||||
|
||||
// PollCandidateResult holds the Position (aka Rank) of a Candidate in the Leaderboard,
|
||||
// as well as analysis data.
|
||||
type PollCandidateResult struct {
|
||||
Poll *Poll
|
||||
CandidateID int64 // Issue Index (or internal candidate index, later on)
|
||||
Position uint64 // Two Candidates may share the same Position (perfect equality)
|
||||
MedianGrade uint8
|
||||
Score string
|
||||
Tally *PollCandidateTally
|
||||
MeritProfile *PollCandidateMeritProfile
|
||||
CreatedUnix timeutil.TimeStamp
|
||||
}
|
||||
|
||||
func (result *PollCandidateResult) GetColorWord() (_ string) {
|
||||
return result.Poll.GetGradeColorWord(result.MedianGrade)
|
||||
}
|
||||
|
||||
func (result *PollCandidateResult) GetCandidateName() (_ string) {
|
||||
issue, err := issues_model.GetIssueByID(db.DefaultContext, result.CandidateID)
|
||||
if nil != err {
|
||||
return "Candidate #" + strconv.FormatInt(result.CandidateID, 10)
|
||||
}
|
||||
return issue.Title
|
||||
}
|
||||
|
||||
// ____ _ _ _ _ ____ _ _
|
||||
// / ___|__ _ _ __ __| (_) __| | __ _| |_ ___| _ \ ___ ___ _ _| | |_ ___
|
||||
// | | / _` | '_ \ / _` | |/ _` |/ _` | __/ _ \ |_) / _ \/ __| | | | | __/ __|
|
||||
// | |__| (_| | | | | (_| | | (_| | (_| | || __/ _ < __/\__ \ |_| | | |_\__ \
|
||||
// \____\__,_|_| |_|\__,_|_|\__,_|\__,_|\__\___|_| \_\___||___/\__,_|_|\__|___/
|
||||
//
|
||||
|
||||
// PollCandidateResults implements the sort.Interface based on the Score field.
|
||||
type PollCandidateResults []*PollCandidateResult
|
||||
|
||||
func (a PollCandidateResults) Len() int { return len(a) }
|
||||
func (a PollCandidateResults) Less(i, j int) bool { return a[i].Score < a[j].Score }
|
||||
func (a PollCandidateResults) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||
|
||||
// ____ _ _
|
||||
// | _ \ ___ ___ _ _| | |_
|
||||
// | |_) / _ \/ __| | | | | __|
|
||||
// | _ < __/\__ \ |_| | | |_
|
||||
// |_| \_\___||___/\__,_|_|\__|
|
||||
//
|
||||
|
||||
type PollResult struct {
|
||||
Poll *Poll
|
||||
Tally *PollTally
|
||||
Candidates PollCandidateResults
|
||||
CreatedUnix timeutil.TimeStamp
|
||||
}
|
||||
|
||||
func (result *PollResult) GetCandidate(candidateID int64) (_ *PollCandidateResult) {
|
||||
// A `for` loop is pretty inefficient, index this somehow?
|
||||
for _, candidate := range result.Candidates {
|
||||
if candidate.CandidateID == candidateID {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
@ -0,0 +1,219 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
)
|
||||
|
||||
// ____ _ _____ _ _
|
||||
// / ___|_ __ __ _ __| | ___ |_ _|_ _| | |_ _
|
||||
// | | _| '__/ _` |/ _` |/ _ \ | |/ _` | | | | | |
|
||||
// | |_| | | | (_| | (_| | __/ | | (_| | | | |_| |
|
||||
// \____|_| \__,_|\__,_|\___| |_|\__,_|_|_|\__, |
|
||||
// |___/
|
||||
|
||||
type PollCandidateGradeTally struct {
|
||||
Grade uint8
|
||||
Amount uint64
|
||||
CreatedUnix timeutil.TimeStamp
|
||||
}
|
||||
|
||||
// ____ _ _ _ _ _____ _ _
|
||||
// / ___|__ _ _ __ __| (_) __| | __ _| |_ ___ |_ _|_ _| | |_ _
|
||||
// | | / _` | '_ \ / _` | |/ _` |/ _` | __/ _ \ | |/ _` | | | | | |
|
||||
// | |__| (_| | | | | (_| | | (_| | (_| | || __/ | | (_| | | | |_| |
|
||||
// \____\__,_|_| |_|\__,_|_|\__,_|\__,_|\__\___| |_|\__,_|_|_|\__, |
|
||||
// |___/
|
||||
|
||||
type PollCandidateTally struct {
|
||||
Poll *Poll
|
||||
CandidateID int64 // Issue Index (or internal candidate index, later on)
|
||||
Grades []*PollCandidateGradeTally // Sorted by grade (0 == REJECT)
|
||||
JudgmentsAmount uint64
|
||||
CreatedUnix timeutil.TimeStamp
|
||||
}
|
||||
|
||||
func (pct *PollCandidateTally) Copy() (_ *PollCandidateTally) {
|
||||
|
||||
grades := make([]*PollCandidateGradeTally, 0, 8)
|
||||
for _, grade := range pct.Grades {
|
||||
grades = append(grades, &PollCandidateGradeTally{
|
||||
Grade: grade.Grade,
|
||||
Amount: grade.Amount,
|
||||
CreatedUnix: grade.CreatedUnix,
|
||||
})
|
||||
}
|
||||
|
||||
return &PollCandidateTally{
|
||||
Poll: pct.Poll,
|
||||
CandidateID: pct.CandidateID,
|
||||
Grades: grades,
|
||||
JudgmentsAmount: pct.JudgmentsAmount,
|
||||
CreatedUnix: pct.CreatedUnix,
|
||||
}
|
||||
}
|
||||
|
||||
func (pct *PollCandidateTally) GetMedian() (_ uint8) {
|
||||
|
||||
if 0 == pct.JudgmentsAmount {
|
||||
return uint8(0)
|
||||
//return 0 // to test
|
||||
}
|
||||
|
||||
adjustedTotal := pct.JudgmentsAmount - 1
|
||||
//if opts.UseHighMedian {
|
||||
// adjustedTotal := pct.JudgmentsAmount + 1
|
||||
//}
|
||||
medianIndex := adjustedTotal / 2 // Euclidean div
|
||||
cursorIndex := uint64(0)
|
||||
for _, grade := range pct.Grades {
|
||||
if 0 == grade.Amount {
|
||||
continue
|
||||
}
|
||||
|
||||
startIndex := cursorIndex
|
||||
cursorIndex += grade.Amount
|
||||
endIndex := cursorIndex
|
||||
if (startIndex <= medianIndex) && (medianIndex < endIndex) {
|
||||
return grade.Grade
|
||||
}
|
||||
}
|
||||
println("warning: GetMedian defaulting to 0")
|
||||
return uint8(0)
|
||||
}
|
||||
|
||||
func (pct *PollCandidateTally) GetBiggestGroup(aroundGrade uint8) (groupSize int, groupSign int, groupGrade uint8) {
|
||||
belowGroupSize := 0
|
||||
belowGroupSign := -1
|
||||
belowGroupGrade := uint8(0)
|
||||
|
||||
aboveGroupSize := 0
|
||||
aboveGroupSign := 1
|
||||
aboveGroupGrade := uint8(0)
|
||||
|
||||
for k, _ := range pct.Poll.GetGradationList() {
|
||||
grade := uint8(k)
|
||||
gradeAmount := int(pct.Grades[grade].Amount)
|
||||
|
||||
if 0 == gradeAmount {
|
||||
continue
|
||||
}
|
||||
if grade < aroundGrade {
|
||||
belowGroupSize += gradeAmount
|
||||
belowGroupGrade = grade
|
||||
}
|
||||
if grade > aroundGrade {
|
||||
aboveGroupSize += gradeAmount
|
||||
if 0 == aboveGroupGrade {
|
||||
aboveGroupGrade = grade
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// /!. Assumption of LOW median with `>` /!.
|
||||
if aboveGroupSize > belowGroupSize {
|
||||
return aboveGroupSize, aboveGroupSign, aboveGroupGrade
|
||||
}
|
||||
return belowGroupSize, belowGroupSign, belowGroupGrade
|
||||
}
|
||||
|
||||
func (pct *PollCandidateTally) RegradeJudgments(fromGrade uint8, toGrade uint8) {
|
||||
if toGrade == fromGrade {
|
||||
return
|
||||
}
|
||||
pct.Grades[toGrade].Amount += pct.Grades[fromGrade].Amount
|
||||
pct.Grades[fromGrade].Amount = 0
|
||||
}
|
||||
|
||||
// _____ _ _
|
||||
// |_ _|_ _| | |_ _
|
||||
// | |/ _` | | | | | |
|
||||
// | | (_| | | | |_| |
|
||||
// |_|\__,_|_|_|\__, |
|
||||
// |___/
|
||||
|
||||
type PollTally struct {
|
||||
Poll *Poll
|
||||
MaxJudgmentsAmount uint64 // per candidate, will help including default grade 0=TO_REJECT
|
||||
Candidates []*PollCandidateTally
|
||||
CreatedUnix timeutil.TimeStamp
|
||||
}
|
||||
|
||||
// _____ _ _ _
|
||||
// |_ _|_ _| | (_) ___ _ __
|
||||
// | |/ _` | | | |/ _ \ '__|
|
||||
// | | (_| | | | | __/ |
|
||||
// |_|\__,_|_|_|_|\___|_|
|
||||
//
|
||||
|
||||
type PollTallier interface {
|
||||
Tally(poll *Poll) (tally *PollTally, err error)
|
||||
}
|
||||
|
||||
// _ _ _
|
||||
// | \ | | __ _(_)_ _____
|
||||
// | \| |/ _` | \ \ / / _ \
|
||||
// | |\ | (_| | |\ V / __/
|
||||
// |_| \_|\__,_|_| \_/ \___|
|
||||
//
|
||||
|
||||
type PollNaiveTallier struct{}
|
||||
|
||||
func (tallier *PollNaiveTallier) Tally(poll *Poll) (_ *PollTally, err error) {
|
||||
|
||||
gradation := poll.GetGradationList()
|
||||
|
||||
candidatesIDs, errG := poll.GetCandidatesIDs()
|
||||
if nil != errG {
|
||||
return nil, errG
|
||||
}
|
||||
|
||||
candidates := make([]*PollCandidateTally, 0, 64)
|
||||
maximumAmount := uint64(0)
|
||||
|
||||
for _, candidateID := range candidatesIDs {
|
||||
|
||||
grades := make([]*PollCandidateGradeTally, 0, 8)
|
||||
judgmentsAmount := uint64(0)
|
||||
|
||||
for gradeInt, _ := range gradation {
|
||||
grade := uint8(gradeInt)
|
||||
amount, errC := poll.CountGrades(candidateID, grade)
|
||||
if nil != errC {
|
||||
return nil, errC
|
||||
}
|
||||
|
||||
judgmentsAmount += amount
|
||||
grades = append(grades, &PollCandidateGradeTally{
|
||||
Grade: grade,
|
||||
Amount: amount,
|
||||
CreatedUnix: timeutil.TimeStampNow(),
|
||||
})
|
||||
}
|
||||
|
||||
//maximumAmount = util.Max(judgmentsAmount, maximumAmount)
|
||||
if maximumAmount < judgmentsAmount {
|
||||
maximumAmount = judgmentsAmount
|
||||
}
|
||||
|
||||
candidates = append(candidates, &PollCandidateTally{
|
||||
Poll: poll,
|
||||
CandidateID: candidateID,
|
||||
Grades: grades,
|
||||
JudgmentsAmount: judgmentsAmount,
|
||||
CreatedUnix: timeutil.TimeStampNow(),
|
||||
})
|
||||
}
|
||||
|
||||
tally := &PollTally{
|
||||
Poll: poll,
|
||||
MaxJudgmentsAmount: maximumAmount,
|
||||
Candidates: candidates,
|
||||
CreatedUnix: timeutil.TimeStampNow(),
|
||||
}
|
||||
|
||||
return tally, nil
|
||||
}
|
@ -0,0 +1,139 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
//"sort"
|
||||
"testing"
|
||||
//"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTally(t *testing.T) {
|
||||
assert.NoError(t, PrepareTestDatabase())
|
||||
|
||||
repo := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)
|
||||
userAli := AssertExistsAndLoadBean(t, &User{ID: 1}).(*User)
|
||||
userBob := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
|
||||
|
||||
poll, err := CreatePoll(&CreatePollOptions{
|
||||
Repo: repo,
|
||||
Author: userAli,
|
||||
Subject: "Demand",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, poll)
|
||||
|
||||
pnt := &PollNaiveTallier{}
|
||||
|
||||
// No judgments yet
|
||||
|
||||
tally, err := pnt.Tally(poll)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tally)
|
||||
|
||||
assert.Len(t, tally.Candidates, 0)
|
||||
assert.Equal(t, uint64(0), tally.MaxJudgmentsAmount)
|
||||
|
||||
// Add a Judgment from Ali
|
||||
|
||||
judgment, err := CreateJudgment(&CreateJudgmentOptions{
|
||||
Judge: userAli,
|
||||
Poll: poll,
|
||||
Grade: 3,
|
||||
CandidateID: 1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, judgment)
|
||||
|
||||
tally, err = pnt.Tally(poll)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tally)
|
||||
|
||||
assert.Len(t, tally.Candidates, 1)
|
||||
assert.Equal(t, uint64(1), tally.MaxJudgmentsAmount)
|
||||
|
||||
// Add a judgment from Bob
|
||||
|
||||
judgment, err = CreateJudgment(&CreateJudgmentOptions{
|
||||
Judge: userBob,
|
||||
Poll: poll,
|
||||
Grade: 2,
|
||||
CandidateID: 1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, judgment)
|
||||
|
||||
tally, err = pnt.Tally(poll)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tally)
|
||||
|
||||
assert.Len(t, tally.Candidates, 1)
|
||||
assert.Equal(t, uint64(2), tally.MaxJudgmentsAmount)
|
||||
|
||||
// Add another judgment from Ali, on another candidate
|
||||
|
||||
judgment, err = CreateJudgment(&CreateJudgmentOptions{
|
||||
Judge: userAli,
|
||||
Poll: poll,
|
||||
Grade: 3,
|
||||
CandidateID: 2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, judgment)
|
||||
|
||||
tally, err = pnt.Tally(poll)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tally)
|
||||
|
||||
assert.Len(t, tally.Candidates, 2)
|
||||
assert.Equal(t, uint64(2), tally.MaxJudgmentsAmount)
|
||||
}
|
||||
|
||||
func TestPollCandidateTally_GetMedian(t *testing.T) {
|
||||
tally := buildCandidateTally([]int{2, 3, 5, 7, 11, 13})
|
||||
assert.Equal(t, uint8(4), tally.GetMedian())
|
||||
|
||||
tally = buildCandidateTally([]int{0, 0, 0, 0, 0, 0})
|
||||
assert.Equal(t, uint8(0), tally.GetMedian())
|
||||
|
||||
tally = buildCandidateTally([]int{0, 0, 0, 1, 0, 0})
|
||||
assert.Equal(t, uint8(3), tally.GetMedian())
|
||||
|
||||
tally = buildCandidateTally([]int{0, 0, 1, 0, 0, 1})
|
||||
assert.Equal(t, uint8(2), tally.GetMedian())
|
||||
|
||||
tally = buildCandidateTally([]int{1, 0, 1, 0, 0, 1})
|
||||
assert.Equal(t, uint8(2), tally.GetMedian())
|
||||
|
||||
tally = buildCandidateTally([]int{1, 0, 1, 0, 0, 3})
|
||||
assert.Equal(t, uint8(5), tally.GetMedian())
|
||||
|
||||
tally = buildCandidateTally([]int{0, 2, 2})
|
||||
assert.Equal(t, uint8(1), tally.GetMedian())
|
||||
}
|
||||
|
||||
func buildCandidateTally(grades []int) (_ *PollCandidateTally) {
|
||||
things := make([]*PollCandidateGradeTally, 0, len(grades))
|
||||
totalAmount := 0
|
||||
for grade, amount := range grades {
|
||||
things = append(things, &PollCandidateGradeTally{
|
||||
Grade: uint8(grade),
|
||||
Amount: uint64(amount),
|
||||
CreatedUnix: timeutil.TimeStampNow(),
|
||||
})
|
||||
totalAmount += amount
|
||||
}
|
||||
|
||||
return &PollCandidateTally{
|
||||
Poll: nil, // mock me
|
||||
CandidateID: 0,
|
||||
Grades: things,
|
||||
JudgmentsAmount: uint64(totalAmount),
|
||||
CreatedUnix: timeutil.TimeStampNow(),
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
//"sort"
|
||||
"testing"
|
||||
//"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPoll_Create(t *testing.T) {
|
||||
assert.NoError(t, PrepareTestDatabase())
|
||||
|
||||
repo := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)
|
||||
user := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
|
||||
|
||||
var opts = CreatePollOptions{
|
||||
Repo: repo,
|
||||
Author: user,
|
||||
Subject: "Quality",
|
||||
}
|
||||
poll, err := CreatePoll(&opts)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, poll)
|
||||
}
|
@ -0,0 +1,207 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/context"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
)
|
||||
|
||||
const (
|
||||
tplPollsIndex base.TplName = "repo/polls/polls_index"
|
||||
tplPollsView base.TplName = "repo/polls/polls_view"
|
||||
tplPollsNew base.TplName = "repo/polls/polls_new"
|
||||
)
|
||||
|
||||
// IndexPolls renders an index of all the polls
|
||||
func IndexPolls(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.polls.index.title")
|
||||
ctx.Data["PageIsPolls"] = true
|
||||
|
||||
page := ctx.FormInt("page") // 0 if not defined ?
|
||||
if page <= 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
polls, err := models.GetPolls(ctx.Repo.Repository.ID, page)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetPolls", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Polls"] = polls
|
||||
|
||||
//pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5)
|
||||
//pager.AddParam(ctx, "state", "State")
|
||||
//ctx.Data["Page"] = pager
|
||||
|
||||
ctx.HTML(200, tplPollsIndex)
|
||||
}
|
||||
|
||||
// NewPoll renders the "new poll" page with its form
|
||||
func NewPoll(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.polls.new")
|
||||
ctx.Data["PageIsPolls"] = true
|
||||
|
||||
//ctx.Data["DateLang"] = setting.DateLang(ctx.Locale.Language())
|
||||
|
||||
ctx.HTML(200, tplPollsNew)
|
||||
}
|
||||
|
||||
// NewPollPost processes the "new poll" form and redirects
|
||||
func NewPollPost(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.CreatePollForm)
|
||||
ctx.Data["Title"] = ctx.Tr("repo.polls.new")
|
||||
ctx.Data["PageIsPolls"] = true
|
||||
if ctx.HasError() {
|
||||
ctx.HTML(200, tplPollsNew)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := models.CreatePoll(&models.CreatePollOptions{
|
||||
Author: ctx.Doer,
|
||||
Repo: ctx.Repo.Repository,
|
||||
Subject: form.Subject,
|
||||
Description: form.Description,
|
||||
}); err != nil {
|
||||
ctx.ServerError("CreatePoll", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.polls.create.success", form.Subject))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/polls")
|
||||
}
|
||||
|
||||
// ViewPoll renders display poll page
|
||||
func ViewPoll(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.polls.view")
|
||||
ctx.Data["PageIsPolls"] = true
|
||||
|
||||
poll, err := models.GetPollByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
|
||||
if nil != err {
|
||||
if models.IsErrPollNotFound(err) {
|
||||
ctx.NotFound("", nil)
|
||||
} else {
|
||||
ctx.ServerError("GetPollByRepoID", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
poll.RenderedDescription, err = markdown.RenderString(&markup.RenderContext{
|
||||
URLPrefix: ctx.Repo.RepoLink,
|
||||
Metas: ctx.Repo.Repository.ComposeMetas(),
|
||||
GitRepo: ctx.Repo.GitRepo,
|
||||
Ctx: ctx,
|
||||
}, poll.Description)
|
||||
if err != nil {
|
||||
ctx.ServerError("RenderString", err)
|
||||
return
|
||||
}
|
||||
|
||||
//poll.RenderedDescription = string(markdown.Render(
|
||||
// &markup.RenderContext{
|
||||
// URLPrefix: ctx.Repo.RepoLink,
|
||||
// Metas: ctx.Repo.Repository.ComposeMetas(),
|
||||
// GitRepo: ctx.Repo.GitRepo,
|
||||
// Ctx: ctx,
|
||||
// },
|
||||
// []byte(poll.Description),
|
||||
//ctx.Repo.RepoLink,
|
||||
//ctx.Repo.Repository.ComposeMetas()
|
||||
//))
|
||||
//poll.RenderedDescription = string(markdown.Render(
|
||||
// []byte(poll.Description),
|
||||
// ctx.Repo.RepoLink,
|
||||
// ctx.Repo.Repository.ComposeMetas()
|
||||
//))
|
||||
|
||||
ctx.Data["Poll"] = poll
|
||||
ctx.HTML(200, tplPollsView)
|
||||
}
|
||||
|
||||
// EditPoll renders editing poll page
|
||||
func EditPoll(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.polls.edit")
|
||||
ctx.Data["PageIsPolls"] = true
|
||||
ctx.Data["PageIsEditPoll"] = true
|
||||
//ctx.Data["DateLang"] = setting.DateLang(ctx.Locale.Language())
|
||||
|
||||
m, err := models.GetPollByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
|
||||
if nil != err {
|
||||
if models.IsErrPollNotFound(err) {
|
||||
ctx.NotFound("", nil)
|
||||
} else {
|
||||
ctx.ServerError("GetPollByRepoID", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Data["subject"] = m.Subject
|
||||
ctx.Data["description"] = m.Description
|
||||
ctx.HTML(200, tplPollsNew)
|
||||
}
|
||||
|
||||
// EditPollPost response for editing poll
|
||||
func EditPollPost(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.CreatePollForm)
|
||||
ctx.Data["Title"] = ctx.Tr("repo.polls.edit")
|
||||
ctx.Data["PageIsPolls"] = true
|
||||
ctx.Data["PageIsEditPoll"] = true
|
||||
//ctx.Data["DateLang"] = setting.DateLang(ctx.Locale.Language())
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.HTML(200, tplPollsNew)
|
||||
return
|
||||
}
|
||||
|
||||
//if len(form.Deadline) == 0 {
|
||||
// form.Deadline = "9999-12-31"
|
||||
//}
|
||||
//deadline, err := time.ParseInLocation("2006-01-02", form.Deadline, time.Local)
|
||||
//if err != nil {
|
||||
// ctx.Data["Err_Deadline"] = true
|
||||
// ctx.RenderWithErr(ctx.Tr("repo.polls.invalid_due_date_format"), tplPollNew, &form)
|
||||
// return
|
||||
//}
|
||||
|
||||
//deadline = time.Date(deadline.Year(), deadline.Month(), deadline.Day(), 23, 59, 59, 0, deadline.Location())
|
||||
m, err := models.GetPollByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
|
||||
if err != nil {
|
||||
if models.IsErrPollNotFound(err) {
|
||||
ctx.NotFound("", nil)
|
||||
} else {
|
||||
ctx.ServerError("GetPollByRepoID", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
m.Subject = form.Subject
|
||||
m.Description = form.Description
|
||||
|
||||
if err = models.UpdatePoll(m); err != nil {
|
||||
ctx.ServerError("UpdatePoll", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.polls.edit.success", m.Subject))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/polls")
|
||||
}
|
||||
|
||||
// DeletePoll deletes a poll and redirects
|
||||
func DeletePoll(ctx *context.Context) {
|
||||
if err := models.DeletePollByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")); err != nil {
|
||||
ctx.Flash.Error("DeletePollByRepoID: " + err.Error())
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.polls.delete.success"))
|
||||
}
|
||||
|
||||
ctx.JSON(200, map[string]interface{}{
|
||||
"redirect": ctx.Repo.RepoLink + "/polls",
|
||||
})
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/modules/context"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"path"
|
||||
)
|
||||
|
||||
type CreateJudgmentResponse struct {
|
||||
Judgment *models.PollJudgment
|
||||
}
|
||||
|
||||
// EmitJudgment Creates, Updates or Deletes a Judgment depending on the parameters
|
||||
func EmitJudgment(ctx *context.Context) {
|
||||
judge := ctx.Doer
|
||||
//judge := ctx.ContextUser // TODO: figure out which is correct
|
||||
|
||||
grade := uint8(ctx.FormInt("grade")) // 0 if not defined
|
||||
if grade < 1 {
|
||||
grade = 0
|
||||
}
|
||||
|
||||
pollId := ctx.ParamsInt64(":id")
|
||||
candidateID := ctx.FormInt64("candidate")
|
||||
|
||||
poll, errP := models.GetPollByRepoID(ctx.Repo.Repository.ID, pollId)
|
||||
if nil != errP {
|
||||
ctx.NotFound("EmitJudgment.GetPollByRepoID", errP)
|
||||
return
|
||||
}
|
||||
|
||||
judgment := poll.GetJudgmentOnCandidate(judge, candidateID)
|
||||
if nil != judgment {
|
||||
|
||||
if judgment.Grade == grade {
|
||||
// Delete a judgment if it exists and is the same as submitted.
|
||||
// Not obvious nor usual behavior, but pretty handy for now.
|
||||
errD := models.DeleteJudgment(&models.DeleteJudgmentOptions{
|
||||
Poll: poll,
|
||||
Judge: judge,
|
||||
CandidateID: candidateID,
|
||||
})
|
||||
if nil != errD {
|
||||
ctx.ServerError("EmitJudgment.DeleteJudgment", errD)
|
||||
return
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.polls.judgments.delete.success"))
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
_, errU := models.UpdateJudgment(&models.UpdateJudgmentOptions{
|
||||
Poll: poll,
|
||||
Judge: judge,
|
||||
Grade: grade,
|
||||
CandidateID: candidateID,
|
||||
})
|
||||
if nil != errU {
|
||||
ctx.ServerError("EmitJudgment.UpdateJudgment", errU)
|
||||
return
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.polls.judgments.update.success"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
_, errC := models.CreateJudgment(&models.CreateJudgmentOptions{
|
||||
Poll: poll,
|
||||
Judge: judge,
|
||||
Grade: grade,
|
||||
CandidateID: candidateID,
|
||||
})
|
||||
if nil != errC {
|
||||
ctx.ServerError("EmitJudgment.EmitJudgment", errC)
|
||||
return
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.polls.judgments.create.success"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
redirectPath := ctx.FormTrim("redirect")
|
||||
ctx.Redirect(path.Join(setting.AppSubURL, redirectPath))
|
||||
|
||||
// Nice, but yields too much (passwords, lol)
|
||||
//ctx.JSON(200, &CreateJudgmentResponse{
|
||||
// Judgment: judgment,
|
||||
//})
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/context"
|
||||
"code.gitea.io/gitea/modules/web/middleware"
|
||||
|
||||
"gitea.com/go-chi/binding"
|
||||
)
|
||||
|
||||
// CreatePollForm creates a new poll
|
||||
type CreatePollForm struct {
|
||||
Subject string `binding:"Required;MaxSize(128)"` // 128 is duplicated in the template
|
||||
Description string
|
||||
}
|
||||
|
||||
// Validate the form fields
|
||||
func (f *CreatePollForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
|
||||
ctx := context.GetContext(req)
|
||||
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
{{ $merit := . }}
|
||||
|
||||
{{/*
|
||||
|
||||
Radial representation of a merit profile.
|
||||
The $merit above is an instance of PollCandidateMeritProfile.
|
||||
In this SVG, the ViewBox centers the (0.0), and both axes go from -1 to 1
|
||||
|
||||
*/}}
|
||||
|
||||
{{ $size := 34 }}{{/* size of the SVG (pixels) */}}
|
||||
{{ $delimiter := 0.04 }}{{/* breadth of the white line between grades (viewbox referential) */}}
|
||||
{{ $half_gap := 0.38 }}{{/* ~asin(GOLDEN_RATIO-1) adjusted for the delimiter - (radians) */}}
|
||||
{{ $half_rest := 2.7616 }}{{/* MUST BE (PI - $half_gap) ARITHMETIC IS 404 - (radians) */}}
|
||||
{{ $exterior_radius := 0.9167 }}{{/* About 11/12 to leave some padding (viewbox referential) */}}
|
||||
{{ $interior_radius := 0.3501 }}{{/* exterior_radius/GOLDEN_RATIO (viewbox referential) */}}
|
||||
|
||||
<svg
|
||||
style="transform: rotate(0.25turn);"
|
||||
width="{{ $size }}px"
|
||||
height="{{ $size }}px"
|
||||
{{/* min_x, min_y, width, height */}}
|
||||
viewBox="-1 -1 2 2"
|
||||
>
|
||||
{{ range $grade_id, $amount := .Grades }}
|
||||
{{ $angle := $merit.GetGradeAngle $grade_id $half_gap }}
|
||||
{{ $exterior := $merit.GetCirclePoints $grade_id $exterior_radius $half_gap }}
|
||||
{{ $interior := $merit.GetCirclePoints $grade_id $interior_radius $half_gap }}
|
||||
{{ $large_flag := 0 }}
|
||||
{{ if gt $angle $half_rest }}
|
||||
{{ $large_flag = 1 }}
|
||||
{{ end }}
|
||||
{{ $color := $merit.GetColorCode $grade_id }}
|
||||
{{/* (M)ove x y */}}
|
||||
{{/* (A)rc rx ry x-axis-rotation large-arc-flag sweep-flag x y */}}
|
||||
{{/* (L)ine x y */}}
|
||||
<path
|
||||
d="M {{$exterior.Start.X}} {{$exterior.Start.Y}} A {{ $exterior_radius }} {{ $exterior_radius }} 0 {{ $large_flag }} 1 {{$exterior.End.X}} {{$exterior.End.Y}} L {{$interior.End.X}} {{$interior.End.Y}} A {{ $interior_radius }} {{ $interior_radius }} 0 {{ $large_flag }} 0 {{$interior.Start.X}} {{$interior.Start.Y}}"
|
||||
fill="{{ $color }}"
|
||||
></path>
|
||||
{{ end }}
|
||||
|
||||
{{/* Draw white delimiters between grades, for accessibility */}}
|
||||
{{/* As a second pass. (you know what they say about premature optimizations) */}}
|
||||
{{/* If we can do this all in one pass, by resizing the arcs, it's faster. */}}
|
||||
{{/* Keep in mind that in that case we'd need a background color. */}}
|
||||
{{/* We need a background color anyways, at least a white padding. */}}
|
||||
{{ range $grade_id, $amount := .Grades }}
|
||||
{{ $exterior := $merit.GetCirclePoints $grade_id $exterior_radius $half_gap }}
|
||||
{{ $interior := $merit.GetCirclePoints $grade_id $interior_radius $half_gap }}
|
||||
{{ $color := "#FFFFFF" }}
|
||||
<path
|
||||
d="M {{$exterior.End.X}} {{$exterior.End.Y}} L {{$interior.End.X}} {{$interior.End.Y}}"
|
||||
stroke="{{ $color }}"
|
||||
stroke-width="{{ $delimiter }}"
|
||||
></path>
|
||||
{{ if eq $grade_id 0 }}
|
||||
<path
|
||||
d="M {{$exterior.Start.X}} {{$exterior.Start.Y}} L {{$interior.Start.X}} {{$interior.Start.Y}}"
|
||||
stroke="{{ $color }}"
|
||||
stroke-width="{{ $delimiter }}"
|
||||
></path>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</svg>
|
@ -0,0 +1,66 @@
|
||||
{{/*
|
||||
|
||||
A large label for polls' candidates.
|
||||
No javascript for now. There may be _some_.
|
||||
.Poll : the Poll in question
|
||||
.CandidateID : the Candidate
|
||||
.Super : $ of parent template
|
||||
|
||||
*/}}
|
||||
{{ $pollResult := .Poll.GetResult }}
|
||||
{{/*{{ $candidateID := .Issue.Index }}*/}}
|
||||
{{ $candidateID := .CandidateID }}
|
||||
{{ $pollID := .Poll.ID }}
|
||||
{{ $pollCandidateResult := ($pollResult.GetCandidate $candidateID) }}
|
||||
{{ $medianColorWord := "grey" }}
|
||||
{{ if $pollCandidateResult }}
|
||||
{{ $medianColorWord = $pollCandidateResult.GetColorWord }}
|
||||
{{ end }}
|
||||
{{/*{{ $judgment := nil }} ??? */}}
|
||||
{{ $judgment := "" }}
|
||||
{{ if $.Super.IsSigned }}
|
||||
{{ $judgment = (.Poll.GetJudgmentOnCandidate $.Super.SignedUser $candidateID) }}
|
||||
{{ end }}
|
||||
{{/*{{ $userGrade := nil }}*/}}
|
||||
{{ $userGrade := -1 }}
|
||||
{{ if $judgment }}
|
||||
{{ $userGrade = $judgment.Grade }}
|
||||
{{ end }}
|
||||
|
||||
{{/* Wrapper for the :hover, old school */}}
|
||||
<div class="ui poll-badge" tabindex="0">
|
||||
|
||||
<div class="ui large label {{ $medianColorWord }}">
|
||||
{{/* Oddly enough, this svg requires javascript. Hmmm… */}}
|
||||
{{ svg "octicon-law" 16 }}
|
||||
{{ .Poll.Subject }}
|
||||
{{ if $pollCandidateResult -}}
|
||||
N°{{ $pollCandidateResult.Position }}
|
||||
{{- end }}
|
||||
</div>
|
||||
|
||||
{{ if $.Super.IsSigned }}
|
||||
<div class="judgment-forms">
|
||||
{{ range $grade, $icon := .Poll.GetGradationList }}
|
||||
<form class="judgment-form {{ if (eq $grade $userGrade) }}selected{{ end }}" action="{{ $.Super.AppSubUrl }}/{{ $.Super.RepoRelPath }}/polls/{{ $pollID }}/judgments" method="post">
|
||||
{{ $.Super.CsrfTokenHtml }}
|
||||
<input type="hidden" name="redirect" value="{{ $.Super.AppSubUrl }}/{{ $.Super.RepoRelPath }}/issues/{{ $candidateID }}">
|
||||
<input type="hidden" name="grade" value="{{ $grade }}">
|
||||
<input type="hidden" name="candidate" value="{{ $candidateID }}">
|
||||
|
||||
{{ if $pollCandidateResult }}
|
||||
{{ if eq $pollCandidateResult.MedianGrade $grade }}
|
||||
<div class="background-merit-profile">
|
||||
{{ template "repo/polls/merit_radial" $pollCandidateResult.MeritProfile }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{/* Add the emote AFTER the profile or the absolute of the profile is offset (somehow) */}}
|
||||
<input class="emote" type="submit" value="{{ $icon }}">
|
||||
</form>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
|
@ -0,0 +1,74 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="repository polls">
|
||||
{{template "repo/header" .}}
|
||||
<div class="ui container">
|
||||
<div class="navbar">
|
||||
{{/* {{template "repo/poll/navbar" .}}*/}}
|
||||
{{if and (or .CanWriteIssues .CanWritePulls) (not .Repository.IsArchived)}}
|
||||
<div class="ui right">
|
||||
<a class="ui green button" href="{{$.Link}}/new">{{.locale.Tr "repo.polls.new"}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="ui divider"></div>
|
||||
{{template "base/alert" .}}
|
||||
<div class="poll list">
|
||||
{{range .Polls}}
|
||||
<li class="item">
|
||||
{{svg "octicon-law" 16}} <a href="{{$.RepoLink}}/polls/{{.ID}}">{{.Subject}}</a>
|
||||
{{if and ($.CanWritePolls) (not $.Repository.IsArchived)}}
|
||||
<div class="ui right operate">
|
||||
<a href="{{$.Link}}/{{.ID}}/edit" data-id={{.ID}} data-title={{.Subject}}>{{svg "octicon-pencil" 16}} {{$.locale.Tr "repo.polls.operate.edit"}}</a>
|
||||
{{/* {{if .IsClosed}}*/}}
|
||||
{{/* <a class="link-action" href data-url="{{$.Link}}/{{.ID}}/open">{{svg "octicon-check" 16}} {{$.locale.Tr "repo.milestones.open"}}</a>*/}}
|
||||
{{/* {{else}}*/}}
|
||||
{{/* <a class="link-action" href data-url="{{$.Link}}/{{.ID}}/close">{{svg "octicon-x" 16}} {{$.locale.Tr "repo.milestones.close"}}</a>*/}}
|
||||
{{/* {{end}}*/}}
|
||||
{{/* href is empty on purpose ; people without js don't want to delete by misclick. */}}
|
||||
{{/* there might also be a DELETE HTTP Method lurking around, or is it API only? */}}
|
||||
<a class="delete-button" href="#" data-url="{{$.Link}}/{{.ID}}/delete" data-id="{{.ID}}">{{svg "octicon-trashcan" 16}} {{$.locale.Tr "repo.polls.operate.delete"}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Description}}
|
||||
<div class="content">
|
||||
{{.RenderedDescription|Str2html}}
|
||||
</div>
|
||||
{{end}}
|
||||
</li>
|
||||
{{else}}
|
||||
<p>{{.locale.Tr "repo.polls.index.nothing"}}</p>
|
||||
{{if not (.CanWritePolls)}}
|
||||
<p>{{.locale.Tr "repo.polls.new.no_permissions"}}</p>
|
||||
{{end}}
|
||||
{{if .Repository.IsArchived}}
|
||||
<p>{{.locale.Tr "repo.polls.new.archived"}}</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{template "base/paginate" .}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if or .CanWriteIssues .CanWritePulls}}
|
||||
<div class="ui small basic delete modal">
|
||||
<div class="ui icon header">
|
||||
<i class="trash icon"></i>
|
||||
{{.locale.Tr "repo.polls.modal.deletion.header"}}
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>{{.locale.Tr "repo.polls.modal.deletion.description"}}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui red basic inverted cancel button">
|
||||
<i class="remove icon"></i>
|
||||
{{.locale.Tr "modal.no"}}
|
||||
</div>
|
||||
<div class="ui green basic inverted ok button">
|
||||
<i class="checkmark icon"></i>
|
||||
{{.locale.Tr "modal.yes"}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{template "base/footer" .}}
|
@ -0,0 +1,78 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="repository new poll">
|
||||
{{template "repo/header" .}}
|
||||
<div class="ui container">
|
||||
<h2 class="ui dividing header">
|
||||
{{if .PageIsEditPoll}}
|
||||
{{.locale.Tr "repo.polls.edit"}}
|
||||
<div class="sub header">{{.locale.Tr "repo.polls.edit.subheader"}}</div>
|
||||
{{else}}
|
||||
{{.locale.Tr "repo.polls.new"}}
|
||||
<div class="sub header">{{.locale.Tr "repo.polls.new.subheader"}}</div>
|
||||
{{end}}
|
||||
</h2>
|
||||
{{template "base/alert" .}}
|
||||
<form class="ui form grid" action="{{.Link}}" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="twelve wide column">
|
||||
<div class="field {{if .Err_Title}}error{{end}}">
|
||||
{{/* form_poll_subject or form-poll-subject ? */}}
|
||||
<label for="form_poll_subject">{{.locale.Tr "repo.polls.subject.label"}}</label>
|
||||
<input id="form_poll_subject" name="subject"
|
||||
placeholder="{{.locale.Tr "repo.polls.subject.placeholder"}}"
|
||||
value="{{.subject}}" autofocus required maxlength="128">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form_poll_description">{{.locale.Tr "repo.polls.description.label"}}</label>
|
||||
<textarea id="form_poll_description" name="description">{{.description}}</textarea>
|
||||
</div>
|
||||
<div class="field disabled">
|
||||
<label for="form_poll_gradation">{{.locale.Tr "repo.polls.gradation.label"}}</label>
|
||||
<select id="form_poll_gradation" name="gradation" class="emote">
|
||||
<option value="emote6-0" selected>😫 😒 😐 😌 😀 😍</option>
|
||||
<option value="emote6-1">🤮 😒 😐 🙂 😀 🤩</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field disabled">
|
||||
<label>{{ .locale.Tr "repo.polls.candidates.label" }}</label>
|
||||
<label>
|
||||
<input type="radio" name="candidates_pool" value="issues_merges" checked>
|
||||
{{ .locale.Tr "repo.polls.candidates.pool.issues_merges" }}
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="candidates_pool" value="issues">
|
||||
{{ .locale.Tr "repo.polls.candidates.pool.issues" }}
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="candidates_pool" value="merges">
|
||||
{{ .locale.Tr "repo.polls.candidates.pool.merges" }}
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="candidates_pool" value="indices">
|
||||
{{ .locale.Tr "repo.polls.candidates.pool.indices" }}
|
||||
<input class="inline-input" type="text" name="candidates_pool_indices" title="{{ .locale.Tr "repo.polls.candidates.pool.indices.help" }}" placeholder="1, 9, 12">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui container">
|
||||
<div class="ui divider"></div>
|
||||
<div class="ui right">
|
||||
{{if .PageIsEditPoll}}
|
||||
<a class="ui blue basic button" href="{{.RepoLink}}/polls">
|
||||
{{.locale.Tr "repo.polls.cancel"}}
|
||||
</a>
|
||||
<button class="ui green button">
|
||||
{{.locale.Tr "repo.polls.modify"}}
|
||||
</button>
|
||||
{{else}}
|
||||
<button class="ui green button">
|
||||
{{.locale.Tr "repo.polls.create"}}
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
@ -0,0 +1,62 @@
|
||||
{{ template "base/head" . }}
|
||||
|
||||
{{/*
|
||||
|
||||
Index of the candidates for this poll.
|
||||
The candidates are sorted by decreasing poll success.
|
||||
|
||||
*/}}
|
||||
|
||||
<div class="repository polls">
|
||||
{{ template "repo/header" . }}
|
||||
<div class="ui container">
|
||||
<div class="navbar">
|
||||
{{ if and (or .CanWriteIssues .CanWritePulls) (not .Repository.IsArchived) }}
|
||||
<div class="ui right">
|
||||
<a class="ui green button" href="{{ $.Link }}/edit">{{ .locale.Tr "repo.polls.edit" }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="ui divider"></div>
|
||||
{{ template "base/alert" . }}
|
||||
|
||||
<div>
|
||||
<h2>{{ .Poll.Subject }}</h2>
|
||||
|
||||
{{ if .Poll.RenderedDescription }}
|
||||
<div class="ui attached segment">
|
||||
<div class="render-content markdown">
|
||||
{{ .Poll.RenderedDescription|Str2html }}
|
||||
{{/* <span class="no-content">{{ .locale.Tr "repo.polls.no_description" }}</span>*/}}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{/* Move these away once they're stable */}}
|
||||
<style>
|
||||
|
||||
</style>
|
||||
|
||||
{{ $pollResult := .Poll.GetResult }}
|
||||
|
||||
<ul class="candidates issue list">
|
||||
{{ range $key, $candidate := $pollResult.Candidates }}
|
||||
<li class="item">
|
||||
{{ template "repo/polls/merit_radial" $candidate.MeritProfile }}
|
||||
|
||||
N°{{ $candidate.Position }}
|
||||
—
|
||||
|
||||
<a href="{{ $.AppSubUrl }}/{{ $.RepoRelPath }}/issues/{{ $candidate.CandidateID }}">
|
||||
{{ $candidate.GetCandidateName }}
|
||||
(#{{ $candidate.CandidateID }})
|
||||
</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ template "base/footer" . }}
|
Loading…
Reference in new issue