Document schema slot label assignment and usage in the admin integration guide. Add a step-by-step dynamic practice creation guide for course/module/lesson scopes. Remove CHART and FLOW_CHART from the stimulus component catalog. Co-authored-by: Cursor <cursoragent@cursor.com>
27 KiB
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
- Architecture
- Prerequisites and permissions
- Standard response envelopes
- ID map (what to store after each step)
- Publishing model
- End-to-end flow overview
- Step 0 — Resolve LMS parent IDs
- Step 1 — (Optional) Upload media
- Step 2 — Create or select a question type definition
- Step 3 — Create dynamic question(s)
- Step 4 — Create PRACTICE question set
- Step 5 — Add questions to the set
- Step 6 — Create practice shell (course / module / lesson)
- Step 7 — Verify and inspect
- Optional — Reorder, update, publish
- Worked example — Lesson practice with TABLE + OPTION
- Scope-specific quick reference
- API index
- 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
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]
- Definition — template (which stimulus/response slots exist).
- Questions — instances with
dynamic_payload(real TABLE rows, OPTION choices, PDF URLs, etc.). - Question set — ordered list of question IDs (
set_type: "PRACTICE"). - Practice — links
question_set_idto 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
{
"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
{
"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_CLIP, 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:
{
"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:
{
"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:
{
"stimulus_component_kinds": ["QUESTION_TEXT", "TABLE", "IMAGE"],
"response_component_kinds": ["OPTION", "ANSWER_TIMER"]
}
Success 200 — data:
{
"valid": true
}
9.3 Create definition (example with TABLE)
| POST | /questions/type-definitions |
| Permission | questions.create |
Request:
{
"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):
{
"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_typemust be"DYNAMIC".question_type_definition_idis required.dynamic_payloadis required.- Do not send top-level
question_text(prompt lives in stimulus). - Do not send legacy
options/short_answersfor pure dynamic MCQ (useOPTIONin payload).
Request (TABLE + OPTION example):
{
"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:
{
"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:
{
"message": "Invalid dynamic_payload",
"error": "dynamic_payload.stimulus: required element id \"data_table\" is missing"
}
{
"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:
{
"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:
{
"title": "Module 3 — Review set",
"set_type": "PRACTICE",
"owner_type": "MODULE",
"owner_id": 3,
"status": "PUBLISHED",
"shuffle_questions": false
}
Request — course scope:
{
"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:
{
"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):
{
"question_id": 1001,
"display_order": 1
}
Request (second question):
{
"question_id": 1002,
"display_order": 2
}
| Field | Type | Required |
|---|---|---|
question_id |
int64 |
Yes |
display_order |
int32 |
No |
Success 201 — data:
{
"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:
{
"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:
{
"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:
{
"parent_kind": "MODULE",
"parent_id": 3,
"title": "Module 3 review",
"question_set_id": 55,
"publish_status": "PUBLISHED"
}
Request — course:
{
"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):
{
"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:
{
"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):
{
"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:
{
"display_order": 2
}
Success 200:
{
"message": "Question order updated successfully",
"success": true,
"status_code": 200
}
Publish practice shell
| PUT | /practices/:id |
| Permission | practices.update |
Request:
{
"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
// 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
{ "owner_type": "MODULE", "owner_id": <module_id> }
{ "parent_kind": "MODULE", "parent_id": <module_id> }
Verify: GET /modules/<module_id>/practices
Course practice
{ "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 (
GETreturns 200) - Definition includes
TABLE(or other) slots used in payload - Dynamic question created without
question_textin request - TABLE
valuehascolumns+rowsaligned - Question set
set_typeisPRACTICEandowner_typematches practice scope - All questions added to set with correct
display_order - Practice
question_set_idmatches set id parent_kind/parent_idmatch intended scopeGETlist practices under parent shows new practiceGET /question-sets/:id/questionsshowsdynamic_payload- Publish: question
PUBLISHED, setPUBLISHED, practicepublish_status: PUBLISHEDwhen going live OPEN_LEARNERsees unlocked content;STUDENTrespects practice sequence on same owner scope
Pitfalls
- Do not send
question_texton dynamic question create/update — useQUESTION_TEXT(orINSTRUCTION) indynamic_payload.stimulus. owner_typeon question set should matchparent_kindon practice for consistent gating and admin filters.- One practice → one
question_set_idin normal authoring; add multiple questions to the same set, not multiple sets per practice. - TABLE content is per question — the definition only declares the slot; each
POST /questionssupplies its owncolumns/rows. GET /practices/:practiceId/questions— usequestion_set_idfrom practice when calling set-based list endpoints (see §14.3).- 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.