diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-09-06 01:30:31 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-09-06 01:30:31 +0900 |
| commit | 8d739b386f2b555292fd8082c9de2199228737c9 (patch) | |
| tree | 69ec44d214686cf80627216e984ae523eae8b98b | |
| parent | e7b35cd81f2e515371a30a59ea4173a31dbeefc5 (diff) | |
| download | iosdc-japan-2025-albatross-8d739b386f2b555292fd8082c9de2199228737c9.tar.gz iosdc-japan-2025-albatross-8d739b386f2b555292fd8082c9de2199228737c9.tar.zst iosdc-japan-2025-albatross-8d739b386f2b555292fd8082c9de2199228737c9.zip | |
feat(backend): add admin pages for testcases
| -rw-r--r-- | backend/admin/handler.go | 201 | ||||
| -rw-r--r-- | backend/admin/templates/problem_edit.html | 6 | ||||
| -rw-r--r-- | backend/admin/templates/testcase_edit.html | 36 | ||||
| -rw-r--r-- | backend/admin/templates/testcase_new.html | 27 | ||||
| -rw-r--r-- | backend/admin/templates/testcases.html | 26 | ||||
| -rw-r--r-- | backend/db/query.sql.go | 134 | ||||
| -rw-r--r-- | backend/query.sql | 31 |
7 files changed, 461 insertions, 0 deletions
diff --git a/backend/admin/handler.go b/backend/admin/handler.go index 19e44e6..86ee3f7 100644 --- a/backend/admin/handler.go +++ b/backend/admin/handler.go @@ -73,6 +73,12 @@ func (h *Handler) RegisterHandlers(g *echo.Group) { g.POST("/problems/new", h.postProblemNew) g.GET("/problems/:problemID", h.getProblemEdit) g.POST("/problems/:problemID", h.postProblemEdit) + g.GET("/problems/:problemID/testcases", h.getTestcases) + g.GET("/problems/:problemID/testcases/new", h.getTestcaseNew) + g.POST("/problems/:problemID/testcases/new", h.postTestcaseNew) + g.GET("/problems/:problemID/testcases/:testcaseID", h.getTestcaseEdit) + g.POST("/problems/:problemID/testcases/:testcaseID", h.postTestcaseEdit) + g.POST("/problems/:problemID/testcases/:testcaseID/delete", h.postTestcaseDelete) } func (h *Handler) getDashboard(c echo.Context) error { @@ -605,3 +611,198 @@ func (h *Handler) postProblemEdit(c echo.Context) error { return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"admin/problems") } + +func (h *Handler) getTestcases(c echo.Context) error { + problemID, err := strconv.Atoi(c.Param("problemID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid problem id") + } + + // Get problem info + problem, err := h.q.GetProblemByID(c.Request().Context(), int32(problemID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + // Get testcases for this problem + rows, err := h.q.ListTestcasesByProblemID(c.Request().Context(), int32(problemID)) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + testcases := make([]echo.Map, len(rows)) + for i, tc := range rows { + testcases[i] = echo.Map{ + "TestcaseID": tc.TestcaseID, + "ProblemID": tc.ProblemID, + "Stdin": tc.Stdin, + "Stdout": tc.Stdout, + } + } + + return c.Render(http.StatusOK, "testcases", echo.Map{ + "BasePath": h.conf.BasePath, + "Title": "Testcases for " + problem.Title, + "Problem": echo.Map{"ProblemID": problem.ProblemID, "Title": problem.Title}, + "Testcases": testcases, + }) +} + +func (h *Handler) getTestcaseNew(c echo.Context) error { + problemID, err := strconv.Atoi(c.Param("problemID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid problem id") + } + + // Get problem info + problem, err := h.q.GetProblemByID(c.Request().Context(), int32(problemID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.Render(http.StatusOK, "testcase_new", echo.Map{ + "BasePath": h.conf.BasePath, + "Title": "New Testcase for " + problem.Title, + "Problem": echo.Map{"ProblemID": problem.ProblemID, "Title": problem.Title}, + }) +} + +func (h *Handler) postTestcaseNew(c echo.Context) error { + problemID, err := strconv.Atoi(c.Param("problemID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid problem_id") + } + stdin := c.FormValue("stdin") + stdout := c.FormValue("stdout") + + _, err = h.q.CreateTestcase(c.Request().Context(), db.CreateTestcaseParams{ + ProblemID: int32(problemID), + Stdin: stdin, + Stdout: stdout, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"admin/problems/"+strconv.Itoa(problemID)+"/testcases") +} + +func (h *Handler) getTestcaseEdit(c echo.Context) error { + problemID, err := strconv.Atoi(c.Param("problemID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid problem id") + } + testcaseID, err := strconv.Atoi(c.Param("testcaseID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid testcase id") + } + + // Get problem info + problem, err := h.q.GetProblemByID(c.Request().Context(), int32(problemID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + // Get testcase info and verify it belongs to this problem + testcase, err := h.q.GetTestcaseByID(c.Request().Context(), int32(testcaseID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + // Verify the testcase belongs to the specified problem + if testcase.ProblemID != int32(problemID) { + return echo.NewHTTPError(http.StatusNotFound) + } + + return c.Render(http.StatusOK, "testcase_edit", echo.Map{ + "BasePath": h.conf.BasePath, + "Title": "Edit Testcase for " + problem.Title, + "Problem": echo.Map{"ProblemID": problem.ProblemID, "Title": problem.Title}, + "Testcase": echo.Map{ + "TestcaseID": testcase.TestcaseID, + "ProblemID": testcase.ProblemID, + "Stdin": testcase.Stdin, + "Stdout": testcase.Stdout, + }, + }) +} + +func (h *Handler) postTestcaseEdit(c echo.Context) error { + problemID, err := strconv.Atoi(c.Param("problemID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid problem_id") + } + testcaseID, err := strconv.Atoi(c.Param("testcaseID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid testcase_id") + } + + // Verify the testcase belongs to this problem before updating + testcase, err := h.q.GetTestcaseByID(c.Request().Context(), int32(testcaseID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + if testcase.ProblemID != int32(problemID) { + return echo.NewHTTPError(http.StatusNotFound) + } + + stdin := c.FormValue("stdin") + stdout := c.FormValue("stdout") + + err = h.q.UpdateTestcase(c.Request().Context(), db.UpdateTestcaseParams{ + TestcaseID: int32(testcaseID), + ProblemID: int32(problemID), + Stdin: stdin, + Stdout: stdout, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"admin/problems/"+strconv.Itoa(problemID)+"/testcases") +} + +func (h *Handler) postTestcaseDelete(c echo.Context) error { + problemID, err := strconv.Atoi(c.Param("problemID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid problem_id") + } + testcaseID, err := strconv.Atoi(c.Param("testcaseID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid testcase_id") + } + + // Verify the testcase belongs to this problem before deleting + testcase, err := h.q.GetTestcaseByID(c.Request().Context(), int32(testcaseID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + if testcase.ProblemID != int32(problemID) { + return echo.NewHTTPError(http.StatusNotFound) + } + + err = h.q.DeleteTestcase(c.Request().Context(), int32(testcaseID)) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"admin/problems/"+strconv.Itoa(problemID)+"/testcases") +} diff --git a/backend/admin/templates/problem_edit.html b/backend/admin/templates/problem_edit.html index cc700f4..722473b 100644 --- a/backend/admin/templates/problem_edit.html +++ b/backend/admin/templates/problem_edit.html @@ -33,4 +33,10 @@ <button type="submit">Save</button> </div> </form> +<div> + <a href="{{ .BasePath }}admin/problems/{{ .Problem.ProblemID }}/testcases">View Testcases</a> +</div> +<div> + <a href="{{ .BasePath }}admin/problems/{{ .Problem.ProblemID }}/testcases/new">Add New Testcase</a> +</div> {{ end }} diff --git a/backend/admin/templates/testcase_edit.html b/backend/admin/templates/testcase_edit.html new file mode 100644 index 0000000..65e181e --- /dev/null +++ b/backend/admin/templates/testcase_edit.html @@ -0,0 +1,36 @@ +{{ template "base.html" . }} + +{{ define "breadcrumb" }} +<a href="{{ .BasePath }}admin/dashboard">Dashboard</a> | <a href="{{ .BasePath }}admin/problems">Problems</a> | <a href="{{ .BasePath }}admin/problems/{{ .Problem.ProblemID }}">{{ .Problem.Title }}</a> | <a href="{{ .BasePath }}admin/problems/{{ .Problem.ProblemID }}/testcases">Testcases</a> +{{ end }} + +{{ define "content" }} +<h2>Edit Testcase for {{ .Problem.Title }}</h2> +<form method="post"> + <div> + <label>Testcase ID</label> + <input type="text" name="testcase_id" value="{{ .Testcase.TestcaseID }}" readonly required> + </div> + <div> + <label>Problem</label> + <input type="text" value="{{ .Problem.Title }}" readonly> + <input type="hidden" name="problem_id" value="{{ .Problem.ProblemID }}"> + </div> + <div> + <label>Stdin</label> + <textarea name="stdin" rows="10" required>{{ .Testcase.Stdin }}</textarea> + </div> + <div> + <label>Stdout</label> + <textarea name="stdout" rows="10" required>{{ .Testcase.Stdout }}</textarea> + </div> + <div> + <button type="submit">Save</button> + </div> +</form> +<form method="post" action="{{ .BasePath }}admin/problems/{{ .Problem.ProblemID }}/testcases/{{ .Testcase.TestcaseID }}/delete" onsubmit="return confirm('Are you sure you want to delete this testcase?');"> + <div> + <button type="submit">Delete</button> + </div> +</form> +{{ end }} diff --git a/backend/admin/templates/testcase_new.html b/backend/admin/templates/testcase_new.html new file mode 100644 index 0000000..828406a --- /dev/null +++ b/backend/admin/templates/testcase_new.html @@ -0,0 +1,27 @@ +{{ template "base.html" . }} + +{{ define "breadcrumb" }} +<a href="{{ .BasePath }}admin/dashboard">Dashboard</a> | <a href="{{ .BasePath }}admin/problems">Problems</a> | <a href="{{ .BasePath }}admin/problems/{{ .Problem.ProblemID }}">{{ .Problem.Title }}</a> | <a href="{{ .BasePath }}admin/problems/{{ .Problem.ProblemID }}/testcases">Testcases</a> +{{ end }} + +{{ define "content" }} +<h2>New Testcase for {{ .Problem.Title }}</h2> +<form method="post"> + <div> + <label>Problem</label> + <input type="text" value="{{ .Problem.Title }}" readonly> + <input type="hidden" name="problem_id" value="{{ .Problem.ProblemID }}"> + </div> + <div> + <label>Stdin</label> + <textarea name="stdin" rows="10" required></textarea> + </div> + <div> + <label>Stdout</label> + <textarea name="stdout" rows="10" required></textarea> + </div> + <div> + <button type="submit">Create</button> + </div> +</form> +{{ end }} diff --git a/backend/admin/templates/testcases.html b/backend/admin/templates/testcases.html new file mode 100644 index 0000000..c27a45f --- /dev/null +++ b/backend/admin/templates/testcases.html @@ -0,0 +1,26 @@ +{{ template "base.html" . }} + +{{ define "breadcrumb" }} +<a href="{{ .BasePath }}admin/dashboard">Dashboard</a> | <a href="{{ .BasePath }}admin/problems">Problems</a> | <a href="{{ .BasePath }}admin/problems/{{ .Problem.ProblemID }}">{{ .Problem.Title }}</a> +{{ end }} + +{{ define "content" }} +<h2>Testcases for {{ .Problem.Title }}</h2> +<div> + <a href="{{ .BasePath }}admin/problems/{{ .Problem.ProblemID }}/testcases/new">Create New Testcase</a> +</div> +{{ range .Testcases }} + <h3>{{ .TestcaseID }}</h3> + <div> + <a href="{{ $.BasePath }}admin/problems/{{ $.Problem.ProblemID }}/testcases/{{ .TestcaseID }}">Edit</a> + </div> + <h4>Stdin</h4> + <div> + <pre><code>{{ .Stdin }}</code></pre> + </div> + <h4>Stdout</h4> + <div> + <pre><code>{{ .Stdout }}</code></pre> + </div> +{{ end }} +{{ end }} diff --git a/backend/db/query.sql.go b/backend/db/query.sql.go index 001933a..23be719 100644 --- a/backend/db/query.sql.go +++ b/backend/db/query.sql.go @@ -127,6 +127,25 @@ func (q *Queries) CreateSubmission(ctx context.Context, arg CreateSubmissionPara return submission_id, err } +const createTestcase = `-- name: CreateTestcase :one +INSERT INTO testcases (problem_id, stdin, stdout) +VALUES ($1, $2, $3) +RETURNING testcase_id +` + +type CreateTestcaseParams struct { + ProblemID int32 + Stdin string + Stdout string +} + +func (q *Queries) CreateTestcase(ctx context.Context, arg CreateTestcaseParams) (int32, error) { + row := q.db.QueryRow(ctx, createTestcase, arg.ProblemID, arg.Stdin, arg.Stdout) + var testcase_id int32 + err := row.Scan(&testcase_id) + return testcase_id, err +} + const createTestcaseResult = `-- name: CreateTestcaseResult :exec INSERT INTO testcase_results (submission_id, testcase_id, status, stdout, stderr) VALUES ($1, $2, $3, $4, $5) @@ -179,6 +198,16 @@ func (q *Queries) CreateUserAuth(ctx context.Context, arg CreateUserAuthParams) return err } +const deleteTestcase = `-- name: DeleteTestcase :exec +DELETE FROM testcases +WHERE testcase_id = $1 +` + +func (q *Queries) DeleteTestcase(ctx context.Context, testcaseID int32) error { + _, err := q.db.Exec(ctx, deleteTestcase, testcaseID) + return err +} + const getGameByID = `-- name: GetGameByID :one SELECT game_id, game_type, is_public, display_name, duration_seconds, created_at, started_at, games.problem_id, problems.problem_id, title, description, language, sample_code FROM games JOIN problems ON games.problem_id = problems.problem_id @@ -461,6 +490,24 @@ func (q *Queries) GetRanking(ctx context.Context, gameID int32) ([]GetRankingRow return items, nil } +const getTestcaseByID = `-- name: GetTestcaseByID :one +SELECT testcase_id, problem_id, stdin, stdout FROM testcases +WHERE testcase_id = $1 +LIMIT 1 +` + +func (q *Queries) GetTestcaseByID(ctx context.Context, testcaseID int32) (Testcase, error) { + row := q.db.QueryRow(ctx, getTestcaseByID, testcaseID) + var i Testcase + err := row.Scan( + &i.TestcaseID, + &i.ProblemID, + &i.Stdin, + &i.Stdout, + ) + return i, err +} + const getUserAuthByUsername = `-- name: GetUserAuthByUsername :one SELECT users.user_id, username, display_name, icon_path, is_admin, label, created_at, user_auth_id, user_auths.user_id, auth_type, password_hash FROM users JOIN user_auths ON users.user_id = user_auths.user_id @@ -759,6 +806,36 @@ func (q *Queries) ListSubmissionIDs(ctx context.Context) ([]int32, error) { return items, nil } +const listTestcases = `-- name: ListTestcases :many +SELECT testcase_id, problem_id, stdin, stdout FROM testcases +ORDER BY testcase_id +` + +func (q *Queries) ListTestcases(ctx context.Context) ([]Testcase, error) { + rows, err := q.db.Query(ctx, listTestcases) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Testcase + for rows.Next() { + var i Testcase + if err := rows.Scan( + &i.TestcaseID, + &i.ProblemID, + &i.Stdin, + &i.Stdout, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listTestcasesByGameID = `-- name: ListTestcasesByGameID :many SELECT testcase_id, problem_id, stdin, stdout FROM testcases WHERE testcases.problem_id = (SELECT problem_id FROM games WHERE game_id = $1) @@ -790,6 +867,37 @@ func (q *Queries) ListTestcasesByGameID(ctx context.Context, gameID int32) ([]Te return items, nil } +const listTestcasesByProblemID = `-- name: ListTestcasesByProblemID :many +SELECT testcase_id, problem_id, stdin, stdout FROM testcases +WHERE problem_id = $1 +ORDER BY testcase_id +` + +func (q *Queries) ListTestcasesByProblemID(ctx context.Context, problemID int32) ([]Testcase, error) { + rows, err := q.db.Query(ctx, listTestcasesByProblemID, problemID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Testcase + for rows.Next() { + var i Testcase + if err := rows.Scan( + &i.TestcaseID, + &i.ProblemID, + &i.Stdin, + &i.Stdout, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listUsers = `-- name: ListUsers :many SELECT user_id, username, display_name, icon_path, is_admin, label, created_at FROM users ORDER BY users.user_id @@ -1015,6 +1123,32 @@ func (q *Queries) UpdateSubmissionStatus(ctx context.Context, arg UpdateSubmissi return err } +const updateTestcase = `-- name: UpdateTestcase :exec +UPDATE testcases +SET + problem_id = $2, + stdin = $3, + stdout = $4 +WHERE testcase_id = $1 +` + +type UpdateTestcaseParams struct { + TestcaseID int32 + ProblemID int32 + Stdin string + Stdout string +} + +func (q *Queries) UpdateTestcase(ctx context.Context, arg UpdateTestcaseParams) error { + _, err := q.db.Exec(ctx, updateTestcase, + arg.TestcaseID, + arg.ProblemID, + arg.Stdin, + arg.Stdout, + ) + return err +} + const updateUser = `-- name: UpdateUser :exec UPDATE users SET diff --git a/backend/query.sql b/backend/query.sql index 4d95e3f..76af82b 100644 --- a/backend/query.sql +++ b/backend/query.sql @@ -222,3 +222,34 @@ SET language = $4, sample_code = $5 WHERE problem_id = $1; + +-- name: ListTestcases :many +SELECT * FROM testcases +ORDER BY testcase_id; + +-- name: ListTestcasesByProblemID :many +SELECT * FROM testcases +WHERE problem_id = $1 +ORDER BY testcase_id; + +-- name: GetTestcaseByID :one +SELECT * FROM testcases +WHERE testcase_id = $1 +LIMIT 1; + +-- name: CreateTestcase :one +INSERT INTO testcases (problem_id, stdin, stdout) +VALUES ($1, $2, $3) +RETURNING testcase_id; + +-- name: UpdateTestcase :exec +UPDATE testcases +SET + problem_id = $2, + stdin = $3, + stdout = $4 +WHERE testcase_id = $1; + +-- name: DeleteTestcase :exec +DELETE FROM testcases +WHERE testcase_id = $1; |
