feat: Majority Judgment Polls (2023-02)

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 index
mj-v1.18.3
domi41 4 years ago
parent f6cb7860a2
commit b3250a061d

1
.gitignore vendored

@ -50,6 +50,7 @@ cpu.out
/gitea
/gitea-vet
/gitea-repositories
/debug
/integrations.test

@ -6,6 +6,7 @@
package models
import (
user_model "code.gitea.io/gitea/models/user"
"fmt"
repo_model "code.gitea.io/gitea/models/repo"
@ -603,3 +604,43 @@ func (err ErrPullRequestHasMerged) Error() string {
return fmt.Sprintf("pull request has merged [id: %d, issue_id: %d, head_repo_id: %d, base_repo_id: %d, head_branch: %s, base_branch: %s]",
err.ID, err.IssueID, err.HeadRepoID, err.BaseRepoID, err.HeadBranch, err.BaseBranch)
}
// ___ _ _ __ _ _ _
// | _ \___| | | / _|___ _ | |_ _ __| |__ _ _ __ ___ _ _| |_
// | _/ _ \ | | > _|_ _| | || | || / _` / _` | ' \/ -_) ' \ _|
// |_| \___/_|_| \_____| \__/ \_,_\__,_\__, |_|_|_\___|_||_\__|
// |___/
// ErrPollNotFound represents a "PollNotFound" kind of error.
type ErrPollNotFound struct {
ID int64
RepoID int64
}
// IsErrPollNotFound checks if an error is a ErrPollNotFound.
func IsErrPollNotFound(err error) bool {
_, ok := err.(ErrPollNotFound)
return ok
}
func (err ErrPollNotFound) Error() string {
return fmt.Sprintf("poll not found [id: %d, repo_id: %d]", err.ID, err.RepoID)
}
type ErrJudgmentNotFound struct {
Judge *user_model.User
Poll *Poll
CandidateID int64
}
func IsErrJudgmentNotFound(err error) bool {
_, ok := err.(ErrJudgmentNotFound)
return ok
}
func (err ErrJudgmentNotFound) Error() string {
return fmt.Sprintf("Judgment not found.")
//return fmt.Sprintf("Judgment not found" +
// " for judge %d, poll %d and candidate %d.",
// err.Judge.ID, err.Poll.ID, err.CandidateID)
}

@ -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)
}

@ -556,6 +556,7 @@ func RepoAssignment(ctx *Context) (cancel context.CancelFunc) {
ctx.Data["CanWriteCode"] = ctx.Repo.CanWrite(unit_model.TypeCode)
ctx.Data["CanWriteIssues"] = ctx.Repo.CanWrite(unit_model.TypeIssues)
ctx.Data["CanWritePulls"] = ctx.Repo.CanWrite(unit_model.TypePullRequests)
ctx.Data["CanWritePolls"] = ctx.Repo.IsOwner() // todo: add a 'real' permission
canSignedUserFork, err := repo_module.CanUserForkRepo(ctx.Doer, ctx.Repo.Repository)
if err != nil {

@ -1667,6 +1667,39 @@ milestones.filter_sort.most_complete = Most complete
milestones.filter_sort.most_issues = Most issues
milestones.filter_sort.least_issues = Least issues
polls = Polls
polls.create = Create Poll
polls.create.success = You created the poll '%s'.
polls.cancel = Cancel
polls.delete.success = You deleted the poll. Why would you do such a thing?! I'm hurt.
polls.modify = Update Poll
polls.operate.edit = Edit
polls.operate.delete = Delete
polls.index.nothing = There are no ongoing polls in this repository.
polls.view = View Poll
polls.new = New Poll
polls.new.subheader = This poll will use Majority Judgment on all the issues
polls.new.no_permissions = You do not have the required permissions to create a Poll here.
polls.new.archived = This repository is archived. No new polls may be started.
polls.subject.label = Subject (one-word, short name is advised)
polls.subject.placeholder = Urgency, Importance, Demand…
polls.description.label = Description (rationale, constitutive details, consequences…)
polls.gradation.label = Grades
polls.candidates.label = Candidates
polls.candidates.pool.issues_merges = All Issues and Merge Requests
polls.candidates.pool.issues = All Issues only
polls.candidates.pool.merges = All Merge Requests only
polls.candidates.pool.indices = All of these
polls.candidates.pool.indices.help = Comma-separated list of issues
polls.edit = Edit Poll
polls.edit.subheader = Should one be allowed to change the subject after the poll has started?
polls.edit.success = You edited the poll '%s' successfully.
polls.modal.deletion.header = Are you sure you want to delete this poll?
polls.modal.deletion.description = This operation CANNOT be undone! It will also delete all attached judgments.
polls.judgments.create.success = Your judgment was recorded. Thank you ! (+0.618 karma)
polls.judgments.update.success = Your judgment was updated.
polls.judgments.delete.success = Your judgment was discarded, and then burnt, and the ashes were scattered into lava.
signing.will_sign = This commit will be signed with key '%s'
signing.wont_sign.error = There was an error whilst checking if the commit could be signed
signing.wont_sign.nokey = There is no key available to sign this commit

@ -1635,6 +1635,40 @@ signing.wont_sign.commitssigned=La fusion ne sera pas signée car toutes les ré
signing.wont_sign.approved=La fusion ne sera pas signée car la PR n'a pas approuvée
signing.wont_sign.not_signed_in=Vous n'êtes pas authentifié
polls = Scrutins
polls.create = Créer un Scrutin
polls.create.success = Vous avez créé le scrutin '%s'.
polls.cancel = Annuler
polls.delete.success = Vous avez supprimé le scrutin. M'enfin !?
polls.modify = Mettre à jour le Scrutin
polls.operate.edit = Éditer
polls.operate.delete = Supprimer
polls.index.nothing = Il n'y a pas de scrutins en cours dans ce dépôt.
polls.view = Voir le Scrutin
polls.new = Nouveau Scrutin
polls.new.subheader = Ce scrutin appliquera le Jugement Majoritaire
polls.new.no_permissions = Vous n'avez pas la permission de créer un scrutin sur ce dépôt.
polls.new.archived = Ce dépôt est archivé. Les scrutins sont désactivés.
polls.subject.label = Sujet (en un mot, le plus court possible)
polls.subject.placeholder = Urgence, Importance, Demande…
polls.description.label = Description (motivation, détails constitutifs, conséquences…)
polls.gradation.label = Mentions
polls.candidates.label = Candidat⋅es
polls.candidates.pool.issues_merges = Tous les Tickets et Demandes d'Ajout
polls.candidates.pool.issues = Tickets seulement
polls.candidates.pool.merges = Demandes d'Ajout seulement
polls.candidates.pool.indices = Seulement ceux-là
polls.candidates.pool.indices.help = Liste d'identifiants, séparés par des virgules
polls.edit = Éditer le Scrutin
polls.edit.subheader = Devrait-on être autorisé à changer le sujet à postériori ?
polls.edit.success = Vous avez modifié le scrutin '%s' avec succès.
polls.modal.deletion.header = Êtes-vous sûr⋅e de vouloir supprimer ce scrutin ?
polls.modal.deletion.description = Cette opération ne PEUT PAS ÊTRE ANNULÉE. Tous les jugements associés seront supprimés.
polls.judgments.create.success = Votre jugement a été enregistré. Merci !
polls.judgments.update.success = Votre jugement a été mis à jour.
polls.judgments.delete.success = Votre jugement a été rétracté, puis brûlé, et ses cendres dispersées dans un volcan.
ext_wiki=Wiki externe
ext_wiki.desc=Lier un wiki externe.
wiki=Wiki

@ -7,6 +7,7 @@ package repo
import (
"bytes"
"code.gitea.io/gitea/models"
stdCtx "context"
"errors"
"fmt"
@ -1770,6 +1771,12 @@ func ViewIssue(ctx *context.Context) {
ctx.ServerError("BlockingDependencies", err)
return
}
// Get Polls
ctx.Data["Polls"], err = models.GetPolls(ctx.Repo.Repository.ID, 0)
if err != nil {
ctx.ServerError("IssueView.GetPolls", err)
return
}
ctx.Data["Participants"] = participants
ctx.Data["NumParticipants"] = len(participants)

@ -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,
//})
}

@ -910,6 +910,10 @@ func RegisterRoutes(m *web.Route) {
m.Get("/info", repo.GetIssueInfo)
})
})
m.Group("/polls", func() {
m.Get("", repo.IndexPolls)
m.Get("/{id}", repo.ViewPoll)
}, context.RepoAssignment, context.RepoRef())
}, context.RepoAssignment, context.UnitTypes())
// Grouping for those endpoints that do require authentication
@ -995,6 +999,16 @@ func RegisterRoutes(m *web.Route) {
m.Group("/pull", func() {
m.Post("/{index}/target_branch", repo.UpdatePullRequestTarget)
}, context.RepoMustNotBeArchived())
m.Group("/polls", func() {
m.Combo("/new").
Get(repo.NewPoll).
Post(bindIgnErr(forms.CreatePollForm{}), repo.NewPollPost)
m.Get("/{id}/edit", repo.EditPoll)
m.Post("/{id}/edit", bindIgnErr(forms.CreatePollForm{}), repo.EditPollPost)
m.Post("/{id}/delete", repo.DeletePoll)
m.Post("/{id}/judgments", repo.EmitJudgment)
//m.Delete("/{id}/judgments", repo.DeleteJudgment)
}, context.RepoMustNotBeArchived())
m.Group("", func() {
m.Group("", func() {

@ -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)
}

@ -211,6 +211,13 @@
</a>
{{end}}
{{/* TODO: make a permission for reading polls and use it here */}}
{{ if (.Permission.CanReadAny $.UnitTypePullRequests $.UnitTypeIssues $.UnitTypeReleases) }}
<a class="{{ if .PageIsPolls }}active{{ end }} item" href="{{ .RepoLink }}/polls">
{{svg "octicon-law" 16}} {{.locale.Tr "repo.polls"}}
</a>
{{ end }}
{{if and (.Permission.CanReadAny $.UnitTypePullRequests $.UnitTypeIssues $.UnitTypeReleases) (not .IsEmptyRepo)}}
<a class="{{if .PageIsActivity}}active{{end}} item" href="{{.RepoLink}}/activity">
{{svg "octicon-pulse"}} {{.locale.Tr "repo.activity"}}

@ -19,6 +19,7 @@
</div>
{{end}}
</div>
{{if .HasMerged}}
<div class="ui purple large label">{{svg "octicon-git-merge" 16}} {{if eq .Issue.PullRequest.Status 3}}{{.locale.Tr "repo.pulls.manually_merged"}}{{else}}{{.locale.Tr "repo.pulls.merged"}}{{end}}</div>
{{else if .Issue.IsClosed}}
@ -33,6 +34,10 @@
<div class="ui green large label">{{svg "octicon-issue-opened"}} {{.locale.Tr "repo.issues.open_title"}}</div>
{{end}}
{{ range $poll := .Polls }}
{{ template "repo/polls/poll_badge" Dict "Poll" $poll "CandidateID" $.Issue.Index "Super" $ }}
{{ end }}
{{if .Issue.IsPull}}
{{$headHref := .HeadTarget|Escape}}
{{if .HeadBranchHTMLURL}}

@ -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 -}}
{{ $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 }}
&nbsp;
{{ $candidate.Position }}
&mdash;
&nbsp;
<a href="{{ $.AppSubUrl }}/{{ $.RepoRelPath }}/issues/{{ $candidate.CandidateID }}">
{{ $candidate.GetCandidateName }}
(#{{ $candidate.CandidateID }})
</a>
</li>
{{ end }}
</ul>
</div>
</div>
{{ template "base/footer" . }}

@ -0,0 +1,184 @@
// ____ _ _
// | _ \ ___ | | |___
// | |_) / _ \| | / __|
// | __/ (_) | | \__ \
// |_| \___/|_|_|___/
//
// Poll Badges require tweaking on mobile to stack vertically ; improve at will
@mobile: ~"only screen and (max-width: 600px)";
/*
Emote Support
What would be a good approach here?
Perhaps we should provide a font as well, for emotes.
Privilege ubiquitous emotes nonetheless, for accessibility.
Research notes:
- SVGinOT (is that what firefox uses already?)
- fontello (font generator from SVG files)
*/
.emote {
font-family: "Source Code Pro", Consolas, monaco, monospace !important;
}
// _____ _
// |_ _|_ _____ __ _| | _____
// | | \ \ /\ / / _ \/ _` | |/ / __|
// | | \ V V / __/ (_| | <\__ \
// |_| \_/\_/ \___|\__,_|_|\_\___/
//
.ui.label {
padding-top: 0.6em;
padding-bottom: 0.6em;
}
.ui.form input[type="text"].inline-input {
display: inline-block;
width: initial;
line-height: 1em;
height: 1.62em;
padding-left: 0.3em;
padding-right: 0.3em;
}
// ____ _ _ ____ _
// | _ \ ___ | | | | __ ) __ _ __| | __ _ ___
// | |_) / _ \| | | | _ \ / _` |/ _` |/ _` |/ _ \
// | __/ (_) | | | | |_) | (_| | (_| | (_| | __/
// |_| \___/|_|_| |____/ \__,_|\__,_|\__, |\___|
// |___/
.poll-badge {
display: inline-block;
margin-top: 0.3em;
margin-bottom: 0.3em;
/* Otherwise the h1 blocks a portion of the label's hover area */
position: relative;
z-index: 1;
@media @mobile {
display: block;
}
}
.poll-badge:focus-within .judgment-forms,
.poll-badge:focus .judgment-forms,
.poll-badge:hover .judgment-forms {
display: inline-block;
left: -9px;
opacity: 1.0;
padding-left: 1em;
}
.judgment-forms {
display: none;
position: relative;
top: 0;
left: -90px; /* keep as fallback */
opacity: 0.0; /* keep as fallback */
animation: fade_in_judgment_forms 0.4s ease-out;
/*transition: left 0.4s ease-out 0s, opacity 0.3s ease-out 0s;*/
vertical-align: middle;
}
/* Since transitions won't play with a change of display, we use animation */
@keyframes fade_in_judgment_forms {
0% {
pointer-events: none;
opacity: 0;
left: -150px;
transform: scale(0.2);
}
95% {
transform: scale(1.0);
}
99% {
pointer-events: none;
}
100% {
pointer-events: initial;
opacity: 1;
left: -9px;
}
}
.judgment-form {
display: inline-block;
position: relative;
margin: 0;
padding: 0;
}
.judgment-form input.emote {
position: relative;
width: 1.818rem;
height: 1.918rem;
padding: 0;
margin: 0;
border: 0;
font-size: 1.618em;
background: none;
transition: 0.3s filter, 0.2s transform;
cursor: pointer;
z-index: 2;
}
.judgment-form:not(.selected) input.emote {
filter: grayscale(1.0);
}
.judgment-form.selected:focus-within::after,
.judgment-form.selected:hover::after {
content: "×";
pointer-events: none;
position: absolute;
top: 0;
left: 0;
color: darkred;
opacity: 0.8;
font-size: 3em;
z-index: 3;
line-height: 62%;
}
.judgment-form input.emote:hover,
.judgment-form input.emote:focus {
background: none;
filter: grayscale(0.0);
transform: scale(1.1);
}
// __ __ _ _ ____ __ _ _
// | \/ | ___ _ __(_) |_| _ \ _ __ ___ / _(_) | ___
// | |\/| |/ _ \ '__| | __| |_) | '__/ _ \| |_| | |/ _ \
// | | | | __/ | | | |_| __/| | | (_) | _| | | __/
// |_| |_|\___|_| |_|\__|_| |_| \___/|_| |_|_|\___|
//
.background-merit-profile {
position: absolute;
top: -0.29em;
left: -0.3em;
z-index: 1;
}
// ____ _ _ _ _
// / ___|__ _ _ __ __| (_) __| | __ _| |_ ___ ___
// | | / _` | '_ \ / _` | |/ _` |/ _` | __/ _ \/ __|
// | |__| (_| | | | | (_| | | (_| | (_| | || __/\__ \
// \____\__,_|_| |_|\__,_|_|\__,_|\__,_|\__\___||___/
//
ul.candidates.list li {
display: flex;
align-items: center; /* align vertically */
}

@ -34,6 +34,7 @@
@import "_dashboard";
@import "_admin";
@import "_explore";
@import "_poll";
@import "_review";
@import "_package";

Loading…
Cancel
Save