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" }} + +
Clarifications
+ +
+
+ + +
+ {{ range .Clarifications }} +
+
+
+
+ {{ with (index $.Contests .ContestID) }} + {{ $contestLink := .AdminLink }} + Contest: + {{.Name}} + {{ end }} +
+
+ {{ if .ProblemID.Valid }} + {{ with (index $.Problems .ProblemID.Int64) }} + {{ $problemLink := .AdminLink }} + re: + + {{.Name}}. {{.DisplayName}} + + {{ end }} + {{ else }} + General Question + {{ end }} +
+
+
+
+
By: + {{ $user_link := printf "/admin/users/%s" .UserID }} + {{.UserID}} +
+
+
+
{{- printf "%s" .Content -}}
+ {{ if .Response }} +
+
Response:
+
{{- printf "%s" .Response -}}
+
+ {{ else }} +
+ + + + + + +
+ + +
+
+ {{ end }} +
+ + {{ else }} +
No Clarifications
+ {{ end }} + +
+ +{{ end }} diff --git a/frontend/html/admin/contest.html b/frontend/html/admin/contest.html index bf3c180..39aa062 100644 --- a/frontend/html/admin/contest.html +++ b/frontend/html/admin/contest.html @@ -21,6 +21,8 @@ {{ $contest_link := printf "/admin/contests/%d" .Contest.ID }}
{{.Name}} ( + Announcements | Scoreboard | + +
New Announcement
+
+ +
Past Announcements
+
+ +{{ end }} + +{{ define "admin-content" }} +{{ $contest_link := printf "/admin/contests/%d" .Contest.ID }} +
+ + {{.Contest.Name}} + + >> + Announcements +
+ +
New Announcement
+{{ template "form-error" .Error }} +
+ + + + + + +
+ + +
+
+ +
Past Announcements
+
+ {{ 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
+ +
+
Clarifications
+ +
+
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 }} + +
Messages
+
+ {{ range .Messages }} + {{ with .Announcement }} + {{ template "announcement" (zip . $) }} + {{ end }} + {{ with .Clarification }} + {{ template "clarification" (zip . $) }} + {{ end }} + {{ else }} +
No messages
+ {{ end }} +
+ +{{ if isFuture .Contest.EndTime }} +
Request a Clarification
+{{ 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
+ +
+
Messages
+ +
+
+
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;