diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-20 21:30:49 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-20 21:30:49 +0900 |
| commit | fa788237eb5649e08b2a38ec21689b481b10c073 (patch) | |
| tree | 13ad37ff587b81810f59e8e7c5943aafd25f61dd /backend | |
| parent | 9f9efc2bc07810d2e06b37bad94e5922681eadef (diff) | |
| download | phperkaigi-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>
Diffstat (limited to 'backend')
| -rw-r--r-- | backend/admin/handler.go | 61 | ||||
| -rw-r--r-- | backend/admin/handler_test.go | 205 | ||||
| -rw-r--r-- | backend/admin/templates/submission_detail.html | 4 | ||||
| -rw-r--r-- | backend/db/querier.go | 1 | ||||
| -rw-r--r-- | backend/db/query.sql.go | 9 | ||||
| -rw-r--r-- | backend/main.go | 2 | ||||
| -rw-r--r-- | 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: "<?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 |
