diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-20 21:38:58 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-20 21:38:58 +0900 |
| commit | 85b7a14913c05b88b720fc546eaca5575ffe53fd (patch) | |
| tree | b19d66d3500bb4b697cf656a769a111411793cf3 /backend | |
| parent | fa788237eb5649e08b2a38ec21689b481b10c073 (diff) | |
| download | phperkaigi-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.go | 84 | ||||
| -rw-r--r-- | backend/admin/handler_test.go | 127 | ||||
| -rw-r--r-- | backend/admin/templates/submissions.html | 6 | ||||
| -rw-r--r-- | backend/db/querier.go | 1 | ||||
| -rw-r--r-- | backend/db/query.sql.go | 35 | ||||
| -rw-r--r-- | backend/query.sql | 6 |
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 |
