diff --git a/.gitignore b/.gitignore index 2cb2a205e..8bafec3e8 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ coverage.all /gitea /gitea-vet +/gitea-repositories /debug /integrations.test diff --git a/models/error.go b/models/error.go index 6e110f94d..c1ca7a9b7 100644 --- a/models/error.go +++ b/models/error.go @@ -2136,3 +2136,43 @@ func IsErrOAuthApplicationNotFound(err error) bool { func (err ErrOAuthApplicationNotFound) Error() string { return fmt.Sprintf("OAuth application not found [ID: %d]", err.ID) } + +// ___ _ _ __ _ _ _ +// | _ \___| | | / _|___ _ | |_ _ __| |__ _ _ __ ___ _ _| |_ +// | _/ _ \ | | > _|_ _| | || | || / _` / _` | ' \/ -_) ' \ _| +// |_| \___/_|_| \_____| \__/ \_,_\__,_\__, |_|_|_\___|_||_\__| +// |___/ + +// 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 + 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) +} diff --git a/models/models.go b/models/models.go index 6c92e8d65..d73752469 100644 --- a/models/models.go +++ b/models/models.go @@ -134,6 +134,8 @@ func init() { new(ProjectIssue), new(Session), new(RepoTransfer), + new(Poll), + new(PollJudgment), ) gonicNames := []string{"SSL", "UID"} diff --git a/models/poll.go b/models/poll.go new file mode 100644 index 000000000..186b8f1ed --- /dev/null +++ b/models/poll.go @@ -0,0 +1,331 @@ +// 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/setting" + "fmt" + "strings" + + //"code.gitea.io/gitea/modules/references" + "code.gitea.io/gitea/modules/timeutil" + //"fmt" + "xorm.io/xorm" +) + +// A Poll on with the issues of a repository as candidates. +type Poll struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX"` + Repo *Repository `xorm:"-"` + AuthorID int64 `xorm:"INDEX"` + Author *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) + 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, candidateID int64) (judgmernt *PollJudgment) { + 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) + + 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 + Repo *Repository + Subject string + Description string + //Grades string +} + +func CreatePoll(opts *CreatePollOptions) (poll *Poll, err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return nil, err + } + + poll, err = createPoll(sess, opts) + if err != nil { + return nil, err + } + + if err = sess.Commit(); err != nil { + return nil, err + } + + return poll, nil +} + +func createPoll(e *xorm.Session, 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 + } + + 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) + 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 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) { + return getPollByRepoID(x, repoID, id) +} + +// _ _ _ _ +// | | | |_ __ __| | __ _| |_ ___ +// | | | | '_ \ / _` |/ _` | __/ _ \ +// | |_| | |_) | (_| | (_| | || __/ +// \___/| .__/ \__,_|\__,_|\__\___| +// |_| + +func updatePoll(e 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(m *Poll) error { + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + if err := updatePoll(sess, m); err != nil { + return err + } + + //if err := updatePollCompleteness(sess, m.ID); err != nil { + // return err + //} + + return sess.Commit() +} + +// ____ _ _ +// | _ \ ___| | ___| |_ ___ +// | | | |/ _ \ |/ _ \ __/ _ \ +// | |_| | __/ | __/ || __/ +// |____/ \___|_|\___|\__\___| +// + +// 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 + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.ID(m.ID).Delete(new(Poll)); err != nil { + return err + } + + return sess.Commit() +} diff --git a/models/poll_candidate_merit_profile.go b/models/poll_candidate_merit_profile.go new file mode 100644 index 000000000..f4e076d31 --- /dev/null +++ b/models/poll_candidate_merit_profile.go @@ -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)) +} diff --git a/models/poll_deliberator.go b/models/poll_deliberator.go new file mode 100644 index 000000000..dab69d4cd --- /dev/null +++ b/models/poll_deliberator.go @@ -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. ) + +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 +} diff --git a/models/poll_deliberator_test.go b/models/poll_deliberator_test.go new file mode 100644 index 000000000..72eb83c50 --- /dev/null +++ b/models/poll_deliberator_test.go @@ -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) +} diff --git a/models/poll_judgment.go b/models/poll_judgment.go new file mode 100644 index 000000000..855020f16 --- /dev/null +++ b/models/poll_judgment.go @@ -0,0 +1,187 @@ +// 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/modules/references" + "code.gitea.io/gitea/modules/timeutil" + "fmt" + + //"fmt" + "xorm.io/xorm" +) + +// A Judgment on 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 `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 + Grade uint8 + CandidateID int64 +} + +type UpdateJudgmentOptions struct { + Poll *Poll + Judge *User + Grade uint8 + CandidateID int64 +} + +type DeleteJudgmentOptions struct { + Poll *Poll + Judge *User + CandidateID int64 +} + +func CreateJudgment(opts *CreateJudgmentOptions) (judgment *PollJudgment, err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return nil, err + } + + judgment, err = createJudgment(sess, opts) + if err != nil { + return nil, err + } + + if err = sess.Commit(); err != nil { + return nil, err + } + + return judgment, nil +} + +func createJudgment(e *xorm.Session, 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, + } + //e.Find() + 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 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 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) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return nil, err + } + + judgment, errJ := getJudgmentOfJudgeOnPollCandidate(sess, opts.Judge.ID, opts.Poll.ID, opts.CandidateID) + if nil != errJ { + return nil, errJ + } + + judgment.Grade = opts.Grade + + _, err = sess.ID(judgment.ID). + Cols("grade", "updated_unix"). + Update(judgment) + if err != nil { + return nil, err + } + + if err = sess.Commit(); err != nil { + return nil, err + } + + return judgment, nil +} + +func DeleteJudgment(opts *DeleteJudgmentOptions) (err error) { + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + judgment, errJ := getJudgmentOfJudgeOnPollCandidate(sess, opts.Judge.ID, opts.Poll.ID, opts.CandidateID) + if nil != errJ { + return errJ + } + + if _, errD := sess.Delete(judgment); nil != errD { + return errD + } + + if err = sess.Commit(); nil != err { + return err + } + + return nil +} diff --git a/models/poll_judgment_test.go b/models/poll_judgment_test.go new file mode 100644 index 000000000..035f78fff --- /dev/null +++ b/models/poll_judgment_test.go @@ -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) +} diff --git a/models/poll_result.go b/models/poll_result.go new file mode 100644 index 000000000..d11ac15e7 --- /dev/null +++ b/models/poll_result.go @@ -0,0 +1,78 @@ +// 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" + "strconv" +) + +// ____ _ _ _ _ ____ _ _ +// / ___|__ _ _ __ __| (_) __| | __ _| |_ ___| _ \ ___ ___ _ _| | |_ +// | | / _` | '_ \ / _` | |/ _` |/ _` | __/ _ \ |_) / _ \/ __| | | | | __| +// | |__| (_| | | | | (_| | | (_| | (_| | || __/ _ < __/\__ \ |_| | | |_ +// \____\__,_|_| |_|\__,_|_|\__,_|\__,_|\__\___|_| \_\___||___/\__,_|_|\__| +// + +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) { // FIXME + isssue, err := GetIssueByID(result.CandidateID) + if nil != err { + return "Candidate #" + strconv.FormatInt(result.CandidateID, 10) + } + return isssue.Title +} + +// ____ _ _ _ _ ____ _ _ +// / ___|__ _ _ __ __| (_) __| | __ _| |_ ___| _ \ ___ ___ _ _| | |_ ___ +// | | / _` | '_ \ / _` | |/ _` |/ _` | __/ _ \ |_) / _ \/ __| | | | | __/ __| +// | |__| (_| | | | | (_| | | (_| | (_| | || __/ _ < __/\__ \ |_| | | |_\__ \ +// \____\__,_|_| |_|\__,_|_|\__,_|\__,_|\__\___|_| \_\___||___/\__,_|_|\__|___/ +// + +// PollCandidateResults implements 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 +} diff --git a/models/poll_tally.go b/models/poll_tally.go new file mode 100644 index 000000000..9531740d6 --- /dev/null +++ b/models/poll_tally.go @@ -0,0 +1,214 @@ +// 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) + //for _, grade := range pct.Poll.GetGrades() { + if grade < aroundGrade { + belowGroupSize += int(pct.Grades[grade].Amount) + belowGroupGrade = grade + } + if grade > aroundGrade { + aboveGroupSize += int(pct.Grades[grade].Amount) + if 0 == aboveGroupGrade { + aboveGroupGrade = grade + } + } + } + + 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 +} diff --git a/models/poll_tally_test.go b/models/poll_tally_test.go new file mode 100644 index 000000000..9a1a1c1fa --- /dev/null +++ b/models/poll_tally_test.go @@ -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(), + } +} diff --git a/models/poll_test.go b/models/poll_test.go new file mode 100644 index 000000000..0747b8364 --- /dev/null +++ b/models/poll_test.go @@ -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) +} diff --git a/models/repo.go b/models/repo.go index 61b0e0f5b..2fee15649 100644 --- a/models/repo.go +++ b/models/repo.go @@ -532,6 +532,10 @@ func (repo *Repository) deleteWiki(e Engine) error { return err } +func (repo *Repository) GetPolls() (_ PollList, err error) { + return GetPolls(repo.ID, 0) +} + func (repo *Repository) getAssignees(e Engine) (_ []*User, err error) { if err = repo.getOwner(e); err != nil { return nil, err diff --git a/modules/auth/poll_form.go b/modules/auth/poll_form.go new file mode 100644 index 000000000..15ecc8a97 --- /dev/null +++ b/modules/auth/poll_form.go @@ -0,0 +1,17 @@ +package auth // not sure why this is in package auth? + +import ( + "gitea.com/macaron/binding" + "gitea.com/macaron/macaron" +) + +// Form for creating a poll +type CreatePollForm struct { + Subject string `binding:"Required;MaxSize(128)"` // 128 is duplicated in the template + Description string +} + +// Validate validates the form fields +func (f *CreatePollForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { + return validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index a2320a20e..9f2195321 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1395,6 +1395,36 @@ 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.view = View Poll +polls.new = New Poll +polls.new.subheader = This poll will use Majority Judgment on all the Issues +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 diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini index 11bca5a9d..2ecc9a2a4 100644 --- a/options/locale/locale_fr-FR.ini +++ b/options/locale/locale_fr-FR.ini @@ -1192,6 +1192,36 @@ signing.wont_sign.headsigned=La fusion ne sera pas signée car la révision sour signing.wont_sign.commitssigned=La fusion ne sera pas signée car toutes les révisions associées ne sont pas signées signing.wont_sign.approved=La fusion ne sera pas signée car la PR n'a pas approuvée +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.view = Voir le Scrutin +polls.new = Nouveau Scrutin +polls.new.subheader = Ce scrutin appliquera le Jugement Majoritaire +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. diff --git a/routers/repo/issue.go b/routers/repo/issue.go index fb7dac297..ce6597532 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -1562,6 +1562,13 @@ func ViewIssue(ctx *context.Context) { return } + // Get Polls + ctx.Data["Polls"], err = ctx.Repo.Repository.GetPolls() + if err != nil { + ctx.ServerError("IssueView.GetPolls", err) + return + } + ctx.Data["Participants"] = participants ctx.Data["NumParticipants"] = len(participants) ctx.Data["Issue"] = issue diff --git a/routers/repo/poll.go b/routers/repo/poll.go new file mode 100644 index 000000000..2a3961e36 --- /dev/null +++ b/routers/repo/poll.go @@ -0,0 +1,181 @@ +// 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/auth" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/markup/markdown" + //"code.gitea.io/gitea/modules/setting" + //"code.gitea.io/gitea/modules/timeutil" + //"time" +) + +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.QueryInt("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 auth.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.User, + 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 = 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 edting poll +func EditPollPost(ctx *context.Context, form auth.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 delete 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", + }) +} diff --git a/routers/repo/poll_judgment.go b/routers/repo/poll_judgment.go new file mode 100644 index 000000000..c1a93b717 --- /dev/null +++ b/routers/repo/poll_judgment.go @@ -0,0 +1,100 @@ +// 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/setting" + "path" + + //"code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + //"code.gitea.io/gitea/modules/setting" + //"code.gitea.io/gitea/modules/timeutil" + //"time" +) + +type CreateJudgmentResponse struct { + Judgment *models.PollJudgment +} + +// Creates, Updates or Deletes a Judgment depending on the parameters +func EmitJudgment(ctx *context.Context) { + judge := ctx.User + + grade := uint8(ctx.QueryInt("grade")) // 0 if not defined + if grade < 1 { + grade = 0 + } + + pollId := ctx.ParamsInt64(":id") + candidateID := ctx.QueryInt64("candidate") + + poll, errP := models.GetPollByRepoID(ctx.Repo.Repository.ID, pollId) + if nil != errP { + ctx.NotFound("EmitJudgment.GetPollByRepoID", errP) + return + } + + judgment := poll.GetJudgmentOnCandidate(ctx.User, 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.QueryTrim("redirect") + ctx.Redirect(path.Join(setting.AppSubURL, redirectPath)) + + // Nice, but yields too much (passwords, lol) + //ctx.JSON(200, &CreateJudgmentResponse{ + // Judgment: judgment, + //}) +} diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index 71963d698..97d024be6 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -154,6 +154,13 @@ {{end}} + {{/* TODO: make a permission for reading polls and use it here */}} + {{ if (.Permission.CanReadAny $.UnitTypePullRequests $.UnitTypeIssues $.UnitTypeReleases) }} + + {{svg "octicon-law" 16}} {{.i18n.Tr "repo.polls"}} + + {{ end }} + {{if and (.Permission.CanReadAny $.UnitTypePullRequests $.UnitTypeIssues $.UnitTypeReleases) (not .IsEmptyRepo)}} {{svg "octicon-pulse"}} {{.i18n.Tr "repo.activity"}} diff --git a/templates/repo/issue/view_title.tmpl b/templates/repo/issue/view_title.tmpl index f6cbb9206..973ad86cb 100644 --- a/templates/repo/issue/view_title.tmpl +++ b/templates/repo/issue/view_title.tmpl @@ -19,6 +19,7 @@ {{end}} + {{if .HasMerged}}
{{svg "octicon-git-merge" 16}} {{if eq .Issue.PullRequest.Status 3}}{{.i18n.Tr "repo.pulls.manually_merged"}}{{else}}{{.i18n.Tr "repo.pulls.merged"}}{{end}}
{{else if .Issue.IsClosed}} @@ -29,6 +30,10 @@
{{svg "octicon-issue-opened"}} {{.i18n.Tr "repo.issues.open_title"}}
{{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}} diff --git a/templates/repo/polls/merit_radial.tmpl b/templates/repo/polls/merit_radial.tmpl new file mode 100644 index 000000000..f046d74a5 --- /dev/null +++ b/templates/repo/polls/merit_radial.tmpl @@ -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) */}} + + +{{ 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 */}} + +{{ 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" }} + + {{ if eq $grade_id 0 }} + + {{ end }} +{{ end }} + diff --git a/templates/repo/polls/poll_badge.tmpl b/templates/repo/polls/poll_badge.tmpl new file mode 100644 index 000000000..5a6d717c1 --- /dev/null +++ b/templates/repo/polls/poll_badge.tmpl @@ -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 */}} +
+ +
+ {{/* Oddly enough, this svg requires javascript. Hmmm… */}} + {{ svg "octicon-law" 16 }} + {{ .Poll.Subject }} + {{ if $pollCandidateResult -}} + N°{{ $pollCandidateResult.Position }} + {{- end }} +
+ + {{ if $.Super.IsSigned }} +
+ {{ range $grade, $icon := .Poll.GetGradationList }} +
+ {{ $.Super.CsrfTokenHtml }} + + + + + {{ if $pollCandidateResult }} + {{ if eq $pollCandidateResult.MedianGrade $grade }} +
+ {{ template "repo/polls/merit_radial" $pollCandidateResult.MeritProfile }} +
+ {{ end }} + {{ end }} + {{/* Add the emote AFTER the profile or the absolute of the profile is offset (somehow) */}} + +
+ {{ end }} +
+ {{ end }} + +
+ diff --git a/templates/repo/polls/polls_index.tmpl b/templates/repo/polls/polls_index.tmpl new file mode 100644 index 000000000..082bdbcfd --- /dev/null +++ b/templates/repo/polls/polls_index.tmpl @@ -0,0 +1,89 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} +
+ +
+ {{template "base/alert" .}} +
+ {{range .Polls}} +
  • + {{svg "octicon-law" 16}} {{.Subject}} +{{/*
    */}} +{{/*
    */}} +{{/*
    */}} +{{/*
    */}} +{{/*
    */}} +{{/*
    */}} +{{/* {{ $closedDate:= TimeSinceUnix .ClosedDateUnix $.Lang }}*/}} +{{/* {{if .IsClosed}}*/}} +{{/* {{svg "octicon-clock" 16}} {{$.i18n.Tr "repo.milestones.closed" $closedDate|Str2html}}*/}} +{{/* {{else}}*/}} +{{/* {{svg "octicon-calendar" 16}}*/}} +{{/* {{if .DeadlineString}}*/}} +{{/* {{.DeadlineString}}*/}} +{{/* {{else}}*/}} +{{/* {{$.i18n.Tr "repo.milestones.no_due_date"}}*/}} +{{/* {{end}}*/}} +{{/* {{end}}*/}} +{{/* */}} +{{/* {{svg "octicon-issue-opened" 16}} {{$.i18n.Tr "repo.issues.open_tab" .NumOpenIssues}}*/}} +{{/* {{svg "octicon-issue-closed" 16}} {{$.i18n.Tr "repo.issues.close_tab" .NumClosedIssues}}*/}} +{{/* {{if .TotalTrackedTime}}{{svg "octicon-clock" 16}} {{.TotalTrackedTime|Sec2Time}}{{end}}*/}} +{{/* */}} +{{/*
    */}} + {{if and (or $.CanWriteIssues $.CanWritePulls) (not $.Repository.IsArchived)}} +
    + {{svg "octicon-pencil" 16}} {{$.i18n.Tr "repo.polls.operate.edit"}} +{{/* {{if .IsClosed}}*/}} +{{/* {{svg "octicon-check" 16}} {{$.i18n.Tr "repo.milestones.open"}}*/}} +{{/* {{else}}*/}} +{{/* {{svg "octicon-x" 16}} {{$.i18n.Tr "repo.milestones.close"}}*/}} +{{/* {{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? */}} + {{svg "octicon-trashcan" 16}} {{$.i18n.Tr "repo.polls.operate.delete"}} +
    + {{end}} + {{if .Description}} +
    + {{.RenderedDescription|Str2html}} +
    + {{end}} +
  • + {{end}} + + {{template "base/paginate" .}} +
    +
    +
    + +{{if or .CanWriteIssues .CanWritePulls}} + +{{end}} +{{template "base/footer" .}} diff --git a/templates/repo/polls/polls_new.tmpl b/templates/repo/polls/polls_new.tmpl new file mode 100644 index 000000000..2288ec862 --- /dev/null +++ b/templates/repo/polls/polls_new.tmpl @@ -0,0 +1,78 @@ +{{template "base/head" .}} +
    + {{template "repo/header" .}} +
    +

    + {{if .PageIsEditPoll}} + {{.i18n.Tr "repo.polls.edit"}} +
    {{.i18n.Tr "repo.polls.edit.subheader"}}
    + {{else}} + {{.i18n.Tr "repo.polls.new"}} +
    {{.i18n.Tr "repo.polls.new.subheader"}}
    + {{end}} +

    + {{template "base/alert" .}} +
    + {{.CsrfTokenHtml}} +
    +
    + {{/* form_poll_subject or form-poll-subject ? */}} + + +
    +
    + + +
    +
    + + +
    +
    + + + + + +
    +
    + +
    +
    +
    + {{if .PageIsEditPoll}} + + {{.i18n.Tr "repo.polls.cancel"}} + + + {{else}} + + {{end}} +
    +
    +
    +
    +
    +{{template "base/footer" .}} diff --git a/templates/repo/polls/polls_view.tmpl b/templates/repo/polls/polls_view.tmpl new file mode 100644 index 000000000..114b7ba43 --- /dev/null +++ b/templates/repo/polls/polls_view.tmpl @@ -0,0 +1,62 @@ +{{ template "base/head" . }} + +{{/* + +Index of the candidates for this poll. +The candidates are sorted by decreasing poll success. + +*/}} + +
    + {{ template "repo/header" . }} +
    + +
    + {{ template "base/alert" . }} + +
    +

    {{ .Poll.Subject }}

    + + {{ if .Poll.RenderedDescription }} +
    +
    + {{ .Poll.RenderedDescription|Str2html }} +{{/* {{ .i18n.Tr "repo.polls.no_description" }}*/}} +
    +
    + {{ end }} +
    + + {{/* Move these away once they're stable */}} + + + {{ $pollResult := .Poll.GetResult }} + + + +
    +
    + +{{ template "base/footer" . }} diff --git a/web_src/js/features/notification.js b/web_src/js/features/notification.js index 4fa2d3c29..490258d4e 100644 --- a/web_src/js/features/notification.js +++ b/web_src/js/features/notification.js @@ -43,6 +43,7 @@ export async function initNotificationCount() { return; } + //NotificationSettings.EventSourceUpdateTime = 0; // hotfix for 60s /user/events, clear me when fixed if (NotificationSettings.EventSourceUpdateTime > 0 && !!window.EventSource && window.SharedWorker) { // Try to connect to the event source via the shared worker first const worker = new SharedWorker(`${__webpack_public_path__}js/eventsource.sharedworker.js`, 'notification-worker'); diff --git a/web_src/less/_poll.less b/web_src/less/_poll.less new file mode 100644 index 000000000..9b2a48e5a --- /dev/null +++ b/web_src/less/_poll.less @@ -0,0 +1,181 @@ + +// ____ _ _ +// | _ \ ___ | | |___ +// | |_) / _ \| | / __| +// | __/ (_) | | \__ \ +// |_| \___/|_|_|___/ +// + +// 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?) +*/ +.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; +} + +.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.05em; + left: 0.05em; + color: darkred; + opacity: 0.8; + font-size: 3em; + z-index: 3; +} + +.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 */ +} diff --git a/web_src/less/index.less b/web_src/less/index.less index e7f0b45de..b8a854852 100644 --- a/web_src/less/index.less +++ b/web_src/less/index.less @@ -29,6 +29,7 @@ @import "_dashboard"; @import "_admin"; @import "_explore"; +@import "_poll"; @import "_review"; @import "./helpers.less";