quiz resetc
This commit is contained in:
@@ -170,11 +170,20 @@ func (h *QuizHandler) ParticipantResult(w http.ResponseWriter, r *http.Request)
|
|||||||
response.JSON(w, http.StatusOK, res)
|
response.JSON(w, http.StatusOK, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetParticipant — DELETE /admin/quizzes/{id}/participants/{userID}. Deletes
|
||||||
|
// that person's attempt so the quiz is pending for them again.
|
||||||
|
func (h *QuizHandler) ResetParticipant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.svc.ResetParticipant(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "userID")); err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Users ─────────────────────────────────────────────────────────────────
|
// ── Users ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ForPath — GET /quizzes/for?path=… Optional auth: signed-out readers still
|
// ForPath — GET /quizzes/for?path=… Signed in only; returns the quiz on that
|
||||||
// see that a quiz exists; signed-in ones also get their progress. Returns
|
// page with the caller's progress, or null when the page has none.
|
||||||
// null when the page has no quiz.
|
|
||||||
func (h *QuizHandler) ForPath(w http.ResponseWriter, r *http.Request) {
|
func (h *QuizHandler) ForPath(w http.ResponseWriter, r *http.Request) {
|
||||||
card, err := h.svc.ForPath(r.Context(), middleware.UserID(r.Context()), r.URL.Query().Get("path"))
|
card, err := h.svc.ForPath(r.Context(), middleware.UserID(r.Context()), r.URL.Query().Get("path"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ var Catalog = []Permission{
|
|||||||
{"quizzes:write", "Quizzes", "Create, edit and publish quizzes"},
|
{"quizzes:write", "Quizzes", "Create, edit and publish quizzes"},
|
||||||
{"quizzes:delete", "Quizzes", "Delete quizzes"},
|
{"quizzes:delete", "Quizzes", "Delete quizzes"},
|
||||||
{"quizzes:results", "Quizzes", "View participants and responses"},
|
{"quizzes:results", "Quizzes", "View participants and responses"},
|
||||||
|
{"quizzes:reset", "Quizzes", "Reset a participant's attempt"},
|
||||||
|
|
||||||
{"short_urls:read", "Short URLs", "View short links and stats"},
|
{"short_urls:read", "Short URLs", "View short links and stats"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,6 +341,20 @@ func (r *QuizRepo) QuestionStats(ctx context.Context, quizID string) ([]model.Qu
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetAttempt deletes one person's attempt — its answers go with it — so the
|
||||||
|
// quiz is pending for them again and they can take it afresh.
|
||||||
|
func (r *QuizRepo) ResetAttempt(ctx context.Context, quizID, userID string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`DELETE FROM quiz_attempts WHERE quiz_id = $1 AND user_id = $2`, quizID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reset attempt: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrQuizNotStarted
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ── Taking a quiz ─────────────────────────────────────────────────────────
|
// ── Taking a quiz ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// PublishedForPath returns the newest live quiz on a content page, or nil.
|
// PublishedForPath returns the newest live quiz on a content page, or nil.
|
||||||
|
|||||||
@@ -725,17 +725,20 @@ func (s *Server) setupRouter() {
|
|||||||
// Vāṇī of the Day — public daily content pick for the home page.
|
// Vāṇī of the Day — public daily content pick for the home page.
|
||||||
r.Get("/vani/today", s.vaniH.Today)
|
r.Get("/vani/today", s.vaniH.Today)
|
||||||
|
|
||||||
// Quizzes. Browsing is open to signed-out readers (they see that a
|
// Quizzes are for signed-in users: listing, finding one on a page,
|
||||||
// quiz exists); taking one, and seeing results, needs an account.
|
// taking it and reading results all need an account. The one open
|
||||||
|
// endpoint is a quiz's own card — title, description, what it's
|
||||||
|
// attached to, never its questions — so link previews (OpenGraph)
|
||||||
|
// can name the quiz when it's shared.
|
||||||
r.Route("/quizzes", func(r chi.Router) {
|
r.Route("/quizzes", func(r chi.Router) {
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||||
r.Get("/", s.quizH.List)
|
|
||||||
r.Get("/for", s.quizH.ForPath)
|
|
||||||
r.Get("/{id}", s.quizH.Get)
|
r.Get("/{id}", s.quizH.Get)
|
||||||
})
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Get("/", s.quizH.List)
|
||||||
|
r.Get("/for", s.quizH.ForPath)
|
||||||
r.Post("/{id}/start", s.quizH.Start)
|
r.Post("/{id}/start", s.quizH.Start)
|
||||||
r.Post("/{id}/answer", s.quizH.Answer)
|
r.Post("/{id}/answer", s.quizH.Answer)
|
||||||
r.Get("/{id}/result", s.quizH.Result)
|
r.Get("/{id}/result", s.quizH.Result)
|
||||||
@@ -1262,6 +1265,7 @@ func (s *Server) setupRouter() {
|
|||||||
can("quizzes:results").Get("/quizzes/{id}/participants", s.quizH.Participants)
|
can("quizzes:results").Get("/quizzes/{id}/participants", s.quizH.Participants)
|
||||||
can("quizzes:results").Get("/quizzes/{id}/participants/{userID}", s.quizH.ParticipantResult)
|
can("quizzes:results").Get("/quizzes/{id}/participants/{userID}", s.quizH.ParticipantResult)
|
||||||
can("quizzes:results").Get("/quizzes/{id}/responses", s.quizH.Responses)
|
can("quizzes:results").Get("/quizzes/{id}/responses", s.quizH.Responses)
|
||||||
|
can("quizzes:reset").Delete("/quizzes/{id}/participants/{userID}", s.quizH.ResetParticipant)
|
||||||
|
|
||||||
can("short_urls:read").Get("/short-urls", s.adminH.ListShortURLs)
|
can("short_urls:read").Get("/short-urls", s.adminH.ListShortURLs)
|
||||||
can("short_urls:read").Get("/short-urls/stats", s.adminH.ShortURLStats)
|
can("short_urls:read").Get("/short-urls/stats", s.adminH.ShortURLStats)
|
||||||
|
|||||||
@@ -269,6 +269,16 @@ func (s *QuizService) ParticipantResult(ctx context.Context, quizID, userID stri
|
|||||||
return s.quizzes.Result(ctx, quizID, userID, true)
|
return s.quizzes.Result(ctx, quizID, userID, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetParticipant wipes one participant's attempt; the quiz becomes pending
|
||||||
|
// for them. Works on unpublished quizzes too — staff may tidy results up
|
||||||
|
// while a quiz is offline.
|
||||||
|
func (s *QuizService) ResetParticipant(ctx context.Context, quizID, userID string) error {
|
||||||
|
if _, err := s.quizzes.Get(ctx, quizID, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.quizzes.ResetAttempt(ctx, quizID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Taking ────────────────────────────────────────────────────────────────
|
// ── Taking ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// published loads a quiz that users may see.
|
// published loads a quiz that users may see.
|
||||||
|
|||||||
@@ -278,6 +278,24 @@ func TestQuiz_TakingFlow(t *testing.T) {
|
|||||||
t.Errorf("responses = %+v, %v", stats, err)
|
t.Errorf("responses = %+v, %v", stats, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Staff can reset one participant: their attempt and answers go, the quiz
|
||||||
|
// is pending for them again, and they can retake it from the start.
|
||||||
|
if err := svc.ResetParticipant(ctx, quiz.ID, other); !errors.Is(err, repository.ErrQuizNotStarted) {
|
||||||
|
t.Errorf("resetting someone with no attempt: %v, want ErrQuizNotStarted", err)
|
||||||
|
}
|
||||||
|
if err := svc.ResetParticipant(ctx, quiz.ID, user); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if card, _ := svc.Card(ctx, user, quiz.ID); card.My.State != model.QuizNotStarted {
|
||||||
|
t.Errorf("state after reset = %s, want not_started", card.My.State)
|
||||||
|
}
|
||||||
|
if parts, _ := svc.Participants(ctx, quiz.ID); len(parts) != 0 {
|
||||||
|
t.Errorf("participants after reset = %d, want 0", len(parts))
|
||||||
|
}
|
||||||
|
if again, err := svc.Start(ctx, user, quiz.ID); err != nil || again.Answered != 0 || again.Question == nil {
|
||||||
|
t.Errorf("retaking after reset: %+v, %v", again, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Taken, so questions are frozen — but the title can still change.
|
// Taken, so questions are frozen — but the title can still change.
|
||||||
if _, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}); !errors.Is(err, ErrQuizLocked) {
|
if _, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}); !errors.Is(err, ErrQuizLocked) {
|
||||||
t.Errorf("editing questions after attempts: %v, want ErrQuizLocked", err)
|
t.Errorf("editing questions after attempts: %v, want ErrQuizLocked", err)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
UPDATE admin_roles SET permissions = array_remove(permissions, 'quizzes:reset');
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- quizzes:reset — delete one participant's attempt so the quiz is pending
|
||||||
|
-- for them again. Granted to the seeded Admin role (Superadmin has '*';
|
||||||
|
-- Moderator and custom roles get it only when a superadmin adds it).
|
||||||
|
UPDATE admin_roles
|
||||||
|
SET permissions = array_append(permissions, 'quizzes:reset')
|
||||||
|
WHERE key = 'admin' AND NOT ('quizzes:reset' = ANY(permissions));
|
||||||
Reference in New Issue
Block a user