aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-09-06 01:30:31 +0900
committernsfisis <nsfisis@gmail.com>2025-09-06 01:30:31 +0900
commit8d739b386f2b555292fd8082c9de2199228737c9 (patch)
tree69ec44d214686cf80627216e984ae523eae8b98b
parente7b35cd81f2e515371a30a59ea4173a31dbeefc5 (diff)
downloadiosdc-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.go201
-rw-r--r--backend/admin/templates/problem_edit.html6
-rw-r--r--backend/admin/templates/testcase_edit.html36
-rw-r--r--backend/admin/templates/testcase_new.html27
-rw-r--r--backend/admin/templates/testcases.html26
-rw-r--r--backend/db/query.sql.go134
-rw-r--r--backend/query.sql31
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;