Made for Gitea 1.20.5 Features -------- - Adds an optional "Poll" tab menu to repositories. - Polls use Majority Judgment. - Badge Polls can be held per repository, using issues as candidates. - Badge Polls can target a subset of Issues and Merge Requests. - Everything works without javascript (except the "delete" action). Bouerk ------ - Code needs more polishing. - Needs more thinking to allow Inline Polls in Issues. - Ideally this whole thing should probably be a plugin and not a fork. - The merit profile is shown to all logged in, whether they judged or not.mj-v1.20.5
parent
4126aad4aa
commit
81c1ef0b16
@ -0,0 +1,29 @@
|
||||
#!/bin/env sh
|
||||
|
||||
# Helper tool to make AsciiArt titles as comments in the code.
|
||||
# This should probably be a recipe in the Makefile.
|
||||
#
|
||||
# Install
|
||||
# -------
|
||||
#
|
||||
# sudo apt install figlet xclip git
|
||||
# cd /tmp
|
||||
# git clone https://github.com/xero/figlet-fonts
|
||||
# sudo mv -n figlet-fonts/* /usr/share/figlet/
|
||||
# cd -
|
||||
|
||||
if [ "$#" -eq 0 ] ; then
|
||||
echo "Helper tool to generate AsciiArt titles as comments in the code."
|
||||
echo "It will automatically copy the result into your clipboard."
|
||||
echo ""
|
||||
echo "Usage: ./title.sh \"My Awesome Title\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
figlet -f Graffiti -w 300 "$@" \
|
||||
| sed -e 's|^|// |' \
|
||||
| sed -e 's/[ ]*$//' \
|
||||
| tee /dev/tty \
|
||||
| xclip -in -selection clipboard
|
||||
|
||||
#echo "\nThe above AsciiArt was also pasted into the clipboard."
|
@ -0,0 +1,268 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package poll
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issue_model "code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"fmt"
|
||||
mj "github.com/mieuxvoter/majority-judgment-library-go/judgment"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 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.
|
||||
|
||||
// The Subject of the poll should be short.
|
||||
// When the poll uses issues as candidates, the subject should be an issue's trait.
|
||||
// eg: Quality, Importance, Urgency, Wholeness, Relevance, Enthusiasm…
|
||||
Subject string `xorm:"name"`
|
||||
// Description may be used to describe at length the constitutive details of the 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:"-"`
|
||||
|
||||
// Examples of a Candidates Allowed List:
|
||||
// 15
|
||||
// 1, 12, #13
|
||||
// Ideas:
|
||||
// 3-13, 15-99
|
||||
// tag=bug, tag=bugfix, 21878
|
||||
// or perhaps
|
||||
// [bug], [bugfix], 21878
|
||||
// but ideally an issue _search bar_ field
|
||||
CandidatesAllowedList string `xorm:"candidates_allowed_list"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Poll))
|
||||
}
|
||||
|
||||
// PollList is a list of polls offering additional functionality (perhaps)
|
||||
type PollList []*Poll
|
||||
|
||||
// Link to the Poll view page. This panics half the time.
|
||||
func (poll *Poll) Link() string {
|
||||
return poll.Repo.Link() + fmt.Sprintf("/polls/%d", poll.ID)
|
||||
}
|
||||
|
||||
func (poll *Poll) GetGradationList() []string {
|
||||
list := make([]string, 0, 6)
|
||||
|
||||
// TODO 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) {
|
||||
// TODO 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) {
|
||||
// TODO 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"
|
||||
}
|
||||
}
|
||||
|
||||
// GetCandidatesIDs collects the IDs of Candidates having received at least one judgment.
|
||||
func (poll *Poll) GetCandidatesIDs() []int64 {
|
||||
ids := make([]int64, 0, 8)
|
||||
_ = db.GetEngine(db.DefaultContext).
|
||||
Table("poll_judgment").
|
||||
Select("DISTINCT `poll_judgment`.`candidate_id`").
|
||||
Where("`poll_judgment`.`poll_id` = ?", poll.ID).
|
||||
OrderBy("`poll_judgment`.`candidate_id` ASC").
|
||||
Find(&ids)
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// AllowsIssueAsCandidate checks if this Poll may use the provided Issue as Candidate
|
||||
func (poll *Poll) AllowsIssueAsCandidate(issue *issue_model.Issue) bool {
|
||||
if poll.CandidatesAllowedList == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Idea: Instead of handling our parsing here, perhaps use the search bar utils ?
|
||||
csvSeperator := regexp.MustCompile(`\s*,\s*`)
|
||||
issueIdRegex := regexp.MustCompile(`#?(?P<id>[0-9]+)`)
|
||||
issueIdIndex := issueIdRegex.SubexpIndex("id")
|
||||
|
||||
candidatesFilters := csvSeperator.Split(poll.CandidatesAllowedList, -1)
|
||||
for _, candidateFilter := range candidatesFilters {
|
||||
|
||||
issueIdMatches := issueIdRegex.FindStringSubmatch(candidateFilter)
|
||||
if nil != issueIdMatches {
|
||||
acceptedIssueID, err := strconv.Atoi(issueIdMatches[issueIdIndex])
|
||||
if nil == err {
|
||||
if issue.Index == int64(acceptedIssueID) {
|
||||
// fmt.Println("YEP")
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// … perhaps match tags as well, and other filters ? → search bar !?!
|
||||
// /!. CURRENT IMPLEMENTATION ABOVE WILL CHOKE ON COMMAS IN TAG NAMES
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (poll *Poll) GetIdOfCandidateAtIndex(candidateIndex int) int64 {
|
||||
for index, someCandidateID := range poll.GetCandidatesIDs() {
|
||||
if index == candidateIndex {
|
||||
return someCandidateID
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
func (poll *Poll) GetIndexOfCandidate(candidateID int64) int {
|
||||
for index, someCandidateID := range poll.GetCandidatesIDs() {
|
||||
if someCandidateID == candidateID {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
func (poll *Poll) GetNameOfCandidate(candidateID int64) string {
|
||||
issue, err := issue_model.GetIssueByIndex(poll.RepoID, candidateID)
|
||||
if err != nil {
|
||||
return "Issue not found"
|
||||
}
|
||||
|
||||
return issue.Title
|
||||
}
|
||||
|
||||
func (poll *Poll) GetJudgmentOnCandidate(judge *user_model.User, candidateID int64) *PollJudgment {
|
||||
judgment, err := getJudgmentOfJudgeOnPollCandidate(db.DefaultContext, judge.ID, poll.ID, candidateID)
|
||||
if nil != err {
|
||||
return nil
|
||||
}
|
||||
|
||||
return judgment
|
||||
}
|
||||
|
||||
func (poll *Poll) GetResult() (results *PollResult) {
|
||||
proposalsTallies := make([]*mj.ProposalTally, 0, 8)
|
||||
|
||||
for _, candidateID := range poll.GetCandidatesIDs() {
|
||||
tally := make([]uint64, 0, 8)
|
||||
for k, _ := range poll.GetGradationList() {
|
||||
gradeTally, _ := poll.CountGrades(candidateID, uint8(k))
|
||||
tally = append(tally, gradeTally)
|
||||
}
|
||||
proposalTally := &mj.ProposalTally{
|
||||
Tally: tally,
|
||||
}
|
||||
proposalsTallies = append(proposalsTallies, proposalTally)
|
||||
}
|
||||
|
||||
pollTally := &mj.PollTally{
|
||||
Proposals: proposalsTallies,
|
||||
}
|
||||
pollTally.GuessAmountOfJudges()
|
||||
_ = pollTally.BalanceWithStaticDefault(0)
|
||||
|
||||
ranker := &mj.MajorityJudgment{}
|
||||
pollResultBase, err := ranker.Deliberate(pollTally)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
pollResult := &PollResult{
|
||||
PollResult: *pollResultBase,
|
||||
Poll: poll,
|
||||
}
|
||||
|
||||
return pollResult
|
||||
}
|
||||
|
||||
func (poll *Poll) CountGrades(candidateID int64, grade uint8) (_ uint64, err error) {
|
||||
rows := make([]int64, 0, 2)
|
||||
|
||||
if err := db.GetEngine(db.DefaultContext).
|
||||
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
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package poll
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
)
|
||||
|
||||
type PollCandidateMeritProfile struct {
|
||||
Poll *Poll
|
||||
CandidateID int64 // Issue Index (or internal candidate index, later on)
|
||||
Rank uint64 // Two Candidates may share the same Rank. (perfect equality)
|
||||
Grades []uint64 // Amount of Judgments per Grade
|
||||
JudgmentsAmount uint64
|
||||
CreatedUnix timeutil.TimeStamp
|
||||
}
|
||||
|
||||
// _________
|
||||
// / _____/__ ______
|
||||
// \_____ \\ \/ / ___\
|
||||
// / \\ / /_/ >
|
||||
// /_______ / \_/\___ /
|
||||
// \/ /_____/
|
||||
|
||||
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, 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
|
||||
|
||||
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,196 @@
|
||||
package poll
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issue_model "code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// _________ __
|
||||
// \_ ___ \_______ ____ _____ _/ |_ ____
|
||||
// / \ \/\_ __ \_/ __ \\__ \\ __\/ __ \
|
||||
// \ \____| | \/\ ___/ / __ \| | \ ___/
|
||||
// \______ /|__| \___ >____ /__| \___ >
|
||||
// \/ \/ \/ \/
|
||||
|
||||
type CreatePollOptions struct {
|
||||
// Type PollType // TODO for inline polls with their own candidates?
|
||||
Author *user_model.User
|
||||
Repo *repo_model.Repository
|
||||
Subject string
|
||||
Description string
|
||||
CandidatesAllowedList string
|
||||
// Grades string
|
||||
}
|
||||
|
||||
// CreatePoll creates a new Poll with the provided options.
|
||||
func CreatePoll(options *CreatePollOptions) (poll *Poll, err error) {
|
||||
ctx, committer, err := db.TxContext(db.DefaultContext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
poll, err = createPoll(ctx, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = committer.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return poll, nil
|
||||
}
|
||||
|
||||
func createPoll(ctx context.Context, 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,
|
||||
CandidatesAllowedList: opts.CandidatesAllowedList,
|
||||
AreCandidatesIssues: true,
|
||||
}
|
||||
if _, err = db.GetEngine(ctx).Insert(poll); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Why do we load the owner here ?
|
||||
if err = opts.Repo.LoadOwner(ctx); 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.
|
||||
func GetPolls(repoID int64, page int) (PollList, error) {
|
||||
polls := make([]*Poll, 0, setting.UI.IssuePagingNum)
|
||||
query := db.GetEngine(db.DefaultContext).Where("repo_id = ?", repoID)
|
||||
if page > 0 {
|
||||
query = query.Limit(setting.UI.PollsPagingNum, (page-1)*setting.UI.PollsPagingNum)
|
||||
} else {
|
||||
query = query.Limit(setting.UI.PollsPagingNum)
|
||||
}
|
||||
|
||||
return polls, query.Find(&polls)
|
||||
}
|
||||
|
||||
func getPollByRepoID(ctx context.Context, repoID, id int64) (*Poll, error) {
|
||||
poll := new(Poll)
|
||||
has, err := db.GetEngine(ctx).ID(id).Where("repo_id = ?", repoID).Get(poll)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrPollNotFound{PollID: id, RepoID: repoID}
|
||||
}
|
||||
return poll, nil
|
||||
}
|
||||
|
||||
// GetPollByRepoID returns the poll in a repository.
|
||||
func GetPollByRepoID(repoID, id int64) (*Poll, error) {
|
||||
return getPollByRepoID(db.DefaultContext, repoID, id)
|
||||
}
|
||||
|
||||
// GetPollsUsingIssueAsCandidate returns the badge polls using the provided issue as candidate.
|
||||
func GetPollsUsingIssueAsCandidate(issue *issue_model.Issue) (PollList, error) {
|
||||
polls := make([]*Poll, 0, 8)
|
||||
query := db.GetEngine(db.DefaultContext).Where("repo_id = ?", issue.RepoID)
|
||||
|
||||
err := query.Find(&polls)
|
||||
if err != nil {
|
||||
return polls, err
|
||||
}
|
||||
|
||||
filteredPolls := make([]*Poll, 0, len(polls))
|
||||
for _, poll := range polls {
|
||||
if poll.AllowsIssueAsCandidate(issue) {
|
||||
filteredPolls = append(filteredPolls, poll)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredPolls, nil
|
||||
}
|
||||
|
||||
// CountPollsInRepo returns the amount of all the Polls in the provided repository.
|
||||
func CountPollsInRepo(repoID int64) (int64, error) {
|
||||
query := db.GetEngine(db.DefaultContext).Where("repo_id = ?", repoID)
|
||||
|
||||
return query.Count(new(Poll))
|
||||
}
|
||||
|
||||
// ____ ___ .___ __
|
||||
// | | \______ __| _/____ _/ |_ ____
|
||||
// | | /\____ \ / __ |\__ \\ __\/ __ \
|
||||
// | | / | |_> > /_/ | / __ \| | \ ___/
|
||||
// |______/ | __/\____ |(____ /__| \___ >
|
||||
// |__| \/ \/ \/
|
||||
|
||||
func updatePoll(ctx context.Context, poll *Poll) error {
|
||||
poll.Subject = strings.TrimSpace(poll.Subject)
|
||||
_, err := db.GetEngine(ctx).ID(poll.ID).AllCols().Update(poll)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdatePoll updates the information of the given poll.
|
||||
func UpdatePoll(m *Poll) error {
|
||||
ctx, committer, err := db.TxContext(db.DefaultContext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
if err = updatePoll(ctx, m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return committer.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 would be best perhaps?
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, committer, err := db.TxContext(db.DefaultContext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
if _, err = db.GetEngine(ctx).ID(m.ID).Delete(new(Poll)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
package poll
|
||||
|
||||
import (
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrPollNotFound represents an error when a poll was not found.
|
||||
type ErrPollNotFound struct {
|
||||
PollID int64
|
||||
RepoID int64
|
||||
}
|
||||
|
||||
// IsErrPollNotFound checks if an error is a ErrPollNotFound.
|
||||
func IsErrPollNotFound(err error) bool {
|
||||
_, ok := err.(ErrPollNotFound)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Error serializes the ErrPollNotFound error.
|
||||
func (err ErrPollNotFound) Error() string {
|
||||
return fmt.Sprintf("poll not found [id: %d, repo_id: %d]", err.PollID, err.RepoID)
|
||||
}
|
||||
|
||||
// ErrJudgmentNotFound is raised when a judgment was not found.
|
||||
// Perhaps this should only hold primitives, and not pointers ?
|
||||
type ErrJudgmentNotFound struct {
|
||||
Judge *user_model.User
|
||||
Poll *Poll
|
||||
CandidateID int64
|
||||
}
|
||||
|
||||
// IsErrJudgmentNotFound checks if an error is a ErrJudgmentNotFound.
|
||||
func IsErrJudgmentNotFound(err error) bool {
|
||||
_, ok := err.(ErrJudgmentNotFound)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Error serializes the ErrJudgmentNotFound error.
|
||||
func (err ErrJudgmentNotFound) Error() string {
|
||||
return fmt.Sprintf("judgment not found.")
|
||||
}
|
@ -0,0 +1,189 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package poll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
)
|
||||
|
||||
// PollJudgment represents 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_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"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(PollJudgment))
|
||||
}
|
||||
|
||||
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(db.DefaultContext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
judgment, err = createJudgment(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = committer.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return judgment, nil
|
||||
}
|
||||
|
||||
func createJudgment(ctx context.Context, opts *CreateJudgmentOptions) (*PollJudgment, 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 := db.GetEngine(ctx).Insert(judgment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//if err = updatePollInfos(ctx, opts, poll); err != nil {
|
||||
// return nil, err
|
||||
//}
|
||||
|
||||
return judgment, nil
|
||||
}
|
||||
|
||||
func getJudgmentByID(ctx context.Context, id int64) (*PollJudgment, error) {
|
||||
judgment := new(PollJudgment)
|
||||
has, err := db.GetEngine(ctx).ID(id).Get(judgment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrJudgmentNotFound{}
|
||||
}
|
||||
|
||||
return judgment, nil
|
||||
}
|
||||
|
||||
func getJudgmentOfJudgeOnPollCandidate(ctx context.Context, judgeID, pollID, candidateID int64) (judgment *PollJudgment, err error) {
|
||||
// We could probably use only one SQL query instead of two here.
|
||||
judgmentsIds := make([]int64, 0, 1)
|
||||
if err = db.GetEngine(ctx).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, err = getJudgmentByID(ctx, judgmentsIds[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return judgment, nil
|
||||
}
|
||||
|
||||
func UpdateJudgment(opts *UpdateJudgmentOptions) (judgment *PollJudgment, err error) {
|
||||
ctx, committer, err := db.TxContext(db.DefaultContext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
judgment, err = getJudgmentOfJudgeOnPollCandidate(ctx, opts.Judge.ID, opts.Poll.ID, opts.CandidateID)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
judgment.Grade = opts.Grade
|
||||
|
||||
_, err = db.GetEngine(ctx).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) error {
|
||||
ctx, committer, err := db.TxContext(db.DefaultContext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
judgment, err := getJudgmentOfJudgeOnPollCandidate(ctx, opts.Judge.ID, opts.Poll.ID, opts.CandidateID)
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = db.GetEngine(ctx).Delete(judgment); nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = committer.Commit(); nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package poll_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
poll_model "code.gitea.io/gitea/models/poll"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPollJudgment_Create(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
// issue := AssertExistsAndLoadBean(t, &Issue{ID: 2}).(*Issue)
|
||||
// assert.Equal(t, repo, issue.Repo)
|
||||
|
||||
poll, errp := poll_model.CreatePoll(&poll_model.CreatePollOptions{
|
||||
Repo: repo,
|
||||
Author: user,
|
||||
Subject: "Quality",
|
||||
})
|
||||
|
||||
assert.NoError(t, errp)
|
||||
assert.NotNil(t, poll)
|
||||
|
||||
judgment, err := poll_model.CreateJudgment(&poll_model.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 = poll_model.CreateJudgment(&poll_model.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 = poll_model.CreateJudgment(&poll_model.CreateJudgmentOptions{
|
||||
Judge: user,
|
||||
Poll: poll,
|
||||
Grade: 0,
|
||||
CandidateID: 2,
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, judgment)
|
||||
|
||||
// … you have to update it
|
||||
|
||||
judgment, err = poll_model.UpdateJudgment(&poll_model.UpdateJudgmentOptions{
|
||||
Judge: user,
|
||||
Poll: poll,
|
||||
Grade: 0,
|
||||
CandidateID: 2,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, judgment)
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package poll
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
mj "github.com/mieuxvoter/majority-judgment-library-go/judgment"
|
||||
)
|
||||
|
||||
// __________ .__ __
|
||||
// \______ \ ____ ________ __| |_/ |_
|
||||
// | _// __ \ / ___/ | \ |\ __\
|
||||
// | | \ ___/ \___ \| | / |_| |
|
||||
// |____|_ /\___ >____ >____/|____/__|
|
||||
// \/ \/ \/
|
||||
|
||||
type PollResult struct {
|
||||
mj.PollResult
|
||||
Poll *Poll
|
||||
}
|
||||
|
||||
func (result *PollResult) GetResultOfCandidate(candidateID int64) *mj.ProposalResult {
|
||||
index := result.Poll.GetIndexOfCandidate(candidateID)
|
||||
if index >= 0 && index < len(result.Proposals) {
|
||||
return result.Proposals[index]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (result *PollResult) GetMaritProfileOfCandidate(candidateID int64) *PollCandidateMeritProfile {
|
||||
candidateResult := result.GetResultOfCandidate(candidateID)
|
||||
meritProfile := &PollCandidateMeritProfile{
|
||||
Poll: result.Poll,
|
||||
CandidateID: candidateID,
|
||||
Rank: uint64(candidateResult.Rank),
|
||||
Grades: candidateResult.Tally.Tally,
|
||||
JudgmentsAmount: candidateResult.Tally.CountJudgments(),
|
||||
CreatedUnix: timeutil.TimeStampNow(),
|
||||
}
|
||||
|
||||
return meritProfile
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package poll_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
poll_model "code.gitea.io/gitea/models/poll"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPoll_Create(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
opts := poll_model.CreatePollOptions{
|
||||
Repo: repo,
|
||||
Author: user,
|
||||
Subject: "Quality",
|
||||
}
|
||||
poll, err := poll_model.CreatePoll(&opts)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, poll)
|
||||
}
|
@ -0,0 +1,226 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
poll_model "code.gitea.io/gitea/models/poll"
|
||||
"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/setting"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
)
|
||||
|
||||
const (
|
||||
tplPollsStart base.TplName = "repo/polls/start"
|
||||
tplPollsIndex base.TplName = "repo/polls/index"
|
||||
tplPollsView base.TplName = "repo/polls/view"
|
||||
tplPollsNew base.TplName = "repo/polls/new"
|
||||
)
|
||||
|
||||
// IndexPolls renders a paginated index of all the polls of the repository.
|
||||
func IndexPolls(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.polls.index.title")
|
||||
ctx.Data["PageIsPolls"] = true
|
||||
|
||||
page := ctx.FormInt("page")
|
||||
if page <= 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
total, err := poll_model.CountPollsInRepo(ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("IndexPolls→CountPollsInRepo", err)
|
||||
return
|
||||
}
|
||||
|
||||
pager := context.NewPagination(int(total), setting.UI.PollsPagingNum, page, 5)
|
||||
//pager.AddParam(ctx, "state", "State")
|
||||
ctx.Data["Page"] = pager
|
||||
|
||||
page = pager.Paginater.Current()
|
||||
|
||||
polls, err := poll_model.GetPolls(ctx.Repo.Repository.ID, page)
|
||||
if err != nil {
|
||||
ctx.ServerError("IndexPolls→GetPolls", err)
|
||||
return
|
||||
}
|
||||
|
||||
if 0 == len(polls) {
|
||||
ctx.HTML(200, tplPollsStart)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range polls {
|
||||
polls[i].RenderedDescription, err = markdown.RenderString(&markup.RenderContext{
|
||||
URLPrefix: ctx.Repo.RepoLink,
|
||||
Metas: ctx.Repo.Repository.ComposeMetas(),
|
||||
GitRepo: ctx.Repo.GitRepo,
|
||||
Ctx: ctx,
|
||||
}, polls[i].Description)
|
||||
if err != nil {
|
||||
ctx.ServerError("IndexPolls→RenderString", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["Polls"] = polls
|
||||
|
||||
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.HTML(200, tplPollsNew)
|
||||
}
|
||||
|
||||
// NewPollPost processes the "new poll" form and redirects.
|
||||
func NewPollPost(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.CreatePollForm)
|
||||
if ctx.HasError() {
|
||||
NewPoll(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Tr("repo.polls.new")
|
||||
ctx.Data["PageIsPolls"] = true
|
||||
|
||||
candidatesAllowedList := ""
|
||||
if form.CandidatesPool == "allowed_list" {
|
||||
candidatesAllowedList = form.CandidatesAllowedList
|
||||
}
|
||||
|
||||
poll, err := poll_model.CreatePoll(&poll_model.CreatePollOptions{
|
||||
Author: ctx.Doer,
|
||||
Repo: ctx.Repo.Repository,
|
||||
Subject: form.Subject,
|
||||
Description: form.Description,
|
||||
CandidatesAllowedList: candidatesAllowedList,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("NewPollPost→CreatePoll", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.polls.create.success", form.Subject))
|
||||
ctx.Redirect(poll.Link())
|
||||
}
|
||||
|
||||
// ViewPoll renders a page displaying a single poll.
|
||||
func ViewPoll(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.polls.view")
|
||||
ctx.Data["PageIsPolls"] = true
|
||||
|
||||
poll, err := poll_model.GetPollByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
|
||||
if nil != err {
|
||||
if poll_model.IsErrPollNotFound(err) {
|
||||
ctx.NotFound("", nil)
|
||||
} else {
|
||||
ctx.ServerError("ViewPoll→GetPollByRepoID", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
poll.RenderedDescription, err = markdown.RenderString(&markup.RenderContext{
|
||||
Ctx: ctx,
|
||||
URLPrefix: ctx.Repo.RepoLink,
|
||||
Metas: ctx.Repo.Repository.ComposeMetas(),
|
||||
}, poll.Description)
|
||||
|
||||
ctx.Data["Poll"] = poll
|
||||
ctx.HTML(200, tplPollsView)
|
||||
}
|
||||
|
||||
// EditPoll renders the form for editing a poll.
|
||||
func EditPoll(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.polls.edit")
|
||||
ctx.Data["PageIsPolls"] = true
|
||||
ctx.Data["PageIsEditPoll"] = true
|
||||
|
||||
poll, err := poll_model.GetPollByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
|
||||
if nil != err {
|
||||
if poll_model.IsErrPollNotFound(err) {
|
||||
ctx.NotFound("", nil)
|
||||
} else {
|
||||
ctx.ServerError("EditPoll→GetPollByRepoID", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
candidatesPool := "issues_merges"
|
||||
if poll.CandidatesAllowedList != "" {
|
||||
candidatesPool = "allowed_list"
|
||||
}
|
||||
|
||||
ctx.Data["subject"] = poll.Subject
|
||||
ctx.Data["description"] = poll.Description
|
||||
ctx.Data["candidatesPool"] = candidatesPool
|
||||
ctx.Data["candidatesAllowedList"] = poll.CandidatesAllowedList
|
||||
|
||||
ctx.Data["redirect"] = ctx.FormString("redirect")
|
||||
ctx.HTML(200, tplPollsNew)
|
||||
}
|
||||
|
||||
// EditPollPost handles the response for editing a 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
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.HTML(200, tplPollsNew)
|
||||
return
|
||||
}
|
||||
|
||||
poll, err := poll_model.GetPollByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
|
||||
if err != nil {
|
||||
if poll_model.IsErrPollNotFound(err) {
|
||||
ctx.NotFound("", nil)
|
||||
} else {
|
||||
ctx.ServerError("EditPollPost→GetPollByRepoID", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
poll.Subject = form.Subject
|
||||
poll.Description = form.Description
|
||||
poll.CandidatesAllowedList = ""
|
||||
if form.CandidatesPool == "allowed_list" {
|
||||
poll.CandidatesAllowedList = form.CandidatesAllowedList
|
||||
}
|
||||
|
||||
if err = poll_model.UpdatePoll(poll); err != nil {
|
||||
ctx.ServerError("EditPollPost→UpdatePoll", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.polls.edit.success", poll.Subject))
|
||||
|
||||
redirect_path := ctx.FormString("redirect")
|
||||
if redirect_path != "" {
|
||||
ctx.Redirect(redirect_path)
|
||||
return
|
||||
}
|
||||
//ctx.Redirect(poll.Link()) // nope, panic
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/polls")
|
||||
}
|
||||
|
||||
// DeletePoll deletes a poll and redirects to the polls index.
|
||||
func DeletePoll(ctx *context.Context) {
|
||||
if err := poll_model.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,104 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"path"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
poll_model "code.gitea.io/gitea/models/poll"
|
||||
"code.gitea.io/gitea/modules/context"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
type CreateJudgmentResponse struct {
|
||||
Judgment *poll_model.PollJudgment
|
||||
}
|
||||
|
||||
// EmitJudgment creates, updates or deletes a Judgment depending on the parameters.
|
||||
func EmitJudgment(ctx *context.Context) {
|
||||
judge := ctx.Doer
|
||||
|
||||
grade := uint8(ctx.FormInt("grade")) // 0 if not defined
|
||||
if grade < 1 {
|
||||
grade = 0
|
||||
}
|
||||
|
||||
pollId := ctx.ParamsInt64(":id")
|
||||
candidateID := ctx.FormInt64("candidate")
|
||||
|
||||
poll, errP := poll_model.GetPollByRepoID(ctx.Repo.Repository.ID, pollId)
|
||||
if nil != errP {
|
||||
ctx.NotFound("EmitJudgment.GetPollByRepoID", errP)
|
||||
return
|
||||
}
|
||||
|
||||
issue, errI := issues_model.GetIssueByIndex(ctx.Repo.Repository.ID, candidateID)
|
||||
if nil != errI {
|
||||
ctx.NotFound("EmitJudgment.GetIssueByIndex", errI)
|
||||
return
|
||||
}
|
||||
if false == poll.AllowsIssueAsCandidate(issue) {
|
||||
ctx.NotFound("EmitJudgment.AllowsIssueAsCandidate", nil)
|
||||
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.
|
||||
// This allows us to click again on the emote to remove our judgment.
|
||||
errD := poll_model.DeleteJudgment(&poll_model.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 {
|
||||
// The judgment exists and the grade is different, so Update it.
|
||||
_, errU := poll_model.UpdateJudgment(&poll_model.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 {
|
||||
// Create a judgment, since none existed for this candidate and judge.
|
||||
_, errC := poll_model.CreateJudgment(&poll_model.CreateJudgmentOptions{
|
||||
Poll: poll,
|
||||
Judge: judge,
|
||||
Grade: grade,
|
||||
CandidateID: candidateID,
|
||||
})
|
||||
if nil != errC {
|
||||
ctx.ServerError("EmitJudgment.EmitJudgment", errC)
|
||||
return
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.polls.judgments.create.success"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
redirectPath := ctx.FormTrim("redirect")
|
||||
ctx.Redirect(path.Join(setting.AppSubURL, redirectPath))
|
||||
|
||||
// Nice, but yields too much (passwords, lol)
|
||||
//ctx.JSON(200, &CreateJudgmentResponse{
|
||||
// Judgment: judgment,
|
||||
//})
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/context"
|
||||
"code.gitea.io/gitea/modules/web/middleware"
|
||||
|
||||
"gitea.com/go-chi/binding"
|
||||
)
|
||||
|
||||
// __________ .__ .__ ___________
|
||||
// \______ \____ | | | | \_ _____/__________ _____ ______
|
||||
// | ___/ _ \| | | | | __)/ _ \_ __ \/ \ / ___/
|
||||
// | | ( <_> ) |_| |__ | \( <_> ) | \/ Y Y \\___ \
|
||||
// |____| \____/|____/____/ \___ / \____/|__| |__|_| /____ >
|
||||
// \/ \/ \/
|
||||
|
||||
// CreatePollForm is used when creating a new poll.
|
||||
type CreatePollForm struct {
|
||||
Subject string `binding:"Required;MaxSize(128)"` // 128 is duplicated in the template
|
||||
Description string
|
||||
CandidatesPool string
|
||||
CandidatesAllowedList string
|
||||
}
|
||||
|
||||
// Validate the poll creation form fields.
|
||||
func (f *CreatePollForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
|
||||
ctx := context.GetValidateContext(req)
|
||||
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
{{template "base/head" .}}
|
||||
<div role="main" aria-label="{{.Title}}" class="page-content repository polls">
|
||||
{{template "repo/header" .}}
|
||||
<div class="ui container">
|
||||
<div class="navbar gt-mb-4">
|
||||
<div></div>{{/* we have nothing to show on the left for now */}}
|
||||
<div class="group">
|
||||
{{template "repo/polls/navbar/new" .}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "base/alert" .}}
|
||||
|
||||
<div class="poll list milestone-list">
|
||||
{{range .Polls}}
|
||||
<li class="item milestone-card">
|
||||
<h3 id="poll-{{.ID}}" class="flex-text-block gt-m-0">
|
||||
{{svg "octicon-law" 16}}
|
||||
<a class="muted" href="{{$.RepoLink}}/polls/{{.ID}}">{{.Subject}}</a>
|
||||
</h3>
|
||||
{{if and ($.CanWritePolls) (not $.Repository.IsArchived)}}
|
||||
<div class="milestone-toolbar">
|
||||
<div class="group">
|
||||
</div>
|
||||
<div class="group">
|
||||
{{/* What are the purposes of data-id and data-title here ? */}}
|
||||
<a class="flex-text-inline" href="{{$.Link}}/{{.ID}}/edit?redirect={{$.Link}}{{"?page="}}{{$.Page.Paginater.Current}}{{"#poll-"}}{{.ID}}" data-id="{{.ID}}" data-title="{{.Subject}}">
|
||||
{{svg "octicon-pencil" 14}}
|
||||
{{$.locale.Tr "repo.polls.operate.edit"}}
|
||||
</a>
|
||||
{{/* href is empty on purpose -- people without js do not want to delete by misclick. */}}
|
||||
{{/* there might also be a DELETE HTTP Method lurking around, or is it API only? */}}
|
||||
<a class="flex-text-inline delete-button" href data-url="{{$.Link}}/{{.ID}}/delete" data-id="{{.ID}}">
|
||||
{{svg "octicon-trash" 14}}
|
||||
{{$.locale.Tr "repo.polls.operate.delete"}}
|
||||
</a>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{{template "repo/polls/modal/delete" .}}
|
||||
|
||||
{{template "base/footer" .}}
|
@ -0,0 +1,63 @@
|
||||
{{$merit := .}}
|
||||
|
||||
{{/*
|
||||
|
||||
Radial representation of a merit profile.
|
||||
The $merit above is an instance of PollCandidateMeritProfile.
|
||||
In this SVG, the ViewBox is centered on (0.0), and both axes go from -1 to 1.
|
||||
|
||||
*/}}
|
||||
|
||||
{{$size := 32}}{{/* 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) - (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"
|
||||
viewBox="-1 -1 2 2"{{/* min_x, min_y, width, height */}}
|
||||
>
|
||||
{{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. */}}
|
||||
{{/* If we can do this all in one pass, by resizing the arcs, it would be faster. */}}
|
||||
{{/* Keep in mind that in that case we would 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,13 @@
|
||||
{{/* Confirmation modal that pops in whenever we try to delete a poll. */}}
|
||||
{{if .CanWritePolls}}
|
||||
<div class="ui g-modal-confirm delete modal">
|
||||
<div class="header">
|
||||
{{svg "octicon-trash"}}
|
||||
{{.locale.Tr "repo.polls.modal.deletion.header"}}
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>{{.locale.Tr "repo.polls.modal.deletion.description"}}</p>
|
||||
</div>
|
||||
{{template "base/modal_actions_confirm" .}}
|
||||
</div>
|
||||
{{end}}
|
@ -0,0 +1,3 @@
|
||||
{{if and (.CanWritePolls) (not .Repository.IsArchived)}}
|
||||
<a class="ui small green button" href="{{$.RepoLink}}/polls/new">{{.locale.Tr "repo.polls.new"}}</a>
|
||||
{{end}}
|
@ -0,0 +1,73 @@
|
||||
{{template "base/head" .}}
|
||||
<div role="main" aria-label="{{.Title}}" class="page-content repository polls new">
|
||||
{{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}}
|
||||
<input type="hidden" name="redirect" value="{{.redirect}}">
|
||||
<div class="twelve wide column">
|
||||
<div class="field {{if .Err_Title}}error{{end}}">
|
||||
<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">
|
||||
<label>{{.locale.Tr "repo.polls.candidates.label"}}</label>
|
||||
<label>
|
||||
<input type="radio" name="candidates_pool" value="issues_merges"{{if not (eq .candidatesPool "allowed_list")}} checked{{end}}>
|
||||
{{.locale.Tr "repo.polls.candidates.pool.issues_merges"}}
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="candidates_pool" value="allowed_list"{{if eq .candidatesPool "allowed_list"}} checked{{end}}>
|
||||
{{.locale.Tr "repo.polls.candidates.pool.indices"}} :
|
||||
<input class="inline-input" type="text" name="candidates_allowed_list"
|
||||
title="{{.locale.Tr "repo.polls.candidates.pool.indices.help"}}"
|
||||
value="{{.candidatesAllowedList}}"
|
||||
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="{{if .redirect}}{{.redirect}}{{else}}{{.RepoLink}}/polls{{end}}">
|
||||
{{.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,72 @@
|
||||
{{/*
|
||||
A buttonish label for polls candidates.
|
||||
No javascript for now. There may be _some_ later to avoid reloading the whole page.
|
||||
.Poll : the Poll in question
|
||||
.CandidateID : the Candidate
|
||||
.Super : $ of parent template
|
||||
*/}}
|
||||
|
||||
{{$pollResult := .Poll.GetResult}}
|
||||
|
||||
{{if $pollResult}}
|
||||
|
||||
{{$candidateID := .CandidateID}}
|
||||
{{$pollID := .Poll.ID}}
|
||||
{{$pollCandidateResult := ($pollResult.GetResultOfCandidate $candidateID)}}
|
||||
{{$medianColorWord := "grey"}}
|
||||
{{if $pollCandidateResult}}
|
||||
{{$medianColorWord = (.Poll.GetGradeColorWord $pollCandidateResult.Analysis.MedianGrade)}}
|
||||
{{end}}
|
||||
{{$judgment := ""}}
|
||||
{{if $.Super.IsSigned}}
|
||||
{{$judgment = (.Poll.GetJudgmentOnCandidate $.Super.SignedUser $candidateID)}}
|
||||
{{end}}
|
||||
{{$userGrade := -1}}
|
||||
{{if $judgment}}
|
||||
{{$userGrade = $judgment.Grade}}
|
||||
{{end}}
|
||||
|
||||
{{/* Wrapper for the :hover */}}
|
||||
<div class="ui poll-badge" tabindex="0">
|
||||
|
||||
<div class="ui label {{$medianColorWord}} issue-state-label">
|
||||
{{svg "octicon-law"}}
|
||||
{{.Poll.Subject}}
|
||||
{{if $pollCandidateResult -}}
|
||||
N°{{$pollCandidateResult.Rank}}
|
||||
{{- 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.Analysis.MedianGrade $grade}}
|
||||
<div class="background-merit-profile">
|
||||
{{template "repo/polls/merit_radial" ($pollResult.GetMaritProfileOfCandidate $candidateID)}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{/* Add the emote AFTER the profile or the "absolute" CSS property of the profile is offset (somehow) */}}
|
||||
<input class="emote" type="submit" value="{{$icon}}">
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{/* We want a dummy element here so that the .ui.label above is not :last-child, or its margin-right vanishes */}}
|
||||
<span></span>
|
||||
{{/* Instead, it _could_ be a message to entice to login ? */}}
|
||||
{{/*<div class="judgment-forms">*/}}
|
||||
{{/* <p><em>Login to judge "{{.Poll.Subject}}" of this Issue.</em></p> */}}
|
||||
{{/*</div>*/}}
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
|
||||
{{end}}
|
@ -0,0 +1,15 @@
|
||||
{{template "base/head" .}}
|
||||
<div role="main" aria-label="{{.Title}}" class="page-content repository polls start">
|
||||
{{template "repo/header" .}}
|
||||
<div class="ui container">
|
||||
<div class="ui segment center gt-py-5">
|
||||
{{svg "octicon-law" 48}}
|
||||
<h2>{{.locale.Tr "repo.polls.welcome"}}</h2>
|
||||
<p>{{.locale.Tr "repo.polls.welcome_desc"}}</p>
|
||||
{{if and .CanWritePolls (not .Repository.IsMirror)}}
|
||||
<a class="ui green button" href="{{$.Link}}/new">{{.locale.Tr "repo.polls.create_first_poll"}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
@ -0,0 +1,74 @@
|
||||
{{template "base/head" .}}
|
||||
<div role="main" aria-label="{{.Title}}" class="page-content repository polls view">
|
||||
{{template "repo/header" .}}
|
||||
<div class="ui container">
|
||||
<div class="navbar gt-mb-4">
|
||||
<div></div>{{/* we have nothing to show on the left for now */}}
|
||||
<div class="group">
|
||||
{{template "repo/polls/navbar/new" .}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui divider"></div>
|
||||
{{template "base/alert" .}}
|
||||
|
||||
<div class="ui two column stackable grid">
|
||||
|
||||
<div class="column">
|
||||
<h2>{{.Poll.Subject}}</h2>
|
||||
{{if .Poll.RenderedDescription}}
|
||||
<div class="render-content markdown">
|
||||
{{.Poll.RenderedDescription|Str2html}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="column right aligned">
|
||||
{{if and ($.CanWritePolls) (not $.Repository.IsArchived)}}
|
||||
<div class="ui compact mini menu">
|
||||
{{/* .Poll.Link() fails, in the redirect, somehow? Is Repo not hydrated? */}}
|
||||
<a class="item" href="{{$.RepoLink}}/polls/{{.Poll.ID}}/edit?redirect={{$.RepoLink}}/polls/{{.Poll.ID}}">
|
||||
{{svg "octicon-pencil"}}
|
||||
<span class="gt-mx-3">{{.locale.Tr "repo.polls.operate.edit"}}</span>
|
||||
</a>
|
||||
<a class="item delete-button" href="#" data-url="{{$.RepoLink}}/polls/{{.Poll.ID}}/delete" data-id="{{.Poll.ID}}">
|
||||
{{svg "octicon-trash"}}
|
||||
<span class="gt-mx-3">{{.locale.Tr "repo.polls.operate.delete"}}</span>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="ui divider"></div>
|
||||
|
||||
{{$pollResult := .Poll.GetResult}}
|
||||
|
||||
<ul class="candidates issue list">
|
||||
{{range $key, $proposalResult := $pollResult.ProposalsSorted}}
|
||||
{{$candidateID := $.Poll.GetIdOfCandidateAtIndex $proposalResult.Index}}
|
||||
<li class="item">
|
||||
{{template "repo/polls/merit_radial" ($pollResult.GetMaritProfileOfCandidate $candidateID)}}
|
||||
|
||||
N°{{$proposalResult.Rank}}
|
||||
—
|
||||
|
||||
<a href="{{$.AppSubUrl}}/{{$.RepoRelPath}}/issues/{{$candidateID}}">
|
||||
{{($.Poll.GetNameOfCandidate $candidateID)}}
|
||||
(#{{$candidateID}})
|
||||
</a>
|
||||
</li>
|
||||
{{else}}
|
||||
<li>
|
||||
<em>{{.locale.Tr "repo.polls.view.no_judgments"}}</em>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "repo/polls/modal/delete" .}}
|
||||
|
||||
{{template "base/footer" .}}
|
Loading…
Reference in new issue