Skip to content

Commit

Permalink
Scoreboard extension: CSV export, wide version (#90)
Browse files Browse the repository at this point in the history
small fix front-end

Merge remote-tracking branch 'origin/master' into 85-csv-scoreboard

Implement a wide version of the scoreboard page

Move scoreboard view logic to go code

Show scoreboard export buttons

Implement CSV scoreboard export endpoints

Co-authored-by: Thang Pham <[email protected]>
  • Loading branch information
natsukagami and aome510 committed Apr 12, 2020
1 parent 72165da commit 56a9e76
Show file tree
Hide file tree
Showing 11 changed files with 230 additions and 30 deletions.
13 changes: 12 additions & 1 deletion frontend/html/admin/contest_scoreboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,21 @@
ends at <span class="font-semibold display-time" data-time="{{.Contest.EndTime | time}}"></span>.
</div>

<div class="my-2 text-lg">
<a href="{{$contest_link}}/scoreboard?wide=true" class="text-btn hover:text-blue-600">[wide version]</a>
| Download as:
<a href="{{$contest_link}}/scoreboard/json" download="scoreboard.json"
class="text-btn hover:text-green-600">[JSON]</a>
<a href="{{$contest_link}}/scoreboard/csv" download="scoreboard.json"
class="text-btn hover:text-green-600">[CSV]</a>
<a href="{{$contest_link}}/scoreboard/csv?scores_only=true" download="scoreboard.json"
class="text-btn hover:text-green-600">[CSV (scores only)]</a>
</div>

<div id="scoreboard"></div>
<script>
document.initialScoreboard = JSON.parse("{{ json .JSON }}");
document.scoreboardJSONLink = '/admin/contests/{{.Contest.ID}}/scoreboard/json';
</script>
<script src="../../ts/scoreboard/index.tsx"></script>
{{ end }}
{{ end }}
2 changes: 1 addition & 1 deletion frontend/html/admin/submission_inputs.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
{{ end }}
<th class="py-2 border-b">Submit Time</th>
<th class="py-2 border-b">Verdict</th>
<th class="py-2 border-b">Actions</th>
<th class="py-2 border-b" style="min-width: 5.5rem;">Actions</th>
</tr>
</thead>
<tbody>
Expand Down
2 changes: 1 addition & 1 deletion frontend/html/contests/root.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
{{ $ended := (isFuture .Contest.EndTime) }}
{{ range .Problems }}
{{ $link := printf "%s/problems/%s" $contest_link .Name }}
<div class="bg-gray-300 rounded-sm hover:bg-gray-400 m-2 py-2 px-4 flex flex-row justify-between">
<div class="bg-gray-300 rounded-sm hover:bg-gray-400 m-2 py-2 px-4 flex flex-row justify-end flex-wrap">
<a class="flex-grow" href="{{$link}}">
{{.Name}}. {{.DisplayName}}
</a>
Expand Down
36 changes: 19 additions & 17 deletions frontend/html/contests/scoreboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,30 @@
ends at <span class="font-semibold display-time" data-time="{{.Contest.EndTime | time}}"></span>.
</div>

{{ if (isFuture .Contest.StartTime) }}
{{ else }}
{{ if (isPast .Contest.EndTime) }}
<div id="scoreboard"></div>
<script>
document.initialScoreboard = JSON.parse("{{ json .JSON }}");
document.scoreboardJSONLink = '/contests/{{.Contest.ID}}/scoreboard/json';
</script>
<script src="../../ts/scoreboard/index.tsx"></script>
{{ else }}
{{ if eq .Contest.ScoreboardViewStatus "no_scoreboard" }}
{{ else }}
{{ if ( and (eq .Contest.ScoreboardViewStatus "user") (not (loggedIn .)) ) }}
{{ with .Show }}
<div class="text-center subheader">{{.Error}}
</div>
{{ else }}
{{ template "scoreboard-body" . }}
{{ end }}
{{ end }}

{{ define "scoreboard-body" }}
{{ $contest_link := printf "/contests/%d" .Contest.ID }}
<div class="my-2 text-lg">
<a href="{{$contest_link}}/scoreboard?wide=true" class="text-btn hover:text-blue-600">[wide version]</a>
| Download as:
<a href="{{$contest_link}}/scoreboard/json" download="scoreboard.json"
class="text-btn hover:text-green-600">[JSON]</a>
<a href="{{$contest_link}}/scoreboard/csv" download="scoreboard.json"
class="text-btn hover:text-green-600">[CSV]</a>
<a href="{{$contest_link}}/scoreboard/csv?scores_only=true" download="scoreboard.json"
class="text-btn hover:text-green-600">[CSV (scores only)]</a>
</div>
<div id="scoreboard"></div>
<script>
document.initialScoreboard = JSON.parse("{{ json .JSON }}");
document.scoreboardJSONLink = '/contests/{{.Contest.ID}}/scoreboard/json';
</script>
<script src="../../ts/scoreboard/index.tsx"></script>
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
35 changes: 35 additions & 0 deletions frontend/html/contests/scoreboard_wide.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{{ define "title" }}{{.Contest.Name}} [scoreboard]{{ end }}

{{ define "main" }}
<div class="text-center">
<div class="text-4xl py-6 text-center"><b>{{.Contest.Name}}</b></div>

<div class="text-xl my-2 text-gray-800 timer" data-start="{{.Contest.StartTime | time}}"
data-end="{{.Contest.EndTime | time}}"><span class="font-semibold"></span></div>

<div class="text-xl my-2">
The contest starts at <span class="font-semibold display-time" data-time="{{.Contest.StartTime | time}}"></span>
and
ends at <span class="font-semibold display-time" data-time="{{.Contest.EndTime | time}}"></span>.
</div>
</div>

{{ with .Show }}
<div class="text-center subheader">{{.Error}}
</div>
{{ else }}
{{ template "scoreboard-body" . }}
{{ end }}
<hr class="mt-8">
{{ template "footer" . }}
{{ end }}

{{ define "scoreboard-body" }}
{{ $contest_link := printf "/contests/%d" .Contest.ID }}
<div id="scoreboard"></div>
<script>
document.initialScoreboard = JSON.parse("{{ json .JSON }}");
document.scoreboardJSONLink = '{{.JSONLink}}';
</script>
<script src="../../ts/scoreboard/index.tsx"></script>
{{ end }}
62 changes: 62 additions & 0 deletions models/scoreboard.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package models

import (
"encoding/csv"
"fmt"
"io"
"sort"
"time"

"git.nkagami.me/natsukagami/kjudge/db"
"git.nkagami.me/natsukagami/kjudge/server/httperr"
"github.com/pkg/errors"
)

// UserResult stores information about user's preformance in the contest
Expand Down Expand Up @@ -251,3 +255,61 @@ func GetScoreboard(db db.DBContext, contest *Contest, problems []*Problem) (*Sco
ProblemBestSubmissions: problemBestSubmissions,
}, nil
}

// CSVScoresOnly returns the CSV version of the scoreboard, with only scores.
func (s *Scoreboard) CSVScoresOnly(w io.Writer) error {
writer := csv.NewWriter(w)
// First row: Headers
headers := []string{"Username", "Total Score"}
for _, p := range s.Problems {
headers = append(headers, p.Name)
}
if err := writer.Write(headers); err != nil {
return errors.WithStack(err)
}
// One for each contestants
for _, u := range s.UserResults {
row := []string{u.User.ID, fmt.Sprintf("%.2f", u.TotalScore)}
for _, p := range s.Problems {
if score, ok := u.ProblemResults[p.ID]; ok {
row = append(row, fmt.Sprintf("%.2f", score.Score))
} else {
row = append(row, "-")
}
}
if err := writer.Write(row); err != nil {
return errors.Wrapf(err, "row %s", u.User.ID)
}
}
writer.Flush()
return errors.WithStack(writer.Error())
}

// CSV returns the CSV version of the scoreboard, with scores and penalties.
func (s *Scoreboard) CSV(w io.Writer) error {
writer := csv.NewWriter(w)
// First row: Headers
headers := []string{"Username", "Total Score", "Total Penalty"}
for _, p := range s.Problems {
headers = append(headers, p.Name, p.Name+" (Penalty)")
}
if err := writer.Write(headers); err != nil {
return errors.WithStack(err)
}
// One for each contestants
for _, u := range s.UserResults {
row := []string{u.User.ID, fmt.Sprintf("%.2f", u.TotalScore), fmt.Sprint(u.TotalPenalty)}
for _, p := range s.Problems {
if score, ok := u.ProblemResults[p.ID]; ok {
row = append(row, fmt.Sprintf("%.2f", score.Score), fmt.Sprint(score.Penalty))
} else {
row = append(row, "-", "-")
}
}
if err := writer.Write(row); err != nil {
return errors.Wrapf(err, "row %s", u.User.ID)
}
}
writer.Flush()
return errors.WithStack(writer.Error())
}
1 change: 1 addition & 0 deletions server/admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func New(db *db.DB, unauthed *echo.Group) (*Group, error) {
// Contest Scoreboard
g.GET("/contests/:id/scoreboard", grp.ScoreboardGet)
g.GET("/contests/:id/scoreboard/json", grp.ScoreboardJSONGet)
g.GET("/contests/:id/scoreboard/csv", grp.ScoreboardCSVGet)
// Contest Management
g.GET("/contests/:id", grp.ContestGet)
g.GET("/contests/:id/submissions", grp.ContestSubmissionsGet)
Expand Down
39 changes: 37 additions & 2 deletions server/admin/scoreboard.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package admin

import (
"bytes"
"database/sql"
"fmt"
"net/http"
"strconv"

Expand All @@ -17,8 +19,21 @@ type ScoreboardCtx struct {
*models.Scoreboard
}

// Show decides whether the scoreboard can be shown. For compability with contests.ScoreboardCtx
func (s *ScoreboardCtx) Show() error {
return nil
}

// JSONLink returns the link to the JSON scoreboard.
func (s *ScoreboardCtx) JSONLink() string {
return fmt.Sprintf("/admin/contests/%d/scoreboard/json", s.Contest.ID)
}

// Render renders the scoreboard context
func (s *ScoreboardCtx) Render(c echo.Context) error {
func (s *ScoreboardCtx) Render(c echo.Context, wide bool) error {
if wide {
return c.Render(http.StatusOK, "contests/scoreboard_wide", s)
}
return c.Render(http.StatusOK, "admin/contest_scoreboard", s)
}

Expand Down Expand Up @@ -64,7 +79,7 @@ func (g *Group) ScoreboardGet(c echo.Context) error {
if err != nil {
return err
}
return ctx.Render(c)
return ctx.Render(c, c.QueryParam("wide") == "true")
}

// ScoreboardJSONGet implements GET /admin/contests/:id/scoreboard/json
Expand All @@ -75,3 +90,23 @@ func (g *Group) ScoreboardJSONGet(c echo.Context) error {
}
return ctx.RenderJSON(c)
}

// ScoreboardCSVGet implements GET /admin/contests/:id/scoreboard/csv
func (g *Group) ScoreboardCSVGet(c echo.Context) error {
ctx, err := getScoreboardCtx(g.db, c)
if err != nil {
return err
}
var buf bytes.Buffer
if c.QueryParam("scores_only") == "true" {
if err := ctx.CSVScoresOnly(&buf); err != nil {
return err
}
} else {
if err := ctx.CSV(&buf); err != nil {
return err
}
}
c.Response().Header().Add("Content-Disposition", `attachment; filename="scoreboard.csv"`)
return c.Blob(http.StatusOK, "text/csv", buf.Bytes())
}
1 change: 1 addition & 0 deletions server/contests/contests.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func New(db *db.DB, g *echo.Group) (*Group, error) {
g.GET("", grp.ContestsGet)
g.GET("/:id/scoreboard", grp.ScoreboardGet)
g.GET("/:id/scoreboard/json", grp.ScoreboardJSONGet)
g.GET("/:id/scoreboard/csv", grp.ScoreboardCSVGet)
authed := g.Group("/", auth.MustAuth(db))
authed.GET(":id", grp.OverviewGet)
authed.GET(":id/problems/:problem", grp.ProblemGet)
Expand Down
56 changes: 54 additions & 2 deletions server/contests/scoreboard.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package contests

import (
"bytes"
"errors"
"fmt"
"net/http"
"time"

Expand All @@ -16,8 +19,37 @@ type ScoreboardCtx struct {
*models.Scoreboard
}

// Show decides whether the scoreboard can be shown.
func (s *ScoreboardCtx) Show() error {
if s.Contest.StartTime.After(time.Now()) {
return errors.New("Contest has not started")
}
if s.Contest.EndTime.Before(time.Now()) {
return nil
}
switch s.Contest.ScoreboardViewStatus {
case models.ScoreboardViewStatusNoScoreboard:
return errors.New("Scoreboard has been disabled by the contest organizers until the end of the contest")
case models.ScoreboardViewStatusUser:
if s.GetMe() != nil {
return nil
}
return errors.New("Please log in to see the scoreboard.")
default:
return nil
}
}

// JSONLink returns the link to the JSON scoreboard.
func (s *ScoreboardCtx) JSONLink() string {
return fmt.Sprintf("/contests/%d/scoreboard/json", s.Contest.ID)
}

// Render renders the scoreboard context
func (s *ScoreboardCtx) Render(c echo.Context) error {
func (s *ScoreboardCtx) Render(c echo.Context, wide bool) error {
if wide {
return c.Render(http.StatusOK, "contests/scoreboard_wide", s)
}
return c.Render(http.StatusOK, "contests/scoreboard", s)
}

Expand Down Expand Up @@ -55,7 +87,7 @@ func (g *Group) ScoreboardGet(c echo.Context) error {
if err != nil {
return err
}
return ctx.Render(c)
return ctx.Render(c, c.QueryParam("wide") == "true")
}

// ScoreboardJSONGet implements GET /contest/:id/scoreboard/json
Expand All @@ -77,3 +109,23 @@ func (g *Group) ScoreboardJSONGet(c echo.Context) error {
return ctx.RenderJSON(c)
}
}

// ScoreboardCSVGet implements GET /contests/:id/scoreboard/csv
func (g *Group) ScoreboardCSVGet(c echo.Context) error {
ctx, err := getScoreboardCtx(g.db, c)
if err != nil {
return err
}
var buf bytes.Buffer
if c.QueryParam("scores_only") == "true" {
if err := ctx.CSVScoresOnly(&buf); err != nil {
return err
}
} else {
if err := ctx.CSV(&buf); err != nil {
return err
}
}
c.Response().Header().Add("Content-Disposition", `attachment; filename="scoreboard.csv"`)
return c.Blob(http.StatusOK, "text/csv", buf.Bytes())
}
13 changes: 7 additions & 6 deletions server/template/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,13 @@ var templateList = map[string][]string{
"user/login": []string{"user_root"},
"user/home": []string{"user_root"},

"contests/home": []string{"user_root"},
"contests/root": []string{"user_root"},
"contests/overview": []string{"contests/root"},
"contests/problem": []string{"contests/root"},
"contests/submission": []string{"contests/root"},
"contests/scoreboard": []string{"contests/root"},
"contests/home": []string{"user_root"},
"contests/root": []string{"user_root"},
"contests/overview": []string{"contests/root"},
"contests/problem": []string{"contests/root"},
"contests/submission": []string{"contests/root"},
"contests/scoreboard": []string{"contests/root"},
"contests/scoreboard_wide": []string{},

"error": []string{},
}
Expand Down

0 comments on commit 56a9e76

Please sign in to comment.