Remove AUDIO_CLIP from the stimulus component catalog and use AUDIO_PROMPT for all question-side audio. Update integration, practice, and Postman docs accordingly. Co-authored-by: Cursor <cursoragent@cursor.com>
996 lines
27 KiB
Markdown
996 lines
27 KiB
Markdown
# Dynamic Practice Creation — LMS Guide (Course / Module / Lesson)
|
||
|
||
This guide explains **step by step** how to create **practices** in the Learn English LMS hierarchy using **dynamic question types** (`DYNAMIC` questions with `question_type_definition_id` + `dynamic_payload`).
|
||
|
||
It is the companion to:
|
||
|
||
- **Type builder (definitions + components):** `docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md`
|
||
- **Lesson-only quick path (legacy + dynamic):** `docs/PRACTICE_CREATION_API_GUIDE.md`
|
||
|
||
**Base URL:** `{API_HOST}/api/v1`
|
||
**Auth:** `Authorization: Bearer <access_token>`
|
||
**Content-Type:** `application/json` (except file upload)
|
||
|
||
---
|
||
|
||
## Table of contents
|
||
|
||
1. [Architecture](#1-architecture)
|
||
2. [Prerequisites and permissions](#2-prerequisites-and-permissions)
|
||
3. [Standard response envelopes](#3-standard-response-envelopes)
|
||
4. [ID map (what to store after each step)](#4-id-map-what-to-store-after-each-step)
|
||
5. [Publishing model](#5-publishing-model)
|
||
6. [End-to-end flow overview](#6-end-to-end-flow-overview)
|
||
7. [Step 0 — Resolve LMS parent IDs](#7-step-0--resolve-lms-parent-ids)
|
||
8. [Step 1 — (Optional) Upload media](#8-step-1--optional-upload-media)
|
||
9. [Step 2 — Create or select a question type definition](#9-step-2--create-or-select-a-question-type-definition)
|
||
10. [Step 3 — Create dynamic question(s)](#10-step-3--create-dynamic-questions)
|
||
11. [Step 4 — Create PRACTICE question set](#11-step-4--create-practice-question-set)
|
||
12. [Step 5 — Add questions to the set](#12-step-5--add-questions-to-the-set)
|
||
13. [Step 6 — Create practice shell (course / module / lesson)](#13-step-6--create-practice-shell-course--module--lesson)
|
||
14. [Step 7 — Verify and inspect](#14-step-7--verify-and-inspect)
|
||
15. [Optional — Reorder, update, publish](#15-optional--reorder-update-publish)
|
||
16. [Worked example — Lesson practice with TABLE + OPTION](#16-worked-example--lesson-practice-with-table--option)
|
||
17. [Scope-specific quick reference](#17-scope-specific-quick-reference)
|
||
18. [API index](#18-api-index)
|
||
19. [QA checklist](#19-qa-checklist)
|
||
|
||
---
|
||
|
||
## 1. Architecture
|
||
|
||
### LMS hierarchy
|
||
|
||
```
|
||
Program
|
||
└── Course
|
||
└── Module
|
||
└── Lesson
|
||
```
|
||
|
||
A **practice** is a learner-facing activity (story, persona, tips) backed by a **question set** containing one or more **questions**.
|
||
|
||
### Database rule (one parent only)
|
||
|
||
Each row in `lms_practices` attaches to **exactly one** of:
|
||
|
||
| Scope | `parent_kind` | `parent_id` refers to |
|
||
|-------|---------------|------------------------|
|
||
| Course-level practice | `COURSE` | `courses.id` |
|
||
| Module-level practice | `MODULE` | `modules.id` |
|
||
| Lesson-level practice | `LESSON` | `lessons.id` |
|
||
|
||
You cannot attach one practice to multiple parents. Choose the scope that matches your curriculum design.
|
||
|
||
### How dynamic questions fit in
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph definitions
|
||
DEF[Question type definition]
|
||
end
|
||
subgraph content
|
||
Q1[Dynamic question 1]
|
||
Q2[Dynamic question 2]
|
||
end
|
||
subgraph packaging
|
||
QS[Question set set_type=PRACTICE]
|
||
P[Practice shell]
|
||
end
|
||
DEF --> Q1
|
||
DEF --> Q2
|
||
Q1 --> QS
|
||
Q2 --> QS
|
||
QS --> P
|
||
P --> L[Lesson / Module / Course]
|
||
```
|
||
|
||
1. **Definition** — template (which stimulus/response slots exist).
|
||
2. **Questions** — instances with `dynamic_payload` (real TABLE rows, OPTION choices, PDF URLs, etc.).
|
||
3. **Question set** — ordered list of question IDs (`set_type: "PRACTICE"`).
|
||
4. **Practice** — links `question_set_id` to a course, module, or lesson.
|
||
|
||
---
|
||
|
||
## 2. Prerequisites and permissions
|
||
|
||
### Minimum permissions (admin authoring)
|
||
|
||
| Permission | Used for |
|
||
|------------|----------|
|
||
| `questions.list` | Component catalog, list definitions |
|
||
| `questions.create` | Definitions, dynamic questions |
|
||
| `questions.get` | Load question / definition details |
|
||
| `questions.update` | Update questions, definitions, publish practice |
|
||
| `question_sets.create` | Create PRACTICE set |
|
||
| `question_set_items.add` | Link questions to set |
|
||
| `question_set_items.list` | List questions in set, type summary |
|
||
| `question_set_items.update_order` | Reorder questions |
|
||
| `practices.create` | Create practice shell |
|
||
| `practices.list` | List practices under course/module/lesson |
|
||
| `practices.get` | Get practice by id |
|
||
| `practices.update` | Publish practice (`publish_status`) |
|
||
| `lessons.get` / `modules.get` / `courses.get` | Resolve parent IDs (as needed) |
|
||
|
||
File upload: authenticated user only (`POST /files/upload`).
|
||
|
||
### Related docs
|
||
|
||
- Full definition API reference: `DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md`
|
||
- Postman collection: `postman/Dynamic-Question-Type-Builder.postman_collection.json`
|
||
|
||
---
|
||
|
||
## 3. Standard response envelopes
|
||
|
||
### Success — `domain.Response`
|
||
|
||
```json
|
||
{
|
||
"message": "Human-readable summary",
|
||
"data": {},
|
||
"success": true,
|
||
"status_code": 200,
|
||
"metadata": null
|
||
}
|
||
```
|
||
|
||
`status_code` in the body may be `200` or `201` depending on the endpoint.
|
||
|
||
### Error — `domain.ErrorResponse`
|
||
|
||
```json
|
||
{
|
||
"message": "Short error title",
|
||
"error": "Detailed validation or system message"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. ID map (what to store after each step)
|
||
|
||
| Step | Capture | Used in |
|
||
|------|---------|---------|
|
||
| Upload media | `url`, `object_key` | `dynamic_payload` stimulus `value` |
|
||
| Create definition | `question_type_definition_id` | Create each dynamic question |
|
||
| Create question | `question_id` | Add to set (repeat per question) |
|
||
| Create question set | `question_set_id` (`set_id`) | Create practice |
|
||
| Create practice | `practice_id` | Admin UI, learner routes |
|
||
| Parent resolution | `course_id` / `module_id` / `lesson_id` | `parent_id` + `owner_id` |
|
||
|
||
---
|
||
|
||
## 5. Publishing model
|
||
|
||
Three layers can affect learner visibility:
|
||
|
||
| Layer | Field | Values | Notes |
|
||
|-------|--------|--------|-------|
|
||
| Question | `status` | `DRAFT`, `PUBLISHED`, `INACTIVE`, `ARCHIVED` | Use `PUBLISHED` for live content |
|
||
| Question set | `status` | `DRAFT`, `PUBLISHED`, … | Use `PUBLISHED` for live sets |
|
||
| Practice shell | `publish_status` | `DRAFT`, `PUBLISHED` | Omit or `PUBLISHED` on create; use `DRAFT` to hide until ready |
|
||
|
||
**Recommendation for go-live:** set question `status`, question set `status`, and practice `publish_status` to published when learners should see the practice immediately.
|
||
|
||
**Draft practice:** create with `"publish_status": "DRAFT"`, then `PUT /practices/:id` with `"publish_status": "PUBLISHED"` when ready.
|
||
|
||
---
|
||
|
||
## 6. End-to-end flow overview
|
||
|
||
| Step | Action | APIs (typical) |
|
||
|------|--------|----------------|
|
||
| 0 | Resolve `parent_id` (course / module / lesson) | `GET /courses/:id`, `GET /modules/:id`, `GET /lessons/:id` |
|
||
| 1 | Upload images / PDF / audio (if needed) | `POST /files/upload` |
|
||
| 2 | Create or pick question type definition | `GET /questions/type-definitions`, `POST /questions/type-definitions` |
|
||
| 3 | Create one or more dynamic questions | `POST /questions` (repeat) |
|
||
| 4 | Create PRACTICE question set | `POST /question-sets` |
|
||
| 5 | Add each question to set (ordered) | `POST /question-sets/:setId/questions` (repeat) |
|
||
| 6 | Create practice at chosen scope | `POST /practices` |
|
||
| 7 | Verify | `GET /lessons/:id/practices` (or course/module), `GET /question-sets/:setId/questions` |
|
||
|
||
---
|
||
|
||
## 7. Step 0 — Resolve LMS parent IDs
|
||
|
||
Before creating a practice, know the target **`parent_id`** and matching **`owner_type`** for the question set.
|
||
|
||
### List lessons in a module (example)
|
||
|
||
| | |
|
||
|--|--|
|
||
| **GET** | `/modules/:moduleId/lessons` |
|
||
| **Permission** | `lessons.list_by_module` |
|
||
|
||
**Query (optional):** `limit`, `offset` (see lesson list handler defaults).
|
||
|
||
**Success `200` — `data`:** array of lessons; capture `id` for `parent_id` when scope is `LESSON`.
|
||
|
||
### Get lesson / module / course
|
||
|
||
| Entity | Method / path | Permission |
|
||
|--------|---------------|------------|
|
||
| Lesson | `GET /lessons/:id` | `lessons.get` |
|
||
| Module | `GET /modules/:id` | `modules.get` |
|
||
| Course | `GET /courses/:id` | `courses.get` |
|
||
|
||
Use these to confirm the parent exists and to display titles in the admin UI.
|
||
|
||
---
|
||
|
||
## 8. Step 1 — (Optional) Upload media
|
||
|
||
Required when the definition uses `IMAGE`, `AUDIO_PROMPT`, or `PDF_ATTACHMENT` stimulus slots.
|
||
|
||
### POST `/files/upload`
|
||
|
||
**Content-Type:** `multipart/form-data`
|
||
|
||
| Field | Value |
|
||
|-------|--------|
|
||
| `file` | Binary |
|
||
| `media_type` | `image`, `audio`, `video`, or `pdf` |
|
||
|
||
**Success `200` — `data`:**
|
||
|
||
```json
|
||
{
|
||
"object_key": "pdf/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf",
|
||
"url": "https://minio.example.com/bucket/pdf/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf?X-Amz-Algorithm=...",
|
||
"content_type": "application/pdf",
|
||
"media_type": "pdf",
|
||
"provider": "MINIO"
|
||
}
|
||
```
|
||
|
||
**Use in `dynamic_payload`:** set stimulus `value` to `data.url` (or store `minio://{object_key}` and resolve with `GET /files/url?key=...`).
|
||
|
||
**Errors `400`:**
|
||
|
||
```json
|
||
{
|
||
"message": "Invalid media_type",
|
||
"error": "media_type must be one of: image, audio, video, pdf"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Step 2 — Create or select a question type definition
|
||
|
||
Skip creation if reusing an existing ACTIVE definition.
|
||
|
||
### 9.1 List existing definitions
|
||
|
||
| | |
|
||
|--|--|
|
||
| **GET** | `/questions/type-definitions?include_system=true&status=ACTIVE` |
|
||
| **Permission** | `questions.list` |
|
||
|
||
**Success `200` — `data`:** array of definitions (see shape in type builder doc).
|
||
|
||
### 9.2 (Optional) Validate kinds
|
||
|
||
| | |
|
||
|--|--|
|
||
| **POST** | `/questions/validate-question-type-definition` |
|
||
| **Permission** | `questions.create` |
|
||
|
||
**Request:**
|
||
|
||
```json
|
||
{
|
||
"stimulus_component_kinds": ["QUESTION_TEXT", "TABLE", "IMAGE"],
|
||
"response_component_kinds": ["OPTION", "ANSWER_TIMER"]
|
||
}
|
||
```
|
||
|
||
**Success `200` — `data`:**
|
||
|
||
```json
|
||
{
|
||
"valid": true
|
||
}
|
||
```
|
||
|
||
### 9.3 Create definition (example with TABLE)
|
||
|
||
| | |
|
||
|--|--|
|
||
| **POST** | `/questions/type-definitions` |
|
||
| **Permission** | `questions.create` |
|
||
|
||
**Request:**
|
||
|
||
```json
|
||
{
|
||
"key": "lesson_table_mcq_v1",
|
||
"display_name": "Lesson Table MCQ",
|
||
"description": "Prompt + optional table + image; MCQ response",
|
||
"stimulus_component_kinds": ["QUESTION_TEXT", "TABLE", "IMAGE"],
|
||
"response_component_kinds": ["OPTION"],
|
||
"stimulus_schema": [
|
||
{
|
||
"id": "prompt",
|
||
"kind": "QUESTION_TEXT",
|
||
"label": "Question prompt",
|
||
"required": true,
|
||
"config": { "max_length": 2000 }
|
||
},
|
||
{
|
||
"id": "data_table",
|
||
"kind": "TABLE",
|
||
"label": "Reference table",
|
||
"required": true,
|
||
"config": { "max_rows": 30, "max_columns": 10 }
|
||
},
|
||
{
|
||
"id": "illustration",
|
||
"kind": "IMAGE",
|
||
"label": "Supporting image",
|
||
"required": false,
|
||
"config": {}
|
||
}
|
||
],
|
||
"response_schema": [
|
||
{
|
||
"id": "choices",
|
||
"kind": "OPTION",
|
||
"label": "Answer choices",
|
||
"required": true,
|
||
"config": { "min_options": 2, "max_options": 6 }
|
||
}
|
||
],
|
||
"status": "ACTIVE"
|
||
}
|
||
```
|
||
|
||
**Success `201` — `data` (full `QuestionTypeDefinition`):**
|
||
|
||
```json
|
||
{
|
||
"id": 42,
|
||
"key": "lesson_table_mcq_v1",
|
||
"display_name": "Lesson Table MCQ",
|
||
"description": "Prompt + optional table + image; MCQ response",
|
||
"stimulus_component_kinds": ["QUESTION_TEXT", "TABLE", "IMAGE"],
|
||
"response_component_kinds": ["OPTION"],
|
||
"stimulus_schema": [ ],
|
||
"response_schema": [ ],
|
||
"is_system": false,
|
||
"status": "ACTIVE",
|
||
"created_at": "2026-06-04T10:00:00Z",
|
||
"updated_at": null
|
||
}
|
||
```
|
||
|
||
**Capture:** `data.id` → `question_type_definition_id` (e.g. `42`).
|
||
|
||
---
|
||
|
||
## 10. Step 3 — Create dynamic question(s)
|
||
|
||
Repeat this step for each question in the practice.
|
||
|
||
### POST `/questions`
|
||
|
||
| | |
|
||
|--|--|
|
||
| **Permission** | `questions.create` |
|
||
|
||
**Rules:**
|
||
|
||
- `question_type` must be `"DYNAMIC"`.
|
||
- `question_type_definition_id` is **required**.
|
||
- `dynamic_payload` is **required**.
|
||
- Do **not** send top-level `question_text` (prompt lives in stimulus).
|
||
- Do **not** send legacy `options` / `short_answers` for pure dynamic MCQ (use `OPTION` in payload).
|
||
|
||
**Request (TABLE + OPTION example):**
|
||
|
||
```json
|
||
{
|
||
"question_type": "DYNAMIC",
|
||
"question_type_definition_id": 42,
|
||
"difficulty_level": "MEDIUM",
|
||
"points": 2,
|
||
"status": "PUBLISHED",
|
||
"dynamic_payload": {
|
||
"stimulus": [
|
||
{
|
||
"id": "prompt",
|
||
"kind": "QUESTION_TEXT",
|
||
"value": "Using the table, choose the correct past tense."
|
||
},
|
||
{
|
||
"id": "data_table",
|
||
"kind": "TABLE",
|
||
"value": {
|
||
"columns": ["Verb", "Past Form"],
|
||
"rows": [
|
||
["go", "went"],
|
||
["write", "wrote"],
|
||
["see", "saw"]
|
||
]
|
||
}
|
||
},
|
||
{
|
||
"id": "illustration",
|
||
"kind": "IMAGE",
|
||
"value": "https://minio.example.com/bucket/image/uuid.jpg"
|
||
}
|
||
],
|
||
"response": [
|
||
{
|
||
"id": "choices",
|
||
"kind": "OPTION",
|
||
"value": {
|
||
"options": [
|
||
{ "id": "a", "text": "He goed home.", "is_correct": false },
|
||
{ "id": "b", "text": "He went home.", "is_correct": true },
|
||
{ "id": "c", "text": "He go home.", "is_correct": false }
|
||
]
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
**TABLE `value` contract:**
|
||
|
||
| Field | Type | Description |
|
||
|-------|------|-------------|
|
||
| `columns` | `string[]` | Header labels |
|
||
| `rows` | `string[][]` | Each row length should match `columns.length` |
|
||
|
||
**Success `201` — `data`:**
|
||
|
||
```json
|
||
{
|
||
"id": 1001,
|
||
"question_type": "DYNAMIC",
|
||
"question_type_definition_id": 42,
|
||
"dynamic_payload": {
|
||
"stimulus": [ ],
|
||
"response": [ ]
|
||
},
|
||
"difficulty_level": "MEDIUM",
|
||
"points": 2,
|
||
"status": "PUBLISHED",
|
||
"created_at": "2026-06-04T11:00:00Z"
|
||
}
|
||
```
|
||
|
||
Note: `question_text` is **omitted** from the JSON response for `DYNAMIC` questions.
|
||
|
||
**Error `400` examples:**
|
||
|
||
```json
|
||
{
|
||
"message": "Invalid dynamic_payload",
|
||
"error": "dynamic_payload.stimulus: required element id \"data_table\" is missing"
|
||
}
|
||
```
|
||
|
||
```json
|
||
{
|
||
"message": "Invalid question_text",
|
||
"error": "question_text is not used for DYNAMIC questions; set prompt text in dynamic_payload stimulus (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE)"
|
||
}
|
||
```
|
||
|
||
**Capture:** `data.id` → `question_id` (repeat list: `[1001, 1002, ...]`).
|
||
|
||
---
|
||
|
||
## 11. Step 4 — Create PRACTICE question set
|
||
|
||
The question set groups questions. Its `owner_type` / `owner_id` should match the practice scope (recommended for reporting and sequence gating).
|
||
|
||
### POST `/question-sets`
|
||
|
||
| | |
|
||
|--|--|
|
||
| **Permission** | `question_sets.create` |
|
||
|
||
**Request — lesson scope:**
|
||
|
||
```json
|
||
{
|
||
"title": "Lesson 12 — Dynamic drill",
|
||
"description": "Practice question set for lesson 12",
|
||
"set_type": "PRACTICE",
|
||
"owner_type": "LESSON",
|
||
"owner_id": 12,
|
||
"status": "PUBLISHED",
|
||
"shuffle_questions": false
|
||
}
|
||
```
|
||
|
||
**Request — module scope:**
|
||
|
||
```json
|
||
{
|
||
"title": "Module 3 — Review set",
|
||
"set_type": "PRACTICE",
|
||
"owner_type": "MODULE",
|
||
"owner_id": 3,
|
||
"status": "PUBLISHED",
|
||
"shuffle_questions": false
|
||
}
|
||
```
|
||
|
||
**Request — course scope:**
|
||
|
||
```json
|
||
{
|
||
"title": "Course 1 — Capstone practice",
|
||
"set_type": "PRACTICE",
|
||
"owner_type": "COURSE",
|
||
"owner_id": 1,
|
||
"status": "PUBLISHED",
|
||
"shuffle_questions": false
|
||
}
|
||
```
|
||
|
||
| Field | Required | Notes |
|
||
|-------|----------|-------|
|
||
| `title` | Yes | Admin display |
|
||
| `set_type` | Yes | Must be `"PRACTICE"` for LMS practices |
|
||
| `owner_type` | Recommended | `LESSON`, `MODULE`, or `COURSE` (match practice parent) |
|
||
| `owner_id` | Recommended | ID of that entity |
|
||
| `description` | No | |
|
||
| `status` | No | Default `DRAFT`; use `PUBLISHED` for learners |
|
||
| `shuffle_questions` | No | Default `false` |
|
||
| `time_limit_minutes` | No | Optional |
|
||
| `passing_score` | No | Optional |
|
||
| `intro_video_url` | No | Optional |
|
||
|
||
**Success `201` — `data`:**
|
||
|
||
```json
|
||
{
|
||
"id": 55,
|
||
"title": "Lesson 12 — Dynamic drill",
|
||
"description": "Practice question set for lesson 12",
|
||
"set_type": "PRACTICE",
|
||
"owner_type": "LESSON",
|
||
"owner_id": 12,
|
||
"shuffle_questions": false,
|
||
"status": "PUBLISHED",
|
||
"created_at": "2026-06-04T11:30:00Z"
|
||
}
|
||
```
|
||
|
||
**Capture:** `data.id` → `question_set_id` / `set_id` (e.g. `55`).
|
||
|
||
---
|
||
|
||
## 12. Step 5 — Add questions to the set
|
||
|
||
Run once per `question_id`. `display_order` controls sequence (important for `STUDENT` practice gating).
|
||
|
||
### POST `/question-sets/:setId/questions`
|
||
|
||
| | |
|
||
|--|--|
|
||
| **Permission** | `question_set_items.add` |
|
||
|
||
**Path:** `setId` = question set id from Step 4.
|
||
|
||
**Request (first question):**
|
||
|
||
```json
|
||
{
|
||
"question_id": 1001,
|
||
"display_order": 1
|
||
}
|
||
```
|
||
|
||
**Request (second question):**
|
||
|
||
```json
|
||
{
|
||
"question_id": 1002,
|
||
"display_order": 2
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required |
|
||
|-------|------|----------|
|
||
| `question_id` | `int64` | Yes |
|
||
| `display_order` | `int32` | No |
|
||
|
||
**Success `201` — `data`:**
|
||
|
||
```json
|
||
{
|
||
"id": 901,
|
||
"set_id": 55,
|
||
"question_id": 1001,
|
||
"display_order": 1
|
||
}
|
||
```
|
||
|
||
**Errors:** `400` invalid ids; `500` link failure.
|
||
|
||
### (Optional) Question type summary for set
|
||
|
||
| | |
|
||
|--|--|
|
||
| **GET** | `/question-sets/:setId/question-types` |
|
||
| **Permission** | `question_set_items.list` |
|
||
|
||
**Success `200` — `data`:**
|
||
|
||
```json
|
||
{
|
||
"question_set_id": 55,
|
||
"total_questions": 2,
|
||
"question_types": [
|
||
{
|
||
"question_type_definition_id": 42,
|
||
"key": "lesson_table_mcq_v1",
|
||
"display_name": "Lesson Table MCQ",
|
||
"count": 2
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 13. Step 6 — Create practice shell (course / module / lesson)
|
||
|
||
Links the question set to exactly one LMS parent.
|
||
|
||
### POST `/practices`
|
||
|
||
| | |
|
||
|--|--|
|
||
| **Permission** | `practices.create` |
|
||
|
||
**Request — lesson:**
|
||
|
||
```json
|
||
{
|
||
"parent_kind": "LESSON",
|
||
"parent_id": 12,
|
||
"title": "Lesson 12 — Table MCQ practice",
|
||
"story_description": "Read the table and choose the best answer.",
|
||
"story_image": "https://minio.example.com/bucket/image/story.webp",
|
||
"question_set_id": 55,
|
||
"quick_tips": "Check every row in the table before selecting.",
|
||
"publish_status": "DRAFT"
|
||
}
|
||
```
|
||
|
||
**Request — module:**
|
||
|
||
```json
|
||
{
|
||
"parent_kind": "MODULE",
|
||
"parent_id": 3,
|
||
"title": "Module 3 review",
|
||
"question_set_id": 55,
|
||
"publish_status": "PUBLISHED"
|
||
}
|
||
```
|
||
|
||
**Request — course:**
|
||
|
||
```json
|
||
{
|
||
"parent_kind": "COURSE",
|
||
"parent_id": 1,
|
||
"title": "Course-wide practice",
|
||
"question_set_id": 55,
|
||
"publish_status": "PUBLISHED"
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required | Notes |
|
||
|-------|------|----------|-------|
|
||
| `parent_kind` | string | Yes | `COURSE`, `MODULE`, or `LESSON` |
|
||
| `parent_id` | int64 | Yes | Target entity id |
|
||
| `question_set_id` | int64 | Yes | From Step 4 |
|
||
| `title` | string | No | Empty string allowed |
|
||
| `story_description` | string | No | |
|
||
| `story_image` | string | No | URL |
|
||
| `persona_id` | int64 | No | `lms_personas` catalog id |
|
||
| `quick_tips` | string | No | |
|
||
| `publish_status` | string | No | `DRAFT` or `PUBLISHED`; default `PUBLISHED` if omitted |
|
||
|
||
**Success `201` — `data` (`domain.Practice`):**
|
||
|
||
```json
|
||
{
|
||
"id": 37,
|
||
"parent_kind": "LESSON",
|
||
"parent_id": 12,
|
||
"title": "Lesson 12 — Table MCQ practice",
|
||
"story_description": "Read the table and choose the best answer.",
|
||
"story_image": "https://minio.example.com/bucket/image/story.webp",
|
||
"persona_id": null,
|
||
"question_set_id": 55,
|
||
"publish_status": "DRAFT",
|
||
"quick_tips": "Check every row in the table before selecting.",
|
||
"created_at": "2026-06-04T12:00:00Z",
|
||
"updated_at": null
|
||
}
|
||
```
|
||
|
||
**Errors:**
|
||
|
||
| Status | `message` | Typical `error` |
|
||
|--------|-----------|-----------------|
|
||
| `404` | Lesson not found | Parent id invalid |
|
||
| `404` | Question set not found | Bad `question_set_id` |
|
||
| `404` | Persona not found | Bad `persona_id` |
|
||
| `400` | Invalid parent | Bad `parent_kind` |
|
||
|
||
**Capture:** `data.id` → `practice_id` (e.g. `37`).
|
||
|
||
---
|
||
|
||
## 14. Step 7 — Verify and inspect
|
||
|
||
### 14.1 List practices under parent
|
||
|
||
| Scope | GET |
|
||
|-------|-----|
|
||
| Lesson | `/lessons/:id/practices?limit=20&offset=0` |
|
||
| Module | `/modules/:id/practices?limit=20&offset=0` |
|
||
| Course | `/courses/:id/practices?limit=20&offset=0` |
|
||
|
||
**Permission:** `practices.list`
|
||
|
||
**Success `200` — `data`:**
|
||
|
||
```json
|
||
{
|
||
"practices": [
|
||
{
|
||
"id": 37,
|
||
"parent_kind": "LESSON",
|
||
"parent_id": 12,
|
||
"title": "Lesson 12 — Table MCQ practice",
|
||
"question_set_id": 55,
|
||
"publish_status": "DRAFT",
|
||
"created_at": "2026-06-04T12:00:00Z"
|
||
}
|
||
],
|
||
"total_count": 1,
|
||
"limit": 20,
|
||
"offset": 0
|
||
}
|
||
```
|
||
|
||
### 14.2 Get practice by id
|
||
|
||
| | |
|
||
|--|--|
|
||
| **GET** | `/practices/:id` |
|
||
| **Permission** | `practices.get` |
|
||
|
||
**Success `200` — `data`:** full `Practice` object (includes `question_set_id`).
|
||
|
||
### 14.3 List questions in set (admin — full dynamic payload)
|
||
|
||
Use **`question_set_id`** from the practice record (not `practice_id`).
|
||
|
||
| | |
|
||
|--|--|
|
||
| **GET** | `/question-sets/:setId/questions` |
|
||
| **Permission** | `question_set_items.list` |
|
||
|
||
**Success `200` — `data`:** array of full questions including `dynamic_payload` and `question_type_definition_id`.
|
||
|
||
For paginated learner-style listing with filters:
|
||
|
||
| | |
|
||
|--|--|
|
||
| **GET** | `/practices/:practiceId/questions?limit=10&offset=0&question_type=DYNAMIC` |
|
||
|
||
**Note:** This route’s path parameter is named `practiceId` in OpenAPI but is implemented against **`question_sets.id`**. For admin, prefer **`GET /question-sets/:setId/questions`** using `practice.question_set_id` from Step 14.2.
|
||
|
||
**Paginated response shape (`GET /practices/.../questions`):**
|
||
|
||
```json
|
||
{
|
||
"questions": [
|
||
{
|
||
"id": 901,
|
||
"set_id": 55,
|
||
"question_id": 1001,
|
||
"display_order": 1,
|
||
"question_type": "DYNAMIC",
|
||
"dynamic_payload": { "stimulus": [ ], "response": [ ] },
|
||
"points": 2,
|
||
"question_status": "PUBLISHED"
|
||
}
|
||
],
|
||
"total_count": 1,
|
||
"limit": 10,
|
||
"offset": 0
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 15. Optional — Reorder, update, publish
|
||
|
||
### Reorder question in set
|
||
|
||
| | |
|
||
|--|--|
|
||
| **PUT** | `/question-sets/:setId/questions/:questionId/order` |
|
||
| **Permission** | `question_set_items.update_order` |
|
||
|
||
**Request:**
|
||
|
||
```json
|
||
{
|
||
"display_order": 2
|
||
}
|
||
```
|
||
|
||
**Success `200`:**
|
||
|
||
```json
|
||
{
|
||
"message": "Question order updated successfully",
|
||
"success": true,
|
||
"status_code": 200
|
||
}
|
||
```
|
||
|
||
### Publish practice shell
|
||
|
||
| | |
|
||
|--|--|
|
||
| **PUT** | `/practices/:id` |
|
||
| **Permission** | `practices.update` |
|
||
|
||
**Request:**
|
||
|
||
```json
|
||
{
|
||
"publish_status": "PUBLISHED"
|
||
}
|
||
```
|
||
|
||
**Success `200` — `data`:** updated `Practice` with `publish_status: "PUBLISHED"`.
|
||
|
||
### Update dynamic question content
|
||
|
||
| | |
|
||
|--|--|
|
||
| **PUT** | `/questions/:id` |
|
||
| **Permission** | `questions.update` |
|
||
|
||
Send updated `dynamic_payload` (and optional metadata). Do not send `question_text` for `DYNAMIC`.
|
||
|
||
### Remove question from set
|
||
|
||
| | |
|
||
|--|--|
|
||
| **DELETE** | `/question-sets/:setId/questions/:questionId` |
|
||
| **Permission** | `question_set_items.remove` |
|
||
|
||
---
|
||
|
||
## 16. Worked example — Lesson practice with TABLE + OPTION
|
||
|
||
**Goal:** Lesson `12` gets one practice with one dynamic TABLE+MCQ question.
|
||
|
||
| Step | API | Key ids |
|
||
|------|-----|---------|
|
||
| 1 | `POST /questions/type-definitions` | `definition_id = 42` |
|
||
| 2 | `POST /questions` | `question_id = 1001` |
|
||
| 3 | `POST /question-sets` (`owner_type: LESSON`, `owner_id: 12`) | `set_id = 55` |
|
||
| 4 | `POST /question-sets/55/questions` | links `1001` order `1` |
|
||
| 5 | `POST /practices` (`parent_kind: LESSON`, `parent_id: 12`, `question_set_id: 55`) | `practice_id = 37` |
|
||
| 6 | `GET /lessons/12/practices` | confirms practice listed |
|
||
| 7 | `GET /question-sets/55/questions` | confirms TABLE payload |
|
||
| 8 | `PUT /practices/37` `{ "publish_status": "PUBLISHED" }` | go live |
|
||
|
||
**Admin UI table editor → API:** bind columns/rows UI to stimulus slot `data_table` / kind `TABLE` before Step 2 (`POST /questions`).
|
||
|
||
---
|
||
|
||
## 17. Scope-specific quick reference
|
||
|
||
### Lesson practice
|
||
|
||
```json
|
||
// Question set
|
||
{ "owner_type": "LESSON", "owner_id": <lesson_id>, "set_type": "PRACTICE" }
|
||
|
||
// Practice
|
||
{ "parent_kind": "LESSON", "parent_id": <lesson_id>, "question_set_id": <set_id> }
|
||
```
|
||
|
||
**Verify:** `GET /lessons/<lesson_id>/practices`
|
||
|
||
### Module practice
|
||
|
||
```json
|
||
{ "owner_type": "MODULE", "owner_id": <module_id> }
|
||
{ "parent_kind": "MODULE", "parent_id": <module_id> }
|
||
```
|
||
|
||
**Verify:** `GET /modules/<module_id>/practices`
|
||
|
||
### Course practice
|
||
|
||
```json
|
||
{ "owner_type": "COURSE", "owner_id": <course_id> }
|
||
{ "parent_kind": "COURSE", "parent_id": <course_id> }
|
||
```
|
||
|
||
**Verify:** `GET /courses/<course_id>/practices`
|
||
|
||
---
|
||
|
||
## 18. API index
|
||
|
||
| # | Method | Path | Permission |
|
||
|---|--------|------|------------|
|
||
| 1 | GET | `/questions/component-catalog` | `questions.list` |
|
||
| 2 | GET | `/questions/type-definitions` | `questions.list` |
|
||
| 3 | POST | `/questions/type-definitions` | `questions.create` |
|
||
| 4 | POST | `/questions/validate-question-type-definition` | `questions.create` |
|
||
| 5 | POST | `/files/upload` | auth |
|
||
| 6 | GET | `/files/url` | auth |
|
||
| 7 | POST | `/questions` | `questions.create` |
|
||
| 8 | PUT | `/questions/:id` | `questions.update` |
|
||
| 9 | POST | `/question-sets` | `question_sets.create` |
|
||
| 10 | POST | `/question-sets/:setId/questions` | `question_set_items.add` |
|
||
| 11 | GET | `/question-sets/:setId/questions` | `question_set_items.list` |
|
||
| 12 | GET | `/question-sets/:setId/question-types` | `question_set_items.list` |
|
||
| 13 | PUT | `/question-sets/:setId/questions/:questionId/order` | `question_set_items.update_order` |
|
||
| 14 | DELETE | `/question-sets/:setId/questions/:questionId` | `question_set_items.remove` |
|
||
| 15 | POST | `/practices` | `practices.create` |
|
||
| 16 | GET | `/practices/:id` | `practices.get` |
|
||
| 17 | PUT | `/practices/:id` | `practices.update` |
|
||
| 18 | DELETE | `/practices/:id` | `practices.delete` |
|
||
| 19 | GET | `/lessons/:id/practices` | `practices.list` |
|
||
| 20 | GET | `/modules/:id/practices` | `practices.list` |
|
||
| 21 | GET | `/courses/:id/practices` | `practices.list` |
|
||
| 22 | GET | `/practices/:practiceId/questions` | `question_set_items.list` (see §14.3 note) |
|
||
|
||
---
|
||
|
||
## 19. QA checklist
|
||
|
||
- [ ] Parent course/module/lesson exists (`GET` returns 200)
|
||
- [ ] Definition includes `TABLE` (or other) slots used in payload
|
||
- [ ] Dynamic question created without `question_text` in request
|
||
- [ ] TABLE `value` has `columns` + `rows` aligned
|
||
- [ ] Question set `set_type` is `PRACTICE` and `owner_type` matches practice scope
|
||
- [ ] All questions added to set with correct `display_order`
|
||
- [ ] Practice `question_set_id` matches set id
|
||
- [ ] `parent_kind` / `parent_id` match intended scope
|
||
- [ ] `GET` list practices under parent shows new practice
|
||
- [ ] `GET /question-sets/:id/questions` shows `dynamic_payload`
|
||
- [ ] Publish: question `PUBLISHED`, set `PUBLISHED`, practice `publish_status: PUBLISHED` when going live
|
||
- [ ] `OPEN_LEARNER` sees unlocked content; `STUDENT` respects practice sequence on same owner scope
|
||
|
||
---
|
||
|
||
## Pitfalls
|
||
|
||
1. **Do not send `question_text`** on dynamic question create/update — use `QUESTION_TEXT` (or `INSTRUCTION`) in `dynamic_payload.stimulus`.
|
||
2. **`owner_type` on question set** should match **`parent_kind` on practice** for consistent gating and admin filters.
|
||
3. **One practice → one `question_set_id`** in normal authoring; add multiple questions to the **same set**, not multiple sets per practice.
|
||
4. **TABLE content is per question** — the definition only declares the slot; each `POST /questions` supplies its own `columns` / `rows`.
|
||
5. **`GET /practices/:practiceId/questions`** — use `question_set_id` from practice when calling set-based list endpoints (see §14.3).
|
||
6. **Dynamic scoring runtime** — verify learner app supports your definition’s response shapes before release.
|
||
|
||
---
|
||
|
||
*Last aligned with backend: LMS practices (`COURSE`/`MODULE`/`LESSON`), dynamic questions, `PDF_ATTACHMENT`, `TABLE` stimulus, practice `publish_status`, DYNAMIC `question_text` API omission.*
|