aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-20 21:30:49 +0900
committernsfisis <nsfisis@gmail.com>2026-02-20 21:30:49 +0900
commitfa788237eb5649e08b2a38ec21689b481b10c073 (patch)
tree13ad37ff587b81810f59e8e7c5943aafd25f61dd
parent9f9efc2bc07810d2e06b37bad94e5922681eadef (diff)
downloadphperkaigi-2026-albatross-fa788237eb5649e08b2a38ec21689b481b10c073.tar.gz
phperkaigi-2026-albatross-fa788237eb5649e08b2a38ec21689b481b10c073.tar.zst
phperkaigi-2026-albatross-fa788237eb5649e08b2a38ec21689b481b10c073.zip
feat(admin): add rejudge functionality for submissions
Allow administrators to re-execute test cases for a specific submission from the submission detail page. This is useful after testcase fixes or worker issues. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--backend/admin/handler.go61
-rw-r--r--backend/admin/handler_test.go205
-rw-r--r--backend/admin/templates/submission_detail.html4
-rw-r--r--backend/db/querier.go1
-rw-r--r--backend/db/query.sql.go9
-rw-r--r--backend/main.go2
-rw-r--r--backend/query.sql3
7 files changed, 249 insertions, 36 deletions
diff --git a/backend/admin/handler.go b/backend/admin/handler.go
index 6e981bc..c8ddd25 100644
--- a/backend/admin/handler.go
+++ b/backend/admin/handler.go
@@ -3,6 +3,7 @@ package admin
import (
"context"
"errors"
+ "fmt"
"log/slog"
"net/http"
"strconv"
@@ -20,14 +21,19 @@ import (
var jst = time.FixedZone("Asia/Tokyo", 9*60*60)
+type GameHub interface {
+ EnqueueTestTasks(ctx context.Context, submissionID, gameID, userID int, language, code string) error
+}
+
type Handler struct {
q db.Querier
txm db.TxManager
+ hub GameHub
conf *config.Config
}
-func NewHandler(q db.Querier, txm db.TxManager, conf *config.Config) *Handler {
- return &Handler{q: q, txm: txm, conf: conf}
+func NewHandler(q db.Querier, txm db.TxManager, hub GameHub, conf *config.Config) *Handler {
+ return &Handler{q: q, txm: txm, hub: hub, conf: conf}
}
func (h *Handler) newAdminMiddleware() echo.MiddlewareFunc {
@@ -67,6 +73,7 @@ func (h *Handler) RegisterHandlers(g *echo.Group) {
g.POST("/games/:gameID/start", h.postGameStart)
g.GET("/games/:gameID/submissions", h.getSubmissions)
g.GET("/games/:gameID/submissions/:submissionID", h.getSubmissionDetail)
+ g.POST("/games/:gameID/submissions/:submissionID/rejudge", h.postSubmissionRejudge)
g.GET("/problems", h.getProblems)
g.GET("/problems/new", h.getProblemNew)
@@ -664,6 +671,56 @@ func (h *Handler) getSubmissionDetail(c echo.Context) error {
})
}
+func (h *Handler) postSubmissionRejudge(c echo.Context) error {
+ gameID, err := strconv.Atoi(c.Param("gameID"))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "Invalid game_id")
+ }
+
+ submissionID, err := strconv.Atoi(c.Param("submissionID"))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "Invalid submission_id")
+ }
+
+ ctx := c.Request().Context()
+
+ submission, err := h.q.GetSubmissionByID(ctx, int32(submissionID))
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return echo.NewHTTPError(http.StatusNotFound)
+ }
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
+ 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())
+ }
+
+ err = h.txm.RunInTx(ctx, func(qtx db.Querier) error {
+ if err := qtx.DeleteTestcaseResultsBySubmissionID(ctx, int32(submissionID)); err != nil {
+ return err
+ }
+ return qtx.UpdateSubmissionStatus(ctx, db.UpdateSubmissionStatusParams{
+ SubmissionID: int32(submissionID),
+ Status: "running",
+ })
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
+ err = h.hub.EnqueueTestTasks(ctx, submissionID, gameID, int(submission.UserID), game.Language, submission.Code)
+ if 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) getProblems(c echo.Context) error {
rows, err := h.q.ListProblems(c.Request().Context())
if err != nil {
diff --git a/backend/admin/handler_test.go b/backend/admin/handler_test.go
index 0e13c60..3b7a2ba 100644
--- a/backend/admin/handler_test.go
+++ b/backend/admin/handler_test.go
@@ -22,38 +22,40 @@ import (
// mockQuerier implements db.Querier for admin handler testing.
type mockQuerier struct {
db.Querier
- getUserByIDFunc func(ctx context.Context, userID int32) (db.User, error)
- listUsersFunc func(ctx context.Context) ([]db.User, error)
- updateUserFunc func(ctx context.Context, arg db.UpdateUserParams) error
- listAllGamesFunc func(ctx context.Context) ([]db.Game, error)
- getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error)
- listProblemsFunc func(ctx context.Context) ([]db.Problem, error)
- getProblemByIDFunc func(ctx context.Context, problemID int32) (db.Problem, error)
- createGameFunc func(ctx context.Context, arg db.CreateGameParams) (int32, error)
- createProblemFunc func(ctx context.Context, arg db.CreateProblemParams) (int32, error)
- updateProblemFunc func(ctx context.Context, arg db.UpdateProblemParams) error
- listTestcasesByProblemIDFunc func(ctx context.Context, problemID int32) ([]db.Testcase, error)
- getTestcaseByIDFunc func(ctx context.Context, testcaseID int32) (db.Testcase, error)
- createTestcaseFunc func(ctx context.Context, arg db.CreateTestcaseParams) (int32, error)
- updateTestcaseFunc func(ctx context.Context, arg db.UpdateTestcaseParams) error
- deleteTestcaseFunc func(ctx context.Context, testcaseID int32) error
- 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)
- getSubmissionByIDFunc func(ctx context.Context, submissionID int32) (db.Submission, error)
- getTestcaseResultsBySubmIDFunc func(ctx context.Context, submissionID int32) ([]db.TestcaseResult, error)
- listTestcasesByGameIDFunc func(ctx context.Context, gameID int32) ([]db.Testcase, error)
- updateGameStartedAtFunc func(ctx context.Context, arg db.UpdateGameStartedAtParams) error
- listTournamentsFunc func(ctx context.Context) ([]db.Tournament, error)
- getTournamentByIDFunc func(ctx context.Context, tournamentID int32) (db.Tournament, error)
- createTournamentFunc func(ctx context.Context, arg db.CreateTournamentParams) (int32, error)
- updateTournamentFunc func(ctx context.Context, arg db.UpdateTournamentParams) error
- listTournamentEntriesFunc func(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error)
- deleteTournamentEntriesFunc func(ctx context.Context, tournamentID int32) error
- createTournamentEntryFunc func(ctx context.Context, arg db.CreateTournamentEntryParams) error
- listTournamentMatchesFunc func(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error)
- createTournamentMatchFunc func(ctx context.Context, arg db.CreateTournamentMatchParams) error
- updateTournamentMatchGameFunc func(ctx context.Context, arg db.UpdateTournamentMatchGameParams) error
+ getUserByIDFunc func(ctx context.Context, userID int32) (db.User, error)
+ listUsersFunc func(ctx context.Context) ([]db.User, error)
+ updateUserFunc func(ctx context.Context, arg db.UpdateUserParams) error
+ listAllGamesFunc func(ctx context.Context) ([]db.Game, error)
+ getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error)
+ listProblemsFunc func(ctx context.Context) ([]db.Problem, error)
+ getProblemByIDFunc func(ctx context.Context, problemID int32) (db.Problem, error)
+ createGameFunc func(ctx context.Context, arg db.CreateGameParams) (int32, error)
+ createProblemFunc func(ctx context.Context, arg db.CreateProblemParams) (int32, error)
+ updateProblemFunc func(ctx context.Context, arg db.UpdateProblemParams) error
+ listTestcasesByProblemIDFunc func(ctx context.Context, problemID int32) ([]db.Testcase, error)
+ getTestcaseByIDFunc func(ctx context.Context, testcaseID int32) (db.Testcase, error)
+ createTestcaseFunc func(ctx context.Context, arg db.CreateTestcaseParams) (int32, error)
+ updateTestcaseFunc func(ctx context.Context, arg db.UpdateTestcaseParams) error
+ deleteTestcaseFunc func(ctx context.Context, testcaseID int32) error
+ deleteTestcaseResultsBySubmissionIDFunc func(ctx context.Context, submissionID int32) error
+ 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)
+ 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
+ listTestcasesByGameIDFunc func(ctx context.Context, gameID int32) ([]db.Testcase, error)
+ updateGameStartedAtFunc func(ctx context.Context, arg db.UpdateGameStartedAtParams) error
+ listTournamentsFunc func(ctx context.Context) ([]db.Tournament, error)
+ getTournamentByIDFunc func(ctx context.Context, tournamentID int32) (db.Tournament, error)
+ createTournamentFunc func(ctx context.Context, arg db.CreateTournamentParams) (int32, error)
+ updateTournamentFunc func(ctx context.Context, arg db.UpdateTournamentParams) error
+ listTournamentEntriesFunc func(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error)
+ deleteTournamentEntriesFunc func(ctx context.Context, tournamentID int32) error
+ createTournamentEntryFunc func(ctx context.Context, arg db.CreateTournamentEntryParams) error
+ listTournamentMatchesFunc func(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error)
+ createTournamentMatchFunc func(ctx context.Context, arg db.CreateTournamentMatchParams) error
+ updateTournamentMatchGameFunc func(ctx context.Context, arg db.UpdateTournamentMatchGameParams) error
}
func (m *mockQuerier) GetUserByID(ctx context.Context, userID int32) (db.User, error) {
@@ -277,6 +279,20 @@ func (m *mockQuerier) CreateTournamentMatch(ctx context.Context, arg db.CreateTo
return nil
}
+func (m *mockQuerier) DeleteTestcaseResultsBySubmissionID(ctx context.Context, submissionID int32) error {
+ if m.deleteTestcaseResultsBySubmissionIDFunc != nil {
+ return m.deleteTestcaseResultsBySubmissionIDFunc(ctx, submissionID)
+ }
+ return nil
+}
+
+func (m *mockQuerier) UpdateSubmissionStatus(ctx context.Context, arg db.UpdateSubmissionStatusParams) error {
+ if m.updateSubmissionStatusFunc != nil {
+ return m.updateSubmissionStatusFunc(ctx, arg)
+ }
+ return nil
+}
+
func (m *mockQuerier) UpdateTournamentMatchGame(ctx context.Context, arg db.UpdateTournamentMatchGameParams) error {
if m.updateTournamentMatchGameFunc != nil {
return m.updateTournamentMatchGameFunc(ctx, arg)
@@ -284,6 +300,18 @@ func (m *mockQuerier) UpdateTournamentMatchGame(ctx context.Context, arg db.Upda
return nil
}
+// mockGameHub implements GameHub for testing.
+type mockGameHub struct {
+ enqueueTestTasksFunc func(ctx context.Context, submissionID, gameID, userID int, language, code string) error
+}
+
+func (m *mockGameHub) EnqueueTestTasks(ctx context.Context, submissionID, gameID, userID int, language, code string) error {
+ if m.enqueueTestTasksFunc != nil {
+ return m.enqueueTestTasksFunc(ctx, submissionID, gameID, userID, language, code)
+ }
+ return nil
+}
+
// mockTxManager implements db.TxManager for testing.
type mockTxManager struct {
runInTxFunc func(ctx context.Context, fn func(q db.Querier) error) error
@@ -312,6 +340,7 @@ func newTestHandler(q *mockQuerier) *Handler {
return &Handler{
q: q,
txm: &mockTxManager{},
+ hub: &mockGameHub{},
conf: &config.Config{BasePath: "/test/"},
}
}
@@ -1230,7 +1259,8 @@ func TestPostTournamentNew_Success(t *testing.T) {
var createdParams db.CreateTournamentParams
var matchCount int
h := &Handler{
- q: &mockQuerier{},
+ q: &mockQuerier{},
+ hub: &mockGameHub{},
txm: &mockTxManager{
runInTxFunc: func(_ context.Context, fn func(q db.Querier) error) error {
return fn(&mockQuerier{
@@ -1360,3 +1390,112 @@ func TestPostTournamentEdit_NotFound(t *testing.T) {
t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound)
}
}
+
+// --- Rejudge tests ---
+
+func TestPostSubmissionRejudge_Success(t *testing.T) {
+ var deletedSubmissionID int32
+ var updatedStatus db.UpdateSubmissionStatusParams
+ var enqueuedSubmissionID, enqueuedGameID, enqueuedUserID int
+ var enqueuedLanguage, enqueuedCode string
+
+ q := &mockQuerier{
+ getSubmissionByIDFunc: func(_ context.Context, submissionID int32) (db.Submission, error) {
+ return db.Submission{
+ SubmissionID: submissionID,
+ GameID: 1,
+ UserID: 10,
+ Code: "<?php echo 1;",
+ CodeSize: 14,
+ Status: "wrong_answer",
+ CreatedAt: pgtype.Timestamp{Valid: true},
+ }, nil
+ },
+ getGameByIDFunc: func(_ context.Context, gameID int32) (db.GetGameByIDRow, error) {
+ return db.GetGameByIDRow{GameID: gameID, ProblemID: 1, Language: "php"}, nil
+ },
+ }
+
+ hub := &mockGameHub{
+ enqueueTestTasksFunc: func(_ context.Context, submissionID, gameID, userID int, language, code string) error {
+ enqueuedSubmissionID = submissionID
+ enqueuedGameID = gameID
+ enqueuedUserID = userID
+ enqueuedLanguage = language
+ enqueuedCode = code
+ return nil
+ },
+ }
+
+ txm := &mockTxManager{
+ runInTxFunc: func(_ context.Context, fn func(q db.Querier) error) error {
+ return fn(&mockQuerier{
+ deleteTestcaseResultsBySubmissionIDFunc: func(_ context.Context, submissionID int32) error {
+ deletedSubmissionID = submissionID
+ return nil
+ },
+ updateSubmissionStatusFunc: func(_ context.Context, arg db.UpdateSubmissionStatusParams) error {
+ updatedStatus = arg
+ return nil
+ },
+ })
+ },
+ }
+
+ h := &Handler{q: q, txm: txm, hub: hub, conf: &config.Config{BasePath: "/test/"}}
+
+ c, rec := newEchoContextWithForm("/admin/games/1/submissions/5/rejudge", map[string]string{
+ "gameID": "1",
+ "submissionID": "5",
+ }, url.Values{})
+
+ err := h.postSubmissionRejudge(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 deletedSubmissionID != 5 {
+ t.Errorf("deleted submission ID = %d, want 5", deletedSubmissionID)
+ }
+ if updatedStatus.SubmissionID != 5 || updatedStatus.Status != "running" {
+ t.Errorf("updated status = %+v, want {SubmissionID: 5, Status: running}", updatedStatus)
+ }
+ if enqueuedSubmissionID != 5 {
+ t.Errorf("enqueued submission ID = %d, want 5", enqueuedSubmissionID)
+ }
+ if enqueuedGameID != 1 {
+ t.Errorf("enqueued game ID = %d, want 1", enqueuedGameID)
+ }
+ if enqueuedUserID != 10 {
+ t.Errorf("enqueued user ID = %d, want 10", enqueuedUserID)
+ }
+ if enqueuedLanguage != "php" {
+ t.Errorf("enqueued language = %q, want %q", enqueuedLanguage, "php")
+ }
+ if enqueuedCode != "<?php echo 1;" {
+ t.Errorf("enqueued code = %q, want %q", enqueuedCode, "<?php echo 1;")
+ }
+}
+
+func TestPostSubmissionRejudge_SubmissionNotFound(t *testing.T) {
+ h := newTestHandler(&mockQuerier{})
+
+ c, _ := newEchoContextWithForm("/admin/games/1/submissions/999/rejudge", map[string]string{
+ "gameID": "1",
+ "submissionID": "999",
+ }, url.Values{})
+
+ err := h.postSubmissionRejudge(c)
+ if err == nil {
+ t.Fatal("expected error for non-existent submission")
+ }
+ 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/submission_detail.html b/backend/admin/templates/submission_detail.html
index 406c0b4..ed601ba 100644
--- a/backend/admin/templates/submission_detail.html
+++ b/backend/admin/templates/submission_detail.html
@@ -18,6 +18,10 @@
<li>Created At: {{ .Submission.CreatedAt }}</li>
</ul>
+<form method="POST" action="{{ .BasePath }}admin/games/{{ .GameID }}/submissions/{{ .Submission.SubmissionID }}/rejudge">
+ <button type="submit">Rejudge</button>
+</form>
+
<h3>Code</h3>
<pre><code>{{ .Submission.Code }}</code></pre>
diff --git a/backend/db/querier.go b/backend/db/querier.go
index 3b9545a..2b957ba 100644
--- a/backend/db/querier.go
+++ b/backend/db/querier.go
@@ -25,6 +25,7 @@ type Querier interface {
DeleteExpiredSessions(ctx context.Context) error
DeleteSession(ctx context.Context, sessionID string) error
DeleteTestcase(ctx context.Context, testcaseID int32) error
+ DeleteTestcaseResultsBySubmissionID(ctx context.Context, submissionID int32) error
DeleteTournamentEntries(ctx context.Context, tournamentID int32) error
DeleteTournamentMatches(ctx context.Context, tournamentID int32) error
GetGameByID(ctx context.Context, gameID int32) (GetGameByIDRow, error)
diff --git a/backend/db/query.sql.go b/backend/db/query.sql.go
index 50aa02e..8a13726 100644
--- a/backend/db/query.sql.go
+++ b/backend/db/query.sql.go
@@ -293,6 +293,15 @@ func (q *Queries) DeleteTestcase(ctx context.Context, testcaseID int32) error {
return err
}
+const deleteTestcaseResultsBySubmissionID = `-- name: DeleteTestcaseResultsBySubmissionID :exec
+DELETE FROM testcase_results WHERE submission_id = $1
+`
+
+func (q *Queries) DeleteTestcaseResultsBySubmissionID(ctx context.Context, submissionID int32) error {
+ _, err := q.db.Exec(ctx, deleteTestcaseResultsBySubmissionID, submissionID)
+ return err
+}
+
const deleteTournamentEntries = `-- name: DeleteTournamentEntries :exec
DELETE FROM tournament_entries
WHERE tournament_id = $1
diff --git a/backend/main.go b/backend/main.go
index f936cf4..3ea4493 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -105,7 +105,7 @@ func main() {
apiHandler := api.NewHandler(queries, txm, gameHub, authenticator, conf)
api.RegisterHandlers(apiGroup, api.NewStrictHandler(apiHandler, nil))
- adminHandler := admin.NewHandler(queries, txm, conf)
+ adminHandler := admin.NewHandler(queries, txm, gameHub, conf)
adminGroup := e.Group(conf.BasePath + "admin")
adminGroup.Use(api.SessionCookieMiddleware(queries))
adminHandler.RegisterHandlers(adminGroup)
diff --git a/backend/query.sql b/backend/query.sql
index b4b589c..1e49780 100644
--- a/backend/query.sql
+++ b/backend/query.sql
@@ -271,6 +271,9 @@ FROM submissions
WHERE submission_id = $1
LIMIT 1;
+-- name: DeleteTestcaseResultsBySubmissionID :exec
+DELETE FROM testcase_results WHERE submission_id = $1;
+
-- name: GetTestcaseResultsBySubmissionID :many
SELECT *
FROM testcase_results