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)
|
||||
}
|
||||
|
||||
// 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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ForPath — GET /quizzes/for?path=… Optional auth: signed-out readers still
|
||||
// see that a quiz exists; signed-in ones also get their progress. Returns
|
||||
// null when the page has no quiz.
|
||||
// ForPath — GET /quizzes/for?path=… Signed in only; returns the quiz on that
|
||||
// page with the caller's progress, or null when the page has none.
|
||||
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"))
|
||||
if err != nil {
|
||||
|
||||
@@ -59,6 +59,7 @@ var Catalog = []Permission{
|
||||
{"quizzes:write", "Quizzes", "Create, edit and publish quizzes"},
|
||||
{"quizzes:delete", "Quizzes", "Delete quizzes"},
|
||||
{"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"},
|
||||
}
|
||||
|
||||
@@ -341,6 +341,20 @@ func (r *QuizRepo) QuestionStats(ctx context.Context, quizID string) ([]model.Qu
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
// 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.
|
||||
r.Get("/vani/today", s.vaniH.Today)
|
||||
|
||||
// Quizzes. Browsing is open to signed-out readers (they see that a
|
||||
// quiz exists); taking one, and seeing results, needs an account.
|
||||
// Quizzes are for signed-in users: listing, finding one on a page,
|
||||
// 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.Group(func(r chi.Router) {
|
||||
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.Group(func(r chi.Router) {
|
||||
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}/answer", s.quizH.Answer)
|
||||
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/{userID}", s.quizH.ParticipantResult)
|
||||
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/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)
|
||||
}
|
||||
|
||||
// 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
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)
|
||||
|
||||
@@ -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