feat: list and show quizzes over MCP for their owner

- quizzes_list_quizzes and quizzes_show_quiz bound to GET /api/v1/quizzes
  and /api/v1/quizzes/{slug} behind user.api_token, the per-user
  golem15.quizzes.manage throttle, a scope and the access_quizzes permission
- the permission is grantable through pact.HasFrontendPermissions
- owner JSON shapes with solutions in manage_json.go, never used by a
  student route
This commit is contained in:
Jakub Zych
2026-11-09 14:25:07 +01:00
parent 174be3bcbd
commit e615cc88db
10 changed files with 645 additions and 2 deletions

89
controllers/api/manage.go Normal file
View File

@@ -0,0 +1,89 @@
package api
// The owner manage routes under /api/v1/quizzes, behind user.api_token, the
// golem15.quizzes.manage throttle, a token scope and the access_quizzes
// permission. The MCP quiz tools bind them. Answers use the owner shapes of
// manage_json.go, which carry the solutions.
import (
"errors"
"net/http"
"strings"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/summercms/modules/wire"
"gorm.io/gorm"
)
// manageDB returns the application database, or answers 503 as the student
// routes do without one.
func (h *Handlers) manageDB(w http.ResponseWriter) (*gorm.DB, bool) {
var gdb *gorm.DB
if h.App != nil {
gdb, _ = h.App.Lookup[*gorm.DB]()
}
if gdb == nil {
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "Database unavailable"})
return nil, false
}
return gdb, true
}
// writeManaged answers {data: data}, or maps err: ErrNotFound is a 404,
// ErrQuizExists a 409, anything else an opaque 500.
func writeManaged(w http.ResponseWriter, status int, data any, err error) {
switch {
case err == nil:
wire.WriteJSON(w, status, map[string]any{"data": data})
case errors.Is(err, classes.ErrNotFound):
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": true, "message": "Quiz not found."})
case errors.Is(err, classes.ErrQuizExists):
wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": true, "message": "A quiz with this slug already exists."})
default:
wire.WriteOpaque500(w)
}
}
// ManageListQuizzes answers GET /api/v1/quizzes?context=: every quiz,
// published or not, with its question and completed-attempt counts.
func (h *Handlers) ManageListQuizzes(w http.ResponseWriter, r *http.Request) {
db, ok := h.manageDB(w)
if !ok {
return
}
rows, err := classes.ListManagedQuizzes(r.Context(), db, strings.TrimSpace(r.URL.Query().Get("context")))
if err != nil {
writeManaged(w, 0, nil, err)
return
}
out := make([]managedQuizJSON, 0, len(rows))
for _, row := range rows {
out = append(out, managedQuizRow(row))
}
writeManaged(w, http.StatusOK, out, nil)
}
// ManageShowQuiz answers GET /api/v1/quizzes/{slug}: the quiz with every
// question and answer, solutions included.
func (h *Handlers) ManageShowQuiz(w http.ResponseWriter, r *http.Request) {
db, ok := h.manageDB(w)
if !ok {
return
}
h.writeManagedQuiz(w, r, db, http.StatusOK, r.PathValue("slug"))
}
// writeManagedQuiz answers the quiz with its questions and solutions.
func (h *Handlers) writeManagedQuiz(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, slug string) {
quiz, err := classes.ManagedQuiz(r.Context(), db, slug)
if err != nil {
writeManaged(w, 0, nil, err)
return
}
attempts, err := classes.CompletedAttemptCount(r.Context(), db, quiz.ID)
if err != nil {
writeManaged(w, 0, nil, err)
return
}
writeManaged(w, status, managedQuizDetail(*quiz, attempts), nil)
}

View File

@@ -0,0 +1,116 @@
package api
// The owner shapes of the manage routes. They carry the solutions
// (is_correct, explanations, answer_text) and are never used by a student
// route; the student shapes live in json.go. Built field by field, so a new
// column never leaks.
import (
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/wire"
)
type managedQuizJSON struct {
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Description *string `json:"description"`
Context *string `json:"context"`
Mode string `json:"mode"`
Navigation string `json:"navigation"`
MaxAttempts *int `json:"max_attempts"`
TimeLimitSeconds *int `json:"time_limit_seconds"`
PassingScore int `json:"passing_score"`
ShuffleQuestions bool `json:"shuffle_questions"`
RoundSize *int `json:"round_size"`
IsPublished bool `json:"is_published"`
QuestionCount int `json:"question_count"`
AttemptsCount int `json:"attempts_count"`
CreatedAt wire.Time `json:"created_at"`
UpdatedAt wire.Time `json:"updated_at"`
}
type managedQuizDetailJSON struct {
managedQuizJSON
Questions []managedQuestionJSON `json:"questions"`
}
type managedQuestionJSON struct {
ID uint `json:"id"`
Text string `json:"text"`
Type string `json:"type"`
Category *string `json:"category"`
AnswerText *string `json:"answer_text"`
Explanation *string `json:"explanation"`
Points int `json:"points"`
SortOrder int `json:"sort_order"`
Answers []managedAnswerJSON `json:"answers"`
}
type managedAnswerJSON struct {
ID uint `json:"id"`
Text string `json:"text"`
IsCorrect bool `json:"is_correct"`
Explanation *string `json:"explanation"`
SortOrder int `json:"sort_order"`
}
func managedQuiz(q models.Quiz, questions, attempts int) managedQuizJSON {
return managedQuizJSON{
ID: q.ID,
Slug: q.Slug,
Title: q.Title,
Description: q.Description,
Context: q.Context,
Mode: quizMode(&q),
Navigation: quizNavigation(&q),
MaxAttempts: q.MaxAttempts,
TimeLimitSeconds: q.TimeLimitSeconds,
PassingScore: q.PassingScore,
ShuffleQuestions: q.ShuffleQuestions,
RoundSize: q.RoundSize,
IsPublished: q.IsPublished,
QuestionCount: questions,
AttemptsCount: attempts,
CreatedAt: wireTime(q.CreatedAt),
UpdatedAt: wireTime(q.UpdatedAt),
}
}
func managedQuizRow(row classes.ManagedQuizRow) managedQuizJSON {
return managedQuiz(row.Quiz, row.QuestionCount, row.AttemptsCount)
}
// managedQuizDetail is a quiz loaded by classes.ManagedQuiz with its
// questions; attempts is its completed-attempt count.
func managedQuizDetail(q models.Quiz, attempts int) managedQuizDetailJSON {
out := managedQuizDetailJSON{
managedQuizJSON: managedQuiz(q, len(q.Questions), attempts),
Questions: make([]managedQuestionJSON, 0, len(q.Questions)),
}
for _, question := range q.Questions {
out.Questions = append(out.Questions, managedQuestion(question))
}
return out
}
func managedQuestion(q models.Question) managedQuestionJSON {
out := managedQuestionJSON{
ID: q.ID,
Text: q.Text,
Type: q.Type,
Category: q.Category,
AnswerText: q.AnswerText,
Explanation: q.Explanation,
Points: q.Points,
SortOrder: q.SortOrder,
Answers: make([]managedAnswerJSON, 0, len(q.Answers)),
}
for _, a := range q.Answers {
out.Answers = append(out.Answers, managedAnswerJSON{
ID: a.ID, Text: a.Text, IsCorrect: a.IsCorrect, Explanation: a.Explanation, SortOrder: a.SortOrder,
})
}
return out
}

View File

@@ -0,0 +1,22 @@
package api
import (
"net/http"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
)
// TestManageHandlersWithoutDatabase: every manage handler answers 503
// when the application has no database.
func TestManageHandlersWithoutDatabase(t *testing.T) {
h := New(backpack.New(nil))
for name, fn := range map[string]http.HandlerFunc{
"list": h.ManageListQuizzes, "show": h.ManageShowQuiz,
} {
code, _ := call(t, fn, "GET", "/", "", 7, map[string]string{"slug": "x"})
if code != http.StatusServiceUnavailable {
t.Errorf("%s without a database = %d, want 503", name, code)
}
}
}