# 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 ` **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_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`:** ```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": , "set_type": "PRACTICE" } // Practice { "parent_kind": "LESSON", "parent_id": , "question_set_id": } ``` **Verify:** `GET /lessons//practices` ### Module practice ```json { "owner_type": "MODULE", "owner_id": } { "parent_kind": "MODULE", "parent_id": } ``` **Verify:** `GET /modules//practices` ### Course practice ```json { "owner_type": "COURSE", "owner_id": } { "parent_kind": "COURSE", "parent_id": } ``` **Verify:** `GET /courses//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.*