- quizzes_create_question, update_question, delete_question and
reorder_questions on /api/v1/quizzes/{slug}/questions
- update_question replaces answers by id (kept, created, deleted), so
answer ids stored in past attempts stay valid; foreign ids are refused
- classes.CheckQuestion holds the importer's per-question rules, shared
with the manage routes; attempt scores are never touched (D-16)
359 lines
12 KiB
Go
359 lines
12 KiB
Go
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 (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
|
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
|
"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, a *classes.ValidationError the import route's 422
|
|
// {"message"}, anything else an opaque 500.
|
|
func writeManaged(w http.ResponseWriter, status int, data any, err error) {
|
|
var invalid *classes.ValidationError
|
|
switch {
|
|
case err == nil:
|
|
wire.WriteJSON(w, status, map[string]any{"data": data})
|
|
case errors.As(err, &invalid):
|
|
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": invalid.Error()})
|
|
case errors.Is(err, classes.ErrQuestionNotFound):
|
|
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": true, "message": "Question not found."})
|
|
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)
|
|
}
|
|
|
|
// readManaged reads a JSON body into dest, refusing unknown keys: 413 over
|
|
// the route's body limit, 400 for an unreadable body, 422 {"message"} for
|
|
// invalid JSON. It writes the answer and reports false on failure.
|
|
func readManaged(w http.ResponseWriter, r *http.Request, dest any) bool {
|
|
raw, err := io.ReadAll(r.Body)
|
|
var tooLarge *http.MaxBytesError
|
|
switch {
|
|
case errors.As(err, &tooLarge):
|
|
wire.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": true, "message": "Request body too large."})
|
|
return false
|
|
case err != nil:
|
|
wire.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": true, "message": "Unreadable request body."})
|
|
return false
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(dest); err != nil {
|
|
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "invalid JSON: " + err.Error()})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// refuse answers the 422 of a ValidationError and reports whether there
|
|
// were problems.
|
|
func refuse(w http.ResponseWriter, problems []string) bool {
|
|
if len(problems) == 0 {
|
|
return false
|
|
}
|
|
writeManaged(w, 0, nil, &classes.ValidationError{Problems: problems})
|
|
return true
|
|
}
|
|
|
|
// updateQuizBody is the PATCH /{slug} body: QuizInput without the slug,
|
|
// which an update cannot change, so sending it is refused as unknown.
|
|
type updateQuizBody struct {
|
|
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"`
|
|
RoundSize *int `json:"round_size"`
|
|
ShuffleQuestions *bool `json:"shuffle_questions"`
|
|
}
|
|
|
|
func (b updateQuizBody) input() classes.QuizInput {
|
|
return classes.QuizInput{
|
|
Title: b.Title, Description: b.Description, Context: b.Context, Mode: b.Mode, Navigation: b.Navigation,
|
|
MaxAttempts: b.MaxAttempts, TimeLimitSeconds: b.TimeLimitSeconds, PassingScore: b.PassingScore,
|
|
RoundSize: b.RoundSize, ShuffleQuestions: b.ShuffleQuestions,
|
|
}
|
|
}
|
|
|
|
// answerQuiz counts and answers a quiz a write returned, or maps err.
|
|
func answerQuiz(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, quiz *models.Quiz, err error) {
|
|
if err != nil {
|
|
writeManaged(w, 0, nil, err)
|
|
return
|
|
}
|
|
row, err := classes.ManagedRow(r.Context(), db, *quiz)
|
|
if err != nil {
|
|
writeManaged(w, 0, nil, err)
|
|
return
|
|
}
|
|
writeManaged(w, status, managedQuizRow(row), nil)
|
|
}
|
|
|
|
// ManageCreateQuiz answers POST /api/v1/quizzes: 201 with a new,
|
|
// unpublished quiz, 409 on a taken slug. The input is checked before the
|
|
// database is touched.
|
|
func (h *Handlers) ManageCreateQuiz(w http.ResponseWriter, r *http.Request) {
|
|
var in classes.QuizInput
|
|
if !readManaged(w, r, &in) || refuse(w, classes.CheckQuizInput(in, true)) {
|
|
return
|
|
}
|
|
db, ok := h.manageDB(w)
|
|
if !ok {
|
|
return
|
|
}
|
|
quiz, err := classes.CreateQuiz(r.Context(), db, in)
|
|
answerQuiz(w, r, db, http.StatusCreated, quiz, err)
|
|
}
|
|
|
|
// ManageUpdateQuiz answers PATCH /api/v1/quizzes/{slug}: only the sent
|
|
// fields change.
|
|
func (h *Handlers) ManageUpdateQuiz(w http.ResponseWriter, r *http.Request) {
|
|
var body updateQuizBody
|
|
if !readManaged(w, r, &body) || refuse(w, classes.CheckQuizInput(body.input(), false)) {
|
|
return
|
|
}
|
|
db, ok := h.manageDB(w)
|
|
if !ok {
|
|
return
|
|
}
|
|
quiz, err := classes.UpdateQuiz(r.Context(), db, r.PathValue("slug"), body.input())
|
|
answerQuiz(w, r, db, http.StatusOK, quiz, err)
|
|
}
|
|
|
|
// ManageDeleteQuiz answers DELETE /api/v1/quizzes/{slug}?confirm={slug}:
|
|
// the quiz goes with its questions, answers and attempts. A confirm that
|
|
// does not repeat the slug is a 422 before the database is read.
|
|
func (h *Handlers) ManageDeleteQuiz(w http.ResponseWriter, r *http.Request) {
|
|
slug := r.PathValue("slug")
|
|
if err := classes.ValidateDeleteConfirm(slug, r.URL.Query().Get("confirm")); err != nil {
|
|
writeManaged(w, 0, nil, err)
|
|
return
|
|
}
|
|
db, ok := h.manageDB(w)
|
|
if !ok {
|
|
return
|
|
}
|
|
del, err := classes.DeleteQuiz(r.Context(), db, slug)
|
|
if err != nil {
|
|
writeManaged(w, 0, nil, err)
|
|
return
|
|
}
|
|
writeManaged(w, http.StatusOK, map[string]any{
|
|
"id": del.ID, "slug": del.Slug, "title": del.Title, "questions": del.Questions, "attempts": del.Attempts,
|
|
}, nil)
|
|
}
|
|
|
|
// ManagePublish answers POST /api/v1/quizzes/{slug}/publish.
|
|
func (h *Handlers) ManagePublish(w http.ResponseWriter, r *http.Request) { h.setPublished(w, r, true) }
|
|
|
|
// ManageUnpublish answers POST /api/v1/quizzes/{slug}/unpublish.
|
|
func (h *Handlers) ManageUnpublish(w http.ResponseWriter, r *http.Request) {
|
|
h.setPublished(w, r, false)
|
|
}
|
|
|
|
func (h *Handlers) setPublished(w http.ResponseWriter, r *http.Request, published bool) {
|
|
db, ok := h.manageDB(w)
|
|
if !ok {
|
|
return
|
|
}
|
|
quiz, err := classes.SetPublished(r.Context(), db, r.PathValue("slug"), published)
|
|
answerQuiz(w, r, db, http.StatusOK, quiz, err)
|
|
}
|
|
|
|
// ManageQuizStats answers GET /api/v1/quizzes/{slug}/stats: per student
|
|
// and per question, after closing the quiz's expired open test attempts.
|
|
func (h *Handlers) ManageQuizStats(w http.ResponseWriter, r *http.Request) {
|
|
svc, ok := h.service(w)
|
|
if !ok {
|
|
return
|
|
}
|
|
stats, err := classes.QuizStats(r.Context(), svc.DB, r.PathValue("slug"), svc)
|
|
if err != nil {
|
|
writeManaged(w, 0, nil, err)
|
|
return
|
|
}
|
|
writeManaged(w, http.StatusOK, quizStats(stats), nil)
|
|
}
|
|
|
|
// questionID reads the {id} path value: anything but a positive integer
|
|
// is the 404 of a missing question.
|
|
func questionID(w http.ResponseWriter, r *http.Request) (uint, bool) {
|
|
id, err := strconv.ParseUint(r.PathValue("id"), 10, 32)
|
|
if err != nil || id == 0 {
|
|
writeManaged(w, 0, nil, classes.ErrQuestionNotFound)
|
|
return 0, false
|
|
}
|
|
return uint(id), true
|
|
}
|
|
|
|
// ManageCreateQuestion answers POST /api/v1/quizzes/{slug}/questions: 201
|
|
// with the new question and its answers.
|
|
func (h *Handlers) ManageCreateQuestion(w http.ResponseWriter, r *http.Request) {
|
|
var in classes.QuestionInput
|
|
if !readManaged(w, r, &in) || refuse(w, classes.CheckNewQuestion(in)) {
|
|
return
|
|
}
|
|
db, ok := h.manageDB(w)
|
|
if !ok {
|
|
return
|
|
}
|
|
q, err := classes.CreateQuestion(r.Context(), db, r.PathValue("slug"), in)
|
|
if err != nil {
|
|
writeManaged(w, 0, nil, err)
|
|
return
|
|
}
|
|
writeManaged(w, http.StatusCreated, managedQuestion(*q), nil)
|
|
}
|
|
|
|
// ManageUpdateQuestion answers PATCH /api/v1/quizzes/{slug}/questions/{id}:
|
|
// only the sent fields change; a sent answers list replaces the answers by
|
|
// id.
|
|
func (h *Handlers) ManageUpdateQuestion(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := questionID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var in classes.QuestionInput
|
|
if !readManaged(w, r, &in) {
|
|
return
|
|
}
|
|
if in.SortOrder != nil && *in.SortOrder < 1 {
|
|
refuse(w, []string{"sort_order must be at least 1"})
|
|
return
|
|
}
|
|
db, ok := h.manageDB(w)
|
|
if !ok {
|
|
return
|
|
}
|
|
q, err := classes.UpdateQuestion(r.Context(), db, r.PathValue("slug"), id, in)
|
|
if err != nil {
|
|
writeManaged(w, 0, nil, err)
|
|
return
|
|
}
|
|
writeManaged(w, http.StatusOK, managedQuestion(*q), nil)
|
|
}
|
|
|
|
// ManageDeleteQuestion answers DELETE /api/v1/quizzes/{slug}/questions/{id}
|
|
// with {id, quiz_slug}.
|
|
func (h *Handlers) ManageDeleteQuestion(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := questionID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
db, ok := h.manageDB(w)
|
|
if !ok {
|
|
return
|
|
}
|
|
slug := r.PathValue("slug")
|
|
if err := classes.DeleteQuestion(r.Context(), db, slug, id); err != nil {
|
|
writeManaged(w, 0, nil, err)
|
|
return
|
|
}
|
|
writeManaged(w, http.StatusOK, map[string]any{"id": id, "quiz_slug": slug}, nil)
|
|
}
|
|
|
|
// reorderBody is the PUT /{slug}/questions/order body.
|
|
type reorderBody struct {
|
|
QuestionIDs []uint `json:"question_ids"`
|
|
}
|
|
|
|
// ManageReorderQuestions answers PUT /api/v1/quizzes/{slug}/questions/order
|
|
// with {"question_ids": [...]}, every question of the quiz exactly once,
|
|
// with the quiz and its questions in the new order.
|
|
func (h *Handlers) ManageReorderQuestions(w http.ResponseWriter, r *http.Request) {
|
|
var body reorderBody
|
|
if !readManaged(w, r, &body) || refuse(w, classes.CheckQuestionOrder(body.QuestionIDs)) {
|
|
return
|
|
}
|
|
db, ok := h.manageDB(w)
|
|
if !ok {
|
|
return
|
|
}
|
|
slug := r.PathValue("slug")
|
|
if _, err := classes.ReorderQuestions(r.Context(), db, slug, body.QuestionIDs); err != nil {
|
|
writeManaged(w, 0, nil, err)
|
|
return
|
|
}
|
|
h.writeManagedQuiz(w, r, db, http.StatusOK, slug)
|
|
}
|