aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-20 21:38:58 +0900
committernsfisis <nsfisis@gmail.com>2026-02-20 21:38:58 +0900
commit85b7a14913c05b88b720fc546eaca5575ffe53fd (patch)
treeb19d66d3500bb4b697cf656a769a111411793cf3 /backend
parentfa788237eb5649e08b2a38ec21689b481b10c073 (diff)
downloadphperkaigi-2026-albatross-85b7a14913c05b88b720fc546eaca5575ffe53fd.tar.gz
phperkaigi-2026-albatross-85b7a14913c05b88b720fc546eaca5575ffe53fd.tar.zst
phperkaigi-2026-albatross-85b7a14913c05b88b720fc546eaca5575ffe53fd.zip
feat(admin): add bulk rejudge for game submissions
Extract common rejudge logic into a helper method and add two new endpoints: rejudge-latest (per-user latest only) and rejudge-all. This allows re-running submissions in bulk after testcase changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'backend')
-rw-r--r--backend/admin/handler.go84
-rw-r--r--backend/admin/handler_test.go127
-rw-r--r--backend/admin/templates/submissions.html6
-rw-r--r--backend/db/querier.go1
-rw-r--r--backend/db/query.sql.go35
-rw-r--r--backend/query.sql6
6 files changed, 249 insertions, 10 deletions
diff --git a/backend/admin/handler.go b/backend/admin/handler.go
index c8ddd25..9bdeb69 100644
--- a/backend/admin/handler.go
+++ b/backend/admin/handler.go
@@ -72,6 +72,8 @@ func (h *Handler) RegisterHandlers(g *echo.Group) {
g.POST("/games/:gameID", h.postGameEdit)
g.POST("/games/:gameID/start", h.postGameStart)
g.GET("/games/:gameID/submissions", h.getSubmissions)
+ g.POST("/games/:gameID/submissions/rejudge-latest", h.postSubmissionsRejudgeLatest)
+ g.POST("/games/:gameID/submissions/rejudge-all", h.postSubmissionsRejudgeAll)
g.GET("/games/:gameID/submissions/:submissionID", h.getSubmissionDetail)
g.POST("/games/:gameID/submissions/:submissionID/rejudge", h.postSubmissionRejudge)
@@ -671,6 +673,22 @@ func (h *Handler) getSubmissionDetail(c echo.Context) error {
})
}
+func (h *Handler) rejudgeSubmission(ctx context.Context, submission db.Submission, language string) error {
+ err := h.txm.RunInTx(ctx, func(qtx db.Querier) error {
+ if err := qtx.DeleteTestcaseResultsBySubmissionID(ctx, submission.SubmissionID); err != nil {
+ return err
+ }
+ return qtx.UpdateSubmissionStatus(ctx, db.UpdateSubmissionStatusParams{
+ SubmissionID: submission.SubmissionID,
+ Status: "running",
+ })
+ })
+ if err != nil {
+ return err
+ }
+ return h.hub.EnqueueTestTasks(ctx, int(submission.SubmissionID), int(submission.GameID), int(submission.UserID), language, submission.Code)
+}
+
func (h *Handler) postSubmissionRejudge(c echo.Context) error {
gameID, err := strconv.Atoi(c.Param("gameID"))
if err != nil {
@@ -700,25 +718,71 @@ func (h *Handler) postSubmissionRejudge(c echo.Context) error {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
- err = h.txm.RunInTx(ctx, func(qtx db.Querier) error {
- if err := qtx.DeleteTestcaseResultsBySubmissionID(ctx, int32(submissionID)); err != nil {
- return err
+ if err := h.rejudgeSubmission(ctx, submission, game.Language); err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
+ return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%sadmin/games/%d/submissions/%d", h.conf.BasePath, gameID, submissionID))
+}
+
+func (h *Handler) postSubmissionsRejudgeLatest(c echo.Context) error {
+ gameID, err := strconv.Atoi(c.Param("gameID"))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "Invalid game_id")
+ }
+
+ ctx := c.Request().Context()
+
+ game, err := h.q.GetGameByID(ctx, int32(gameID))
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return echo.NewHTTPError(http.StatusNotFound)
}
- return qtx.UpdateSubmissionStatus(ctx, db.UpdateSubmissionStatusParams{
- SubmissionID: int32(submissionID),
- Status: "running",
- })
- })
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
+ submissions, err := h.q.GetLatestSubmissionsByGameID(ctx, int32(gameID))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
- err = h.hub.EnqueueTestTasks(ctx, submissionID, gameID, int(submission.UserID), game.Language, submission.Code)
+ for _, s := range submissions {
+ if err := h.rejudgeSubmission(ctx, s, game.Language); err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ }
+
+ return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%sadmin/games/%d/submissions", h.conf.BasePath, gameID))
+}
+
+func (h *Handler) postSubmissionsRejudgeAll(c echo.Context) error {
+ gameID, err := strconv.Atoi(c.Param("gameID"))
if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "Invalid game_id")
+ }
+
+ ctx := c.Request().Context()
+
+ game, err := h.q.GetGameByID(ctx, int32(gameID))
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return echo.NewHTTPError(http.StatusNotFound)
+ }
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
- return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%sadmin/games/%d/submissions/%d", h.conf.BasePath, gameID, submissionID))
+ submissions, err := h.q.GetSubmissionsByGameID(ctx, int32(gameID))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
+ for _, s := range submissions {
+ if err := h.rejudgeSubmission(ctx, s, game.Language); err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ }
+
+ return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%sadmin/games/%d/submissions", h.conf.BasePath, gameID))
}
func (h *Handler) getProblems(c echo.Context) error {
diff --git a/backend/admin/handler_test.go b/backend/admin/handler_test.go
index 3b7a2ba..7249c2b 100644
--- a/backend/admin/handler_test.go
+++ b/backend/admin/handler_test.go
@@ -41,6 +41,7 @@ type mockQuerier struct {
listMainPlayersFunc func(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error)
listSubmissionIDsFunc func(ctx context.Context) ([]int32, error)
getSubmissionsByGameIDFunc func(ctx context.Context, gameID int32) ([]db.Submission, error)
+ getLatestSubmissionsByGameIDFunc func(ctx context.Context, gameID int32) ([]db.Submission, error)
getSubmissionByIDFunc func(ctx context.Context, submissionID int32) (db.Submission, error)
getTestcaseResultsBySubmIDFunc func(ctx context.Context, submissionID int32) ([]db.TestcaseResult, error)
updateSubmissionStatusFunc func(ctx context.Context, arg db.UpdateSubmissionStatusParams) error
@@ -184,6 +185,13 @@ func (m *mockQuerier) GetSubmissionsByGameID(ctx context.Context, gameID int32)
return nil, nil
}
+func (m *mockQuerier) GetLatestSubmissionsByGameID(ctx context.Context, gameID int32) ([]db.Submission, error) {
+ if m.getLatestSubmissionsByGameIDFunc != nil {
+ return m.getLatestSubmissionsByGameIDFunc(ctx, gameID)
+ }
+ return nil, nil
+}
+
func (m *mockQuerier) GetSubmissionByID(ctx context.Context, submissionID int32) (db.Submission, error) {
if m.getSubmissionByIDFunc != nil {
return m.getSubmissionByIDFunc(ctx, submissionID)
@@ -1499,3 +1507,122 @@ func TestPostSubmissionRejudge_SubmissionNotFound(t *testing.T) {
t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound)
}
}
+
+func TestPostSubmissionsRejudgeLatest_Success(t *testing.T) {
+ var enqueuedIDs []int
+
+ q := &mockQuerier{
+ getGameByIDFunc: func(_ context.Context, gameID int32) (db.GetGameByIDRow, error) {
+ return db.GetGameByIDRow{GameID: gameID, ProblemID: 1, Language: "php"}, nil
+ },
+ getLatestSubmissionsByGameIDFunc: func(_ context.Context, _ int32) ([]db.Submission, error) {
+ return []db.Submission{
+ {SubmissionID: 10, GameID: 1, UserID: 1, Code: "<?php echo 1;", CreatedAt: pgtype.Timestamp{Valid: true}},
+ {SubmissionID: 20, GameID: 1, UserID: 2, Code: "<?php echo 2;", CreatedAt: pgtype.Timestamp{Valid: true}},
+ }, nil
+ },
+ }
+
+ hub := &mockGameHub{
+ enqueueTestTasksFunc: func(_ context.Context, submissionID, _, _ int, _, _ string) error {
+ enqueuedIDs = append(enqueuedIDs, submissionID)
+ return nil
+ },
+ }
+
+ txm := &mockTxManager{
+ runInTxFunc: func(_ context.Context, fn func(q db.Querier) error) error {
+ return fn(&mockQuerier{})
+ },
+ }
+
+ h := &Handler{q: q, txm: txm, hub: hub, conf: &config.Config{BasePath: "/test/"}}
+
+ c, rec := newEchoContextWithForm("/admin/games/1/submissions/rejudge-latest", map[string]string{
+ "gameID": "1",
+ }, url.Values{})
+
+ err := h.postSubmissionsRejudgeLatest(c)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if rec.Code != http.StatusSeeOther {
+ t.Errorf("status = %d, want %d", rec.Code, http.StatusSeeOther)
+ }
+ if len(enqueuedIDs) != 2 {
+ t.Fatalf("enqueued count = %d, want 2", len(enqueuedIDs))
+ }
+ if enqueuedIDs[0] != 10 || enqueuedIDs[1] != 20 {
+ t.Errorf("enqueued IDs = %v, want [10, 20]", enqueuedIDs)
+ }
+}
+
+func TestPostSubmissionsRejudgeAll_Success(t *testing.T) {
+ var enqueuedIDs []int
+
+ q := &mockQuerier{
+ getGameByIDFunc: func(_ context.Context, gameID int32) (db.GetGameByIDRow, error) {
+ return db.GetGameByIDRow{GameID: gameID, ProblemID: 1, Language: "php"}, nil
+ },
+ getSubmissionsByGameIDFunc: func(_ context.Context, _ int32) ([]db.Submission, error) {
+ return []db.Submission{
+ {SubmissionID: 10, GameID: 1, UserID: 1, Code: "<?php echo 1;", CreatedAt: pgtype.Timestamp{Valid: true}},
+ {SubmissionID: 11, GameID: 1, UserID: 1, Code: "<?php echo 11;", CreatedAt: pgtype.Timestamp{Valid: true}},
+ {SubmissionID: 20, GameID: 1, UserID: 2, Code: "<?php echo 2;", CreatedAt: pgtype.Timestamp{Valid: true}},
+ }, nil
+ },
+ }
+
+ hub := &mockGameHub{
+ enqueueTestTasksFunc: func(_ context.Context, submissionID, _, _ int, _, _ string) error {
+ enqueuedIDs = append(enqueuedIDs, submissionID)
+ return nil
+ },
+ }
+
+ txm := &mockTxManager{
+ runInTxFunc: func(_ context.Context, fn func(q db.Querier) error) error {
+ return fn(&mockQuerier{})
+ },
+ }
+
+ h := &Handler{q: q, txm: txm, hub: hub, conf: &config.Config{BasePath: "/test/"}}
+
+ c, rec := newEchoContextWithForm("/admin/games/1/submissions/rejudge-all", map[string]string{
+ "gameID": "1",
+ }, url.Values{})
+
+ err := h.postSubmissionsRejudgeAll(c)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if rec.Code != http.StatusSeeOther {
+ t.Errorf("status = %d, want %d", rec.Code, http.StatusSeeOther)
+ }
+ if len(enqueuedIDs) != 3 {
+ t.Fatalf("enqueued count = %d, want 3", len(enqueuedIDs))
+ }
+ if enqueuedIDs[0] != 10 || enqueuedIDs[1] != 11 || enqueuedIDs[2] != 20 {
+ t.Errorf("enqueued IDs = %v, want [10, 11, 20]", enqueuedIDs)
+ }
+}
+
+func TestPostSubmissionsRejudgeAll_GameNotFound(t *testing.T) {
+ h := newTestHandler(&mockQuerier{})
+
+ c, _ := newEchoContextWithForm("/admin/games/999/submissions/rejudge-all", map[string]string{
+ "gameID": "999",
+ }, url.Values{})
+
+ err := h.postSubmissionsRejudgeAll(c)
+ if err == nil {
+ t.Fatal("expected error for non-existent game")
+ }
+ httpErr, ok := err.(*echo.HTTPError)
+ if !ok {
+ t.Fatalf("expected echo.HTTPError, got %T", err)
+ }
+ if httpErr.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound)
+ }
+}
diff --git a/backend/admin/templates/submissions.html b/backend/admin/templates/submissions.html
index 6870c2a..c53c5b3 100644
--- a/backend/admin/templates/submissions.html
+++ b/backend/admin/templates/submissions.html
@@ -8,6 +8,12 @@
{{ define "content" }}
<h2>Submissions for Game {{ .GameID }}</h2>
+<form method="POST" action="{{ .BasePath }}admin/games/{{ .GameID }}/submissions/rejudge-latest" style="display:inline">
+ <button type="submit">Rejudge Latest</button>
+</form>
+<form method="POST" action="{{ .BasePath }}admin/games/{{ .GameID }}/submissions/rejudge-all" style="display:inline">
+ <button type="submit">Rejudge All</button>
+</form>
<table>
<thead>
<tr>
diff --git a/backend/db/querier.go b/backend/db/querier.go
index 2b957ba..220a86c 100644
--- a/backend/db/querier.go
+++ b/backend/db/querier.go
@@ -31,6 +31,7 @@ type Querier interface {
GetGameByID(ctx context.Context, gameID int32) (GetGameByIDRow, error)
GetLatestState(ctx context.Context, arg GetLatestStateParams) (GetLatestStateRow, error)
GetLatestStatesOfMainPlayers(ctx context.Context, gameID int32) ([]GetLatestStatesOfMainPlayersRow, error)
+ GetLatestSubmissionsByGameID(ctx context.Context, gameID int32) ([]Submission, error)
GetProblemByID(ctx context.Context, problemID int32) (Problem, error)
GetQualifyingRanking(ctx context.Context, arg GetQualifyingRankingParams) ([]GetQualifyingRankingRow, error)
GetRanking(ctx context.Context, gameID int32) ([]GetRankingRow, error)
diff --git a/backend/db/query.sql.go b/backend/db/query.sql.go
index 8a13726..02f1abf 100644
--- a/backend/db/query.sql.go
+++ b/backend/db/query.sql.go
@@ -472,6 +472,41 @@ func (q *Queries) GetLatestStatesOfMainPlayers(ctx context.Context, gameID int32
return items, nil
}
+const getLatestSubmissionsByGameID = `-- name: GetLatestSubmissionsByGameID :many
+SELECT DISTINCT ON (user_id) submission_id, game_id, user_id, code, code_size, status, created_at
+FROM submissions
+WHERE game_id = $1
+ORDER BY user_id, created_at DESC
+`
+
+func (q *Queries) GetLatestSubmissionsByGameID(ctx context.Context, gameID int32) ([]Submission, error) {
+ rows, err := q.db.Query(ctx, getLatestSubmissionsByGameID, gameID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Submission
+ for rows.Next() {
+ var i Submission
+ if err := rows.Scan(
+ &i.SubmissionID,
+ &i.GameID,
+ &i.UserID,
+ &i.Code,
+ &i.CodeSize,
+ &i.Status,
+ &i.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getProblemByID = `-- name: GetProblemByID :one
SELECT problem_id, title, description, language, sample_code FROM problems
WHERE problem_id = $1
diff --git a/backend/query.sql b/backend/query.sql
index 1e49780..45ac46f 100644
--- a/backend/query.sql
+++ b/backend/query.sql
@@ -265,6 +265,12 @@ FROM submissions
WHERE game_id = $1
ORDER BY created_at DESC;
+-- name: GetLatestSubmissionsByGameID :many
+SELECT DISTINCT ON (user_id) *
+FROM submissions
+WHERE game_id = $1
+ORDER BY user_id, created_at DESC;
+
-- name: GetSubmissionByID :one
SELECT *
FROM submissions