diff --git a/assets/sql/v6.sql b/assets/sql/v6.sql
new file mode 100644
index 0000000..1545085
--- /dev/null
+++ b/assets/sql/v6.sql
@@ -0,0 +1,35 @@
+BEGIN TRANSACTION;
+
+-- An "Announcements" table.
+CREATE TABLE announcements (
+ id INTEGER NOT NULL PRIMARY KEY,
+ contest_id INTEGER NOT NULL,
+ problem_id INTEGER,
+ content BLOB NOT NULL,
+ created_at DATETIME NOT NULL,
+
+ FOREIGN KEY (contest_id) REFERENCES contests(id) ON DELETE CASCADE,
+ FOREIGN KEY (problem_id) REFERENCES problems(id) ON DELETE CASCADE
+);
+CREATE INDEX announcements_by_contest ON announcements(contest_id ASC, id DESC);
+
+-- Clarifications table.
+CREATE TABLE clarifications (
+ id INTEGER NOT NULL PRIMARY KEY,
+ user_id VARCHAR NOT NULL,
+ contest_id INTEGER NOT NULL,
+ problem_id INTEGER,
+
+ content BLOB NOT NULL,
+ updated_at DATETIME NOT NULL, -- Updated when responded
+
+ response BLOB,
+
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY (contest_id) REFERENCES contests(id) ON DELETE CASCADE,
+ FOREIGN KEY (problem_id) REFERENCES problems(id) ON DELETE CASCADE
+);
+
+CREATE INDEX clarifications_by_user ON clarifications(contest_id ASC, user_id ASC, id DESC);
+
+COMMIT;
diff --git a/frontend/html/admin/clarifications.html b/frontend/html/admin/clarifications.html
new file mode 100644
index 0000000..15c38fa
--- /dev/null
+++ b/frontend/html/admin/clarifications.html
@@ -0,0 +1,80 @@
+{{ define "admin-title" }}Clarifications{{ end }}
+
+{{ define "admin-content" }}
+
+
{{.Name}}
(
+ Announcements |
Scoreboard |
+
+ New Announcement
+
+
+ Past Announcements
+
+
+{{ end }}
+
+{{ define "admin-content" }}
+{{ $contest_link := printf "/admin/contests/%d" .Contest.ID }}
+
+
+
+{{ template "form-error" .Error }}
+
+
+
+
+ {{ range .Announcements }}
+
+
+
+ {{ if .ProblemID.Valid }}
+ {{ with (index $.Problems .ProblemID.Int64) }}
+ {{ $problem_link := printf "/admin/problems/%d" .ID }}
+ Problem
+
{{.Name}}. {{.DisplayName}}
+ {{ end }}
+ {{ else }}
+
General Announcement
+ {{ end }}
+
+
+
+
+ {{- printf "%s" .Content -}}
+
+
+ {{ else }}
+
No announcements
+ {{ end }}
+
+
+{{ end }}
diff --git a/frontend/html/admin/contest_inputs.html b/frontend/html/admin/contest_inputs.html
index 809216a..1f4265d 100644
--- a/frontend/html/admin/contest_inputs.html
+++ b/frontend/html/admin/contest_inputs.html
@@ -77,6 +77,8 @@
{{.StartTime}} |
{{.EndTime}} |
+ [a]
[s]
Contests
+
+
+
Users
@@ -33,5 +39,6 @@
{{ block "admin-content" . }}{{ end }}
+
{{ end }}
diff --git a/frontend/html/contests/messages.html b/frontend/html/contests/messages.html
new file mode 100644
index 0000000..1e4a067
--- /dev/null
+++ b/frontend/html/contests/messages.html
@@ -0,0 +1,130 @@
+{{ define "inner-title" }}Messages{{ end }}
+
+{{ define "content" }}
+{{.Contest.Name}}: Messages
+
+
+
+{{ if isFuture .Contest.EndTime }}
+Request a
+ Clarification
+{{ end }}
+
+
+
+ {{ range .Messages }}
+ {{ with .Announcement }}
+ {{ template "announcement" (zip . $) }}
+ {{ end }}
+ {{ with .Clarification }}
+ {{ template "clarification" (zip . $) }}
+ {{ end }}
+ {{ else }}
+ No messages
+ {{ end }}
+
+
+{{ if isFuture .Contest.EndTime }}
+
+{{ template "form-error" .FormError }}
+{{ template "send-clarification" . }}
+{{ end }}
+
+
+
+{{ end }}
+
+{{ define "send-clarification" }}
+{{ $form_link := printf "/contests/%d/messages" .Contest.ID }}
+
+{{ end }}
+
+{{ define "clarification" }}
+{{ with (index . 0) }}
+
+
+
+
+ {{ if .ProblemID.Valid }}
+ {{ with (index (index $ 1).ProblemsMap .ProblemID.Int64) }}
+ {{ $problem_link := .Link }}
+ re:
+
+ {{.Name}}. {{.DisplayName}}
+
+ {{ end }}
+ {{ else }}
+ General Question
+ {{ end }}
+
+
+
+
+ {{- printf "%s" .Content -}}
+ {{ if .Response }}
+
+ Response:
+ {{- printf "%s" .Response -}}
+
+ {{ else }}
+ No responses yet.
+ {{ end }}
+
+{{ end }}
+{{ end }}
+
+{{ define "announcement" }}
+{{ with (index . 0) }}
+
+
+
+ {{ if .ProblemID.Valid }}
+ {{ with (index (index $ 1).ProblemsMap .ProblemID.Int64) }}
+ Problem
+ {{ $problem_link := .Link }}
+ {{.Name}}. {{.DisplayName}}
+ {{ end }}
+ {{ else }}
+ General Announcement
+ {{ end }}
+
+
+
+
+ {{- printf "%s" .Content -}}
+
+
+{{ end }}
+{{ end }}
diff --git a/frontend/html/contests/root.html b/frontend/html/contests/root.html
index 8988ea3..0c4cfaf 100644
--- a/frontend/html/contests/root.html
+++ b/frontend/html/contests/root.html
@@ -15,6 +15,13 @@
Overview
+
+
+
+
Scoreboard
diff --git a/frontend/sounds/notification.ogg b/frontend/sounds/notification.ogg
new file mode 100644
index 0000000..ba78dc1
Binary files /dev/null and b/frontend/sounds/notification.ogg differ
diff --git a/frontend/ts/admin.ts b/frontend/ts/admin.ts
new file mode 100644
index 0000000..26cd6e0
--- /dev/null
+++ b/frontend/ts/admin.ts
@@ -0,0 +1,19 @@
+// Unanswered clarifications
+(() => {
+ const counter = document.getElementById("unanswered-counter");
+ if (!counter) return;
+ const update = () => {
+ fetch("/admin/clarifications?unanswered=true")
+ .then((res) => res.json())
+ .then((count: number) => {
+ if (count > 0) {
+ counter.classList.remove("hidden");
+ counter.innerHTML = count.toString();
+ } else {
+ counter.classList.add("hidden");
+ }
+ });
+ };
+ setInterval(update, 10 * 1000);
+ update();
+})();
diff --git a/frontend/ts/admin_clarifications.ts b/frontend/ts/admin_clarifications.ts
new file mode 100644
index 0000000..aa87364
--- /dev/null
+++ b/frontend/ts/admin_clarifications.ts
@@ -0,0 +1,40 @@
+// Handle "show unanswered"
+(() => {
+ const noUnanswered = document.getElementById("no-unanswered");
+ const showUnanswered = document.getElementById(
+ "show-unanswered",
+ ) as HTMLInputElement | null;
+ if (!showUnanswered) return;
+
+ const scan = () => {
+ let hasUnanswered = false;
+ for (const div of document.getElementsByClassName("clarification")) {
+ if (div.getAttribute("data-answered") === "true") {
+ if (showUnanswered.checked) div.classList.add("hidden");
+ else {
+ div.classList.remove("hidden");
+ }
+ } else hasUnanswered = true;
+ }
+ if (!hasUnanswered && showUnanswered.checked) {
+ noUnanswered?.classList.remove("hidden");
+ } else {
+ noUnanswered?.classList.add("hidden");
+ }
+ };
+
+ showUnanswered.addEventListener("change", scan);
+})();
+
+// Handle "template response"
+(() => {
+ for (const elem of document.getElementsByClassName("premade")) {
+ const textarea = elem.parentElement?.getElementsByClassName(
+ "form-input",
+ )[0] as HTMLTextAreaElement;
+ const select = elem as HTMLSelectElement;
+ select.addEventListener("change", () => {
+ textarea.value = select.selectedOptions[0].value || textarea.value;
+ });
+ }
+})();
diff --git a/frontend/ts/announcements.ts b/frontend/ts/announcements.ts
new file mode 100644
index 0000000..b620436
--- /dev/null
+++ b/frontend/ts/announcements.ts
@@ -0,0 +1,117 @@
+declare interface Window {
+ contestId: number;
+ announcements: {
+ setLast: (x: number | string) => void;
+ markUnread: () => void;
+ };
+}
+
+const notificationSound = new Audio(require("../sounds/notification.ogg"));
+
+// Stores the last announcement and clarification read.
+interface Store {
+ contestId: number;
+ lastAnnouncement: number;
+ lastClarification: number;
+}
+
+// Periodically fetch announcements
+window.announcements = (() => {
+ const announcementKey = "kjudge-announcement-last";
+ const get = () =>
+ JSON.parse(localStorage.getItem(announcementKey) as string) as Store;
+ const set = (x: Store) =>
+ localStorage.setItem(announcementKey, JSON.stringify(x));
+ // Set a default value
+ localStorage.getItem(announcementKey) === null ||
+ get().contestId !== window.contestId
+ ? set({
+ contestId: window.contestId,
+ lastAnnouncement: 0,
+ lastClarification: 0,
+ })
+ : void 0;
+ // Set announcements count!!
+ const messagesCounter = document.getElementById(
+ "messages-counter",
+ ) as HTMLDivElement;
+ const originalTitle = document.title;
+ const setAnnouncementCount = (x: number) => {
+ if (x > 0) {
+ if (
+ messagesCounter.innerHTML !== "" &&
+ messagesCounter.innerHTML !== x.toString()
+ ) {
+ notificationSound.play();
+ }
+ messagesCounter.classList.remove("hidden");
+ document.title = `[${x}] ${originalTitle}`;
+ } else {
+ messagesCounter.classList.add("hidden");
+ document.title = originalTitle;
+ }
+ messagesCounter.innerHTML = x.toString();
+ };
+
+ // Fetch announcements count
+ const fetchAnnouncements = () => {
+ const info = get();
+ return fetch(
+ `/contests/${window.contestId}/messages/unread?last_announcement=${info.lastAnnouncement}&last_clarification=${info.lastClarification}`,
+ )
+ .then((v) => v.json())
+ .then(setAnnouncementCount);
+ };
+
+ setInterval(fetchAnnouncements, 10 * 1000);
+ const firstLoad = fetchAnnouncements();
+
+ return {
+ setLast: () => {
+ firstLoad.finally(() => {
+ const clars = [
+ ...document.getElementsByClassName("clarification"),
+ ]
+ .filter(
+ (item) =>
+ item.getAttribute("data-responded") === "true",
+ )
+ .map((item) => Number(item.getAttribute("data-id")));
+ const announcements = [
+ ...document.getElementsByClassName("announcement"),
+ ].map((item) => Number(item.getAttribute("data-id")));
+ set({
+ contestId: window.contestId,
+ lastAnnouncement: Math.max(...announcements, 0),
+ lastClarification: Math.max(...clars, 0),
+ });
+ setAnnouncementCount(0);
+ });
+ },
+ markUnread: () => {
+ // Mark the unread announcements with special backgrounds
+ const store = get();
+ for (const item of document.getElementsByClassName(
+ "announcement",
+ )) {
+ if (
+ Number(item.getAttribute("data-id")) >
+ store.lastAnnouncement
+ ) {
+ item.classList.add("bg-green-200", "hover:bg-green-300");
+ }
+ }
+ for (const item of document.getElementsByClassName(
+ "clarification",
+ )) {
+ if (
+ Number(item.getAttribute("data-id")) >
+ store.lastClarification &&
+ item.getAttribute("data-responded") === "true"
+ ) {
+ item.classList.add("bg-green-200", "hover:bg-green-300");
+ }
+ }
+ },
+ };
+})();
diff --git a/frontend/ts/index.ts b/frontend/ts/index.ts
index ca3efb0..515400d 100644
--- a/frontend/ts/index.ts
+++ b/frontend/ts/index.ts
@@ -5,6 +5,13 @@ import "typeface-ibm-plex-mono";
// Moment.js
import hd from "humanize-duration";
+// Set localStorage version.
+(() => {
+ const versionKey = "kjudge-localstorage-version";
+ const version = "1";
+ localStorage.setItem(versionKey, version);
+})();
+
// Set timezone
(function () {
setInterval(() => {
diff --git a/models/announcements.go b/models/announcements.go
new file mode 100644
index 0000000..ac74fb3
--- /dev/null
+++ b/models/announcements.go
@@ -0,0 +1,26 @@
+package models
+
+import (
+ "github.com/natsukagami/kjudge/db"
+ "github.com/natsukagami/kjudge/models/verify"
+ "github.com/pkg/errors"
+)
+
+// Verify verifies Announcement's content.
+func (a *Announcement) Verify() error {
+ if len(a.Content) > 2048 {
+ return verify.Errorf("Content must be at most 2048 characters")
+ }
+ return verify.All(map[string]error{
+ "content": verify.NotNull(a.Content),
+ })
+}
+
+// GetUnreadAnnouncements returns the list of announcements later than the given id.
+func GetUnreadAnnouncements(db db.DBContext, contestID int, sinceID int) ([]*Announcement, error) {
+ var res []*Announcement
+ if err := db.Select(&res, "SELECT * FROM announcements WHERE contest_id = ? AND id > ?"+queryAnnouncementOrderBy, contestID, sinceID); err != nil {
+ return nil, errors.WithStack(err)
+ }
+ return res, nil
+}
diff --git a/models/clarifications.go b/models/clarifications.go
new file mode 100644
index 0000000..b9fff4b
--- /dev/null
+++ b/models/clarifications.go
@@ -0,0 +1,51 @@
+package models
+
+import (
+ "fmt"
+
+ "github.com/natsukagami/kjudge/db"
+ "github.com/natsukagami/kjudge/models/verify"
+ "github.com/pkg/errors"
+)
+
+// Verify verifies Clarification's content.
+func (c *Clarification) Verify() error {
+ if c.Response != nil && len(c.Response) == 0 {
+ return verify.Errorf("field Response: cannot be empty")
+ }
+ if len(c.Content) > 2048 {
+ return verify.Errorf("Content must be at most 2048 characters")
+ }
+ if len(c.Response) > 2048 {
+ return verify.Errorf("Response must be at most 2048 characters")
+ }
+ return verify.All(map[string]error{
+ "content": verify.NotNull(c.Content),
+ })
+}
+
+// Responded returns whether the Clarification has been responded.
+func (c *Clarification) Responded() bool { return c.Response != nil }
+
+// AdminLink returns the link to the Clarification in the Admin Panel.
+func (c *Clarification) AdminLink() string {
+ return fmt.Sprintf("/admin/clarifications/%d", c.ID)
+}
+
+// GetContestUserClarifications returns the clarifications of a contest for an user.
+func GetContestUserClarifications(db db.DBContext, contestID int, userID string) ([]*Clarification, error) {
+ var res []*Clarification
+ if err := db.Select(&res, "SELECT * FROM clarifications WHERE contest_id = ? AND user_id = ?"+queryClarificationOrderBy, contestID, userID); err != nil {
+ return nil, errors.WithStack(err)
+ }
+ return res, nil
+}
+
+// GetUnreadClarifications returns unread clarifications later than the given ID.
+func GetUnreadClarifications(db db.DBContext, contestID int, userID string, sinceID int) ([]*Clarification, error) {
+ var res []*Clarification
+ if err := db.Select(&res, "SELECT * FROM clarifications WHERE contest_id = ? AND user_id = ? AND id > ? AND response IS NOT NULL"+queryClarificationOrderBy, contestID, userID, sinceID); err != nil {
+ return nil, errors.WithStack(err)
+ }
+ return res, nil
+}
diff --git a/models/contests.go b/models/contests.go
index 36a84fd..06ed98f 100644
--- a/models/contests.go
+++ b/models/contests.go
@@ -1,6 +1,8 @@
package models
import (
+ "fmt"
+
"github.com/natsukagami/kjudge/db"
"github.com/natsukagami/kjudge/models/verify"
"github.com/pkg/errors"
@@ -45,6 +47,16 @@ func (c *Contest) Verify() error {
return nil
}
+// Link returns the HTTP link to the contest.
+func (c *Contest) Link() string {
+ return fmt.Sprintf("/contests/%d", c.ID)
+}
+
+// AdminLink returns the link to the contest in the Admin Panel.
+func (c *Contest) AdminLink() string {
+ return fmt.Sprintf("/admin/contests/%d", c.ID)
+}
+
// GetContestsUnfinished gets a list of contests that are unfinished (upcoming or pending).
func GetContestsUnfinished(db db.DBContext) ([]*Contest, error) {
var res []*Contest
diff --git a/models/models.toml b/models/models.toml
index 9c7f2bb..74a7a07 100644
--- a/models/models.toml
+++ b/models/models.toml
@@ -90,3 +90,21 @@ problem_id = "int"
filename = "string"
content = "[]byte"
public = "bool"
+
+[announcements]
+id = "int"
+contest_id = "int"
+problem_id = "sql.NullInt64"
+content = "[]byte"
+created_at = "time.Time"
+_order_by = "id DESC"
+
+[clarifications]
+id = "int"
+user_id = "string"
+contest_id = "int"
+problem_id = "sql.NullInt64"
+content = "[]byte"
+updated_at = "time.Time"
+response = "[]byte"
+_order_by = "user_id ASC, id DESC"
diff --git a/models/problems.go b/models/problems.go
index 077bd29..a8701d6 100644
--- a/models/problems.go
+++ b/models/problems.go
@@ -63,6 +63,16 @@ func (r *Problem) Verify() error {
})
}
+// AdminLink is the link to the problem in the admin panel.
+func (r *Problem) AdminLink() string {
+ return fmt.Sprintf("/admin/problems/%d", r.ID)
+}
+
+// Link is the link to the problem.
+func (r *Problem) Link() string {
+ return fmt.Sprintf("/contests/%d/problems/%s", r.ContestID, r.Name)
+}
+
// ProblemWithTestGroups is a problem with attached test groups,
// that will provide score-related statistics.
type ProblemWithTestGroups struct {
diff --git a/models/verify/bytes.go b/models/verify/bytes.go
index 07b49b3..2b1645b 100644
--- a/models/verify/bytes.go
+++ b/models/verify/bytes.go
@@ -2,7 +2,7 @@ package verify
// NotNull verifies that the []byte is not null.
func NotNull(b []byte) error {
- if b == nil {
+ if len(b) == 0 {
return Errorf("cannot be empty or null")
}
return nil
diff --git a/server/admin/admin.go b/server/admin/admin.go
index f445519..e1d7c00 100644
--- a/server/admin/admin.go
+++ b/server/admin/admin.go
@@ -40,6 +40,9 @@ func New(db *db.DB, unauthed *echo.Group) (*Group, error) {
g.POST("/contests/:id/delete", grp.ContestDelete)
g.POST("/contests/:id/add_problem", grp.ContestAddProblem)
g.POST("/contests/:id/rejudge", grp.ContestRejudgePost)
+ // Contest Announcements
+ g.GET("/contests/:id/announcements", grp.AnnouncementsGet)
+ g.POST("/contests/:id/announcements", grp.AnnouncementAddPost)
// Problem Management
g.GET("/problems/:id", grp.ProblemGet)
g.GET("/problems/:id/submissions", grp.ProblemSubmissionsGet)
@@ -82,6 +85,9 @@ func New(db *db.DB, unauthed *echo.Group) (*Group, error) {
g.GET("/batch_users/empty", grp.BatchUsersEmptyGet)
g.GET("/batch_users/generate", grp.BatchUsersGenerateGet)
g.POST("/batch_users", grp.BatchUsersPost)
+ // Clarifications
+ g.GET("/clarifications", grp.ClarificationsGet)
+ g.POST("/clarifications/:id", grp.ClarificationReplyPost)
return grp, nil
}
diff --git a/server/admin/clarifications.go b/server/admin/clarifications.go
new file mode 100644
index 0000000..3dd88ce
--- /dev/null
+++ b/server/admin/clarifications.go
@@ -0,0 +1,119 @@
+package admin
+
+import (
+ "database/sql"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/labstack/echo/v4"
+ "github.com/natsukagami/kjudge/db"
+ "github.com/natsukagami/kjudge/models"
+ "github.com/natsukagami/kjudge/server/httperr"
+ "github.com/pkg/errors"
+)
+
+// ClarificationsCtx is the context for rendering clarifications.
+type ClarificationsCtx struct {
+ Clarifications []*models.Clarification
+ Problems map[int]*models.Problem
+ Contests map[int]*models.Contest
+}
+
+// Render renders the context.
+func (ctx *ClarificationsCtx) Render(c echo.Context) error {
+ return c.Render(http.StatusOK, "admin/clarifications", ctx)
+}
+
+func getClarificationsCtx(db db.DBContext, c echo.Context) (*ClarificationsCtx, error) {
+ clars, err := models.GetAllClarifications(db)
+ if err != nil {
+ return nil, err
+ }
+ var problemIDs, contestIDs []int
+ for _, c := range clars {
+ if c.ProblemID.Valid {
+ problemIDs = append(problemIDs, int(c.ProblemID.Int64))
+ }
+ contestIDs = append(contestIDs, c.ContestID)
+ }
+ problems, err := models.CollectProblemsByID(db, problemIDs...)
+ if err != nil {
+ return nil, err
+ }
+ contests, err := models.CollectContestsByID(db, contestIDs...)
+ if err != nil {
+ return nil, err
+ }
+ return &ClarificationsCtx{
+ Clarifications: clars,
+ Problems: problems,
+ Contests: contests,
+ }, nil
+}
+
+// ClarificationsGet implements GET /admin/clarifications
+func (g *Group) ClarificationsGet(c echo.Context) error {
+ ctx, err := getClarificationsCtx(g.db, c)
+ if err != nil {
+ return err
+ }
+ if c.QueryParam("unanswered") == "true" {
+ return c.JSON(http.StatusOK, ctx.getUnansweredClarificationsCount())
+ }
+ return ctx.Render(c)
+}
+
+// ClarificationReplyForm is a form for replying a clarification.
+type ClarificationReplyForm struct {
+ Response string
+}
+
+// ClarificationReplyPost implements POST /admin/clarifications/:id
+func (g *Group) ClarificationReplyPost(c echo.Context) error {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ return httperr.NotFoundf("Clarification not found: %s", c.Param("id"))
+ }
+ tx, err := g.db.Beginx()
+ if err != nil {
+ return errors.WithStack(err)
+ }
+ defer db.Rollback(tx)
+
+ clar, err := models.GetClarification(tx, id)
+ if errors.Is(err, sql.ErrNoRows) {
+ return httperr.NotFoundf("Clarification not found: %s", c.Param("id"))
+ } else if err != nil {
+ return err
+ }
+
+ if clar.Responded() {
+ return httperr.BadRequestf("Clarification has already been responded.")
+ }
+ var form ClarificationReplyForm
+ if err := c.Bind(&form); err != nil {
+ return httperr.BindFail(err)
+ }
+ clar.Response = []byte(form.Response)
+ clar.UpdatedAt = time.Now()
+
+ if err := clar.Write(tx); err != nil {
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return errors.WithStack(err)
+ }
+
+ return c.Redirect(http.StatusSeeOther, "/admin/clarifications")
+}
+
+func (ctx *ClarificationsCtx) getUnansweredClarificationsCount() int {
+ count := 0
+ for _, c := range ctx.Clarifications {
+ if !c.Responded() {
+ count++
+ }
+ }
+ return count
+}
diff --git a/server/admin/contest_announcements.go b/server/admin/contest_announcements.go
new file mode 100644
index 0000000..5bb52ff
--- /dev/null
+++ b/server/admin/contest_announcements.go
@@ -0,0 +1,124 @@
+package admin
+
+import (
+ "database/sql"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/labstack/echo/v4"
+ "github.com/natsukagami/kjudge/db"
+ "github.com/natsukagami/kjudge/models"
+ "github.com/natsukagami/kjudge/models/verify"
+ "github.com/natsukagami/kjudge/server/httperr"
+ "github.com/pkg/errors"
+)
+
+// AnnouncementsCtx is the context for rendering admin/contest_announcements
+type AnnouncementsCtx struct {
+ Contest *models.Contest
+ Problems map[int]*models.Problem
+ Announcements []*models.Announcement
+
+ Error error
+ Form AnnouncementForm
+}
+
+// Render renders the context.
+func (a *AnnouncementsCtx) Render(c echo.Context) error {
+ status := http.StatusOK
+ if a.Error != nil {
+ status = http.StatusBadRequest
+ }
+ return c.Render(status, "admin/contest_announcements", a)
+}
+
+// AnnouncementForm is the form for creating a new announcement.
+type AnnouncementForm struct {
+ Problem int `form:"problem"`
+ Content string `form:"content"`
+}
+
+// Bind binds the form into a model.
+func (f *AnnouncementForm) Bind(a *models.Announcement) {
+ if f.Problem == 0 {
+ a.ProblemID = sql.NullInt64{Valid: false}
+ } else {
+ a.ProblemID = sql.NullInt64{Valid: true, Int64: int64(f.Problem)}
+ }
+ a.Content = []byte(f.Content)
+ a.CreatedAt = time.Now()
+}
+
+// get an announcements ctx.
+func getAnnouncementsCtx(db db.DBContext, c echo.Context) (*AnnouncementsCtx, error) {
+ idStr := c.Param("id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ return nil, httperr.NotFoundf("Contest not found: %s", idStr)
+ }
+ contest, err := models.GetContest(db, id)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, httperr.NotFoundf("Contest not found: %s", idStr)
+ } else if err != nil {
+ return nil, err
+ }
+ problemsList, err := models.GetContestProblems(db, contest.ID)
+ if err != nil {
+ return nil, err
+ }
+ problems := make(map[int]*models.Problem)
+ for _, p := range problemsList {
+ problems[p.ID] = p
+ }
+ announcements, err := models.GetContestAnnouncements(db, contest.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ return &AnnouncementsCtx{
+ Contest: contest,
+ Problems: problems,
+ Announcements: announcements,
+ }, nil
+}
+
+// AnnouncementsGet implements GET /admin/contests/:id/announcements
+func (g *Group) AnnouncementsGet(c echo.Context) error {
+ ctx, err := getAnnouncementsCtx(g.db, c)
+ if err != nil {
+ return err
+ }
+ return ctx.Render(c)
+}
+
+// AnnouncementAddPost implements POST /admin/contests/:id/announcements
+func (g *Group) AnnouncementAddPost(c echo.Context) error {
+ ctx, err := getAnnouncementsCtx(g.db, c)
+ if err != nil {
+ return err
+ }
+ if err := c.Bind(&ctx.Form); err != nil {
+ return httperr.BindFail(err)
+ }
+ var ann models.Announcement
+ ctx.Form.Bind(&ann)
+ ann.ContestID = ctx.Contest.ID
+ perform := func() error {
+ if ann.ProblemID.Valid {
+ if _, ok := ctx.Problems[int(ann.ProblemID.Int64)]; !ok {
+ return verify.Errorf("Problem does not belong to the current contest")
+ }
+ }
+ if err := ann.Write(g.db); err != nil {
+ return err
+ }
+ return nil
+ }
+ if err := perform(); err != nil {
+ ctx.Error = err
+ return ctx.Render(c)
+ }
+ return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/admin/contests/%d/announcements#list", ctx.Contest.ID))
+}
diff --git a/server/contests/contests.go b/server/contests/contests.go
index e08aaeb..7e2e7b7 100644
--- a/server/contests/contests.go
+++ b/server/contests/contests.go
@@ -56,6 +56,9 @@ func New(db *db.DB, g *echo.Group) (*Group, error) {
g.GET("/:id/scoreboard/csv", grp.ScoreboardCSVGet)
authed := g.Group("/", auth.MustAuth(db))
authed.GET(":id", grp.OverviewGet)
+ authed.GET(":id/messages", grp.MessagesGet)
+ authed.GET(":id/messages/unread", grp.MessagesUnreadGet)
+ authed.POST(":id/messages", grp.SendClarificationPost)
authed.GET(":id/problems/:problem", grp.ProblemGet)
authed.GET(":id/problems/:problem/files/:file", grp.FileGet)
authed.POST(":id/problems/:problem/submit", grp.SubmitPost)
diff --git a/server/contests/messages.go b/server/contests/messages.go
new file mode 100644
index 0000000..3570556
--- /dev/null
+++ b/server/contests/messages.go
@@ -0,0 +1,160 @@
+package contests
+
+import (
+ "database/sql"
+ "errors"
+ "net/http"
+ "sort"
+ "time"
+
+ "github.com/labstack/echo/v4"
+ "github.com/natsukagami/kjudge/db"
+ "github.com/natsukagami/kjudge/models"
+ "github.com/natsukagami/kjudge/server/httperr"
+)
+
+// MessagesCtx is the context for rendering contests/messages.
+type MessagesCtx struct {
+ *ContestCtx
+
+ ProblemsMap map[int]*models.Problem
+ Messages []Message
+
+ FormError error
+ Form ClarificationForm
+}
+
+// ClarificationForm is a form for sending clarifications.
+type ClarificationForm struct {
+ Problem int64
+ Content string
+}
+
+// Bind binds the form into a Clarification.
+func (f *ClarificationForm) Bind(c *models.Clarification) {
+ if f.Problem == 0 {
+ c.ProblemID = sql.NullInt64{Valid: false}
+ } else {
+ c.ProblemID = sql.NullInt64{Valid: true, Int64: f.Problem}
+ }
+ c.Content = []byte(f.Content)
+ c.UpdatedAt = time.Now()
+}
+
+// Message is either an Announcement or a Clarification.
+type Message struct {
+ *models.Announcement
+ *models.Clarification
+}
+
+// UpdatedAt returns the last updated time of the Message.
+func (m Message) UpdatedAt() time.Time {
+ if m.Announcement != nil {
+ return m.Announcement.CreatedAt
+ }
+ return m.Clarification.UpdatedAt
+}
+
+func (a *MessagesCtx) Render(c echo.Context) error {
+ status := http.StatusOK
+ if a.FormError != nil {
+ status = http.StatusBadRequest
+ }
+ return c.Render(status, "contests/messages", a)
+}
+
+func getMessagesCtx(db db.DBContext, c echo.Context) (*MessagesCtx, error) {
+ contest, err := getContestCtx(db, c)
+ if err != nil {
+ return nil, err
+ }
+ problems := make(map[int]*models.Problem)
+ for _, p := range contest.Problems {
+ problems[p.ID] = p
+ }
+ announcements, err := models.GetContestAnnouncements(db, contest.Contest.ID)
+ if err != nil {
+ return nil, err
+ }
+ clars, err := models.GetContestUserClarifications(db, contest.Contest.ID, contest.Me.ID)
+ if err != nil {
+ return nil, err
+ }
+ var messages []Message
+ for _, a := range announcements {
+ messages = append(messages, Message{Announcement: a})
+ }
+ for _, a := range clars {
+ messages = append(messages, Message{Clarification: a})
+ }
+ sort.Slice(messages, func(i, j int) bool { return messages[i].UpdatedAt().After(messages[j].UpdatedAt()) })
+ return &MessagesCtx{
+ ContestCtx: contest,
+ ProblemsMap: problems,
+ Messages: messages,
+ }, nil
+}
+
+// MessagesGet implements GET /contests/:id/messages.
+func (g *Group) MessagesGet(c echo.Context) error {
+ ctx, err := getMessagesCtx(g.db, c)
+ if err != nil {
+ return err
+ }
+ return ctx.Render(c)
+}
+
+// SendClarificationPost implements POST /contests/:id/messages.
+func (g *Group) SendClarificationPost(c echo.Context) error {
+ ctx, err := getMessagesCtx(g.db, c)
+ if err != nil {
+ return err
+ }
+ if ctx.Contest.EndTime.Before(time.Now()) {
+ return httperr.BadRequestf("Cannot send a clarification after the contest ends")
+ }
+ if err := c.Bind(&ctx.Form); err != nil {
+ return httperr.BindFail(err)
+ }
+ var clar models.Clarification
+ ctx.Form.Bind(&clar)
+ clar.UserID = ctx.Me.ID
+ clar.ContestID = ctx.Contest.ID
+ if clar.ProblemID.Valid {
+ if _, ok := ctx.ProblemsMap[int(clar.ProblemID.Int64)]; !ok {
+ ctx.FormError = errors.New("Problem is not part of contest")
+ return ctx.Render(c)
+ }
+ }
+
+ if err := clar.Write(g.db); err != nil {
+ ctx.FormError = err
+ return ctx.Render(c)
+ }
+ return c.Redirect(http.StatusSeeOther, ctx.Contest.Link()+"/messages")
+}
+
+// MessagesUnreadGet returns the number of unread messages.
+// Implements GET /contests/:id/messages/unread
+func (g *Group) MessagesUnreadGet(c echo.Context) error {
+ ctx, err := getContestCtx(g.db, c)
+ if err != nil {
+ return err
+ }
+ var last struct {
+ LastAnnouncement int `query:"last_announcement"`
+ LastClarification int `query:"last_clarification"`
+ }
+ if err := c.Bind(&last); err != nil {
+ return httperr.BindFail(err)
+ }
+ anns, err := models.GetUnreadAnnouncements(g.db, ctx.Contest.ID, last.LastAnnouncement)
+ if err != nil {
+ return err
+ }
+ clars, err := models.GetUnreadClarifications(g.db, ctx.Contest.ID, ctx.Me.ID, last.LastClarification)
+ if err != nil {
+ return err
+ }
+ return c.JSON(http.StatusOK, len(anns)+len(clars))
+}
diff --git a/server/static.go b/server/static.go
index 0f7b36a..c61cadf 100644
--- a/server/static.go
+++ b/server/static.go
@@ -15,7 +15,7 @@ import (
// It filters away files that don't end with ".css", ".js" or ".map"
func StaticFiles(c echo.Context) error {
path := c.Request().URL.Path
- for _, suffix := range []string{".woff2", ".woff", ".css", ".js", ".map", ".png"} {
+ for _, suffix := range []string{".woff2", ".woff", ".css", ".js", ".map", ".png", ".ogg"} {
if strings.HasSuffix(path, suffix) {
return serveFile(stdPath.Join("templates", path), c)
}
diff --git a/server/template/template.go b/server/template/template.go
index 5da2e95..049d0b6 100644
--- a/server/template/template.go
+++ b/server/template/template.go
@@ -18,20 +18,22 @@ import (
//
// The root template "root" is always prepended at the beginning.
var templateList = map[string][]string{
- "admin/home": []string{"admin/root", "admin/contest_inputs"},
- "admin/contests": []string{"admin/root", "admin/contest_inputs"},
- "admin/contest": []string{"admin/root", "admin/contest_inputs", "admin/problem_inputs"},
- "admin/contest_submissions": []string{"admin/root", "admin/submission_inputs"},
- "admin/problem": []string{"admin/root", "admin/problem_inputs", "admin/test_inputs", "admin/test_group_inputs", "admin/file_inputs"},
- "admin/test_group": []string{"admin/root", "admin/test_inputs", "admin/test_group_inputs"},
- "admin/problem_submissions": []string{"admin/root", "admin/submission_inputs"},
- "admin/users": []string{"admin/root", "admin/user_inputs"},
- "admin/user": []string{"admin/root", "admin/user_inputs", "admin/submission_inputs"},
- "admin/submissions": []string{"admin/root", "admin/submission_inputs"},
- "admin/submission": []string{"admin/root"},
- "admin/jobs": []string{"admin/root"},
- "admin/contest_scoreboard": []string{"admin/root"},
- "admin/login": []string{},
+ "admin/home": []string{"admin/root", "admin/contest_inputs"},
+ "admin/contests": []string{"admin/root", "admin/contest_inputs"},
+ "admin/contest": []string{"admin/root", "admin/contest_inputs", "admin/problem_inputs"},
+ "admin/contest_submissions": []string{"admin/root", "admin/submission_inputs"},
+ "admin/contest_announcements": {"admin/root"},
+ "admin/problem": []string{"admin/root", "admin/problem_inputs", "admin/test_inputs", "admin/test_group_inputs", "admin/file_inputs"},
+ "admin/test_group": []string{"admin/root", "admin/test_inputs", "admin/test_group_inputs"},
+ "admin/problem_submissions": []string{"admin/root", "admin/submission_inputs"},
+ "admin/users": []string{"admin/root", "admin/user_inputs"},
+ "admin/user": []string{"admin/root", "admin/user_inputs", "admin/submission_inputs"},
+ "admin/submissions": []string{"admin/root", "admin/submission_inputs"},
+ "admin/submission": []string{"admin/root"},
+ "admin/jobs": []string{"admin/root"},
+ "admin/contest_scoreboard": []string{"admin/root"},
+ "admin/clarifications": []string{"admin/root"},
+ "admin/login": []string{},
"user/login": []string{"user_root"},
"user/home": []string{"user_root"},
@@ -39,6 +41,7 @@ var templateList = map[string][]string{
"contests/home": []string{"user_root"},
"contests/root": []string{"user_root"},
"contests/overview": []string{"contests/root"},
+ "contests/messages": {"contests/root"},
"contests/problem": []string{"contests/root"},
"contests/submission": []string{"contests/root"},
"contests/scoreboard": []string{"contests/root"},
@@ -108,6 +111,7 @@ func parseRootTemplate() (*template.Template, error) {
"version": version,
"loggedIn": loggedIn,
"json": func(item interface{}) (string, error) { b, err := json.Marshal(item); return string(b), err },
+ "zip": func(items ...interface{}) []interface{} { return items },
})
tRoot, err = tRoot.Parse(string(root))
if err != nil {
diff --git a/test/data.sql b/test/data.sql
index c000618..160edec 100644
--- a/test/data.sql
+++ b/test/data.sql
@@ -393,4 +393,11 @@ INSERT INTO files VALUES(18,5,'statements.tex',X'5c646f63756d656e74636c6173737b6
INSERT INTO config VALUES(X'd0d64ad3f04a24ba9371f9b5ac8bc5b8ba871fc1b31c7d3a13ebd52c9af46131736adb000cf8246a6f087c0d578b956ccdcf961df5975852dbdf1d89f6f93057',1,1);
+INSERT INTO announcements VALUES(1,2,NULL,X'576f616821','2020-04-21 17:14:27.958671599-04:00');
+INSERT INTO announcements VALUES(2,1,1,X'59617921','2020-04-21 17:14:53.008157208-04:00');
+
+INSERT INTO clarifications VALUES(1,'misaka',2,NULL,X'486f7720746f207375626d697420612066696c653f','2020-04-21 17:06:31.130637303-04:00',NULL);
+INSERT INTO clarifications VALUES(2,'misaka',2,5,X'49732074686973207265616c3f','2020-04-21 17:07:00.260361338-04:00',X'596573');
+INSERT INTO clarifications VALUES(3,'misaka',1,2,X'4f68206e6f','2020-04-21 17:18:08.332213584-04:00',X'4e6f');
+
COMMIT;
|