From fa788237eb5649e08b2a38ec21689b481b10c073 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 20 Feb 2026 21:30:49 +0900 Subject: 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 --- backend/admin/handler.go | 61 +++++++- backend/admin/handler_test.go | 205 +++++++++++++++++++++---- backend/admin/templates/submission_detail.html | 4 + backend/db/querier.go | 1 + backend/db/query.sql.go | 9 ++ backend/main.go | 2 +- backend/query.sql | 3 + 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: "Created At: {{ .Submission.CreatedAt }} +
+ +
+

Code

{{ .Submission.Code }}
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 -- cgit v1.3.1