> ## Documentation Index
> Fetch the complete documentation index at: https://theteam-13fc7f49.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Quizzes & Exams — Progress Tracking API

###### ***Student Attempt Lifecycle (start / finish / score / review)***

This file documents `Quizzes_Exams_Progress_Tracking_Controller`, which owns the **student-facing attempt lifecycle** for both Quizzes and Exams: starting an attempt, submitting answers, persisting the computed score, and retrieving a student's prior answers.

The container model (`Quiz`), the question bank (`QuizQuestion`, `QuizQuestionOption`, `QuizQuestionOptionItem`, `QuizQuestionType`), and authoring / import-export are documented in [Quizzes & Exams endpoints](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-endpoints.md).

***

## **Table of Contents**

1. **Overview**
2. **Base URL and Headers**
3. **Lifecycle Diagram**
4. **Data Model:** `QuizStudentAttempt `**&** `QuizStudentReset`
5. **Endpoints**
   * **5.1 Start Student Quiz**
   * **5.2 Finish Student Quiz (submit answers)**
   * **5.3 Save Student Quiz Result (score)**
   * **5.4 Show Quiz Student Answers**
   * **5.5 Reset Student Attempts (non-destructive)**
6. **Answer Payload Shape (by question type)**
7. **Business Rules & Edge Cases**
8. **History Tracking Actions**
9. **Related Files**

***

## **1. Overview**

A "quiz/exam attempt" for a student is one row in `quiz_student_attempts` (`QuizStudentAttempt` pivot model). The answers chosen during that attempt are rows in the `quiz_student_answers` join table — each row links the attempt to a question and to either the selected option or, for drag-and-drop, an option item.

The five endpoints in this controller drive the lifecycle:

1. `start_student_quiz` — creates a new attempt row (monotonic `attempt` number per student × quiz; the attempt ceiling is reset-aware — see §5.1).
2. `finish_student_quiz` — stamps `finished_at` and writes the answer rows into `quiz_student_answers`.
3. `save_student_quiz_result` — persists the **scored** result (`correct_answers`, `uncorrect_answers`, `score_percentage`, `passed_at`) onto the attempt row. The score itself is computed client-side (or by the caller) before this is invoked.
4. `show_quiz_student_answers` — fetches the authenticated student's attempts (with filters); each attempt is annotated with a derived `reset_cycle`.
5. `reset_student_attempts` — admin/instructor grants a student one more full cycle of `max_attempts` **without deleting any history** (logged in `quiz_student_resets`).

***

## **2. Base URL and Headers**

```text theme={null}
{{base_url}}/api
```

```text theme={null}
Accept: application/json
Content-Type: application/json
Authorization: Bearer {{token}}
```

> Most endpoints take `student_id` explicitly in the body (so an admin can drive a flow on behalf of a student). `show_quiz_student_answers` is the exception — it uses `auth()->id()` directly.

***

## **3. Lifecycle Diagram**

```text theme={null}
        ┌─────────────────────────────────────────────────────────┐
        │   POST /api/quiz/start_student_quiz                     │
        │   → creates quiz_student_attempts row                   │
        │   → attempt = max(attempt)+1   (per student × quiz)     │
        │   → ceiling = max_attempts * (reset_count + 1)          │
        │   → 401 if attempt > ceiling  (max_attempts==0=∞)       │
        └────────────────────────┬────────────────────────────────┘
                                 │
                                 ▼
        ┌─────────────────────────────────────────────────────────┐
        │   POST /api/quiz/finish_student_quiz                    │
        │   → stamps finished_at on latest attempt                │
        │   → writes rows into quiz_student_answers:              │
        │       • options (single / multi / true-or-false)        │
        │       • option items (drag & drop)                      │
        │   → 500 "Quiz has not started!" if no open attempt      │
        └────────────────────────┬────────────────────────────────┘
                                 │
                                 ▼
        ┌─────────────────────────────────────────────────────────┐
        │   POST /api/quiz/save_student_quiz_result/{attempt_id}  │
        │   → writes correct/uncorrect/score%/passed_at           │
        └────────────────────────┬────────────────────────────────┘
                                 │
                                 ▼
        ┌─────────────────────────────────────────────────────────┐
        │   GET  /api/quiz/show_quiz_student_answers              │
        │   → reads attempts for auth()->id()                     │
        │   → filterable by quiz_id, training_id, is_exam         │
        │   → each attempt annotated with derived reset_cycle     │
        └─────────────────────────────────────────────────────────┘

   ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ (admin / instructor, out-of-band) ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
        ┌─────────────────────────────────────────────────────────┐
        │   POST /api/quiz/{quiz_id}/reset_student_attempts      │
        │   → logs one row in quiz_student_resets                 │
        │   → raises this student's ceiling by one max_attempts   │
        │     cycle; NO attempt rows are deleted (history kept)   │
        └─────────────────────────────────────────────────────────┘
```

***

## **4. Data Model:** `QuizStudentAttempt `**&** `QuizStudentReset`

### `QuizStudentAttempt`

File: `app/Models/QuizStudentAttempt.php` — extends `Illuminate\Database\Eloquent\Relations\Pivot`.

```text theme={null}
protected $table = 'quiz_student_attempts';

protected $casts = [
    'attempt'           => 'integer',
    'correct_answers'   => 'integer',
    'uncorrect_answers' => 'integer',
    'score_percentage'  => 'float',
];
```

Significant columns:

| **Column** | **Meaning** |
| :- | :- |
| `quiz_id` | FK to `quizzes` |
| `student_id` | FK to `users` |
| `attempt` | 1-based, **monotonic** attempt counter, scoped per student × quiz — derived via `max(attempt) + 1`; it is **never reset** even after an attempt-reset, so the full history stays ordered |
| `finished_at` | timestamp set by `finish_student_quiz` |
| `correct_answers` | integer, written by `save_student_quiz_result` |
| `uncorrect_answers` | integer, written by `save_student_quiz_result` (note the spelling — `uncorrect_answers`) |
| `score_percentage` | 0–100 float |
| `passed_at` | datetime — when the student "passed" / submitted the result |

Relations:

* `quiz()` — `belongsTo(Quiz::class, 'quiz_id')`
* `student()` — `belongsTo(User::class, 'student_id')`
* `quiz_questions()` — `belongsToMany(QuizQuestion::class, 'quiz_student_answers', 'quiz_student_attempt_id', 'quiz_question_id')` with pivot `quiz_question_option_id, quiz_question_option_item_id`
* `quiz_question_options()` — same join through `quiz_question_option_id`
* `quiz_question_option_items()` — same join through `quiz_question_option_item_id`

Each **answer** the student gives ends up as one row in `quiz_student_answers`. The same join table is read three different ways via these three relations.

### `QuizStudentReset`

File: `app/Models/QuizStudentReset.php` — table `quiz_student_resets`. A **non-destructive reset log**: each row grants one student one more full cycle of `quiz.max_attempts` on a given quiz/exam. Attempt rows are never touched.

```text theme={null}
protected $table = 'quiz_student_resets';

protected $fillable = ['quiz_id', 'student_id', 'reset_by', 'note'];

protected $casts = [
    'quiz_id'    => 'integer',
    'student_id' => 'integer',
    'reset_by'   => 'integer',
];
```

| **Column** | **Meaning** |
| :- | :- |
| `quiz_id` | FK to `quizzes` — which quiz/exam |
| `student_id` | FK to `users` — the student being granted more attempts |
| `reset_by` | FK to `users` — the admin/instructor who triggered the reset |
| `note` | text, nullable — optional reason (audit) |
| `created_at` / `updated_at` | timestamps |

Relations: `quiz()`, `student()`, `resetBy()` — all `belongsTo`. Indexed on `(quiz_id, student_id)` for the fast ceiling lookup in `start_student_quiz`.

> **Ceiling model:** the number of times a student may start a quiz is `max_attempts * (reset_count + 1)`, where `reset_count` = number of `quiz_student_resets` rows for that student × quiz. `max_attempts == 0` (unlimited) is unaffected by resets. See [Suggestions: user attempts fix](https://theteam-13fc7f49.mintlify.site/api-reference/suggestions-user-attempts-fix.md) for the full design rationale.

***

## **5. Endpoints**

Routes (from `routes/api.php`):

```text theme={null}
Route::controller(Quizzes_Exams_Progress_Tracking_Controller::class)->group(function () {
    Route::post('/quiz/start_student_quiz',                       'start_student_quiz');
    Route::post('/quiz/finish_student_quiz',                      'finish_student_quiz');
    Route::post('/quiz/save_student_quiz_result/{attempt_id}',    'save_student_quiz_result');
    Route::get( '/quiz/show_quiz_student_answers',                'show_quiz_student_answers');
    Route::post('/quiz/{quiz_id}/reset_student_attempts',         'reset_student_attempts');
});
```

### **5.1 Start Student Quiz**

`POST /api/quiz/start_student_quiz`

Creates a new attempt row for the student on the given quiz.

**Request Fields:**

| **Field** | **Type** | **Required** | **Notes** |
| :- | :- | :- | :- |
| `student_id` | integer | **yes** | must exist in `users` |
| `quiz_id` | integer | **yes** | must exist in `quizzes` |

**Request Body:**

```text theme={null}
{
  "student_id": 7,
  "quiz_id": 5
}
```

**Behavior:**

1. Computes the next **absolute** attempt number — `(max(attempt) for this student × quiz) + 1`, or `1` if none. This counter is monotonic and never resets.
2. Counts the student's reset rows for this quiz (`reset_count`) and computes the ceiling `allowed_attempts = quiz.max_attempts * (reset_count + 1)`.
3. If `quiz.max_attempts > 0` and the new `attempt > allowed_attempts` → `401 Quiz/Exam cannot be started, too many attempts.` (When `max_attempts == 0`, there is no ceiling — unlimited.)
4. Creates a `quiz_student_attempts` row with `quiz_id`, `student_id`, `attempt`.
5. History: `start_student_quiz` (resource: student id, table: `quiz_student_attempts`).

> **Reset-aware ceiling:** every `quiz_student_resets` row (created via §5.5) grants one more full cycle of `max_attempts`. With `max_attempts = 3` and 1 reset, the student may start the quiz `3 * (1 + 1) = 6` times total. The historical `attempt` numbers keep climbing (4, 5, 6 …) — they are not rewound.

**Success Response (200):**

```text theme={null}
{
  "message": "Quiz/Exam started successfully."
}
```

**Error Responses:**

| **Status** | **Message** | **Reason** |
| :- | :- | :- |
| `401` | `"Quiz/Exam cannot be started, too many attempts."` | Student has already used all allowed attempts |
| `422` | Validation error object | Missing or invalid `student_id` / `quiz_id` |

> The response does not include the new attempt id. Retrieve it via §5.4 or by reading the attempts table directly.

***

### **5.2 Finish Student Quiz (submit answers)**

`POST /api/quiz/finish_student_quiz`

Stamps `finished_at` on the latest open attempt and writes the student's selected answers into `quiz_student_answers`.

**Request Fields:**

| **Field** | **Type** | **Required** | **Notes** |
| :- | :- | :- | :- |
| `student_id` | integer | **yes** | must exist in `users` |
| `quiz_id` | integer | **yes** | must exist in `quizzes` |
| `quiz_question_options_ids` | array of integers | **yes** (present, may be empty) | option ids for True/False, Single Answer, and Multiple Answer questions |
| `quiz_question_option_items_groups` | array of integers | **yes** (present, may be empty) | option ids (the "buckets") for Drag & Drop questions |
| `quiz_question_option_items_ids` | array of arrays of integers | **yes** (present, may be empty) | item ids dropped into each bucket; must align by index with `quiz_question_option_items_groups` |

> All four answer arrays are required by the `present` validation rule but may be empty arrays `[]`.

***

**Example A — Quiz with only True/False and Single Answer questions:**

The student answers 3 non-drag-and-drop questions by selecting option ids `60`, `63`, and `66`. No drag-and-drop buckets used.

```text theme={null}
{
  "student_id": 7,
  "quiz_id": 5,
  "quiz_question_options_ids": [60, 63, 66],
  "quiz_question_option_items_groups": [],
  "quiz_question_option_items_ids": []
}
```

***

**Example B — Quiz with Multiple Answer questions:**

The student picks options `65`, `66`, and `68` for a multiple-answer question (option ids can be from different questions — each id carries its own question reference).

```text theme={null}
{
  "student_id": 7,
  "quiz_id": 5,
  "quiz_question_options_ids": [61, 65, 66, 68],
  "quiz_question_option_items_groups": [],
  "quiz_question_option_items_ids": []
}
```

***

**Example C — Quiz with only Drag & Drop questions:**

Two drag-and-drop buckets: option `69` (bucket for "len()") gets item `201`; option `70` (bucket for "int()") gets item `202`.

```text theme={null}
{
  "student_id": 7,
  "quiz_id": 5,
  "quiz_question_options_ids": [],
  "quiz_question_option_items_groups": [69, 70],
  "quiz_question_option_items_ids": [
    [201],
    [202]
  ]
}
```

***

**Example D — Mixed quiz (Single Answer + Multiple Answer + Drag & Drop):**

* Non-drag-and-drop question answers: option ids `61` and `65`, `66`, `68`
* Drag-and-drop answers: bucket `69` receives items `201`; bucket `70` receives items `202`

```text theme={null}
{
  "student_id": 7,
  "quiz_id": 5,
  "quiz_question_options_ids": [61, 65, 66, 68],
  "quiz_question_option_items_groups": [69, 70],
  "quiz_question_option_items_ids": [
    [201],
    [202]
  ]
}
```

***

**Example E — Student submits nothing (empty submission):**

```text theme={null}
{
  "student_id": 7,
  "quiz_id": 5,
  "quiz_question_options_ids": [],
  "quiz_question_option_items_groups": [],
  "quiz_question_option_items_ids": []
}
```

***

**Behavior:**

1. Looks up the **latest** attempt for `student × quiz`.
2. Only writes answers if the attempt exists **and** no answers have been recorded yet. Otherwise returns `500 Quiz has not started!`.
3. Stamps `finished_at = now()`.
4. For each id in `quiz_question_options_ids`, writes a row to `quiz_student_answers` linking the attempt, the option, and the derived question.
5. For each drag-and-drop pair `[i]`, writes a row linking the attempt, the option item, the derived question, and the bucket option `quiz_question_option_items_groups[i]`.
6. History: `finish_student_quiz`.

**Success Response (200):**

```text theme={null}
{
  "message": "Quiz Options saved successfully.",
  "quiz_student_attempt": [
    {
      "id": 14,
      "quiz_id": 5,
      "student_id": 7,
      "attempt": 2,
      "correct_answers": null,
      "uncorrect_answers": null,
      "score_percentage": null,
      "finished_at": "2026-05-16T14:28:00.000000Z",
      "passed_at": null,
      "created_at": "2026-05-16T14:00:00.000000Z",
      "updated_at": "2026-05-16T14:28:00.000000Z",
      "quiz_questions": [
        {
          "id": 21,
          "quiz_id": 5,
          "label": "Python is a statically typed language.",
          "pivot": {
            "quiz_student_attempt_id": 14,
            "quiz_question_id": 21,
            "quiz_question_option_id": 61,
            "quiz_question_option_item_id": null
          }
        }
      ]
    }
  ]
}
```

> The score is **not** computed here. Compute it client-side from the saved answers and call §5.3 with the totals.

**Error Responses:**

| **Status** | **Message** | **Reason** |
| :- | :- | :- |
| `500` | `"Quiz has not started! Please start a quiz before finishing it."` | No open attempt exists, or answers were already submitted for this attempt |
| `422` | `"quiz_question_option_items_groups & quiz_question_option_items_ids must have the same number of items."` | The two drag-and-drop arrays have different lengths |
| `422` | Validation error object | Missing or invalid fields |

***

### **5.3 Save Student Quiz Result (score)**

`POST /api/quiz/save_student_quiz_result/{attempt_id}`

Persists the computed score onto an existing attempt row. The score is calculated by the client — this endpoint just writes the values.

**Path Parameter:** `attempt_id` — the id of the `quiz_student_attempts` row to score (returned in §5.2 response or retrievable via §5.4).

**Request Fields:**

| **Field** | **Type** | **Required** | **Notes** |
| :- | :- | :- | :- |
| `student_id` | integer | **yes** | must exist in `users` (validated for existence only — the attempt row is identified by the path param) |
| `quiz_id` | integer | **yes** | must exist in `quizzes` (validated for existence only) |
| `correct_answers` | integer | **yes** | number of correct answers |
| `uncorrect_answers` | integer | **yes** | number of wrong answers (note: the column is spelled `uncorrect_answers`) |
| `score_percentage` | numeric | **yes** | 0–100 |
| `passed_at` | date | **yes** | when the student submitted the result; any valid date string |

**Request URL + Body:**

```text theme={null}
POST {{base_url}}/api/quiz/save_student_quiz_result/14
```

```text theme={null}
{
  "student_id": 7,
  "quiz_id": 5,
  "correct_answers": 8,
  "uncorrect_answers": 2,
  "score_percentage": 80,
  "passed_at": "2026-05-16 14:30:00"
}
```

**Behavior:**

1. `QuizStudentAttempt::findOrFail($attempt_id)` — 404 if not found.
2. Writes `correct_answers`, `uncorrect_answers`, `score_percentage`, `passed_at` and saves.
3. History: `save_student_quiz_result`.

**Success Response (200):**

```text theme={null}
{
  "message": "Quiz resuts saved successfully."
}
```

> The typo `"resuts"` (missing 'l') is in the actual controller response — keep it as-is in your test assertions.

**Error Responses:**

| **Status** | **Reason** |
| :- | :- |
| `404` | `attempt_id` not found |
| `422` | Missing or invalid fields |

**Additional Examples:**

Student passed with a perfect score:

```text theme={null}
{
  "student_id": 7,
  "quiz_id": 5,
  "correct_answers": 10,
  "uncorrect_answers": 0,
  "score_percentage": 100,
  "passed_at": "2026-05-16 15:00:00"
}
```

Student failed (below passing threshold):

```text theme={null}
{
  "student_id": 7,
  "quiz_id": 5,
  "correct_answers": 3,
  "uncorrect_answers": 7,
  "score_percentage": 30,
  "passed_at": "2026-05-16 15:45:00"
}
```

***

### **5.4 Show Quiz Student Answers**

`GET /api/quiz/show_quiz_student_answers`

Returns all `quiz_student_attempts` for the **authenticated** student (`auth()->id()`), ordered latest first. Eager-loads `quiz_questions.image` on each attempt unless `summary=1`. Each attempt is annotated with a derived `reset_cycle` and, since 19/10/2026, with the ceiling of its quiz (`reset_count`, `allowed_attempts`, `attempts_used`, see the notes after the example).

**Query Parameters (all optional):**

| **Param** | **Type** | **Effect** |
| :- | :- | :- |
| `quiz_id` | integer | restrict to attempts for a specific quiz |
| `is_exam` | boolean | `true` → restrict to attempts whose quiz is a standalone exam |
| `training_id` | integer | restrict to attempts whose quiz belongs to that training (directly or via a chapter) |
| `summary` | boolean | `1` → skip the `quiz_questions` relation (rows only). The student Attempts tab lists with it (19/10/2026) |
| `attempt_id` | integer | restrict to one attempt (the review fetches it with its answers) |

**Example Requests:**

```text theme={null}
GET {{base_url}}/api/quiz/show_quiz_student_answers
GET {{base_url}}/api/quiz/show_quiz_student_answers?quiz_id=5
GET {{base_url}}/api/quiz/show_quiz_student_answers?is_exam=true
GET {{base_url}}/api/quiz/show_quiz_student_answers?training_id=3
GET {{base_url}}/api/quiz/show_quiz_student_answers?is_exam=1&summary=1
GET {{base_url}}/api/quiz/show_quiz_student_answers?quiz_id=5&attempt_id=14
```

**Success Response (200):**

```text theme={null}
[
  {
    "id": 14,
    "quiz_id": 5,
    "student_id": 7,
    "attempt": 2,
    "reset_cycle": 1,
    "reset_count": 0,
    "allowed_attempts": 3,
    "attempts_used": 2,
    "correct_answers": 8,
    "uncorrect_answers": 2,
    "score_percentage": 80,
    "finished_at": "2026-05-16T14:28:00.000000Z",
    "passed_at": "2026-05-16T14:30:00.000000Z",
    "created_at": "2026-05-16T14:00:00.000000Z",
    "updated_at": "2026-05-16T14:30:00.000000Z",
    "quiz_questions": [
      {
        "id": 21,
        "quiz_id": 5,
        "quiz_question_type_id": 1,
        "image_id": null,
        "label": "Python is a statically typed language.",
        "tip": null,
        "explanation": "Python is dynamically typed.",
        "image": null,
        "pivot": {
          "quiz_student_attempt_id": 14,
          "quiz_question_id": 21,
          "quiz_question_option_id": 61,
          "quiz_question_option_item_id": null
        }
      },
      {
        "id": 22,
        "quiz_id": 5,
        "quiz_question_type_id": 2,
        "image_id": null,
        "label": "Which keyword is used to define a function in Python?",
        "tip": "It is a 3-letter keyword.",
        "explanation": "`def` is used to define functions in Python.",
        "image": null,
        "pivot": {
          "quiz_student_attempt_id": 14,
          "quiz_question_id": 22,
          "quiz_question_option_id": 63,
          "quiz_question_option_item_id": null
        }
      }
    ]
  },
  {
    "id": 11,
    "quiz_id": 5,
    "student_id": 7,
    "attempt": 1,
    "reset_cycle": 1,
    "reset_count": 0,
    "allowed_attempts": 3,
    "attempts_used": 2,
    "correct_answers": 5,
    "uncorrect_answers": 5,
    "score_percentage": 50,
    "finished_at": "2026-05-15T10:10:00.000000Z",
    "passed_at": "2026-05-15T10:12:00.000000Z",
    "created_at": "2026-05-15T10:00:00.000000Z",
    "updated_at": "2026-05-15T10:12:00.000000Z",
    "quiz_questions": []
  }
]
```

> The `pivot` on each `quiz_questions` item holds the answer details: `quiz_question_option_id` = which option the student chose; `quiz_question_option_item_id` = which drag-and-drop item was placed (or `null` for non-drag-and-drop questions).

> `reset_cycle` is a **derived, non-stored** field computed in-memory per attempt: `1 + (number of resets for this student × quiz whose created_at ≤ the attempt's created_at)`. So attempts taken before any reset are cycle `1`; each reset bumps subsequent attempts to cycle `2`, `3`, … Use it to group attempts in the UI ("Cycle 1 — attempts 1–3", "Cycle 2 — attempts 4–6 after reset"). It is **not** a column on `quiz_student_attempts`.

> `reset_count`, `allowed_attempts` and `attempts_used` (19/10/2026) are the **same numbers `start_student_quiz` enforces**, computed once per quiz for all rows: `allowed_attempts = max_attempts * (reset_count + 1)` (`null` when `max_attempts` is 0 = unlimited) and `attempts_used = MAX(attempt)`. The apps read them instead of guessing the ceiling from `reset_cycle`. The same block is returned as `attempts` by `GET /auth/user` (`my_exams[].attempts`) and `GET /quiz/{id}` for a student caller.

**Empty Response (200) — no attempts yet:**

```text theme={null}
[]
```

***

### **5.5 Reset Student Attempts (non-destructive)**

`POST /api/quiz/{quiz_id}/reset_student_attempts`

Grants a student **one more full cycle** of `max_attempts` on a quiz/exam **without deleting any historical attempt**. Each call appends a row to `quiz_student_resets`; `start_student_quiz` then allows `max_attempts * (reset_count + 1)` attempts. All previous attempts (scores + answers) remain visible via §5.4.

> Works for any `Quiz` — both quizzes (training/chapter-linked) and standalone exams. The `quiz_id` is the route path param.

**Authorization:** gated by the `update `**Quiz policy** — admins and instructors with quiz-update rights only. Students cannot self-reset (`403` otherwise).

**Path Parameter:** `quiz_id` — the quiz/exam to grant attempts on (must exist in `quizzes`).

**Request Fields:**

| **Field** | **Type** | **Required** | **Notes** |
| :- | :- | :- | :- |
| `student_id` | integer | **yes** | must exist in `users` — the student receiving the extra cycle |
| `note` | string | no | optional reason, stored for audit (e.g. "technical failure during attempt 3") |

**Request URL + Body:**

```text theme={null}
POST {{base_url}}/api/quiz/5/reset_student_attempts
```

```text theme={null}
{
  "student_id": 7,
  "note": "Student experienced a technical failure during attempt 3 — granting a fresh start."
}
```

**Behavior:**

1. Validates `student_id` (exists in `users`) and `note` (nullable string).
2. Loads the quiz (`404` if `quiz_id` not found) and authorizes the caller via the `update` Quiz policy (`403` if unauthorized).
3. Creates a `quiz_student_resets` row: `quiz_id`, `student_id`, `reset_by = auth()->id()`, `note`.
4. History: `reset_student_attempts` (resource: student id, table: `quiz_student_resets`).
5. Returns the new reset count and the recomputed ceiling.

**Success Response (200):**

```text theme={null}
{
  "message": "Student attempts reset successfully.",
  "quiz_id": 5,
  "student_id": 7,
  "reset_count": 1,
  "allowed_attempts": 6,
  "note": "Student experienced a technical failure during attempt 3 — granting a fresh start.",
  "reset_at": "2026-06-05 16:40:00"
}
```

| **Field** | **Meaning** |
| :- | :- |
| `reset_count` | total number of resets now on record for this student × quiz |
| `allowed_attempts` | the new ceiling: `max_attempts * (reset_count + 1)`, or `0` when the quiz has unlimited attempts (`max_attempts == 0`) |
| `reset_at` | timestamp of the new reset row (`Y-m-d H:i:s`) |

**Error Responses:**

| **Status** | **Message** | **Reason** |
| :- | :- | :- |
| `403` | `"You are not authorized to reset attempts for this quiz/exam."` | Caller lacks the `update` Quiz policy |
| `404` | Not found | `quiz_id` does not exist |
| `422` | Validation error object | Missing/invalid `student_id` |
| `500` | Error message | Unexpected failure (reported via `report()`) |

> **Resetting an unlimited quiz** (`max_attempts == 0`) is a functional no-op — the ceiling check never blocks anyway — but the reset row is still recorded for the audit log, and `allowed_attempts` comes back as `0` (still unlimited).

***

## **6. Answer Payload Shape (by question type)**

The two answer arrays in §5.2 encode differently for each question type. Use this as a quick reference when building the `finish_student_quiz` payload.

```text theme={null}
TRUE_OR_FALSE / SINGLE_ANSWER / MULTIPLE_ANSWER
    → push the chosen option id(s) into  quiz_question_options_ids
    → leave the *_items_* arrays empty for these questions

DRAG_AND_DROP
    → for each option (the "bucket"), push the option id into quiz_question_option_items_groups
    → push the matching array of option-item ids into quiz_question_option_items_ids
    → the two arrays must align by index
```

**Reference table — what goes where:**

| **Question Type** | `quiz_question_options_ids` | `quiz_question_option_items_groups` | `quiz_question_option_items_ids` |
| :- | :- | :- | :- |
| True/False (type 1) | `[selected_option_id]` | `[]` | `[]` |
| Single Answer (type 2) | `[selected_option_id]` | `[]` | `[]` |
| Multiple Answer (type 3) | `[opt_id_1, opt_id_2, ...]` | `[]` | `[]` |
| Drag & Drop (type 4) | `[]` | `[bucket_opt_id_1, bucket_opt_id_2, ...]` | `[[item_id, ...], [item_id, ...], ...]` |

**Full worked example** — a quiz with 4 questions, one of each type:

* **Q1 (True/False):** option ids are `60` (True) and `61` (False). Student selects `61`.
* **Q2 (Single Answer):** option ids are `62`, `63`, `64`. Student selects `63`.
* **Q3 (Multiple Answer):** option ids are `65`, `66`, `67`, `68`. Student selects `65`, `66`, `68`.
* **Q4 (Drag & Drop):** buckets are options `69` and `70`. Student drops item `201` into bucket `69` and item `202` into bucket `70`.

```text theme={null}
{
  "student_id": 7,
  "quiz_id": 5,

  "quiz_question_options_ids": [61, 63, 65, 66, 68],

  "quiz_question_option_items_groups": [69, 70],
  "quiz_question_option_items_ids": [
    [201],
    [202]
  ]
}
```

**A bucket with multiple items** — student drops items `201` and `203` into bucket `69`, and item `202` into bucket `70`:

```text theme={null}
{
  "student_id": 7,
  "quiz_id": 5,
  "quiz_question_options_ids": [],
  "quiz_question_option_items_groups": [69, 70],
  "quiz_question_option_items_ids": [
    [201, 203],
    [202]
  ]
}
```

Note that all four arrays are required by validation (`present`) but may be empty.

***

## **7. Business Rules & Edge Cases**

* `attempt `**is auto-derived** server-side from `(max(attempt) + 1) ?? 1`, and is **monotonic** — it is never rewound, even after a reset. Never pass it in.
* `max_attempts == 0 `**(or** `null`**) means unlimited.** Only `> 0` enforces a ceiling.
* **The ceiling is reset-aware.** `start_student_quiz` blocks when `attempt > max_attempts * (reset_count + 1)`. Each `quiz_student_resets` row (§5.5) grants one more full cycle. Resets never delete attempts, so history and analytics stay intact.
* `reset_cycle `**is derived, not stored.** §5.4 computes it in-memory by bucketing each attempt against the reset timestamps; it never appears as a DB column.
* **Only admins/instructors can reset** (the `update` Quiz policy). Students cannot grant themselves more attempts.
* `finish_student_quiz `**is single-shot per attempt.** It checks `!$quiz_student_attempt->quiz_questions()->exists()` — once any answer row is present for that attempt, a second submit returns `500 Quiz has not started!`. To re-answer, start a new attempt.
* `distinct `**validation on option ids** — `quiz_question_options_ids.*` is `distinct`, so the same option id can't appear twice. This matters for multi-answer questions where you'd never want duplicates anyway.
* **The two drag-and-drop arrays must be the same length**, otherwise `422`.
* **Score is not computed here.** This controller only persists what the caller passes in `save_student_quiz_result`. The "correctness" comparison happens elsewhere (client / dedicated grader).
* **Column name typo:** `uncorrect_answers`**.** Keep it spelled this way in payloads — that is the actual column name.
* `show_quiz_student_answers `**is implicitly per-user** — it doesn't accept `student_id`. Admin / instructor views over another student's attempts live elsewhere (see `CertificationsController` and the [Instructor dashboard](https://theteam-13fc7f49.mintlify.site/api-reference/instructor-dashboard.md) page).

***

## **8. History Tracking Actions**

Calls to `insert_in_history_table($action, $resource_id, $table)`:

| **Action** | **Endpoint** | **Resource id** |
| :- | :- | :- |
| `start_student_quiz` | §5.1 | `student.id` |
| `finish_student_quiz` | §5.2 | `student_id` (from request) |
| `save_student_quiz_result` | §5.3 | `student_id` (from request) |
| `reset_student_attempts` | §5.5 | `student_id` (from request) — table `quiz_student_resets` |

§5.4 is read-only — no history is written.

***

## **9. Related Files**

* `app/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.php`
* `app/Models/QuizStudentAttempt.php`
* `app/Models/QuizStudentReset.php` — non-destructive attempt-reset log
* `database/migrations/2026_06_05_000000_create_quiz_student_resets_table.php`
* `app/Models/QuizQuestion.php`, `QuizQuestionOption.php`, `QuizQuestionOptionItem.php`, `QuizQuestionType.php`
* `app/Models/Quiz.php` — `max_attempts`, `isExam()` scope
* `routes/api.php` — `Quizzes_Exams_Progress_Tracking_Controller` group
* DB table `quiz_student_answers` — the answer join table (attempt × question × option × option-item)
* [Quizzes & Exams endpoints](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-endpoints.md) — authoring, listing, import / export
* [Suggestions: user attempts fix](https://theteam-13fc7f49.mintlify.site/api-reference/suggestions-user-attempts-fix.md) — attempt-reset design rationale & shipped variant
* [Instructor dashboard](https://theteam-13fc7f49.mintlify.site/api-reference/instructor-dashboard.md) — instructor / dashboard views over student progress
* [Certifications](https://theteam-13fc7f49.mintlify.site/api-reference/certifications.md) — certification flow once a student passes


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.