Skip to main content

✅ STATUS — SHIPPED (count-based variant). See v3 backlog below.

The feature described in §1–§12 below was implemented, but with a simpler count-based ceiling, not the timestamp-cutoff design originally proposed here. What actually shipped:
  • Table / model: quiz_student_resets + App\Models\QuizStudentReset — as proposed.
  • Ceiling rule (the key difference): start_student_quiz does not count “attempts after the latest reset timestamp.” Instead it counts reset rows and allows max_attempts * (reset_count + 1) total attempts, gated against the absolute monotonic attempt counter. max_attempts == 0 stays unlimited.
  • Endpoint: POST /api/quiz/{quiz_id}/reset_student_attempts (reset_student_attempts($quiz_id)), authorized via $this->authorize('update', $quiz) with a 403 on AuthorizationException. Returns reset_count + computed allowed_attempts.
  • show_quiz_student_answers: the optional reset_cycle annotation was implemented (derived in-memory, timestamp-bucketed). The ?include_resets=true reset-log payload was not.
  • History: reset_student_attempts action logged against quiz_student_resets.
⚠️ The pseudocode/snippets in §3, §7 below describe the original (timestamp-based) proposal, which is NOT what runs in the codebase. Treat §1–§12 as design history; the v3 section is the source of truth for current behavior and open work. 📎 The backend CLAUDE.md still lists this document as x_README_API_Documentation/suggestions_attempts_readme.md (its file was readme_suggestions.md). That folder no longer exists in LMS_Backend; this page is the surviving copy.

Suggestions — Reset Student Attempts (Non-Destructive)

Context: This document proposes how to add an “attempt reset” feature that lets an admin give a student a fresh start on a quiz or exam without deleting any historical attempt records. References: Quizzes & Exams endpoints · Quizzes & Exams progress tracking

Table of Contents

  1. Problem Statement
  2. Why Not Just Delete the Old Attempts?
  3. Recommended Approach — quiz_student_resets Table
  4. New Database Table
  5. New Model: QuizStudentReset
  6. New Endpoint — Reset Attempts
  7. Modified Endpoint — start_student_quiz
  8. Optional Enhancement — show_quiz_student_answers
  9. History Tracking
  10. Routes Addition
  11. Business Rules & Edge Cases
  12. Summary of Files to Create / Modify

1. Problem Statement

Currently start_student_quiz computes the next attempt number as:
If quiz.max_attempts > 0 and the new number exceeds it, the endpoint returns:
There is no way for an admin to grant extra chances without either:
  • Deleting rows from quiz_student_attempts / quiz_student_answers (destroys history), or
  • Increasing quiz.max_attempts globally (affects every student, not just one).

2. Why Not Just Delete the Old Attempts?

The only safe path is to keep all rows and make the “max attempts” check aware of resets.
Introduce a lightweight reset log table. Each row says:
“As of this timestamp, student X’s attempt counter for quiz Y is considered to restart.”
When start_student_quiz checks the attempt ceiling, it:
  1. Finds the latest reset for this student × quiz (if any).
  2. Counts only the attempts created after that reset timestamp.
  3. Uses that count (+ 1) as the “effective attempt number” against max_attempts.
The absolute attempt column in quiz_student_attempts continues to increment monotonically (preserving the full record), but it is no longer what gates the ceiling check.

4. New Database Table

Migration: create_quiz_student_resets_table
Table name: quiz_student_resets
Columns:

5. New Model: QuizStudentReset.php

File: app/Models/QuizStudentReset.php

6. New Endpoint — Reset Attempts

Controller: app/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.php
Route: POST /api/quiz/{quiz_id}/reset_student_attempts
Authorization: admin / instructor only (use existing policy gate — e.g. update on Quiz or a dedicated resetAttempts policy method)

Request

Behavior

  1. Validate student_id (exists in users) and quiz_id (exists in quizzes, found via route param).
  2. Authorize the caller (update on the quiz, or a dedicated policy).
  3. Insert a row into quiz_student_resets:
    • quiz_id = route param
    • student_id = request body
    • reset_by = auth()->id()
    • note = request body (nullable)
  4. Return success response.
  5. History: reset_student_attempts.

Success Response (200)

Error Responses


7. Modified Endpoint — start_student_quiz

File: app/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.php
Route: POST /api/quiz/start_student_quiz (unchanged)

Current logic (pseudocode)

Proposed new logic (pseudocode)

Key points

  • The attempt column in quiz_student_attempts never resets — it always grows (1, 2, 3, 4 …). This preserves the full ordered history.
  • Only the ceiling check uses the post-reset count, so old attempts remain permanently visible.
  • No columns are added to quiz_student_attempts; all reset state lives in quiz_student_resets.

Updated PHP snippet (for the controller method)


8. Optional Enhancement — show_quiz_student_answers

The existing read endpoint already returns all attempts ordered latest-first. Optionally, annotate each attempt with which reset cycle it belongs to, so the frontend can group them visually (e.g. “Cycle 1 — attempts 1–3”, “Cycle 2 — attempts 4–5 (after reset)”).

Suggested addition to the response

reset_cycle is computed in-memory by loading the reset timestamps and bucketing each attempt by when it was created relative to those timestamps. It is a derived display value, not a stored column.

Optional resets query parameter

Add ?include_resets=true to also return the reset log alongside attempts:
This is entirely optional and can be added in a follow-up iteration.

9. History Tracking

Add a new action to insert_in_history_table:

10. Routes Addition

In routes/api.php, inside the Quizzes_Exams_Progress_Tracking_Controller group:

11. Business Rules & Edge Cases


12. Summary of Files to Create / Modify


Suggestions & Backlog (v2)

Tracked improvements, follow-ups, and security notes that are not yet implemented
This file collects suggestions raised during development — things worth doing but not yet shipped. Each entry records what to do, why, the status, and enough context to pick it up later. When a suggestion is implemented, mark it Done and link the doc that now describes the shipped behavior.

Table of Contents

  1. Strip answer data from the player-facing quiz fetch (GET /api/quiz/{id})

1. Strip answer data from the player-facing quiz fetch (GET /api/quiz/{id})

The finding

GET /api/quiz/{id} (QuizzesController@show) is the real question-delivery endpoint a quiz player uses to render a quiz — POST /api/quiz/start_student_quiz only creates an attempt row and returns a message, it carries no question data. With WithQuizQuestions=true (the default), show eager-loads quiz_questions.quiz_question_options.quiz_question_option_items and returns the quiz with no stripping, so it exposes:
  • every option’s is_correct flag (True/False, Single, Multiple), and
  • the intact drag-&-drop grouping — which quiz_question_option_items sit under which quiz_question_option (the correct answer for type 4).
Any caller who passes the view policy therefore receives the correct answers.
Current mitigation (why this is “medium”, not “high”): QuizPolicy@view allows only super-admin / admin / QUIZZES_VIEW permission holders — the student line is commented out. So as currently seeded a plain student cannot call show. The risk is realized only if QUIZZES_VIEW is ever granted to students, or if the player is wired to this endpoint.
GET /api/quiz/{id} is not a safe payload to serve to a student who is taking the quiz.

What already exists

The instructor listing GET /api/instructors/{instructor_id}/quizzes (UsersController@quizzes_by_instructor) already strips answer data:
  • hides is_correct on every option, and
  • for Drag & Drop, removes the per-option item lists and returns all items flattened + shuffled under draggable_items (no quiz_question_option_id back-reference).
See Instructor API doc §7. That same logic is the model for the fix below.

Suggested fix

  1. Extract the stripping logic from quizzes_by_instructor() into a small reusable helper (e.g. stripQuizAnswerData($quiz|$quizzes)), so the listing and show/index share one implementation.
  2. Apply it in QuizzesController@show (and @index) conditionally:
    • Skip stripping when the caller has quiz-edit rights (instructors authoring / admins legitimately need is_correct).
    • Strip otherwise (anyone fetching a quiz to take it). Gate on a capability check such as Gate::allows('update', $quiz) / the QUIZZES_UPDATE permission rather than a raw role string.
  3. Keep the strip per-response (makeHidden + draggable_items), never on the model’s global $hidden — global hiding would break the authoring endpoints that must serialize is_correct.

Acceptance criteria

  • A non-editor calling GET /api/quiz/{id} receives no is_correct and no drag-&-drop grouping; drag-&-drop items arrive flattened + shuffled under draggable_items.
  • An editor/admin calling the same endpoint still receives the full structure (authoring unaffected).
  • index follows the same rule.
  • Quizzes/Exams doc §5.2 updated to drop the ⚠️ exposure note once shipped.

Notes / risks

  • show/index are widely consumed — this is a behavior change on a hot path; coordinate with the frontend before shipping.
  • Confirm where the student quiz player actually fetches questions. If it uses a different/dedicated endpoint, that endpoint must be the one that strips (and show may legitimately stay editor-only).

[Suggestions] User Attempts Fix — v3 (Post-Implementation Backlog)

Reconciles the shipped count-based reset feature against the original proposal, and tracks the next round of hardening / UX gaps found by reading the live code.
Scope: the attempt-reset feature is live (quiz_student_resets, QuizStudentReset, reset_student_attempts, the count-based ceiling in start_student_quiz, and the reset_cycle annotation in show_quiz_student_answers). This section does not re-propose it — it documents exactly what runs today and the concrete follow-ups worth doing. Files in play: app/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.php · app/Models/QuizStudentReset.php · database/migrations/2026_06_05_000000_create_quiz_student_resets_table.php · routes/api.php

Table of Contents

  1. What actually shipped (proposal vs. reality)
  2. V3-1 — Abandoned/empty attempts burn the ceiling
  3. V3-2 — Wrong HTTP status for “too many attempts” (401)
  4. V3-3 — No company/tenant check on student_id
  5. V3-4 — Reset is unconditional & cannot be undone
  6. V3-5 — No reset-log read endpoint (?include_resets=true never shipped)
  7. V3-6 — reset_cycle (timestamp) vs. ceiling (count) use different mental models
  8. V3-7 — Race condition on the absolute attempt counter
  9. V3-8 — allowed_attempts: 0 overloads “unlimited” and “none”
  10. V3-9 — Student is not notified when granted a reset
  11. Priority summary

1. What actually shipped (proposal vs. reality)

Net effect: behaviorally the count-based variant is equivalent to the timestamp variant as long as resets are only granted after a cycle is exhausted — but it is simpler and does not depend on attempt timestamps. The divergences below stem from that simplification.

2. V3-1 — Abandoned/empty attempts burn the ceiling

The finding

start_student_quiz() increments the absolute attempt counter on every call and immediately creates a quiz_student_attempts row — before any question is answered:
finish_student_quiz() only ever operates on latest('attempt')->first() if that latest row has no answers yet. So if a student hits “start” three times by accident (network retry, double-click, refresh), they create attempts 1, 2, 3 — all empty — and a quiz with max_attempts = 3 is now exhausted with zero completed attempts. The admin’s only recourse is to grant a reset.

Suggested fix

Before creating a new attempt, reuse the latest unfinished attempt instead of minting a new one:
Only consume a new attempt number when the previous one is actually finished_at. This makes “attempt” mean a completed sitting, which is what max_attempts is meant to bound.

Acceptance criteria

  • Repeated start_student_quiz calls with no intervening finish do not increment attempt.
  • The ceiling counts finished sittings, not abandoned starts.

3. V3-2 — Wrong HTTP status for “too many attempts” (401)

The ceiling rejection returns 401:
401 Unauthorized means “you are not authenticated.” This case is an authenticated, authorized student who has simply run out of attempts. A frontend that auto-redirects to login on 401 will mis-handle it. Use 403 Forbidden (policy-style refusal) or 409 Conflict / 429 Too Many Requests (state-based refusal). Recommend 403. Also route the message through trans('messages.too_many_attempts') for i18n parity with the rest of the app.

4. V3-3 — No company/tenant check on student_id

reset_student_attempts validates only student_id => exists:users,id — globally, with no company_id scoping. authorize('update', $quiz) + company_policy_middleware constrain the quiz to the caller’s company, but the student is never checked against it. An admin could pass a student_id belonging to another company and create a quiz_student_resets row pairing a foreign student with a local quiz.

Suggested fix

Verify the target student shares the quiz’s company before writing the reset (per CLAUDE.md §3 — company scoping is never automatic):

5. V3-4 — Reset is unconditional & cannot be undone

Every call adds a reset row and grants another full cycle, with no guard and no inverse operation:
  • No “already has attempts left” guard. Granting a reset to a student who hasn’t exhausted their current cycle inflates the ceiling further. Optionally reject (or warn) when current_attempt_count < allowed_attempts — i.e. the student still has chances.
  • No undo. A mis-click permanently grants a cycle; the only fix is deleting the row in the DB. Consider a DELETE /api/quiz/{quiz_id}/reset_student_attempts/{reset_id} (admin-only, soft-delete-friendly) so the audit trail records the revocation rather than vanishing.
  • No cap. Nothing stops unbounded growth of allowed_attempts. A max_resets config or per-quiz limit may be warranted.

6. V3-5 — No reset-log read endpoint (?include_resets=true never shipped)

The reset log (who reset, when, why) is written but never read back by any API. show_quiz_student_answers annotates reset_cycle but exposes neither reset_by, note, nor reset_at. Admins/instructors have no way to see why a student has an inflated ceiling without DB access.

Suggested fix

Ship the originally-proposed ?include_resets=true (or a dedicated GET /api/quiz/{quiz_id}/student_attempts/{student_id}/resets) returning:
Gate it behind the same update-on-Quiz policy used to create resets (students should not see who granted them attempts).

7. V3-6 — reset_cycle (timestamp) vs. ceiling (count) use different mental models

The ceiling is purely count-based (reset_count + 1 cycles, timestamps ignored). The reset_cycle display buckets each attempt by reset.created_at <= attempt.created_at. These agree under normal use (reset → student takes attempts → reset again), but diverge if resets are granted out of order relative to attempts, e.g. two resets created before the student takes any attempt: the ceiling correctly grants 3 cycles, but every attempt is labelled reset_cycle = 3, which reads oddly. This is cosmetic; just document that reset_cycle is a best-effort display grouping, not the enforcement unit.

8. V3-7 — Race condition on the absolute attempt counter

$attempt = max('attempt') + 1 is a read-then-write with no lock and no unique constraint. Two near-simultaneous start_student_quiz calls (double-submit, retried request) can both read the same max and insert duplicate (quiz_id, student_id, attempt) rows. The migration only has a non-unique index ['quiz_id', 'student_id'].

Suggested fix

Add a unique constraint ['quiz_id', 'student_id', 'attempt'] (new migration) and/or wrap the read-increment-insert in a DB::transaction with lockForUpdate(). Note V3-1’s “reuse open attempt” fix also largely neutralizes this by not minting a new number on every call.

9. V3-8 — allowed_attempts: 0 overloads “unlimited” and “none”

The response returns allowed_attempts: 0 to mean unlimited (when max_attempts == 0). A client reading 0 literally would conclude the student has no attempts — the opposite of the truth. Prefer an explicit "unlimited": true flag or allowed_attempts: null with a documented contract, rather than overloading 0.

10. V3-9 — Student is not notified when granted a reset

A reset silently changes what the student is allowed to do. Consider firing an in-app notification (the unified NotificationService, NotificationType Quiz/Notify) so the student knows they have a fresh chance. Per CLAUDE.md §7 this is a single-user target, so SMTP would be respected if desired.

11. Priority summary

Recommended first slice: V3-1 (correctness — stops self-lockout), V3-3 (tenant safety), V3-5 (audit visibility). These are independent and low-blast-radius.