{
 "openapi": "3.0.3",
 "info": {
  "title": "Yoshuko API",
  "version": "1.0.0",
  "description": "Yoshuko LMS REST API \u2014 all endpoints under /api/v1/"
 },
 "paths": {
  "/api/v1/affiliates/": {
   "get": {
    "operationId": "affiliates_retrieve",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/{id}/": {
   "patch": {
    "operationId": "affiliates_partial_update",
    "description": "PATCH \u2014 approve, decline or suspend one affiliate.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/claim/": {
   "post": {
    "operationId": "affiliates_claim_create",
    "description": "POST /api/v1/affiliates/claim/ \u2014 bind this browser's referral cookie to me.\n\nCalled once per session by the sales page. It exists for the case the cookie alone\ncannot survive: a learner who already had an account, clicked a referral link, and\nbuys weeks later after the cookie is gone. Binding on the click makes the\nattribution durable from that moment.\n\n204 whether or not anything was bound. Reporting which referral a visitor carries\nwould let any signed-in user enumerate live affiliate slugs.",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/commissions/": {
   "get": {
    "operationId": "affiliates_commissions_retrieve",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/commissions/{id}/settle/": {
   "patch": {
    "operationId": "affiliates_commissions_settle_partial_update",
    "description": "PATCH /api/v1/affiliates/commissions/<pk>/settle/ \u2014 the creator's own mark.\n\n``{\"settled\": true|false}``. It records that the creator has paid this commission\nto the affiliate. **No money moves and no payment API is called** \u2014 the platform is\nnot a party to that payment and must not present itself as one. This is bookkeeping\non the creator's own ledger, and it is what keeps that ledger usable once it is\nhundreds of rows long.\n\nReversible rather than confirmed: a mistake costs one more press, and a confirmation\ndialog on a cheap reversible action is one people learn to dismiss unread.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/course-rules/": {
   "get": {
    "operationId": "affiliates_course_rules_retrieve",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "put": {
    "operationId": "affiliates_course_rules_update",
    "description": "Upsert one course's override. PUT because the key is the course, not a row id.",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "affiliates_course_rules_destroy",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/courses/{id}/": {
   "get": {
    "operationId": "affiliates_courses_retrieve",
    "description": "GET /api/v1/affiliates/courses/<pk>/ \u2014 one course's referral performance.\n\nIts own endpoint rather than a filter on the org lists, because the per-course\nscreen needs four aggregates and the org list would have to ship every commission\nrow for the browser to add them up \u2014 which is the shape that turns a busy program's\nscreen into a slow one.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/invites/": {
   "get": {
    "operationId": "affiliates_invites_retrieve",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "affiliates_invites_create",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/invites/{id}/": {
   "patch": {
    "operationId": "affiliates_invites_partial_update",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "affiliates_invites_destroy",
    "description": "Deactivate, never delete.\n\nThe code is the only record of which recruitment push an affiliate came from\n(``Affiliate.joined_via``), so a hard delete would erase the answer to \"where\ndid our partners come from?\" for everyone who ever used it.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/join/": {
   "post": {
    "operationId": "affiliates_join_create",
    "description": "POST /api/v1/affiliates/join/ \u2014 redeem an invite code as the signed-in user.",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/links/": {
   "get": {
    "operationId": "affiliates_links_retrieve",
    "description": "Every link in the org, so a creator can see what is being distributed.",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "affiliates_links_create",
    "description": "A creator minting a link ON BEHALF OF one of their affiliates.",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/me/": {
   "get": {
    "operationId": "affiliates_me_retrieve",
    "description": "GET /api/v1/affiliates/me/ \u2014 every program this user is an affiliate of.\n\nNot scoped to the current tenant: an affiliate can promote several creators, and\nthe app shell needs to know whether to show the Referrals entry at all before any\norg has been chosen.",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/me/commissions/": {
   "get": {
    "operationId": "affiliates_me_commissions_retrieve",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/me/dashboard/": {
   "get": {
    "operationId": "affiliates_me_dashboard_retrieve",
    "description": "GET /api/v1/affiliates/me/dashboard/ \u2014 one affiliate's own numbers.",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/me/links/": {
   "get": {
    "operationId": "affiliates_me_links_retrieve",
    "description": "The affiliate's own links. They may add course-specific ones themselves.",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "affiliates_me_links_create",
    "description": "The affiliate's own links. They may add course-specific ones themselves.",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/overview/": {
   "get": {
    "operationId": "affiliates_overview_retrieve",
    "description": "GET /api/v1/affiliates/overview/ \u2014 the numbers on the Share screen's first tab.",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/affiliates/program/": {
   "get": {
    "operationId": "affiliates_program_retrieve",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "patch": {
    "operationId": "affiliates_program_partial_update",
    "tags": [
     "affiliates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/agent/courses/": {
   "get": {
    "operationId": "agent_courses_retrieve",
    "description": "Requires `courses: view`.",
    "summary": "List courses",
    "tags": [
     "Agent: courses"
    ],
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     }
    }
   },
   "post": {
    "operationId": "agent_courses_create",
    "description": "Requires `courses: edit`. Subject to the plan's course limit, exactly as in the creator studio: over it, the answer is 403 with `code: course_limit`.",
    "summary": "Create a course",
    "tags": [
     "Agent: courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "201": {
      "description": "No response body"
     },
     "400": {
      "description": "Validation failed; the body names each field."
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     }
    }
   }
  },
  "/api/v1/agent/courses/{course_pk}/chapters/": {
   "get": {
    "operationId": "agent_courses_chapters_retrieve",
    "description": "Requires `chapters: view`.",
    "summary": "List chapters",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: chapters"
    ],
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     }
    }
   },
   "post": {
    "operationId": "agent_courses_chapters_create",
    "description": "Requires `chapters: edit`.",
    "summary": "Create a chapter",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: chapters"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "201": {
      "description": "No response body"
     },
     "400": {
      "description": "Validation failed; the body names each field."
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     }
    }
   }
  },
  "/api/v1/agent/courses/{course_pk}/chapters/{chapter_pk}/lessons/": {
   "get": {
    "operationId": "agent_courses_chapters_lessons_retrieve",
    "description": "Requires `lessons: view`.",
    "summary": "List lessons",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: lessons"
    ],
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     }
    }
   },
   "post": {
    "operationId": "agent_courses_chapters_lessons_create",
    "description": "Requires `lessons: edit`. `content_type` selects the lesson kind (`quiz`, `lesson`, `video`, `document`, `assignment`, `embed`); `content` is validated by the same rules the creator studio uses.",
    "summary": "Create a lesson",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: lessons"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/AgentLesson"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/AgentLesson"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/AgentLesson"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "201": {
      "description": "No response body"
     },
     "400": {
      "description": "Validation failed; the body names each field."
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     }
    }
   }
  },
  "/api/v1/agent/courses/{course_pk}/chapters/{chapter_pk}/lessons/{id}/": {
   "get": {
    "operationId": "agent_courses_chapters_lessons_retrieve_2",
    "description": "Requires `lessons: view`.",
    "summary": "Get a lesson",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: lessons"
    ],
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     },
     "404": {
      "description": "No such object in this key's organisation."
     }
    }
   },
   "patch": {
    "operationId": "agent_courses_chapters_lessons_partial_update",
    "description": "Requires `lessons: edit`. **Optimistic concurrency:** send back the `updated_at` value you last read. If the lesson changed since (a person or another agent edited it), the request is refused with 409 `stale_lesson` and the current lesson in the body \u2014 re-apply your change to that and retry. Nothing is silently overwritten.",
    "summary": "Update a lesson",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: lessons"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PatchedAgentLesson"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/PatchedAgentLesson"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/PatchedAgentLesson"
       }
      }
     }
    },
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     },
     "400": {
      "description": "Validation failed, or `updated_at` was not sent."
     },
     "409": {
      "description": "`stale_lesson`: changed since you read it; the body carries the current lesson."
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     },
     "404": {
      "description": "No such object in this key's organisation."
     }
    }
   },
   "delete": {
    "operationId": "agent_courses_chapters_lessons_destroy",
    "description": "Requires `lessons: edit`.",
    "summary": "Delete a lesson",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: lessons"
    ],
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     },
     "404": {
      "description": "No such object in this key's organisation."
     }
    }
   }
  },
  "/api/v1/agent/courses/{course_pk}/chapters/{id}/": {
   "get": {
    "operationId": "agent_courses_chapters_retrieve_2",
    "description": "Requires `chapters: view`.",
    "summary": "Get a chapter",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: chapters"
    ],
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     },
     "404": {
      "description": "No such object in this key's organisation."
     }
    }
   },
   "put": {
    "operationId": "agent_courses_chapters_update",
    "description": "Requires `chapters: edit`.",
    "summary": "Replace a chapter",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: chapters"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     },
     "400": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     },
     "404": {
      "description": "No such object in this key's organisation."
     }
    }
   },
   "patch": {
    "operationId": "agent_courses_chapters_partial_update",
    "description": "Requires `chapters: edit`.",
    "summary": "Update a chapter",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: chapters"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PatchedChapter"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/PatchedChapter"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/PatchedChapter"
       }
      }
     }
    },
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     },
     "400": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     },
     "404": {
      "description": "No such object in this key's organisation."
     }
    }
   },
   "delete": {
    "operationId": "agent_courses_chapters_destroy",
    "description": "Requires `chapters: edit`.",
    "summary": "Delete a chapter",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: chapters"
    ],
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     },
     "404": {
      "description": "No such object in this key's organisation."
     }
    }
   }
  },
  "/api/v1/agent/courses/{id}/": {
   "get": {
    "operationId": "agent_courses_retrieve_2",
    "description": "Requires `courses: view`.",
    "summary": "Get a course",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: courses"
    ],
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     },
     "404": {
      "description": "No such object in this key's organisation."
     }
    }
   },
   "put": {
    "operationId": "agent_courses_update",
    "description": "Requires `courses: edit`.",
    "summary": "Replace a course",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     },
     "400": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     },
     "404": {
      "description": "No such object in this key's organisation."
     }
    }
   },
   "patch": {
    "operationId": "agent_courses_partial_update",
    "description": "Requires `courses: edit`.",
    "summary": "Update a course",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PatchedCourse"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/PatchedCourse"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/PatchedCourse"
       }
      }
     }
    },
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     },
     "400": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     },
     "404": {
      "description": "No such object in this key's organisation."
     }
    }
   },
   "delete": {
    "operationId": "agent_courses_destroy",
    "description": "Requires `courses: edit`.",
    "summary": "Delete a course",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "Agent: courses"
    ],
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     },
     "404": {
      "description": "No such object in this key's organisation."
     }
    }
   }
  },
  "/api/v1/agent/org-settings/": {
   "get": {
    "operationId": "agent_org_settings_retrieve",
    "description": "Requires `org_settings: view`.",
    "summary": "Get organisation settings",
    "tags": [
     "Agent: organisation settings"
    ],
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     }
    }
   },
   "patch": {
    "operationId": "agent_org_settings_partial_update",
    "description": "Requires `org_settings: edit`. Only the fields in the response are writable; anything else is ignored.",
    "summary": "Update organisation settings",
    "tags": [
     "Agent: organisation settings"
    ],
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     },
     "400": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     }
    }
   }
  },
  "/api/v1/agent/whoami/": {
   "get": {
    "operationId": "agent_whoami_retrieve",
    "description": "Returns the organisation this key belongs to, the key's name, and its effective scope (`none`/`view`/`edit`) for every resource. Call this first: it proves the key works and tells you what you may do.",
    "summary": "Who am I",
    "tags": [
     "Agent: key"
    ],
    "security": [
     {
      "ApiKeyAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     },
     "401": {
      "description": "Missing, unknown, revoked or expired key; or the organisation's plan is inactive (`subscription_inactive`) or unverified (`org_unverified`)."
     },
     "403": {
      "description": "The key's scope for this resource does not allow the action."
     },
     "429": {
      "description": "Rate limited (300 requests/hour per key). Retry after the `Retry-After` header."
     }
    }
   }
  },
  "/api/v1/analytics/courses/": {
   "get": {
    "operationId": "analytics_courses_retrieve",
    "description": "GET /api/v1/analytics/courses/\n\nPer-course breakdown for the authenticated creator.\nIncludes courses with zero enrollments. Ordered by active_enrollments desc.",
    "tags": [
     "analytics"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/analytics/overview/": {
   "get": {
    "operationId": "analytics_overview_retrieve",
    "description": "GET /api/v1/analytics/overview/\n\nOrg-wide aggregate stats for the authenticated creator.",
    "tags": [
     "analytics"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/analytics/purchases/": {
   "get": {
    "operationId": "analytics_purchases_list",
    "description": "GET /api/v1/analytics/purchases/\n\nThe org's revenue ledger, newest first \u2014 the screen every creator payment email\nlinks to. `OverviewView` answers \"how much\"; this answers \"which sales\", which is\nthe question somebody holding a summary email actually has.\n\nPAGINATED, deliberately. It is the one list in this app that grows without bound\nwith success, and an unpaginated ledger either silently stops at DRF's page size\nor serialises a year of transactions into one response.",
    "parameters": [
     {
      "name": "ordering",
      "required": false,
      "in": "query",
      "description": "Which field to use when ordering the results.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "page",
      "required": false,
      "in": "query",
      "description": "A page number within the paginated result set.",
      "schema": {
       "type": "integer"
      }
     }
    ],
    "tags": [
     "analytics"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PaginatedPurchaseRowList"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/analytics/trends/": {
   "get": {
    "operationId": "analytics_trends_retrieve",
    "description": "GET /api/v1/analytics/trends/?days=30\n\nOrg-wide daily trend, summed across every course, from CourseAnalyticsDaily \u2014\nthe data the revenue dashboard's chart reads. There is no live-query equivalent:\nper-day history is exactly what the summary table exists to make cheap.",
    "tags": [
     "analytics"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/answer-sheets/jobs/": {
   "get": {
    "operationId": "answer_sheets_jobs_retrieve",
    "description": "GET /api/v1/answer-sheets/jobs/?ids=a,b \u2014 where this creator's queued sheets stand.\n``?open=1`` lists every job of theirs from the last few hours instead, so a scanner\nreopened after a reload shows what is still waiting and what has finished.",
    "tags": [
     "answer-sheets"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/answer-sheets/layout/": {
   "get": {
    "operationId": "answer_sheets_layout_retrieve",
    "description": "GET /api/v1/answer-sheets/layout/[?code=0101010101] \u2014 sheet geometry for the device\nreader: the markers and code cells, and with ``code`` every bubble of that layout.\nConstant per layout version, so it is cached; nothing in it is per-organisation.",
    "tags": [
     "answer-sheets"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/answer-sheets/my-numbers/": {
   "get": {
    "operationId": "answer_sheets_my_numbers_retrieve",
    "description": "GET /api/v1/answer-sheets/my-numbers/ \u2014 the Student ID to bubble, per organisation.\n\nA learner may learn with several organisations and has one number in each. Which\norganisations is \"where am I enrolled\", a question about the USER rather than the\nHost's tenant, so it is read unscoped; each number is minted inside its own org.",
    "tags": [
     "answer-sheets"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/answer-sheets/scan/": {
   "post": {
    "operationId": "answer_sheets_scan_create",
    "description": "POST /api/v1/answer-sheets/scan/ \u2014 accept one sheet photo into the grading queue.\n\n**Nothing is read here.** The photo is stored, a ``ScanJob`` queued, and the answer is\n202 with the job's place in the queue; a Celery worker grades it under a global cap\n(``limiter``). Reading in the request held a web worker per frame \u2014 prod runs three.\n\n- ``client_id`` (a uuid the device mints) makes a resend the same job.\n- A full queue is **429 + Retry-After**: the device keeps the photo and resends.\n- The device sends a shrunken JPEG; anything over ``ANSWER_SHEET_UPLOAD_MAX_BYTES``\n  or not an image is refused before it is stored.\n\n``throttle_classes`` REPLACES the project defaults: under the shared ``user`` bucket a\ncreator scanning a class would lock themselves out of the whole API.",
    "tags": [
     "answer-sheets"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/answer-sheets/scan-marks/": {
   "post": {
    "operationId": "answer_sheets_scan_marks_create",
    "description": "POST /api/v1/answer-sheets/scan-marks/ \u2014 grade what the DEVICE read.\n\nThe on-device engine (``ANSWER_SHEET_ENGINE`` ``client`` or ``auto``) reads the sheet\nin a Web Worker and sends only the marks, so no photo leaves the device and the\nserver does no image work: the same ``grade_reading`` the queue uses, minus OpenCV.\nSynchronous, because it is a few database writes.",
    "tags": [
     "answer-sheets"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/answer-sheets/scans/": {
   "get": {
    "operationId": "answer_sheets_scans_retrieve",
    "description": "GET /api/v1/answer-sheets/scans/?since=<iso> \u2014 this creator's own recent scans, so a\nreloaded scanner page shows the session it was in the middle of.",
    "tags": [
     "answer-sheets"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/attendance/self-enrol/": {
   "get": {
    "operationId": "attendance_self_enrol_retrieve",
    "description": "GET (preview, does NOT consume) / POST (save, consumes) \u2014\n/api/v1/attendance/self-enrol/. Token-authenticated only; never a JWT/Django\nsession, because the page it served was opened by an anonymous browser tab that\nheld nothing else. That page and the route minting its token are retired (QR\nbadges replaced face enrolment); this endpoint stays with the face backend.",
    "tags": [
     "attendance"
    ],
    "security": [
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "attendance_self_enrol_create",
    "description": "GET (preview, does NOT consume) / POST (save, consumes) \u2014\n/api/v1/attendance/self-enrol/. Token-authenticated only; never a JWT/Django\nsession, because the page it served was opened by an anonymous browser tab that\nheld nothing else. That page and the route minting its token are retired (QR\nbadges replaced face enrolment); this endpoint stays with the face backend.",
    "tags": [
     "attendance"
    ],
    "security": [
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/audit-log/": {
   "get": {
    "operationId": "audit_log_list",
    "description": "GET /api/v1/audit-log/\n\n`IsOrgMember` is the gate, and that EXCLUDES affiliates: `PRIVILEGED_ROLES` is an\nallowlist of creator+admin, and these rows name learners, courses and actions an\naffiliate has no business reading about somebody else's organisation.",
    "parameters": [
     {
      "name": "ordering",
      "required": false,
      "in": "query",
      "description": "Which field to use when ordering the results.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "page",
      "required": false,
      "in": "query",
      "description": "A page number within the paginated result set.",
      "schema": {
       "type": "integer"
      }
     }
    ],
    "tags": [
     "audit-log"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PaginatedAuditLogEntryList"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/auth/login/": {
   "post": {
    "operationId": "auth_login_create",
    "description": "POST /api/v1/auth/login/\n\nAuthenticates credentials. Requires email_verified_at to be set.\nReturns a JWT pair on success.",
    "tags": [
     "auth"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/auth/login/mfa/": {
   "post": {
    "operationId": "auth_login_mfa_create",
    "description": "POST /api/v1/auth/login/mfa/ \u2014 exchange ticket + code for a JWT pair.",
    "tags": [
     "auth"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/auth/login/mfa/resend/": {
   "post": {
    "operationId": "auth_login_mfa_resend_create",
    "description": "POST /api/v1/auth/login/mfa/resend/ \u2014 a new code, and a new ticket.",
    "tags": [
     "auth"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/auth/login/totp/": {
   "post": {
    "operationId": "auth_login_totp_create",
    "description": "POST /api/v1/auth/login/totp/ \u2014 exchange ticket + authenticator (or\nbackup) code for a JWT pair. The counterpart to `LoginMfaView` for a user\nwho set up TOTP; reuses the SAME ticket kind \u2014 a ticket proves only that\nthe password was correct, and which second-factor endpoint redeems it is\nwhat decides which check runs.",
    "tags": [
     "auth"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/auth/oauth-login/": {
   "post": {
    "operationId": "auth_oauth_login_create",
    "description": "POST /api/v1/auth/oauth-login/\n\nTakes the redeemed identity token int_social_auth's callback view stashed\nin session (popped by the frontend's greeting-state JS the same way the\nsignup pages do) and either logs the user in, links the identity to an\nexisting account, or reports that no account matches it \u2014 the last of\nwhich the frontend distinguishes from an ordinary login failure so it can\nroute into the signup flow's greeting state, per the plan's own rule:\nnever silently auto-create an account from the login page.\n\n**Account-linking policy** (the confirmed decision, plan \u00a7\"Login\nintegration\"): Google auto-links a login to an existing account on a\nVERIFIED matching email, because Google's own `email_verified` claim is\nalready a strong assertion. Apple does not auto-link \u2014 its email can be a\nprivate-relay address, so linking requires the user to confirm they hold\nthe existing account's password once.",
    "tags": [
     "auth"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/auth/password-change/": {
   "post": {
    "operationId": "auth_password_change_create",
    "description": "POST /api/v1/auth/password-change/\n\nChange your own password, proving you hold the current one. One of the two\npaths an account owing a forced change may reach \u2014 see\napps/accounts/authentication.py.",
    "tags": [
     "auth"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/auth/password-reset/": {
   "post": {
    "operationId": "auth_password_reset_create",
    "description": "POST /api/v1/auth/password-reset/\n\nSends a password reset email. Always returns 200 regardless of whether\nthe email exists to prevent user enumeration (\u00a77.2).",
    "tags": [
     "auth"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/auth/password-reset-confirm/": {
   "post": {
    "operationId": "auth_password_reset_confirm_create",
    "description": "POST /api/v1/auth/password-reset-confirm/\n\nTakes a code per required channel and sets the new password. The uid+token\nLINK shape is gone \u2014 see apps.accounts.recovery.",
    "tags": [
     "auth"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/auth/password-reset/card/": {
   "post": {
    "operationId": "auth_password_reset_card_create",
    "description": "POST /api/v1/auth/password-reset/card/\n\nStripe's return leg. Takes the Checkout Session id, verifies with Stripe that\nthe card just entered is the card on file, and mints the single-use proof the\nconfirm endpoint will accept.\n\n**Arriving here proves nothing.** The session id is a value the browser was\nhanded; every check that matters is `card_recovery.complete_reauth`'s.",
    "tags": [
     "auth"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/auth/phone/send/": {
   "post": {
    "operationId": "auth_phone_send_create",
    "description": "POST /api/v1/auth/phone/send/ \u2014 text a code to a number nobody owns yet.",
    "tags": [
     "auth"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/auth/phone/verify/": {
   "post": {
    "operationId": "auth_phone_verify_create",
    "description": "POST /api/v1/auth/phone/verify/ \u2014 check the code, hand back a receipt.",
    "tags": [
     "auth"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/auth/resend-verification/": {
   "post": {
    "operationId": "auth_resend_verification_create",
    "description": "POST /api/v1/auth/resend-verification/\n\nResends the email verification code. Always returns 200 regardless of\nwhether the address exists to prevent user enumeration.\n\nThat 200 is load-bearing beyond this view: it is the remedy the duplicate-email\nrefusal names on all three signup doors (`email_identity`), so an answer that\ndistinguished a verified address, an unverified one and an address with no\naccount would move the oracle here rather than remove it. Pinned by\n`test_email_identity_parity.py::TestTheRemedyIsNotASecondOracle`.",
    "tags": [
     "auth"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/auth/signup/": {
   "post": {
    "operationId": "auth_signup_create",
    "description": "POST /api/v1/auth/signup/\n\nCreates a new user and sends an email verification link.\nAccepts an optional join_code (query param or body) to store for use at\nemail verification time.\nNo token is issued until the email is verified.",
    "tags": [
     "auth"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/auth/token/refresh/": {
   "post": {
    "operationId": "auth_token_refresh_create",
    "description": "Takes a refresh type JSON web token and returns an access type JSON web\ntoken if the refresh token is valid.",
    "tags": [
     "auth"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/TokenRefresh"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/TokenRefresh"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/TokenRefresh"
       }
      }
     },
     "required": true
    },
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TokenRefresh"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/auth/verify-email/": {
   "post": {
    "operationId": "auth_verify_email_create",
    "description": "POST /api/v1/auth/verify-email/\n\nVerifies an email address using the uid+token from the verification email.\nProvisions the user's org (join code, subdomain, or auto-create).\nReturns a JWT pair on success so the user is logged in immediately.",
    "tags": [
     "auth"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/billing/plans/": {
   "get": {
    "operationId": "billing_plans_list",
    "description": "The plans currently on sale, in pricing-page order.\n\nRetired plans are excluded: they cannot be bought, and listing them would\ninvite a client to offer one. A subscriber ON a retired plan still sees it,\nvia the `plan` object nested in their own subscription.",
    "parameters": [
     {
      "name": "ordering",
      "required": false,
      "in": "query",
      "description": "Which field to use when ordering the results.",
      "schema": {
       "type": "string"
      }
     }
    ],
    "tags": [
     "billing"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/PlatformPlan"
         }
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/billing/stripe-connect/": {
   "get": {
    "operationId": "billing_stripe_connect_retrieve",
    "tags": [
     "billing"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "billing_stripe_connect_create",
    "tags": [
     "billing"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/billing/stripe-connect/refresh/": {
   "post": {
    "operationId": "billing_stripe_connect_refresh_create",
    "tags": [
     "billing"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/billing/subscription/": {
   "get": {
    "operationId": "billing_subscription_retrieve",
    "tags": [
     "billing"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "billing_subscription_create",
    "description": "Start a paid subscription \u2014 returns a Stripe Checkout URL.\n\nThis used to be an `update_or_create` that took no payment and contacted\nStripe not at all, so any org member could award themselves the top tier\nfor free. A plan change is now a purchase: the caller gets a checkout_url\nand the subscription is only recorded once Stripe confirms payment (see\napps.billing.subscriptions.fulfil_subscription_session).",
    "tags": [
     "billing"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/catalog-subscriptions/checkout/": {
   "post": {
    "operationId": "catalog_subscriptions_checkout_create",
    "description": "`POST /api/v1/catalog-subscriptions/checkout/ {plan_id, return_to?}` -> Checkout URL.",
    "tags": [
     "catalog-subscriptions"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/catalog-subscriptions/orgs/{org_slug}/plans/": {
   "get": {
    "operationId": "catalog_subscriptions_orgs_plans_retrieve",
    "description": "`GET /api/v1/catalog-subscriptions/orgs/<slug>/plans/` \u2014 PUBLIC: what a creator offers.",
    "parameters": [
     {
      "in": "path",
      "name": "org_slug",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "catalog-subscriptions"
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/certificates/{certificate_id}/revoke/": {
   "post": {
    "operationId": "certificates_revoke_create",
    "description": "POST /api/v1/certificates/<id>/revoke/ \u2014 withdraw, never delete.\n\n\"This credential was withdrawn\" and \"we have no record of it\" are different answers,\nand a verifier is asking exactly that question.",
    "parameters": [
     {
      "in": "path",
      "name": "certificate_id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "certificates"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/cookie-consent/": {
   "get": {
    "operationId": "cookie_consent_retrieve",
    "description": "GET  /api/v1/cookie-consent/ \u2014 return latest consent for this user/anonymous_id.\nPOST /api/v1/cookie-consent/ \u2014 record consent.\n\nWorks for both authenticated users and anonymous visitors. Anonymous visitors\nidentify via anonymous_id in the request body (generated by the JS banner and\nstored in localStorage).",
    "tags": [
     "cookie-consent"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "cookie_consent_create",
    "description": "GET  /api/v1/cookie-consent/ \u2014 return latest consent for this user/anonymous_id.\nPOST /api/v1/cookie-consent/ \u2014 record consent.\n\nWorks for both authenticated users and anonymous visitors. Anonymous visitors\nidentify via anonymous_id in the request body (generated by the JS banner and\nstored in localStorage).",
    "tags": [
     "cookie-consent"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/course-imports/": {
   "get": {
    "operationId": "course_imports_retrieve",
    "description": "The import this course is currently waiting on, or ``{\"import\": null}``.\n\nTHE RE-ENTRY. The studio row's \"Choose what to import\" link and its progress\nlink both land on the import screen, and without this the panel opens on a\nblank upload form \u2014 so the creator uploads the file a second time.\n\nAnswered by the API rather than rendered into the page, deliberately.\n`CourseImportPageView` is a creator screen: those are **anonymous to Django**\n(auth is a JWT the browser holds) and they issue no database query at all.\nResolving this in `get_context_data` put the first query on that page, which\n`test_creator_web_views.py` caught immediately \u2014 it renders every creator page\nwith no `db` fixture, precisely because none of them should need one.",
    "tags": [
     "course-imports"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "course_imports_create",
    "description": "```\nPOST /api/v1/course-imports/          begin an upload  (mints the course)\nPOST /api/v1/courses/<pk>/imports/    begin an upload  (into this course)\n```\n\n**This request carries no file.** It carries a name, a type and a size \u2014 three\nclaims about a file the browser is about to PUT straight to R2 \u2014 and it answers\nwith one signed URL per part. That is the whole fix: the previous version of this\nview read the archive out of the request body and the gunicorn worker was killed\nat 180 seconds still receiving it, with `workers = 1`, taking the app down with it.\n\nThe route stays NOT under a course for the create flow. A creator on the New\nCourse screen has not made one, and the placeholder this mints is an artefact of\nthe import rather than a course they created \u2014 the URL should not imply otherwise.",
    "tags": [
     "course-imports"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/course-imports/{id}/": {
   "get": {
    "operationId": "course_imports_retrieve_2",
    "description": "`GET`/`POST /api/v1/course-imports/<pk>/[commit/]`",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "course-imports"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "course_imports_destroy",
    "description": "The creator cancelled, or backed out of the New Course screen.\n\nTakes the placeholder course with it \u2014 `import_service.abandon` only removes\none this import minted and only while it is still empty, so a cancel can\nnever delete a course the creator already had or has started authoring in.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "course-imports"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/course-imports/{id}/commit/": {
   "get": {
    "operationId": "course_imports_commit_retrieve",
    "description": "`POST /api/v1/course-imports/<pk>/commit/` \u2014 materialise the choice.\n\nA separate class from the detail view, and therefore a separate ROUTE, so that\ncommitting is something a request has to ask for by name. Registering one view at\nboth paths would make `POST /course-imports/<pk>/` import a course too \u2014 an action\nwith no undo, reachable by a URL that reads like a fetch.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "course-imports"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "course_imports_commit_create",
    "description": "`POST /api/v1/course-imports/<pk>/commit/` \u2014 materialise the choice.\n\nA separate class from the detail view, and therefore a separate ROUTE, so that\ncommitting is something a request has to ask for by name. Registering one view at\nboth paths would make `POST /course-imports/<pk>/` import a course too \u2014 an action\nwith no undo, reachable by a URL that reads like a fetch.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "course-imports"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "course_imports_commit_destroy",
    "description": "The creator cancelled, or backed out of the New Course screen.\n\nTakes the placeholder course with it \u2014 `import_service.abandon` only removes\none this import minted and only while it is still empty, so a cancel can\nnever delete a course the creator already had or has started authoring in.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "course-imports"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/course-imports/{id}/uploaded/": {
   "get": {
    "operationId": "course_imports_uploaded_retrieve",
    "description": "`POST /api/v1/course-imports/<pk>/uploaded/` \u2014 the browser finished PUTting.\n\nIts own route for the reason `commit` has its own route: this assembles a\nmultipart upload and queues a job, and a POST to `\u2026/<pk>/` reading like a fetch\nmust not do either.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "course-imports"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "course_imports_uploaded_create",
    "description": "`POST /api/v1/course-imports/<pk>/uploaded/` \u2014 the browser finished PUTting.\n\nIts own route for the reason `commit` has its own route: this assembles a\nmultipart upload and queues a job, and a POST to `\u2026/<pk>/` reading like a fetch\nmust not do either.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "course-imports"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "course_imports_uploaded_destroy",
    "description": "The creator cancelled, or backed out of the New Course screen.\n\nTakes the placeholder course with it \u2014 `import_service.abandon` only removes\none this import minted and only while it is still empty, so a cancel can\nnever delete a course the creator already had or has started authoring in.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "course-imports"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/": {
   "get": {
    "operationId": "courses_list",
    "description": "Supplies the per-lesson access verdicts a learner-visible read needs.\n\nThe map is put in the serializer context *only* for a non-privileged reader. Its\nabsence is what tells ``LessonSerializer`` it is serializing for an author, so a\ncreator is never gated by the schedule they are in the middle of writing.",
    "parameters": [
     {
      "name": "ordering",
      "required": false,
      "in": "query",
      "description": "Which field to use when ordering the results.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "page",
      "required": false,
      "in": "query",
      "description": "A page number within the paginated result set.",
      "schema": {
       "type": "integer"
      }
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PaginatedCourseList"
        }
       }
      },
      "description": ""
     }
    }
   },
   "post": {
    "operationId": "courses_create",
    "description": "Supplies the per-lesson access verdicts a learner-visible read needs.\n\nThe map is put in the serializer context *only* for a non-privileged reader. Its\nabsence is what tells ``LessonSerializer`` it is serializing for an author, so a\ncreator is never gated by the schedule they are in the middle of writing.",
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "201": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Course"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/answer-sheets/{lesson_pk}/options/": {
   "get": {
    "operationId": "courses_answer_sheets_options_retrieve",
    "description": "GET \u2026/answer-sheets/<lesson>/options/ \u2014 what the print dialog may offer, and why not.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/answer-sheets/{lesson_pk}/pdf/": {
   "post": {
    "operationId": "courses_answer_sheets_pdf_create",
    "description": "POST \u2026/answer-sheets/<lesson>/pdf/ \u2014 print a set of sheets.\n\nA POST because it has effects: it records the batch (the frozen question map every\nscan of these pages is graded against) and mints any IDs the pages carry. The body\nis fetched with ``apiFetch`` into a blob \u2014 a plain link carries no Authorization.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/answer-sheets/{lesson_pk}/pdf/jobs/{print_pk}/": {
   "get": {
    "operationId": "courses_answer_sheets_pdf_jobs_retrieve",
    "description": "GET \u2026/answer-sheets/<lesson>/pdf/jobs/<print>/ \u2014 where a large print stands.\nOnly the creator who asked for it can see it: the row is theirs, the file is filed\nunder their account.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "print_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/answer-sheets/{lesson_pk}/pdf/jobs/{print_pk}/download/": {
   "get": {
    "operationId": "courses_answer_sheets_pdf_jobs_download_retrieve",
    "description": "GET \u2026/pdf/jobs/<print>/download/ \u2014 the PDF, while it is still in date.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "print_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/answer-sheets/learners/{enrollment_pk}/number/": {
   "get": {
    "operationId": "courses_answer_sheets_learners_number_retrieve",
    "description": "GET/PATCH \u2026/answer-sheets/learners/<enrollment>/number/ \u2014 one learner's Student ID.\n\nPATCH ``{\"school_number\": \"4471\"}`` sets the school's own number (1\u201310 digits,\nzero-padded); ``null`` or ``\"\"`` reverts to the number Yoshuko minted. 409\n``number_taken`` when another learner in the organisation already answers to it \u2014\nas a school number OR a minted one, because a scan resolves both.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "enrollment_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "patch": {
    "operationId": "courses_answer_sheets_learners_number_partial_update",
    "description": "GET/PATCH \u2026/answer-sheets/learners/<enrollment>/number/ \u2014 one learner's Student ID.\n\nPATCH ``{\"school_number\": \"4471\"}`` sets the school's own number (1\u201310 digits,\nzero-padded); ``null`` or ``\"\"`` reverts to the number Yoshuko minted. 409\n``number_taken`` when another learner in the organisation already answers to it \u2014\nas a school number OR a minted one, because a scan resolves both.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "enrollment_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/": {
   "get": {
    "operationId": "courses_attendance_retrieve",
    "description": "GET /courses/<course_pk>/attendance/?from=&to= \u2014 the full register.\n\nBoth params are optional; omitting them uses the course's own span, or the\ncurrent month for an open-ended course.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/badges/": {
   "get": {
    "operationId": "courses_attendance_badges_retrieve",
    "description": "GET .../attendance/badges/ \u2014 who has a badge. Carries the public id only; the\nprinted secret leaves the server solely inside the creator-only PDF.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/badges/{enrollment_pk}/reissue/": {
   "post": {
    "operationId": "courses_attendance_badges_reissue_create",
    "description": "POST .../attendance/badges/<enrollment>/reissue/ \u2014 kill a lost card and mint a new one.\n\nThe badge belongs to the learner within the ORGANISATION, so this retires the old\ncard in every course of the org; the client says so before asking.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "enrollment_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/badges/pdf/": {
   "get": {
    "operationId": "courses_attendance_badges_pdf_retrieve",
    "description": "GET .../attendance/badges/pdf/[?enrollment=<id>] \u2014 the printable sheet.\n\nAn attachment fetched through ``apiFetch`` into a blob: a plain link carries no\nAuthorization header. ``no-store`` because the body holds live credentials.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/classroom/mark/": {
   "post": {
    "operationId": "courses_attendance_classroom_mark_create",
    "description": "POST /courses/<course_pk>/attendance/classroom/mark/ \u2014 a recognition hit from\nthe classroom scanner. Re-resolves the roster on every mark (not just at load) so\na mark cannot land after the meeting window has moved on.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/classroom/mark-qr/": {
   "post": {
    "operationId": "courses_attendance_classroom_mark_qr_create",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/classroom/qr-roster/": {
   "get": {
    "operationId": "courses_attendance_classroom_qr_roster_retrieve",
    "description": "GET .../classroom/qr-roster/ \u2014 everyone, as digests. No cap and no meeting-time\ngate: those exist because face matching degrades with roster size, and a QR code\nhas no near neighbour to be confused with.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/classroom/roster/": {
   "get": {
    "operationId": "courses_attendance_classroom_roster_retrieve",
    "description": "GET /courses/<course_pk>/attendance/classroom/roster/ \u2014 classroom (permissive)\nmode's roster: the AI-enrolled ready learners it should try to recognise right\nnow. Creator-authenticated, unlike the kiosk endpoints: this runs from the\ncourse_attendance.html modal, in the creator's own browser tab.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/device-sessions/": {
   "get": {
    "operationId": "courses_attendance_device_sessions_retrieve",
    "description": "GET/POST /courses/<course_pk>/attendance/device-sessions/\n\nMinting does not invalidate earlier sessions \u2014 two doors may legitimately run\ntwo kiosks \u2014 which is exactly why the list exists: a creator has to be able to\nsee what is live and revoke it.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "courses_attendance_device_sessions_create",
    "description": "GET/POST /courses/<course_pk>/attendance/device-sessions/\n\nMinting does not invalidate earlier sessions \u2014 two doors may legitimately run\ntwo kiosks \u2014 which is exactly why the list exists: a creator has to be able to\nsee what is live and revoke it.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/device-sessions/{id}/": {
   "delete": {
    "operationId": "courses_attendance_device_sessions_destroy",
    "description": "DELETE /courses/<course_pk>/attendance/device-sessions/<pk>/",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/kiosk/enrol/": {
   "post": {
    "operationId": "courses_attendance_kiosk_enrol_create",
    "description": "POST /courses/<course_pk>/attendance/kiosk/enrol/ \u2014 store a face reference set.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/kiosk/mark/": {
   "post": {
    "operationId": "courses_attendance_kiosk_mark_create",
    "description": "POST /courses/<course_pk>/attendance/kiosk/mark/ \u2014 a recognition hit.\n\nPresent-only, today-only, own-course-only. None of the three is negotiable and\nnone is taken from the request body.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/kiosk/mark-qr/": {
   "post": {
    "operationId": "courses_attendance_kiosk_mark_qr_create",
    "description": "Shared gate for the device-facing endpoints.\n\nThe kill switch is re-checked on every request rather than only at mint time: a\nsession handed out an hour ago must stop working the moment the feature is\nretracted, not when it happens to expire.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/kiosk/qr-roster/": {
   "get": {
    "operationId": "courses_attendance_kiosk_qr_roster_retrieve",
    "description": "GET .../kiosk/qr-roster/ \u2014 like the face roster, deliberately NOT counting as\nactivity (the kiosk polls it, and polling must not hold the idle lock off).",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/kiosk/roster/": {
   "get": {
    "operationId": "courses_attendance_kiosk_roster_retrieve",
    "description": "GET /courses/<course_pk>/attendance/kiosk/roster/\n\nThe course's learners plus their reference embeddings, so matching happens on the\ndevice and camera frames never cross the network.\n\nDeliberately does NOT bump ``last_activity_at``: the kiosk polls this on load and\non resume, and if polling counted as activity the idle lock would never fire.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/kiosk/unlock/": {
   "post": {
    "operationId": "courses_attendance_kiosk_unlock_create",
    "description": "POST /courses/<course_pk>/attendance/kiosk/unlock/ \u2014 re-arm a locked kiosk.\n\nAuthenticated by device token only. Every failure \u2014 wrong PIN, expired grant,\nrevoked grant \u2014 returns the same status and the same body, so the endpoint\ncannot be used to work out which of the three is true.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/meeting-times/": {
   "get": {
    "operationId": "courses_attendance_meeting_times_retrieve",
    "description": "GET/PUT /courses/<course_pk>/attendance/meeting-times/\n\nFeeds classroom (permissive) attendance mode's ``resolve_classroom_roster``:\nwhen the AI-enrolled roster is over the classroom cap, \"who is meeting now\" is\nresolved from these rows. Scoped to the course's own cohorts only \u2014 a cohort id\nfrom another course is refused as a validation error, never silently ignored or\nwritten cross-course.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "put": {
    "operationId": "courses_attendance_meeting_times_update",
    "description": "GET/PUT /courses/<course_pk>/attendance/meeting-times/\n\nFeeds classroom (permissive) attendance mode's ``resolve_classroom_roster``:\nwhen the AI-enrolled roster is over the classroom cap, \"who is meeting now\" is\nresolved from these rows. Scoped to the course's own cohorts only \u2014 a cohort id\nfrom another course is refused as a validation error, never silently ignored or\nwritten cross-course.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/pin/": {
   "get": {
    "operationId": "courses_attendance_pin_retrieve",
    "description": "GET /courses/<course_pk>/attendance/pin/ \u2014 the current kiosk unlock PIN.\n\nCreator-only, and emphatically not readable with a device token: a kiosk that\ncould fetch its own unlock PIN could re-arm itself forever with no human\ninvolved, which is the entire thing the lock exists to prevent.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/records/{enrollment_pk}/{date}/": {
   "put": {
    "operationId": "courses_attendance_records_update",
    "description": "PUT /courses/<course_pk>/attendance/records/<enrollment_pk>/<date>/\n\nUnticking stores ``present=False`` rather than deleting the row: \"marked absent\"\nand \"not yet marked\" are different states and the grid shows both.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "date",
      "schema": {
       "type": "string"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "enrollment_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/settings/": {
   "get": {
    "operationId": "courses_attendance_settings_retrieve",
    "description": "GET/PUT /courses/<course_pk>/attendance/settings/",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "put": {
    "operationId": "courses_attendance_settings_update",
    "description": "GET/PUT /courses/<course_pk>/attendance/settings/",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/attendance/updates/": {
   "get": {
    "operationId": "courses_attendance_updates_retrieve",
    "description": "GET /courses/<course_pk>/attendance/updates/?since=&from=&to=\n\nThe delta the open register polls. Deliberately not \"re-fetch the grid\": that payload\nis the whole learner x date matrix, and re-rendering it would destroy every checkbox\non screen \u2014 including one the creator is part-way through toggling.\n\n``since`` is required. A client with no cursor is a client with a bug, and quietly\nserving it the entire range would hide that behind a working-looking screen.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/certificate-settings/": {
   "get": {
    "operationId": "courses_certificate_settings_retrieve",
    "description": "GET/PUT /api/v1/courses/<pk>/certificate-settings/ \u2014 the creator's controls.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "put": {
    "operationId": "courses_certificate_settings_update",
    "description": "GET/PUT /api/v1/courses/<pk>/certificate-settings/ \u2014 the creator's controls.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/certificate-settings/backfill/": {
   "post": {
    "operationId": "courses_certificate_settings_backfill_create",
    "description": "POST /api/v1/courses/<pk>/certificate-settings/backfill/ \u2014 issue to learners who\nalready finished. Without this, enabling the feature on an existing course produces\nnothing at all and reads as broken.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/certificate-settings/preview/": {
   "post": {
    "operationId": "courses_certificate_settings_preview_create",
    "description": "POST /api/v1/courses/<pk>/certificate-settings/preview/ \u2014 render a sample.\n\nTakes the DRAFT settings in the body rather than reading the saved row, so a\ncreator previews what they are about to save instead of what they saved last time.\nA preview that shows the previous signatory while the field on screen says something\nelse is worse than no preview: it reports success for a change that has not happened.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/chapters/": {
   "get": {
    "operationId": "courses_chapters_list",
    "description": "Supplies the per-lesson access verdicts a learner-visible read needs.\n\nThe map is put in the serializer context *only* for a non-privileged reader. Its\nabsence is what tells ``LessonSerializer`` it is serializing for an author, so a\ncreator is never gated by the schedule they are in the middle of writing.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "name": "ordering",
      "required": false,
      "in": "query",
      "description": "Which field to use when ordering the results.",
      "schema": {
       "type": "string"
      }
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/Chapter"
         }
        }
       }
      },
      "description": ""
     }
    }
   },
   "post": {
    "operationId": "courses_chapters_create",
    "description": "Supplies the per-lesson access verdicts a learner-visible read needs.\n\nThe map is put in the serializer context *only* for a non-privileged reader. Its\nabsence is what tells ``LessonSerializer`` it is serializing for an author, so a\ncreator is never gated by the schedule they are in the middle of writing.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "201": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Chapter"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/chapters/{chapter_pk}/lessons/": {
   "get": {
    "operationId": "courses_chapters_lessons_list",
    "description": "Supplies the per-lesson access verdicts a learner-visible read needs.\n\nThe map is put in the serializer context *only* for a non-privileged reader. Its\nabsence is what tells ``LessonSerializer`` it is serializing for an author, so a\ncreator is never gated by the schedule they are in the middle of writing.",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "name": "ordering",
      "required": false,
      "in": "query",
      "description": "Which field to use when ordering the results.",
      "schema": {
       "type": "string"
      }
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/Lesson"
         }
        }
       }
      },
      "description": ""
     }
    }
   },
   "post": {
    "operationId": "courses_chapters_lessons_create",
    "description": "Supplies the per-lesson access verdicts a learner-visible read needs.\n\nThe map is put in the serializer context *only* for a non-privileged reader. Its\nabsence is what tells ``LessonSerializer`` it is serializing for an author, so a\ncreator is never gated by the schedule they are in the middle of writing.",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Lesson"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Lesson"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Lesson"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "201": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Lesson"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/chapters/{chapter_pk}/lessons/{lesson_pk}/export/scorm/": {
   "get": {
    "operationId": "courses_chapters_lessons_export_scorm_retrieve",
    "description": "GET .../lessons/<lesson_pk>/export/scorm/ \u2014 download a SCORM 1.2 package.\n\nOnly quiz lessons can be exported; the content must satisfy the quiz schema.",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/chapters/{chapter_pk}/lessons/{id}/": {
   "get": {
    "operationId": "courses_chapters_lessons_retrieve",
    "description": "A locked lesson is a 403 with a structured body \u2014 not a 404, and not a\nsilently emptied 200.\n\n403 rather than 404 because the lesson's *existence* is not a secret: the\nlearner can already see it in the curriculum. The body carries `opens_at` so\nthe client can render a countdown instead of a dead end, and `code` is what the\nclient keys off \u2014 never a human-readable message, which is free to change.",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Lesson"
        }
       }
      },
      "description": ""
     }
    }
   },
   "patch": {
    "operationId": "courses_chapters_lessons_partial_update",
    "description": "Supplies the per-lesson access verdicts a learner-visible read needs.\n\nThe map is put in the serializer context *only* for a non-privileged reader. Its\nabsence is what tells ``LessonSerializer`` it is serializing for an author, so a\ncreator is never gated by the schedule they are in the middle of writing.",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PatchedLesson"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/PatchedLesson"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/PatchedLesson"
       }
      }
     }
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Lesson"
        }
       }
      },
      "description": ""
     }
    }
   },
   "delete": {
    "operationId": "courses_chapters_lessons_destroy",
    "description": "Supplies the per-lesson access verdicts a learner-visible read needs.\n\nThe map is put in the serializer context *only* for a non-privileged reader. Its\nabsence is what tells ``LessonSerializer`` it is serializing for an author, so a\ncreator is never gated by the schedule they are in the middle of writing.",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/chapters/{chapter_pk}/lessons/{id}/duplicate/": {
   "post": {
    "operationId": "courses_chapters_lessons_duplicate_create",
    "description": "POST {\"title\": str} \u2014 copy this lesson right after it within the same chapter.\n\nThe copy carries over content_type, content and is_free_preview but is always a\ndraft (is_published=False). `title` is optional and falls back to\n\"<source title> Copy\".",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Lesson"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Lesson"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Lesson"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Lesson"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/chapters/{chapter_pk}/lessons/{id}/progress/": {
   "get": {
    "operationId": "courses_chapters_lessons_progress_retrieve",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "courses_chapters_lessons_progress_create",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/chapters/{chapter_pk}/lessons/{id}/scan/": {
   "get": {
    "operationId": "courses_chapters_lessons_scan_retrieve",
    "description": "GET issues the attempt's ink token; POST stores the scanned pages.",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "courses_chapters_lessons_scan_create",
    "description": "GET issues the attempt's ink token; POST stores the scanned pages.",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/chapters/{chapter_pk}/lessons/{id}/scan/correction/": {
   "patch": {
    "operationId": "courses_chapters_lessons_scan_correction_partial_update",
    "description": "PATCH \u2014 the learner corrects what the machine read off their pages.\n\nBehind the SAME gate as the upload, for the same reason: a learner who may not submit\nto a closed lesson may not edit an answer on it either, and two copies of that rule is\nhow one of them ends up more permissive.\n\nThe machine's original is preserved on every page (`ocr_original`). That is what makes\nthe edit auditable \u2014 and it matters in both directions, because the recogniser silently\ncorrects a learner's spelling in 8 of 9 measurements and its edits are otherwise\ninvisible.",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/chapters/{chapter_pk}/lessons/reorder/": {
   "post": {
    "operationId": "courses_chapters_lessons_reorder_create",
    "description": "POST {\"order\": [lesson_id, ...]} \u2014 atomically renumber positions within a chapter.",
    "parameters": [
     {
      "in": "path",
      "name": "chapter_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Lesson"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Lesson"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Lesson"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Lesson"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/chapters/{id}/": {
   "get": {
    "operationId": "courses_chapters_retrieve",
    "description": "Supplies the per-lesson access verdicts a learner-visible read needs.\n\nThe map is put in the serializer context *only* for a non-privileged reader. Its\nabsence is what tells ``LessonSerializer`` it is serializing for an author, so a\ncreator is never gated by the schedule they are in the middle of writing.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Chapter"
        }
       }
      },
      "description": ""
     }
    }
   },
   "patch": {
    "operationId": "courses_chapters_partial_update",
    "description": "Supplies the per-lesson access verdicts a learner-visible read needs.\n\nThe map is put in the serializer context *only* for a non-privileged reader. Its\nabsence is what tells ``LessonSerializer`` it is serializing for an author, so a\ncreator is never gated by the schedule they are in the middle of writing.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PatchedChapter"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/PatchedChapter"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/PatchedChapter"
       }
      }
     }
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Chapter"
        }
       }
      },
      "description": ""
     }
    }
   },
   "delete": {
    "operationId": "courses_chapters_destroy",
    "description": "Supplies the per-lesson access verdicts a learner-visible read needs.\n\nThe map is put in the serializer context *only* for a non-privileged reader. Its\nabsence is what tells ``LessonSerializer`` it is serializing for an author, so a\ncreator is never gated by the schedule they are in the middle of writing.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/chapters/{id}/duplicate/": {
   "post": {
    "operationId": "courses_chapters_duplicate_create",
    "description": "POST {\"title\": str} \u2014 copy this chapter (and all its lessons) right after it.\n\nThe copy is always created as a draft (is_published=False, lessons too) so it\nnever silently exposes a half-finished unit to learners; progression gates\n(requires_previous_chapter, sequential_lessons) are carried over. `title` is\noptional and falls back to \"<source title> Copy\".",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Chapter"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/chapters/{id}/publish/": {
   "post": {
    "operationId": "courses_chapters_publish_create",
    "description": "POST {\"cascade\": bool} \u2014 publish this chapter.\n\n`cascade` (default False) also publishes every lesson in the chapter, so the\ncreator can choose \"publish unit only\" vs \"publish unit and all lessons\". The\nchapter is always published. Atomic so a partial failure leaves no half state.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Chapter"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/chapters/reorder/": {
   "post": {
    "operationId": "courses_chapters_reorder_create",
    "description": "POST {\"order\": [chapter_id, ...]} \u2014 atomically renumber positions.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Chapter"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Chapter"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/checkout/": {
   "post": {
    "operationId": "courses_checkout_create",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/codes/": {
   "get": {
    "operationId": "courses_codes_retrieve",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "courses_codes_create",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/codes/{code_pk}/": {
   "patch": {
    "operationId": "courses_codes_partial_update",
    "parameters": [
     {
      "in": "path",
      "name": "code_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "courses_codes_destroy",
    "parameters": [
     {
      "in": "path",
      "name": "code_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/cohorts/": {
   "get": {
    "operationId": "courses_cohorts_retrieve",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "courses_cohorts_create",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/cohorts/{cohort_pk}/": {
   "patch": {
    "operationId": "courses_cohorts_partial_update",
    "parameters": [
     {
      "in": "path",
      "name": "cohort_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "courses_cohorts_destroy",
    "parameters": [
     {
      "in": "path",
      "name": "cohort_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/cohorts/{cohort_pk}/members/": {
   "post": {
    "operationId": "courses_cohorts_members_create",
    "description": "POST {enrollment_id|enrollment_ids, action: add|remove} \u2014 (un)assign learner(s).\n\nAccepts a single ``enrollment_id`` (back-compat) or a list ``enrollment_ids``\nfor bulk moves. Every id must be an active-or-not enrollment of this course;\nany foreign id makes the whole request 404 (all-or-nothing).",
    "parameters": [
     {
      "in": "path",
      "name": "cohort_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/coupons/": {
   "get": {
    "operationId": "courses_coupons_retrieve",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "courses_coupons_create",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/coupons/{id}/": {
   "delete": {
    "operationId": "courses_coupons_destroy",
    "description": "Deactivate, never delete \u2014 a redeemed coupon is part of a sale's history.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/curriculum/": {
   "get": {
    "operationId": "courses_curriculum_retrieve",
    "description": "GET /api/v1/courses/<course_pk>/curriculum/ \u2014 every chapter and lesson SUMMARY\nin one request, for the curriculum editor's initial load.\n\nReplaces an N+1 fetch (one GET per chapter's lessons, each carrying every lesson's\nfull `content` JSONField) with two queries total, independent of course size: this\nis a creator-only, summary-only sibling of `ChapterViewSet`/`LessonViewSet` \u2014 those\nstay untouched because `ChapterSerializer.lessons` (full lesson content, including\n`content`) is depended on by the Flutter learner client's `getCourseChapters`.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/discussion/replies/{reply_pk}/": {
   "patch": {
    "operationId": "courses_discussion_replies_partial_update",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "reply_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "courses_discussion_replies_destroy",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "reply_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/discussion/replies/{reply_pk}/moderate/": {
   "post": {
    "operationId": "courses_discussion_replies_moderate_create",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "reply_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/discussion/settings/": {
   "get": {
    "operationId": "courses_discussion_settings_retrieve",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "put": {
    "operationId": "courses_discussion_settings_update",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/discussion/topics/": {
   "get": {
    "operationId": "courses_discussion_topics_list",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "name": "ordering",
      "required": false,
      "in": "query",
      "description": "Which field to use when ordering the results.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "page",
      "required": false,
      "in": "query",
      "description": "A page number within the paginated result set.",
      "schema": {
       "type": "integer"
      }
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PaginatedTopicList"
        }
       }
      },
      "description": ""
     }
    }
   },
   "post": {
    "operationId": "courses_discussion_topics_create",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Topic"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Topic"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Topic"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "201": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Topic"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/discussion/topics/{topic_pk}/": {
   "get": {
    "operationId": "courses_discussion_topics_retrieve",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "topic_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "patch": {
    "operationId": "courses_discussion_topics_partial_update",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "topic_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "courses_discussion_topics_destroy",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "topic_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/discussion/topics/{topic_pk}/moderate/": {
   "post": {
    "operationId": "courses_discussion_topics_moderate_create",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "topic_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/discussion/topics/{topic_pk}/replies/": {
   "get": {
    "operationId": "courses_discussion_topics_replies_list",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "name": "ordering",
      "required": false,
      "in": "query",
      "description": "Which field to use when ordering the results.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "page",
      "required": false,
      "in": "query",
      "description": "A page number within the paginated result set.",
      "schema": {
       "type": "integer"
      }
     },
     {
      "in": "path",
      "name": "topic_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PaginatedReplyList"
        }
       }
      },
      "description": ""
     }
    }
   },
   "post": {
    "operationId": "courses_discussion_topics_replies_create",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "topic_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Reply"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Reply"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Reply"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "201": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Reply"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/documents/": {
   "post": {
    "operationId": "courses_documents_create",
    "description": "POST /api/v1/courses/<course_pk>/documents/ \u2014 multipart PDF or image upload.\n\nImages are re-encoded to PNG; PDFs are stored as-is (<= COURSE_DOCUMENT_MAX_BYTES).",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/documents/{doc_pk}/": {
   "delete": {
    "operationId": "courses_documents_destroy",
    "description": "DELETE /api/v1/courses/<course_pk>/documents/<doc_pk>/ \u2014 remove file + row.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "doc_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/documents/google/": {
   "post": {
    "operationId": "courses_documents_google_create",
    "description": "POST /api/v1/courses/<course_pk>/documents/google/ \u2014 import a shared Google Doc as PDF.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/enroll/": {
   "post": {
    "operationId": "courses_enroll_create",
    "description": "POST /api/v1/courses/<course_pk>/enroll/ \u2014 join a FREE course directly.\n\nA course with no active price has nothing to check out, so CourseCheckoutView\nrefuses it with \"This course has no active price.\" The sales page therefore had no\nworking CTA for a free course at all: it rendered a plain link to /signup/, which\nsent an already-signed-in learner to the CREATOR signup page and told them to create\na creator account. This is the missing other half of that pair.\n\nIdempotent by design \u2014 a second call is a 200 reporting the existing enrolment, not\na 400. The CTA's only job is to get the learner into the course, and a learner who\ndouble-clicks has not made an error.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/export/": {
   "get": {
    "operationId": "courses_export_retrieve",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "courses_export_create",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/export/download/": {
   "get": {
    "operationId": "courses_export_download_retrieve",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/": {
   "get": {
    "operationId": "courses_gradebook_retrieve",
    "description": "GET /courses/<course_pk>/gradebook/ \u2014 the full grid.\n\nQuery params: order=curriculum|start|due, published_only=1,\nunits=<uuid,uuid>, cohorts=<uuid,uuid>.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/ai-grade/": {
   "post": {
    "operationId": "courses_gradebook_ai_grade_create",
    "description": "POST /courses/<course_pk>/gradebook/ai-grade/ \u2014 start an AI-grading job for\none learner (mode=single) or all visible learners (mode=all) on one assignment.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/ai-jobs/{job_id}/": {
   "get": {
    "operationId": "courses_gradebook_ai_jobs_retrieve",
    "description": "GET /courses/<course_pk>/gradebook/ai-jobs/<job_id>/ \u2014 poll one job.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "job_id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/ai-jobs/{job_id}/cancel/": {
   "get": {
    "operationId": "courses_gradebook_ai_jobs_cancel_retrieve",
    "description": "POST /courses/<course_pk>/gradebook/ai-jobs/<job_id>/cancel/",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "job_id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "courses_gradebook_ai_jobs_cancel_create",
    "description": "POST /courses/<course_pk>/gradebook/ai-jobs/<job_id>/cancel/",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "job_id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/ai-jobs/{job_id}/resume/": {
   "get": {
    "operationId": "courses_gradebook_ai_jobs_resume_retrieve",
    "description": "POST /courses/<course_pk>/gradebook/ai-jobs/<job_id>/resume/",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "job_id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "courses_gradebook_ai_jobs_resume_create",
    "description": "POST /courses/<course_pk>/gradebook/ai-jobs/<job_id>/resume/",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "job_id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/ai-jobs/active/": {
   "get": {
    "operationId": "courses_gradebook_ai_jobs_active_retrieve",
    "description": "GET /courses/<course_pk>/gradebook/ai-jobs/active/ \u2014 this user's current\nactive job for this course (drives the banner and polling). {} when none.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/export/": {
   "get": {
    "operationId": "courses_gradebook_export_retrieve",
    "description": "GET /courses/<course_pk>/gradebook/export/?format=csv|xlsx&scope=view|all\n\nscope=view honours the same order/published/units/cohorts filters as the grid;\nscope=all ignores them and dumps the entire gradebook.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/fill/": {
   "post": {
    "operationId": "courses_gradebook_fill_create",
    "description": "POST /courses/<course_pk>/gradebook/fill/ \u2014 fill a column for a target set.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/grades/{enrollment_pk}/{lesson_pk}/": {
   "put": {
    "operationId": "courses_gradebook_grades_update",
    "description": "PUT /courses/<course_pk>/gradebook/grades/<enrollment_pk>/<lesson_pk>/",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "enrollment_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/learners/{enrollment_pk}/": {
   "get": {
    "operationId": "courses_gradebook_learners_retrieve",
    "description": "GET /courses/<course_pk>/gradebook/learners/<enrollment_pk>/ \u2014 creator view of\none learner's course progress (the target of the learner-name link in the grid).",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "enrollment_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/lessons/{lesson_pk}/ai-config/": {
   "get": {
    "operationId": "courses_gradebook_lessons_ai_config_retrieve",
    "description": "GET /courses/<course_pk>/gradebook/lessons/<lesson_pk>/ai-config/ \u2014 prefill the\nconfirm dialog: retained per-assignment params \u2192 course defaults, plus how many\nlearners are already graded (so the dialog can offer the regrade choice).",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/lessons/{lesson_pk}/config/": {
   "get": {
    "operationId": "courses_gradebook_lessons_config_retrieve",
    "description": "GET/PUT /courses/<course_pk>/gradebook/lessons/<lesson_pk>/config/\n\nBacks the \"Completion\" section on the lesson / assessment editor.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "put": {
    "operationId": "courses_gradebook_lessons_config_update",
    "description": "GET/PUT /courses/<course_pk>/gradebook/lessons/<lesson_pk>/config/\n\nBacks the \"Completion\" section on the lesson / assessment editor.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/lessons/{lesson_pk}/review/": {
   "get": {
    "operationId": "courses_gradebook_lessons_review_retrieve",
    "description": "GET \u2026/review/ \u2014 every learner on the course against this one assignment.\n\nQuery params are the LEARNER and SUBMISSION axis facets from\n``roster_filters`` (``cohorts``, ``submission``, ``grading``, plus ``q``). Assignment\naxis params (``units``, ``published_only``) are parsed and ignored \u2014 the page is\npinned to one assignment, and they ride along in the URL when a creator arrives from\na filtered gradebook.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/lessons/{lesson_pk}/review/{enrollment_pk}/": {
   "get": {
    "operationId": "courses_gradebook_lessons_review_retrieve_2",
    "description": "GET \u2026/review/<enrollment_pk>/ \u2014 one learner's response, grade and history.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "enrollment_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/lessons/{lesson_pk}/review/{enrollment_pk}/grade/": {
   "put": {
    "operationId": "courses_gradebook_lessons_review_grade_update",
    "description": "PUT \u2026/review/<enrollment_pk>/grade/ \u2014 record a new or different grade.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "enrollment_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/lessons/{lesson_pk}/review/{enrollment_pk}/pages/{page_pk}/": {
   "get": {
    "operationId": "courses_gradebook_lessons_review_pages_retrieve",
    "description": "GET \u2026/review/<enrollment_pk>/pages/<page_pk>/ \u2014 one scanned page's bytes.\n\nAuthorisation is course ownership, not file ownership \u2014 see ``review/media.py`` for\nwhy the shared ``int_userfiles`` gate cannot answer this question.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "enrollment_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "page_pk",
      "schema": {
       "type": "integer"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/gradebook/settings/": {
   "get": {
    "operationId": "courses_gradebook_settings_retrieve",
    "description": "GET/PUT /courses/<course_pk>/gradebook/settings/",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "put": {
    "operationId": "courses_gradebook_settings_update",
    "description": "GET/PUT /courses/<course_pk>/gradebook/settings/",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/imports/": {
   "get": {
    "operationId": "courses_imports_retrieve",
    "description": "The import this course is currently waiting on, or ``{\"import\": null}``.\n\nTHE RE-ENTRY. The studio row's \"Choose what to import\" link and its progress\nlink both land on the import screen, and without this the panel opens on a\nblank upload form \u2014 so the creator uploads the file a second time.\n\nAnswered by the API rather than rendered into the page, deliberately.\n`CourseImportPageView` is a creator screen: those are **anonymous to Django**\n(auth is a JWT the browser holds) and they issue no database query at all.\nResolving this in `get_context_data` put the first query on that page, which\n`test_creator_web_views.py` caught immediately \u2014 it renders every creator page\nwith no `db` fixture, precisely because none of them should need one.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "courses_imports_create",
    "description": "```\nPOST /api/v1/course-imports/          begin an upload  (mints the course)\nPOST /api/v1/courses/<pk>/imports/    begin an upload  (into this course)\n```\n\n**This request carries no file.** It carries a name, a type and a size \u2014 three\nclaims about a file the browser is about to PUT straight to R2 \u2014 and it answers\nwith one signed URL per part. That is the whole fix: the previous version of this\nview read the archive out of the request body and the gunicorn worker was killed\nat 180 seconds still receiving it, with `workers = 1`, taking the app down with it.\n\nThe route stays NOT under a course for the create flow. A creator on the New\nCourse screen has not made one, and the placeholder this mints is an artefact of\nthe import rather than a course they created \u2014 the URL should not imply otherwise.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/landing/copy/": {
   "post": {
    "operationId": "courses_landing_copy_create",
    "description": "POST \u2014 draft the landing page's marketing copy. Persists NOTHING.\n\nThe course text comes from the BODY when the client sends it, exactly as the\nrubric assistant does: this dialog is opened seconds after the creator typed a\ndescription, and drafting from the STORED course would write copy for the\nversion before it \u2014 indistinguishable from working.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/landing/hero/": {
   "get": {
    "operationId": "courses_landing_hero_retrieve",
    "description": "GET the candidates \u00b7 POST to generate a new batch.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "courses_landing_hero_create",
    "description": "GET the candidates \u00b7 POST to generate a new batch.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/landing/hero/{id}/": {
   "delete": {
    "operationId": "courses_landing_hero_destroy",
    "description": "DELETE one candidate.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/landing/hero/{id}/select/": {
   "post": {
    "operationId": "courses_landing_hero_select_create",
    "description": "POST \u2014 make one candidate the course's live hero.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/landing/hero/import/": {
   "post": {
    "operationId": "courses_landing_hero_import_create",
    "description": "POST {\"url\": \"...\"} \u2014 COPY the image at a link into this course's storage.\n\nA pasted link is an instruction to copy, never a source to render from. The\nresponse carries our own URL; the original is kept as provenance and is read by\nnothing.\n\nThrottled on `landing_ai` deliberately. This endpoint makes the SERVER issue an\noutbound request to an address the caller chose, so an unbounded one is a traffic\namplifier and a network scanner regardless of how good the address guard is.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/landing/hero/upload/": {
   "post": {
    "operationId": "courses_landing_hero_upload_create",
    "description": "POST \u2014 the creator's OWN image, as a file. multipart/form-data.\n\nAlso the endpoint a clipboard paste uses: the browser hands us a Blob and this\ncannot tell the difference, which is the point. Pasting an image is an upload.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/learners/": {
   "get": {
    "operationId": "courses_learners_list",
    "description": "GET /courses/<course_pk>/learners/ \u2014 the roster, filtered and paginated.\n\nFilters: ``cohorts`` (multi, UUID), ``progress`` (multi band), ``q`` (name/email).\nWas a bare APIView returning an unpaginated list, so a roster of any size arrived\nin one response and the page filtered it in the browser.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "name": "ordering",
      "required": false,
      "in": "query",
      "description": "Which field to use when ordering the results.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "page",
      "required": false,
      "in": "query",
      "description": "A page number within the paginated result set.",
      "schema": {
       "type": "integer"
      }
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PaginatedCourseLearnerList"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/learners/{enrollment_pk}/reset-password/": {
   "post": {
    "operationId": "courses_learners_reset_password_create",
    "description": "POST /api/v1/courses/<pk>/learners/<enrollment_pk>/reset-password/\n\nThe teacher's answer to \"I lost my slip\". Mints a NEW password \u2014 the old one is\nnot stored, which is the same fact that makes the sheet single-download. For a\nlearner with no email and no phone this is the ONLY way back into the account.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "enrollment_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/lessons/{lesson_pk}/move/": {
   "post": {
    "operationId": "courses_lessons_move_create",
    "description": "POST {\"chapter\": uuid, \"position\": int} \u2014 move a lesson to a chapter (same or different).",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/lessons/{lesson_pk}/schedule/": {
   "patch": {
    "operationId": "courses_lessons_schedule_partial_update",
    "description": "PATCH /api/v1/courses/<course_pk>/lessons/<lesson_pk>/schedule/ \u2014 a drag-save.\n\nOptimistic concurrency: the client sends back the ``updated_at`` it last saw. If the\nrow has moved since \u2014 most likely because someone bulk-shifted a selection\ncontaining this lesson \u2014 the write is refused with a 409 carrying the current row,\nso the grid can resync instead of silently clobbering the newer edit.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "lesson_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/lessons/content-assist/": {
   "post": {
    "operationId": "courses_lessons_content_assist_create",
    "description": "POST \u2014 draft a lesson body as student-facing bullets. Persists NOTHING.\n\nTHE LESSON TEXT COMES FROM THE BODY, NOT FROM ``Lesson``. The panel sits above an\neditor whose changes are usually unsaved \u2014 a creator writes two paragraphs and\nthen asks the assistant to carry on. Reading the stored lesson would draft against\nthe version before what they just typed, and be indistinguishable from working.\nThis is the same rule the rubric and landing-copy assistants already follow.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/lessons/content-assist/prompt/": {
   "post": {
    "operationId": "courses_lessons_content_assist_prompt_create",
    "description": "POST \u2014 the same request, written as ONE prompt to paste into another assistant.\n\nBuilt on the server beside the prompt the local model is given. A copy in the\nbrowser would drift from it, leaving the two assistants quietly disagreeing about\nwhat a lesson body is \u2014 and this repo's history is three rounds of removing a\nsecond copy of a fact.\n\nIT CALLS NO MODEL, which is why it does not share `lesson_ai`'s scope: that ceiling\nexists to stop a held-down button starving a class waiting on their handwriting\nscans, and composing text to paste into ChatGPT spends none of that GPU. Throttled\non its own scope so it is still bounded.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/media/": {
   "post": {
    "operationId": "courses_media_create",
    "description": "POST /api/v1/courses/<course_pk>/media/ \u2014 multipart image upload.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/media/{media_pk}/": {
   "delete": {
    "operationId": "courses_media_destroy",
    "description": "DELETE /api/v1/courses/<course_pk>/media/<media_pk>/ \u2014 remove file + row.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "media_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/phone-preview/": {
   "get": {
    "operationId": "courses_phone_preview_retrieve",
    "description": "GET /api/v1/courses/<course_pk>/phone-preview/?lesson=<id> \u2014 \"open on my phone\" (#69).\n\nReturns the learner-player URL for the SAVED lesson, and a QR code of it, so a\ncreator can open the lesson on a real handset. The link carries NO credential: the\nphone signs in the ordinary way. A token in a link or a QR code is a sole-factor\nlogin \u2014 anyone who photographs the screen holds the account \u2014 which decision O-7\nforbids (`docs/specs/magic-link-login.md`). The in-editor preview is different: its\nshort-lived token never leaves this browser.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/preview-token/": {
   "post": {
    "operationId": "courses_preview_token_create",
    "description": "POST /api/v1/courses/<course_pk>/preview-token/ \u2014 mint a short-lived JWT.\n\nLets a creator open the student Flutter player in \"preview as student\" mode.\nThe token is the creator's own identity but short-lived, so it can be carried\nin the player URL fragment without exposing the long-lived session token.\nRequires org membership and that the course belongs to the current tenant.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/price/": {
   "get": {
    "operationId": "courses_price_retrieve",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "put": {
    "operationId": "courses_price_update",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/roster-imports/": {
   "post": {
    "operationId": "courses_roster_imports_create",
    "description": "POST /api/v1/courses/<pk>/roster-imports/ \u2014 upload, detect, preview.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/roster-imports/{import_pk}/": {
   "get": {
    "operationId": "courses_roster_imports_retrieve",
    "description": "GET / PATCH /api/v1/courses/<pk>/roster-imports/<id>/ \u2014 correct the mapping.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "import_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "patch": {
    "operationId": "courses_roster_imports_partial_update",
    "description": "GET / PATCH /api/v1/courses/<pk>/roster-imports/<id>/ \u2014 correct the mapping.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "import_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/roster-imports/{import_pk}/commit/": {
   "post": {
    "operationId": "courses_roster_imports_commit_create",
    "description": "POST /api/v1/courses/<pk>/roster-imports/<id>/commit/ \u2014 create and enrol.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "import_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/roster-imports/{import_pk}/credentials/": {
   "get": {
    "operationId": "courses_roster_imports_credentials_retrieve",
    "description": "GET /api/v1/courses/<pk>/roster-imports/<id>/credentials/ \u2014 ONCE.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "import_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/roster-imports/template/": {
   "get": {
    "operationId": "courses_roster_imports_template_retrieve",
    "description": "GET /api/v1/courses/<pk>/roster-imports/template/?fmt=xlsx|csv",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/rubrics/suggest/": {
   "post": {
    "operationId": "courses_rubrics_suggest_create",
    "description": "AI Assist \u2014 draft the criteria for a set of bands, and persist NOTHING.\n\nTHE RULE THE DIALOG'S BUTTON PROMISES\n\n    bands in the body      \u2192 criteria for exactly those bands\n    no bands in the body   \u2192 the standard scale, with criteria written for it\n\n``bands`` carries BOUNDS, not criteria, so it is read with ``validate_band_ranges``\nrather than ``validate_bands`` \u2014 the latter refuses a band with no description on\npurpose, and here the description is the thing being asked for. Bounds are validated\nby the same rules Save applies, so a grid this endpoint accepts is a grid the\ncreator can then save, and one it refuses is refused with the message they would\nhave got anyway rather than after a pointless model call.\n\nTHE LESSON TEXT COMES FROM THE BODY, NOT FROM ``Lesson``. The dialog is opened from\nan editor whose changes are usually unsaved \u2014 a creator writes the question, then\nasks for a rubric for it. Reading the stored lesson would build criteria for the\nversion before the question they just typed, and be indistinguishable from working.\n\nNOTHING IS CREATED. The response is a draft the dialog fills its form with; the\nrubric is made by the ordinary POST above if and when the creator presses the\nbutton. That is what makes a bad suggestion cost a click rather than an archived row.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/rubrics/templates/": {
   "get": {
    "operationId": "courses_rubrics_templates_retrieve",
    "description": "The templates this course offers \u2014 and **Save as template**, the only way in.\n\nNothing else writes to this table. Saving a lesson stores its rubrics inside the\nlesson, so a creator's library grows when they say it grows and at no other moment;\nthat is the whole of what \"curated\" means here.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "courses_rubrics_templates_create",
    "description": "Save the grid in the body as a template of this course.\n\nThe body is a rubric, not a reference: the creator has been editing it on a\nresponse and what is kept is what is on their screen. No ``source`` is recorded\neven when that grid began life as a copy of another template \u2014 a stored thread\nback is one a later change starts pulling, and \"editing a template never moves a\nlesson\" would stop being true by construction.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/rubrics/templates/{id}/": {
   "delete": {
    "operationId": "courses_rubrics_templates_destroy",
    "description": "Delete \u2014 the other half of curation, and the only other thing that writes here.\n\nThere is no PATCH. A template is a saved snapshot; changing one means changing the\nrubric on a response and saving it again. An edit route would be the one place a\nchange could reach out of a lesson, which is exactly what this design removed.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/schedule/": {
   "get": {
    "operationId": "courses_schedule_retrieve",
    "description": "GET /api/v1/courses/<course_pk>/schedule/ \u2014 the whole grid in one call.\n\nEvery lesson in the course, flattened with its unit, plus the course length the\ngrid draws its day columns from.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/schedule/shift/": {
   "post": {
    "operationId": "courses_schedule_shift_create",
    "description": "POST /api/v1/courses/<course_pk>/schedule/shift/ \u2014 move a selection of bars.\n\nThe shift is a **relative SQL update**, not a read-modify-write. That is what makes\ntwo concurrent shifts compose (+7 then +7 = +14, which is what both creators\nactually asked for) instead of one silently losing the other. It also means an\nunscheduled lesson stays unscheduled: ``F()`` on a NULL leaves it NULL, which is\nexactly the \"never closes\" / \"always open\" semantics.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/uploads/": {
   "post": {
    "operationId": "courses_uploads_create",
    "description": "POST /api/v1/courses/<course_pk>/uploads/ \u2014 reserve quota and sign the parts.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/uploads/{object_pk}/": {
   "get": {
    "operationId": "courses_uploads_retrieve",
    "description": "GET \u00b7 DELETE /api/v1/courses/<course_pk>/uploads/<object_pk>/.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "object_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "courses_uploads_destroy",
    "description": "GET \u00b7 DELETE /api/v1/courses/<course_pk>/uploads/<object_pk>/.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "object_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{course_pk}/uploads/{object_pk}/complete/": {
   "post": {
    "operationId": "courses_uploads_complete_create",
    "description": "POST /api/v1/courses/<course_pk>/uploads/<object_pk>/complete/.\n\nIts own route and its own class, for the reason ``coursepkg`` gives for\n``commit``: one view answering both ``\u2026/<pk>/`` and ``\u2026/<pk>/complete/``\nmakes a POST to a URL that reads like a fetch do something that charges the\norg for the bytes it assembles.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "object_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{id}/": {
   "get": {
    "operationId": "courses_retrieve",
    "description": "Supplies the per-lesson access verdicts a learner-visible read needs.\n\nThe map is put in the serializer context *only* for a non-privileged reader. Its\nabsence is what tells ``LessonSerializer`` it is serializing for an author, so a\ncreator is never gated by the schedule they are in the middle of writing.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Course"
        }
       }
      },
      "description": ""
     }
    }
   },
   "put": {
    "operationId": "courses_update",
    "description": "Supplies the per-lesson access verdicts a learner-visible read needs.\n\nThe map is put in the serializer context *only* for a non-privileged reader. Its\nabsence is what tells ``LessonSerializer`` it is serializing for an author, so a\ncreator is never gated by the schedule they are in the middle of writing.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Course"
        }
       }
      },
      "description": ""
     }
    }
   },
   "patch": {
    "operationId": "courses_partial_update",
    "description": "Supplies the per-lesson access verdicts a learner-visible read needs.\n\nThe map is put in the serializer context *only* for a non-privileged reader. Its\nabsence is what tells ``LessonSerializer`` it is serializing for an author, so a\ncreator is never gated by the schedule they are in the middle of writing.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PatchedCourse"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/PatchedCourse"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/PatchedCourse"
       }
      }
     }
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Course"
        }
       }
      },
      "description": ""
     }
    }
   },
   "delete": {
    "operationId": "courses_destroy",
    "description": "Delete a course, refusing when any learner is enrolled.\n\nTwo defects were fixed here, and the second is the serious one.\n\n1. A course with a PAID purchase raised an uncaught ProtectedError \u2014 not\n   from `Enrollment.course`, which is CASCADE, but from\n   `PlatformFeeRecord.enrollment`, which is PROTECT. So the creator got a\n   500 and a 115KB debug page instead of an explanation.\n\n2. A course whose learners got in for FREE \u2014 a 100%-off code, a\n   complimentary grant \u2014 deleted successfully and CASCADE-destroyed every\n   one of their enrolments, along with their LessonProgress and their\n   submitted LessonResponses. Paying learners were protected only by\n   accident, as a side effect of the fee ledger; everyone else silently\n   lost their work.\n\nThe explicit check below replaces both. It refuses on the presence of a\nlive enrolment regardless of how it was granted, so the guarantee no\nlonger depends on whether money happened to change hands. The\nProtectedError catch is kept as a backstop for any other protected\nrelation reaching this model.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{id}/refund/": {
   "post": {
    "operationId": "courses_refund_create",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{id}/refund-policy/": {
   "get": {
    "operationId": "courses_refund_policy_retrieve",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "put": {
    "operationId": "courses_refund_policy_update",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/courses/{id}/touch/": {
   "post": {
    "operationId": "courses_touch_create",
    "description": "POST /api/v1/courses/{pk}/touch/ \u2014 record that this user opened the course.\n\nFired by the course workspace shell, not by the course detail GET: that GET is\nalso issued by the learner player and by background refreshes, neither of which\nis a creator \"opening\" the course.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string"
      },
      "required": true
     }
    ],
    "tags": [
     "courses"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Course"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Course"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/courses/recent/": {
   "get": {
    "operationId": "courses_recent_retrieve",
    "description": "GET /api/v1/courses/recent/ \u2014 this user's most recently opened courses.\n\nUnpaginated by design: the caller is the nav, which renders a fixed-length list\nand would show nothing if handed a `{\"results\": [...]}` envelope.\n\nArchived courses are included. Recency answers \"what did I have open\", and\nfiltering by status would delete a course from the nav at the exact moment the\ncreator archived it \u2014 while they are still working on it.",
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Course"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/courses/redeem/": {
   "post": {
    "operationId": "courses_redeem_create",
    "description": "POST /api/v1/courses/redeem/ \u2014 redeem a course code.\n\nLearner-facing (not org-scoped). Free/100%-off codes enrol immediately;\npartial-discount codes on priced courses return requires_payment so the\nclient can start a discounted checkout with the same code.",
    "tags": [
     "courses"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/currencies/": {
   "get": {
    "operationId": "currencies_retrieve",
    "description": "GET /api/v1/currencies/ \u2014 labeled currency list for the picker.\n\nPublic (AllowAny): the list is non-sensitive and must be reachable before\nonboarding completes, mirroring TimezoneListView.",
    "tags": [
     "currencies"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/integrations/webhooks/": {
   "get": {
    "operationId": "integrations_webhooks_list",
    "parameters": [
     {
      "name": "ordering",
      "required": false,
      "in": "query",
      "description": "Which field to use when ordering the results.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "page",
      "required": false,
      "in": "query",
      "description": "A page number within the paginated result set.",
      "schema": {
       "type": "integer"
      }
     }
    ],
    "tags": [
     "integrations"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PaginatedWebhookEndpointList"
        }
       }
      },
      "description": ""
     }
    }
   },
   "post": {
    "operationId": "integrations_webhooks_create",
    "tags": [
     "integrations"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WebhookEndpointCreate"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/WebhookEndpointCreate"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/WebhookEndpointCreate"
       }
      }
     },
     "required": true
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "201": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/WebhookEndpointCreate"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/integrations/webhooks/{endpoint_id}/deliveries/": {
   "get": {
    "operationId": "integrations_webhooks_deliveries_list",
    "parameters": [
     {
      "in": "path",
      "name": "endpoint_id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "name": "ordering",
      "required": false,
      "in": "query",
      "description": "Which field to use when ordering the results.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "page",
      "required": false,
      "in": "query",
      "description": "A page number within the paginated result set.",
      "schema": {
       "type": "integer"
      }
     }
    ],
    "tags": [
     "integrations"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PaginatedWebhookDeliveryList"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/integrations/webhooks/{endpoint_id}/deliveries/{delivery_id}/redeliver/": {
   "post": {
    "operationId": "integrations_webhooks_deliveries_redeliver_create",
    "parameters": [
     {
      "in": "path",
      "name": "delivery_id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     },
     {
      "in": "path",
      "name": "endpoint_id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "integrations"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/integrations/webhooks/{id}/": {
   "get": {
    "operationId": "integrations_webhooks_retrieve",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "integrations"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/WebhookEndpoint"
        }
       }
      },
      "description": ""
     }
    }
   },
   "patch": {
    "operationId": "integrations_webhooks_partial_update",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "integrations"
    ],
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/PatchedWebhookEndpoint"
       }
      },
      "application/x-www-form-urlencoded": {
       "schema": {
        "$ref": "#/components/schemas/PatchedWebhookEndpoint"
       }
      },
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/PatchedWebhookEndpoint"
       }
      }
     }
    },
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/WebhookEndpoint"
        }
       }
      },
      "description": ""
     }
    }
   },
   "delete": {
    "operationId": "integrations_webhooks_destroy",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "integrations"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/legal/consent/": {
   "get": {
    "operationId": "legal_consent_retrieve",
    "description": "GET/POST /api/v1/legal/consent/ \u2014 what the client needs to render the gate.\n\nA DRF ``APIView``, not a plain Django ``View``. Every creator surface in this app\nauthenticates with a **JWT in localStorage**, not a session \u2014 so a plain View here\nwould 401 the one client that needs it, while passing a test that used\n``force_login``. Being DRF also means the POST is not subject to CSRF for a\nbearer-token caller, which a session-authenticated View would have been with no\ntoken for the SPA to send.\n\nSeparate from `/api/v1/me/`, which the shell already reads, only because the\naffiliate audience needs a parameter. `/me/` carries the `all` answer so the common\ncase costs no extra request.",
    "tags": [
     "legal"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "legal_consent_create",
    "description": "Accept, or defer, from a client that is not the review page.",
    "tags": [
     "legal"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/": {
   "get": {
    "operationId": "me_retrieve",
    "description": "GET/PATCH /api/v1/me/ \u2014 current authenticated user's profile.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "patch": {
    "operationId": "me_partial_update",
    "description": "GET/PATCH /api/v1/me/ \u2014 current authenticated user's profile.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/attendance/badge/": {
   "get": {
    "operationId": "me_attendance_badge_retrieve",
    "description": "GET /api/v1/me/attendance/badge/ \u2014 the learner's rotating in-app code, per org.\n\nOnly ever the hourly ``A`` payload: the permanent printed secret is never served to a\nphone, so a screenshot of this screen stops working within the hour.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/attendance/face-enrolment/": {
   "get": {
    "operationId": "me_attendance_face_enrolment_retrieve",
    "description": "GET/DELETE /api/v1/me/attendance/face-enrolment/ \u2014 the learner's own status\nacross every AI-enabled course, and full withdrawal.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "me_attendance_face_enrolment_destroy",
    "description": "GET/DELETE /api/v1/me/attendance/face-enrolment/ \u2014 the learner's own status\nacross every AI-enabled course, and full withdrawal.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/attendance/face-enrolment/session/": {
   "post": {
    "operationId": "me_attendance_face_enrolment_session_create",
    "description": "POST /api/v1/me/attendance/face-enrolment/session/ -> 410 Gone\n\nLearner face self-enrolment was replaced by QR attendance badges. This route\nused to mint a ``FaceEnrolmentSession`` and hand back an ``/attendance/enrol/``\npage address; that page was deleted, so an old client is told so explicitly\nrather than sent to a 404 carrying a token able to write biometric data.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "410": {
      "description": "Face enrolment has been retired; use the attendance badge instead."
     }
    }
   }
  },
  "/api/v1/me/catalog-subscriptions/": {
   "get": {
    "operationId": "me_catalog_subscriptions_retrieve",
    "description": "`GET /api/v1/me/catalog-subscriptions/` \u2014 every subscription the learner holds.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/catalog-subscriptions/{id}/cancel/": {
   "post": {
    "operationId": "me_catalog_subscriptions_cancel_create",
    "description": "`POST /api/v1/me/catalog-subscriptions/<id>/cancel/` \u2014 immediate.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/catalog-subscriptions/{id}/change-plan/": {
   "post": {
    "operationId": "me_catalog_subscriptions_change_plan_create",
    "description": "`POST /api/v1/me/catalog-subscriptions/<id>/change-plan/ {plan_id}`.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/certificates/": {
   "get": {
    "operationId": "me_certificates_retrieve",
    "description": "GET /api/v1/me/certificates/ \u2014 the caller's own, newest first.\n\n`tenant_unscoped()` IS REQUIRED HERE, and leaving it out silently loses a learner's\ncertificates. `Certificate.objects` is a `TenantManager`, which filters nothing\nitself \u2014 scoping comes entirely from the RLS policy keyed on `app.current_tenant`,\nand `TenantMiddleware` sets that to the caller's *default* org for any authenticated\nrequest, including one that merely holds an affiliate membership somewhere. A learner\nwho redeemed an affiliate code for org A and then earned certificates at orgs B and C\nwould get `[]` here, with no error anywhere.\n\nWorse, it would disagree with the screen beside it: `enrollment_enrollment` carries\nNO RLS policy, so `MyEnrollmentsView` correctly lists every org's enrolment. The\nlearner would be told they completed the course and that no certificate exists.\n\nSame pattern, same reason as `accounts/views.py::CurrentUserView` and\n`catalog/discover_api.py`. The scoping that matters is `learner=request.user`, which\nis not a tenant question at all.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/certificates/{certificate_id}/download/": {
   "get": {
    "operationId": "me_certificates_download_retrieve",
    "description": "GET /api/v1/me/certificates/<id>/download/ \u2014 the PDF.\n\nRE-RENDERS rather than 404s when the file is missing. This is the one deliberate\ndivergence from `CourseExport.is_downloadable`, which refuses in that case: a stale\nexport lies about a course's current content, whereas **the certificate row IS the\ncredential and the PDF is only a rendering of it**. A learner whose file was never\nwritten, or was swept, still holds the credential.",
    "parameters": [
     {
      "in": "path",
      "name": "certificate_id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "me"
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/courses/{course_pk}/access/": {
   "post": {
    "operationId": "me_courses_access_create",
    "description": "POST /api/v1/me/courses/<course_pk>/access/ \u2014 \"I just opened this course\".\n\n``Enrollment.last_accessed_at`` had no writer outside the dev seeder, so the\nlearner dashboard's \"Continue learning\" card sorted on a column that was NULL for\neveryone and silently fell back to the server's ``-granted_at`` order \u2014 the most\nrecently *bought* course rather than the most recently *opened* one.\n\nPOST, never a side effect of the progress GET: that GET is also issued by a\nbackground refresh and by the course menu, neither of which is the learner\nchoosing to open a course.\n\nDeliberately not tenant-scoped. The enrolment itself is the authorization \u2014 the\nsame shape as ``MyEnrollmentsView`` \u2014 so this works on the platform host, where a\nlearner has no ``OrgMembership`` and the tenant is resolved per course.",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/courses/{course_pk}/progress/": {
   "get": {
    "operationId": "me_courses_progress_retrieve",
    "description": "GET /me/courses/<course_pk>/progress/ \u2014 the authenticated learner's own\ncourse-progress dashboard (consumed by the Flutter app).",
    "parameters": [
     {
      "in": "path",
      "name": "course_pk",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/data-export/": {
   "get": {
    "operationId": "me_data_export_retrieve",
    "description": "GET  /api/v1/me/data-export/ \u2014 status of latest export request.\nPOST /api/v1/me/data-export/ \u2014 create a new export request (idempotent if pending/processing).",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "me_data_export_create",
    "description": "GET  /api/v1/me/data-export/ \u2014 status of latest export request.\nPOST /api/v1/me/data-export/ \u2014 create a new export request (idempotent if pending/processing).",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/data-export/{export_id}/download/": {
   "get": {
    "operationId": "me_data_export_download_retrieve",
    "description": "GET /api/v1/me/data-export/<export_id>/download/ \u2014 stream the export file.",
    "parameters": [
     {
      "in": "path",
      "name": "export_id",
      "schema": {
       "type": "integer"
      },
      "required": true
     }
    ],
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/delete/": {
   "post": {
    "operationId": "me_delete_create",
    "description": "POST /api/v1/me/delete/ \u2014 request account deletion/anonymisation.\n\nSign-in stops AT ONCE: the account is deactivated, its password made unusable and\nits sessions ended, so the token that asked is the last one that works. The login\ndata itself is deleted when the account is anonymised after the 30-day grace\nperiod (Celery beat task). A notification email is sent immediately.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/discover/": {
   "get": {
    "operationId": "me_discover_retrieve",
    "description": "GET /api/v1/me/discover/ \u2014 see module docstring for scope rules.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/engagement/": {
   "get": {
    "operationId": "me_engagement_retrieve",
    "description": "GET /api/v1/me/engagement/ \u2014 the learner dashboard's streak and next lesson (#67).\n\nThe learner's own only, and cross-org by nature (see `engagement.next_lesson`).",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/enrollments/": {
   "get": {
    "operationId": "me_enrollments_retrieve",
    "description": "GET /api/v1/me/enrollments/ \u2014 learner's active enrollments.\n\nRedis-cached (30 s per user). Supports If-Modified-Since / Last-Modified for\nefficient mobile foreground polling without push notifications.\n\n``Last-Modified`` describes **the payload**, not any column in it: it is the\nmoment this payload was generated. Every mutation that can change the list\ndeletes the cache key, so the next request necessarily regenerates and stamps a\nnewer validator. Deriving it from a row instead is what made a course the learner\nhad just finished report the progress it had before they opened it.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/phone/attach/": {
   "post": {
    "operationId": "me_phone_attach_create",
    "description": "POST /api/v1/me/phone/attach/ \u2014 {phone, phone_token} \u2192 the number is on record.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/phone/change/confirm/": {
   "post": {
    "operationId": "me_phone_change_confirm_create",
    "description": "POST /api/v1/me/phone/change/confirm/ \u2014 every code, or nothing changes.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/phone/change/start/": {
   "post": {
    "operationId": "me_phone_change_start_create",
    "description": "POST /api/v1/me/phone/change/start/ \u2014 send every code this change costs.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/totp/backup-codes/regenerate/": {
   "post": {
    "operationId": "me_totp_backup_codes_regenerate_create",
    "description": "POST /api/v1/me/totp/backup-codes/regenerate/ -> {backup_codes}\n\nInvalidates every code shown before \u2014 a learner who suspects a code sheet\nwas seen by someone else must be able to void it without disabling and\nlosing their authenticator pairing.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/totp/disable/": {
   "post": {
    "operationId": "me_totp_disable_create",
    "description": "POST /api/v1/me/totp/disable/\n\nRemoves the device outright \u2014 a learner disabling and re-enabling gets a\nfresh secret and fresh backup codes, never a resurrected old pair.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/totp/enable/confirm/": {
   "post": {
    "operationId": "me_totp_enable_confirm_create",
    "description": "POST /api/v1/me/totp/enable/confirm/ {code} -> {backup_codes}\n\nThe first correct code is what turns a generated secret into a live second\nfactor. `backup_codes` is returned exactly once \u2014 the caller must show it to\nthe user now; it cannot be recovered later, only regenerated (which\ninvalidates the ones just shown).",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/me/totp/enable/start/": {
   "post": {
    "operationId": "me_totp_enable_start_create",
    "description": "POST /api/v1/me/totp/enable/start/\n\nGenerates a new secret and stores it UNCONFIRMED \u2014 an unconfirmed device\nnever challenges a login (see `get_confirmed_device`), so a QR code that is\nscanned and never finished cannot lock anyone out.",
    "tags": [
     "me"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/notifications/org-settings/": {
   "get": {
    "operationId": "notifications_org_settings_retrieve",
    "description": "GET/PUT /api/v1/notifications/org-settings/ \u2014 who in this org is emailed.\n\n`IsOrgMember` is what keeps an AFFILIATE out: these settings decide who reads the\norg's revenue, so the read is as privileged as the write. The screen renders the\ncatalogue of events from the GET rather than hardcoding it, so adding an event is\none entry in `recipients.EVENTS` and no template change.",
    "tags": [
     "notifications"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "put": {
    "operationId": "notifications_org_settings_update",
    "description": "GET/PUT /api/v1/notifications/org-settings/ \u2014 who in this org is emailed.\n\n`IsOrgMember` is what keeps an AFFILIATE out: these settings decide who reads the\norg's revenue, so the read is as privileged as the write. The screen renders the\ncatalogue of events from the GET rather than hardcoding it, so adding an event is\none entry in `recipients.EVENTS` and no template change.",
    "tags": [
     "notifications"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/notifications/preferences/": {
   "get": {
    "operationId": "notifications_preferences_retrieve",
    "tags": [
     "notifications"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "patch": {
    "operationId": "notifications_preferences_partial_update",
    "tags": [
     "notifications"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/attendance/device-sessions/{id}/age/": {
   "post": {
    "operationId": "qa_attendance_device_sessions_age_create",
    "description": "``POST /api/v1/qa/attendance/device-sessions/<pk>/age/`` \u2014 make a kiosk idle.\n\nA kiosk locks when ``now - last_activity_at`` exceeds the course's\n``lock_timeout_minutes``, and that field is constrained to **10\u201360 minutes in steps\nof 10** \u2014 so the shortest honest idle a spec could wait for is ten minutes. F-KSK-020\n(\"Get back into a kiosk that locked itself between lessons\") therefore skipped on\nevery run with \"this kiosk booted unlocked\".\n\nThe spec deliberately refuses the other route \u2014 unhiding ``#k-lock`` \u2014 and it is\nright to: that paints the pad without the state its handlers key on, so every\nkeypress is inert and the spec reports a dead keypad on a kiosk that locks perfectly\nwell. **A fabricated state is worse than an unrun test**, because it fails for a\nreason the product does not have.\n\nThis moves the CLOCK instead of the UI: the session becomes genuinely idle, the\nserver's own ``is_locked`` says so, and every downstream branch \u2014 the boot payload,\n``refuse_if_locked``, the unlock endpoint \u2014 behaves exactly as it does for a tablet\nleft alone over lunch. Nothing about the lock logic is bypassed or simulated.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/attendance/face-enrolment-sessions/expire/": {
   "post": {
    "operationId": "qa_attendance_face_enrolment_sessions_expire_create",
    "description": "``POST /api/v1/qa/attendance/face-enrolment-sessions/expire/`` {token, minutes?}\n\u2014 end the CALLER's own self-enrolment link.\n\n``FaceEnrolmentSession`` lives fifteen minutes, so the page's \"This link has ended\"\nscreen for an EXPIRED link (as opposed to a bogus one, which takes the\n``token_invalid`` branch) could only be reached by waiting out the TTL. Like the kiosk\nseam above, this moves the CLOCK \u2014 ``expires_at`` goes into the past \u2014 and never the\nUI: the page then takes the product's own ``token_expired`` branch.\n\nThe session is named by its RAW token (the value the spec holds in the minted URL),\nhashed exactly as the product hashes it, and looked up under ``learner=request.user``\n\u2014 so a persona holding someone else's token still cannot reach their link.",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/courses/{id}/purge/": {
   "delete": {
    "operationId": "qa_courses_purge_destroy",
    "description": "``DELETE /api/v1/qa/courses/<pk>/purge/`` \u2014 remove a scratch course and its enrolments.\n\n404 rather than 403 for a disabled host and for another org's course: confirming that\neither exists is itself a disclosure.",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/me/catalog-plan/": {
   "put": {
    "operationId": "qa_me_catalog_plan_update",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "qa_me_catalog_plan_destroy",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/me/catalog-subscription/": {
   "post": {
    "operationId": "qa_me_catalog_subscription_create",
    "description": "QA-only: give the CALLING learner a REAL Stripe test-mode subscription to an org's\nQA monthly plan, fulfilled through the real `fulfil_session`.\n\nThe learner's \"My subscriptions\" screen (T-190) cancels and switches plan, and both of\nthose call Stripe on the creator's connected account \u2014 so a row seeded with a made-up\nsubscription id would drive the refusal paths and never the success ones. This mints\nthe genuine article in TEST mode (`pm_card_visa`), which is also the only end-to-end\nrun of the subscription against Stripe this feature has.\n\nGuards: 404 outside dev/qa; a declared QA persona or scratch address; and the target\norg must already carry the plans `QaCatalogPlanView` arranges \u2014 so it can only ever\nsubscribe to a plan this seam family created, never a person's real one. `DELETE`\ncancels the caller's live subscriptions (the spec's teardown and precondition reset).",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "qa_me_catalog_subscription_destroy",
    "description": "QA-only: give the CALLING learner a REAL Stripe test-mode subscription to an org's\nQA monthly plan, fulfilled through the real `fulfil_session`.\n\nThe learner's \"My subscriptions\" screen (T-190) cancels and switches plan, and both of\nthose call Stripe on the creator's connected account \u2014 so a row seeded with a made-up\nsubscription id would drive the refusal paths and never the success ones. This mints\nthe genuine article in TEST mode (`pm_card_visa`), which is also the only end-to-end\nrun of the subscription against Stripe this feature has.\n\nGuards: 404 outside dev/qa; a declared QA persona or scratch address; and the target\norg must already carry the plans `QaCatalogPlanView` arranges \u2014 so it can only ever\nsubscribe to a plan this seam family created, never a person's real one. `DELETE`\ncancels the caller's live subscriptions (the spec's teardown and precondition reset).",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/me/domain-claim/": {
   "put": {
    "operationId": "qa_me_domain_claim_update",
    "description": "``PUT``/``DELETE /api/v1/qa/me/domain-claim/`` \u2014 force the caller's own org's\ncustom-domain claim into an arbitrary state, WITHOUT touching real DNS.\n\nWHY THIS EXISTS\n`Organisation.primary_domain_status` normally moves only by way of a real CNAME +\nTXT lookup against public DNS (`apps.tenants.domain_verification.run_check`), which\na browser spec cannot make happen on demand \u2014 the record has to actually exist and\npropagate. That real path is exercised by a SEPARATE, DNS-backed spec (see\ndocs/specs/custom-domain-completion-plan.md); this seam is for the UI-state\ncoverage that spec cannot cheaply produce: what the Domain card and the Custom\nDomain screen look like in `pending_verification` / `verified` / `failed`, and\nwhat a `failed` reason renders as, without waiting on a single real DNS answer.\n\nMirrors ``QaUnverifyOrgView`` in apps.accounts.qa_views: same `qa_enabled()` gate,\nsame persona/scratch-address allowlist, same RLS-unscoping before the write (the\ncaller's tenant is pinned by TenantMiddleware to one org, and the policy would hide\nany other), and the same cache-invalidation discipline.",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "qa_me_domain_claim_destroy",
    "description": "``PUT``/``DELETE /api/v1/qa/me/domain-claim/`` \u2014 force the caller's own org's\ncustom-domain claim into an arbitrary state, WITHOUT touching real DNS.\n\nWHY THIS EXISTS\n`Organisation.primary_domain_status` normally moves only by way of a real CNAME +\nTXT lookup against public DNS (`apps.tenants.domain_verification.run_check`), which\na browser spec cannot make happen on demand \u2014 the record has to actually exist and\npropagate. That real path is exercised by a SEPARATE, DNS-backed spec (see\ndocs/specs/custom-domain-completion-plan.md); this seam is for the UI-state\ncoverage that spec cannot cheaply produce: what the Domain card and the Custom\nDomain screen look like in `pending_verification` / `verified` / `failed`, and\nwhat a `failed` reason renders as, without waiting on a single real DNS answer.\n\nMirrors ``QaUnverifyOrgView`` in apps.accounts.qa_views: same `qa_enabled()` gate,\nsame persona/scratch-address allowlist, same RLS-unscoping before the write (the\ncaller's tenant is pinned by TenantMiddleware to one org, and the policy would hide\nany other), and the same cache-invalidation discipline.",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/me/lesson-attempts/": {
   "delete": {
    "operationId": "qa_me_lesson_attempts_destroy",
    "description": "``DELETE /api/v1/qa/me/lesson-attempts/?lesson=<id>`` \u2014 un-attempt one lesson.\n\nLeaves the caller as though they had never opened it: no attempts, no stored\nresponse, not complete, no grade. 404 rather than 403 for a disabled host, matching\nthe other two QA routes \u2014 on a host where this does not apply the answer must be\nindistinguishable from a route that was never registered.",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/me/org-membership/": {
   "delete": {
    "operationId": "qa_me_org_membership_destroy",
    "description": "``DELETE /api/v1/qa/me/org-membership/`` \u2014 return the caller to being org-less.\n\n404 rather than 403 for a disabled host, matching the course-purge route: on a host\nwhere this does not apply, the answer must be indistinguishable from a route that was\nnever registered.",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/me/org-verification/": {
   "delete": {
    "operationId": "qa_me_org_verification_destroy",
    "description": "``DELETE /api/v1/qa/me/org-verification/`` \u2014 put the caller's OWN orgs back to\nhaving cleared neither door.\n\nWHY IT LIVES HERE rather than under ``apps.tenants``\nBeside :class:`QaReleaseOrgMembershipView`, which is the other seam about the\ncaller's relationship to their organisation and already reaches into\n``tenants.models``. A third ``qa_urls`` module for one route would put two halves\nof the same question in two places.\n\nWHY IT IS NEEDED AT ALL \u2014 measured, not assumed\nF-CRE-002's entry state (an org that has cleared NEITHER door) cannot be produced\nthrough the product on any role as configured: ``ALPHA_REQUIRE_CODE`` is on, an\nalpha signup requires a handset, and confirming that handset IS the proof, so\n``_provision_user_org`` marks the org verified as it creates it. The learner ->\n``/api/v1/tenants/setup/`` route is gated on ``holds_alpha_place``, and a learner\nsignup deliberately drops any code posted to it. Full reasoning and the\nmeasurements: ``lms/tests/test_qa_org_verification_seam.py``.\n\nGuards are :class:`QaReleasePolicyAcceptancesView`'s, and the last one is what makes\nthis safe to offer: it is RECOVERABLE THROUGH THE PRODUCT \u2014 clearing the doors is\nprecisely what F-CRE-002 then drives the creator to do.",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/me/platform-subscription/": {
   "get": {
    "operationId": "qa_me_platform_subscription_retrieve",
    "description": "Report the row this seam manages, so a fixture can WAIT on it cheaply.\n\nAdded for T-158. Its fixture needs to know when `charge.succeeded` has recorded\nthe recovery anchor, and the only product-facing observable is the password-reset\nconfirm endpoint answering 409 \u2014 which costs a code issue and a real (failing)\nPostmark send every time it is asked, ~2s a poll. Polling that for a minute was\nslower than the thing it was waiting for and drowned the signal.\n\nRead-only, and it reports whether an anchor EXISTS rather than its value: a\nfingerprint is a stable identifier for a real card and there is no reason for a\ntest transport to carry it.",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "put": {
    "operationId": "qa_me_platform_subscription_update",
    "description": "``PUT``/``DELETE /api/v1/qa/me/platform-subscription/`` \u2014 put the caller's org on\na paid plan, and take it off again.\n\nF-LRN-120 step ``s3`` reads ``effective_price`` \u2014 \"the price they AGREED to, never\nthe plan's list price\", the flow's own note calling out that quoting a grandfathered\nsubscriber today's number tells them they pay something they do not. The SPA renders\nthat region under ``if (sub.status != 'free')``\n(``int_lms_client/lib/screens/subscription_screen.dart``), and every QA org is on the\nfree tier \u2014 so the step had never executed on any run and stood as the suite's only\n``G-STEP-UNCOVERED``.\n\nThere is **no product route to this state**, and there should not be: a paid\nsubscription is created by Stripe's webhook after money moves. A spec cannot pay, so\nthe alternative to this seam is a step that can never be driven.\n\n**It fabricates no Stripe identifiers.** The row it creates carries an empty\n``stripe_subscription_id`` on purpose: this is a local fixture, and a fabricated\n``sub_...`` would be a lie that some later reconciliation would chase. Anything\nkeying off a real subscription id is therefore NOT exercised by this state, and a\nspec must not use it to claim otherwise.\n\n``stripe_customer_id`` is the one exception, and it is not an exception to that\nrule: it is accepted only when the caller has really created the customer in Stripe\ntest mode, and it is validated rather than trusted. F-ACC-020 needs it because\n``record_anchor`` finds the subscription BY customer id, so with the empty string a\ngenuine ``charge.succeeded`` matches no row and the card anchor is never written.\nHanding over the real customer is what lets the product's own webhook decide.\n\n**The agreed price deliberately differs from the plan's list price**, because that is\nthe case the flow exists for. Equal prices would render the same region and prove\nnothing about which of the two numbers the screen chose \u2014 the one bug this step can\ncatch.\n\nCalled on ENTRY and restored in a ``finally``, like every other seam here: a run that\ndies mid-flow leaves the org subscribed either way, and an org left on a fake paid\nplan changes what `max_courses` and the transaction fee do to every later spec.",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "qa_me_platform_subscription_destroy",
    "description": "Put the org back on the free tier by removing the row this seam wrote.\n\nIt refuses to delete a row carrying a ``stripe_subscription_id``. Every row this\nseam creates has an empty one, so that condition means a REAL subscription \u2014 and\na QA teardown that silently unsubscribed a paying organisation is a far worse\noutcome than a fixture that failed to clean up.",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/me/policy-acceptances/": {
   "delete": {
    "operationId": "qa_me_policy_acceptances_destroy",
    "description": "``DELETE /api/v1/qa/me/policy-acceptances/`` \u2014 leave the caller owing every\nconsent-bearing document, with the grace unspent.",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/me/storage-addon/": {
   "delete": {
    "operationId": "qa_me_storage_addon_destroy",
    "description": "``DELETE /api/v1/qa/me/storage-addon/`` \u2014 undo a storage purchase this suite made.\n\nF-CRE-151 completes a REAL Stripe Checkout, and what it buys is a **recurring\nsubscription**. The product offers no in-app way to stop one \u2014 cancellation is\nStripe's billing portal (``purchase.py`` says so) \u2014 so without this every run of that\nflow would leave the organisation one block larger and one subscription heavier, for\never. Measured after the first green run: ``blocks: 1``, renewing monthly.\n\nThat is precisely the debris this suite's own rules forbid: the org's allowance is\nshared state, and a flow whose subject is \"an upload that will not fit\" is the last\none that should be quietly enlarging it.\n\nIt cancels at STRIPE first and only then forgets the row. The other way round leaves\na live subscription nothing points at \u2014 invisible locally and still billing \u2014 which\nis the worse of the two failures by a distance. A cancellation that fails is\nreported rather than swallowed, for the same reason.\n\nGated exactly as its siblings: dev/qa only, a declared QA persona, and the caller's\nOWN organisation. It can cancel nothing it cannot already see.",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/me/storage-ballast/": {
   "post": {
    "operationId": "qa_me_storage_ballast_create",
    "description": "``POST``/``DELETE /api/v1/qa/me/storage-ballast/`` \u2014 fill and empty the allowance.\n\n``POST {\"fill_to_remaining_bytes\": N}`` writes one ballast row sized so the org is\nleft with exactly N bytes free \u2014 which is what a spec actually wants to say, and it\nis computed from the SERVER's current snapshot rather than from a number the spec\nguessed. Passing ``gigabytes`` instead writes that much regardless.",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "delete": {
    "operationId": "qa_me_storage_ballast_destroy",
    "description": "``POST``/``DELETE /api/v1/qa/me/storage-ballast/`` \u2014 fill and empty the allowance.\n\n``POST {\"fill_to_remaining_bytes\": N}`` writes one ballast row sized so the org is\nleft with exactly N bytes free \u2014 which is what a spec actually wants to say, and it\nis computed from the SERVER's current snapshot rather than from a number the spec\nguessed. Passing ``gigabytes`` instead writes that much regardless.",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/me/stripe-connect/": {
   "delete": {
    "operationId": "qa_me_stripe_connect_destroy",
    "description": "``DELETE /api/v1/qa/me/stripe-connect/`` \u2014 forget the caller org's Connect account.\n\nF-LRN-130 (\"Set up payouts so the money from my courses can reach me\") can only be\ndriven by an org that has NOT set them up, and driving it CONSUMES that state: the\nConnect button mints a real `acct_...` and the screen never offers it again. Without\nthis the flow covers once on a fresh database and skips as \"already connected\" for\nthe rest of that estate's life \u2014 which is what it had been doing.\n\nCalled on ENTRY rather than as a teardown, for the same reason the lesson-attempt\nrelease is: a run that dies mid-flow leaves the account connected either way, and\nonly the next run's entry can put it right.\n\n**It deliberately forgets the row rather than deleting anything at Stripe.** The\nremote account still exists in the test-mode dashboard; this is a local fixture\nreset, not a cleanup, and saying so is the point \u2014 a QA route that implied it had\nrevoked something at a payment processor would be lying about the blast radius.\n\n**Do not point it at qa-academy.** F-LRN-130 drives the RIVAL creator precisely\nbecause qa-academy's Connect account is load-bearing elsewhere: T-093 completes a\nreal course purchase through it, and with the record gone that task fails 0/6.\nMeasured 2026-08-24 while probing this endpoint's own authz \u2014 the release worked,\nnothing reported it, and the purchase journey failed a whole cycle later looking\nlike a Stripe outage. Restored by restarting the app, which re-runs\n`setup_qa_fixtures`.",
    "tags": [
     "qa"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/qa/signup-email/": {
   "delete": {
    "operationId": "qa_signup_email_destroy",
    "description": "``DELETE /api/v1/qa/signup-email/`` \u2014 delete the User (if any) at one of the\nfixed, Cloudflare-routed ``qamail.intelligena.com`` addresses the QA suite\nreuses for signup/gate journeys, so a re-run does not meet \"already\nregistered\".\n\nWHY THIS EXISTS\n``qamail.intelligena.com`` has Cloudflare Email Routing rules for a small,\nFIXED set of local parts only (``EmailRoutingManager.QA_ALL_LOCAL_PARTS``). A\nspec that mints ``qa-signup-<uuid>@qamail.intelligena.com`` per run has no\nrouting rule for that local part \u2014 it hard-bounces at SMTP, and Postmark then\nsuppresses the exact address account-wide, permanently. A spec needing a\n\"fresh\" signup address must instead REUSE one of the already-routed addresses\nand get uniqueness by deleting the account between runs, never by minting a\nnew one.\n\nWHY IT IS UNAUTHENTICATED, unlike the sibling seams above\nThose act on ``request.user`` because the caller already holds a token for the\naccount being torn down. This one runs BEFORE the address has \u2014 or may ever\nhave had \u2014 an account, so there is nothing to authenticate as. Safety comes\nfrom a HARDCODED allow-list, never from the caller: the posted email must be\nbyte-identical to one of two literals, so nothing about this route can be\naimed at an address it does not already name, and a disabled/misconfigured\ndeployment refuses every request identically to a route that was never\nregistered.",
    "tags": [
     "qa"
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/storage/checkout/": {
   "post": {
    "operationId": "storage_checkout_create",
    "description": "POST /api/v1/storage/checkout/ \u2014 buy N blocks of extra storage.\n\nReturns a Stripe Checkout URL. Nothing is granted here: the blocks are added\nonly once Stripe confirms payment, by ``purchase.fulfil_storage_session``.\nThat is the same rule ``PlatformSubscriptionView.post`` had to be changed to\nobey after it handed out the top tier for free.",
    "tags": [
     "storage"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/storage/objects/": {
   "get": {
    "operationId": "storage_objects_list",
    "description": "GET /api/v1/storage/objects/ \u2014 this org's files, biggest first.\n\nOrdered by size rather than date because the question this table answers is\n\"what is filling my allowance\", and the answer is at the top of the list\nrather than page four of it.",
    "parameters": [
     {
      "name": "ordering",
      "required": false,
      "in": "query",
      "description": "Which field to use when ordering the results.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "page",
      "required": false,
      "in": "query",
      "description": "A page number within the paginated result set.",
      "schema": {
       "type": "integer"
      }
     }
    ],
    "tags": [
     "storage"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PaginatedStoredObjectListList"
        }
       }
      },
      "description": ""
     }
    }
   }
  },
  "/api/v1/storage/usage/": {
   "get": {
    "operationId": "storage_usage_retrieve",
    "description": "GET /api/v1/storage/usage/ \u2014 what is used, what is allowed, what a block costs.",
    "tags": [
     "storage"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/tenants/api-keys/": {
   "get": {
    "operationId": "tenants_api_keys_retrieve",
    "description": "Listing/revoking a key you already have does not itself require an\nactive plan (a lapsed org must still be able to see and clean up its own\nkeys) -- only POST (minting a NEW one) is gated on entitlement, checked\ninline below, per binding decision #3.",
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "tenants_api_keys_create",
    "description": "Listing/revoking a key you already have does not itself require an\nactive plan (a lapsed org must still be able to see and clean up its own\nkeys) -- only POST (minting a NEW one) is gated on entitlement, checked\ninline below, per binding decision #3.",
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/tenants/api-keys/{id}/": {
   "delete": {
    "operationId": "tenants_api_keys_destroy",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "204": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/tenants/api-keys/{id}/scopes/": {
   "put": {
    "operationId": "tenants_api_keys_scopes_update",
    "parameters": [
     {
      "in": "path",
      "name": "id",
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "required": true
     }
    ],
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/tenants/branding/": {
   "patch": {
    "operationId": "tenants_branding_partial_update",
    "description": "PATCH /api/v1/tenants/branding/\n\nUpdate org logo_url, tagline, intro_text, and accent_color (theme key).\nRequires authentication and ADMIN or CREATOR membership in the tenant org.\nReturns 400 if called on a platform host (no tenant).",
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/tenants/config/": {
   "get": {
    "operationId": "tenants_config_retrieve",
    "description": "GET /api/v1/tenants/config/\n\nReturns org bootstrap config for the Flutter client.\n- Platform host (request.tenant is None): {\"platform\": true}\n- Tenant host: full org config including plan and Stripe Connect status\n- Unknown subdomain: 404 (handled by TenantMiddleware before reaching this view)",
    "tags": [
     "tenants"
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/tenants/domain/": {
   "get": {
    "operationId": "tenants_domain_retrieve",
    "description": "GET  /api/v1/tenants/domain/  \u2014 current custom-domain claim, or the \"none\" state\nPOST /api/v1/tenants/domain/  \u2014 submit (or replace) the org's custom domain\n\nGated to plans with `custom_domain_allowed` \u2014 every paid plan grants it, so this\nonly refuses an org with no plan row at all. Enforced HERE, not only by hiding the\ncontrol in the UI. See /intelligena/CLAUDE.md's security rules: a gate that lives\nonly in the client is not a gate.",
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "tenants_domain_create",
    "description": "GET  /api/v1/tenants/domain/  \u2014 current custom-domain claim, or the \"none\" state\nPOST /api/v1/tenants/domain/  \u2014 submit (or replace) the org's custom domain\n\nGated to plans with `custom_domain_allowed` \u2014 every paid plan grants it, so this\nonly refuses an org with no plan row at all. Enforced HERE, not only by hiding the\ncontrol in the UI. See /intelligena/CLAUDE.md's security rules: a gate that lives\nonly in the client is not a gate.",
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/tenants/domain/check/": {
   "post": {
    "operationId": "tenants_domain_check_create",
    "description": "POST /api/v1/tenants/domain/check/ \u2014 re-run the DNS check now, synchronously.\n\nShares apps.tenants.domain_verification.run_check with the Celery poller\n(verify-custom-domains, every 5 min) \u2014 one DNS-checking code path.",
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/tenants/join-code/": {
   "get": {
    "operationId": "tenants_join_code_retrieve",
    "description": "GET  /api/v1/tenants/join-code/  \u2014 return current valid join code, or {\"code\": null}\nPOST /api/v1/tenants/join-code/  \u2014 generate/regenerate a 24-hour code\n\nRequires authentication and membership in request.tenant.",
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "post": {
    "operationId": "tenants_join_code_create",
    "description": "GET  /api/v1/tenants/join-code/  \u2014 return current valid join code, or {\"code\": null}\nPOST /api/v1/tenants/join-code/  \u2014 generate/regenerate a 24-hour code\n\nRequires authentication and membership in request.tenant.",
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/tenants/logo/": {
   "post": {
    "operationId": "tenants_logo_create",
    "description": "POST /api/v1/tenants/logo/ \u2014 multipart image upload, replacing the previous\n\"paste a URL\" Logo field on org settings.\n\nStores the file, replaces any previous OrgLogo row for the org (one logo per org \u2014\na re-upload is a REPLACEMENT, not a second file left orphaned on disk), and writes\nthe resulting serve URL onto `Organisation.logo_url` so every existing reader of\nthat field (the sign-in page, both navbars) keeps working unchanged \u2014 the upload\nmechanism changed, the stored fact did not.",
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/tenants/onboarding/": {
   "get": {
    "operationId": "tenants_onboarding_retrieve",
    "description": "GET /api/v1/tenants/onboarding/ \u2014 the creator setup guide's steps (#80).\n\nEach step's `done` is derived from the org on every request; see\napps.tenants.onboarding and docs/specs/onboarding-wizard.md.",
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/tenants/onboarding/skip/": {
   "post": {
    "operationId": "tenants_onboarding_skip_create",
    "description": "POST /api/v1/tenants/onboarding/skip/ {\"step\", \"skipped\"} \u2014 skip or undo a step.\n\nA skip is this user's alone. Skipping a step that is already done is 409: it would\nmean nothing, and a client offering it is showing a control that should not exist.",
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/tenants/setup/": {
   "post": {
    "operationId": "tenants_setup_create",
    "description": "POST /api/v1/tenants/setup/\n\nCreate an Organisation for an authenticated user who has no existing membership.\nAccepts {\"org_name\": \"<string>\"} (required, non-blank, max 255 chars).\nReturns 400 if the user already belongs to an organisation.",
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/tenants/subdomain/": {
   "get": {
    "operationId": "tenants_subdomain_retrieve",
    "description": "GET   /api/v1/tenants/subdomain/  \u2014 the org's current `<subdomain>.yoshuko.com`\nPATCH /api/v1/tenants/subdomain/  \u2014 change it, on ANY plan\n\nThis is deliberately separate from `OrgDomainView`, which owns a fully\nexternal custom domain and is gated to paid plans (`custom_domain_allowed`). The Yoshuko\nsubdomain is the address every org has from the moment it is created \u2014\n\"one of two proofs of person\" territory, not a premium feature \u2014 so this\nview carries no plan gate at all.",
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   },
   "patch": {
    "operationId": "tenants_subdomain_partial_update",
    "description": "GET   /api/v1/tenants/subdomain/  \u2014 the org's current `<subdomain>.yoshuko.com`\nPATCH /api/v1/tenants/subdomain/  \u2014 change it, on ANY plan\n\nThis is deliberately separate from `OrgDomainView`, which owns a fully\nexternal custom domain and is gated to paid plans (`custom_domain_allowed`). The Yoshuko\nsubdomain is the address every org has from the moment it is created \u2014\n\"one of two proofs of person\" territory, not a premium feature \u2014 so this\nview carries no plan gate at all.",
    "tags": [
     "tenants"
    ],
    "security": [
     {
      "cookieAuth": []
     }
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/api/v1/timezones/": {
   "get": {
    "operationId": "timezones_retrieve",
    "description": "GET /api/v1/timezones/ \u2014 labeled IANA timezone list for the picker.\n\nPublic (AllowAny): the list is non-sensitive and must be reachable before\nonboarding completes. Computed per-request so DST offsets stay current.\n\n``?anchor=<IANA id>`` orders the list by the hour starting at that zone, for the\nweb picker, which passes the browser's detected zone. Unrecognised anchors are\nignored rather than rejected \u2014 see ``labeled_timezones``.",
    "tags": [
     "timezones"
    ],
    "security": [
     {
      "cookieAuth": []
     },
     {}
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  },
  "/billing/stripe/webhook/": {
   "post": {
    "operationId": "billing_stripe_webhook_create",
    "tags": [
     "billing"
    ],
    "responses": {
     "200": {
      "description": "No response body"
     }
    }
   }
  }
 },
 "components": {
  "schemas": {
   "AgentLesson": {
    "type": "object",
    "description": "Strips the learner-facing `access`/`availability_state` machinery an\nagent has no use for and never gates content by them -- an agent acting\non behalf of the creator reads/writes the full lesson, same as the\ncreator's own authoring session (`access_map` is never populated here).",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "chapter_id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "title": {
      "type": "string",
      "maxLength": 255
     },
     "position": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648
     },
     "is_published": {
      "type": "boolean"
     },
     "content_type": {
      "$ref": "#/components/schemas/ContentTypeEnum"
     },
     "content": {},
     "is_free_preview": {
      "type": "boolean"
     },
     "opens_day_offset": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "opens_time": {
      "type": "string",
      "format": "time"
     },
     "closes_day_offset": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "closes_time": {
      "type": "string",
      "format": "time"
     },
     "view_window_minutes": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "availability_state": {
      "type": "string",
      "readOnly": true
     },
     "access": {
      "type": "string",
      "readOnly": true
     },
     "updated_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    },
    "required": [
     "access",
     "availability_state",
     "chapter_id",
     "content_type",
     "id",
     "position",
     "title",
     "updated_at"
    ]
   },
   "AuditLogEntry": {
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "readOnly": true
     },
     "action_type": {
      "type": "string",
      "maxLength": 128
     },
     "action_label": {
      "type": "string",
      "readOnly": true
     },
     "actor_name": {
      "type": "string",
      "readOnly": true
     },
     "actor_email": {
      "type": "string",
      "readOnly": true
     },
     "target_model": {
      "type": "string",
      "maxLength": 128
     },
     "target_id": {
      "type": "string",
      "maxLength": 64
     },
     "before": {},
     "after": {},
     "created_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    },
    "required": [
     "action_label",
     "action_type",
     "actor_email",
     "actor_name",
     "created_at",
     "id"
    ]
   },
   "BlankEnum": {
    "enum": [
     ""
    ]
   },
   "Chapter": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "course_id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "title": {
      "type": "string",
      "maxLength": 255
     },
     "position": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648
     },
     "is_published": {
      "type": "boolean"
     },
     "requires_previous_chapter": {
      "type": "boolean"
     },
     "sequential_lessons": {
      "type": "boolean"
     },
     "lessons": {
      "type": "string",
      "readOnly": true
     }
    },
    "required": [
     "course_id",
     "id",
     "lessons",
     "position",
     "title"
    ]
   },
   "ContentTypeEnum": {
    "enum": [
     "quiz",
     "lesson",
     "video",
     "document",
     "assignment",
     "embed"
    ],
    "type": "string",
    "description": "* `quiz` - Assessment\n* `lesson` - Lesson\n* `video` - Video\n* `document` - Document\n* `assignment` - Assignment\n* `embed` - Embed"
   },
   "Course": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "org_id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "title": {
      "type": "string",
      "maxLength": 255
     },
     "slug": {
      "type": "string",
      "maxLength": 255,
      "pattern": "^[-a-zA-Z0-9_]+$"
     },
     "description": {},
     "thumbnail_url": {
      "oneOf": [
       {
        "type": "string",
        "format": "uri",
        "maxLength": 200
       },
       {
        "type": "string",
        "maxLength": 0
       }
      ]
     },
     "status": {
      "$ref": "#/components/schemas/CourseStatusEnum"
     },
     "visibility": {
      "$ref": "#/components/schemas/VisibilityEnum"
     },
     "level": {
      "$ref": "#/components/schemas/LevelEnum"
     },
     "grade_level": {
      "oneOf": [
       {
        "$ref": "#/components/schemas/GradeLevelEnum"
       },
       {
        "$ref": "#/components/schemas/BlankEnum"
       }
      ]
     },
     "grade_level_display": {
      "type": "string",
      "readOnly": true
     },
     "language": {
      "type": "string",
      "maxLength": 16
     },
     "category": {
      "type": "integer",
      "nullable": true
     },
     "estimated_duration_minutes": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "sales_page_blocks": {},
     "landing_title": {
      "type": "string",
      "maxLength": 255
     },
     "meta_title": {
      "type": "string",
      "maxLength": 255
     },
     "meta_description": {
      "type": "string",
      "maxLength": 512
     },
     "og_image_url": {
      "oneOf": [
       {
        "type": "string",
        "format": "uri",
        "maxLength": 200
       },
       {
        "type": "string",
        "maxLength": 0
       }
      ]
     },
     "published_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true,
      "nullable": true
     },
     "created_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updated_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "timezone": {
      "type": "string",
      "maxLength": 64
     },
     "length_mode": {
      "$ref": "#/components/schemas/LengthModeEnum"
     },
     "duration_days": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "start_date": {
      "type": "string",
      "format": "date",
      "nullable": true
     },
     "end_date": {
      "type": "string",
      "format": "date",
      "nullable": true
     },
     "schedule_basis": {
      "$ref": "#/components/schemas/ScheduleBasisEnum"
     },
     "schedule_horizon_days": {
      "type": "integer",
      "readOnly": true
     },
     "import_state": {
      "type": "string",
      "readOnly": true
     }
    },
    "required": [
     "created_at",
     "grade_level_display",
     "id",
     "import_state",
     "org_id",
     "published_at",
     "schedule_horizon_days",
     "slug",
     "title",
     "updated_at"
    ]
   },
   "CourseLearner": {
    "type": "object",
    "properties": {
     "enrollment_id": {
      "type": "string",
      "format": "uuid"
     },
     "learner_id": {
      "type": "string",
      "format": "uuid"
     },
     "username": {
      "type": "string",
      "nullable": true
     },
     "email": {
      "type": "string",
      "format": "email",
      "nullable": true
     },
     "display_name": {
      "type": "string"
     },
     "granted_at": {
      "type": "string",
      "format": "date-time"
     },
     "progress_pct": {
      "type": "integer"
     },
     "cohorts": {
      "type": "string",
      "readOnly": true
     },
     "can_reset_password": {
      "type": "boolean",
      "readOnly": true
     },
     "student_number": {
      "type": "string",
      "readOnly": true
     },
     "student_number_is_school": {
      "type": "boolean",
      "readOnly": true
     }
    },
    "required": [
     "can_reset_password",
     "cohorts",
     "display_name",
     "email",
     "enrollment_id",
     "granted_at",
     "learner_id",
     "progress_pct",
     "student_number",
     "student_number_is_school",
     "username"
    ]
   },
   "CourseStatusEnum": {
    "enum": [
     "draft",
     "published",
     "archived"
    ],
    "type": "string",
    "description": "* `draft` - Draft\n* `published` - Published\n* `archived` - Archived"
   },
   "EventTypeEnum": {
    "enum": [
     "course.published",
     "course.unpublished",
     "course.archived",
     "enrollment.refunded",
     "org.plan_changed",
     "org.connect_enabled",
     "org.domain_verified",
     "certificate.issued"
    ],
    "type": "string",
    "description": "* `course.published` - Course published\n* `course.unpublished` - Course unpublished\n* `course.archived` - Course archived\n* `enrollment.refunded` - Enrolment refunded\n* `org.plan_changed` - Plan changed\n* `org.connect_enabled` - Stripe Connect enabled\n* `org.domain_verified` - Custom domain verified\n* `certificate.issued` - Certificate issued"
   },
   "FamilyEnum": {
    "enum": [
     "video",
     "audio",
     "image",
     "document",
     "archive"
    ],
    "type": "string",
    "description": "* `video` - Video\n* `audio` - Audio\n* `image` - Image\n* `document` - Document\n* `archive` - Archive \u2014 transient, being processed"
   },
   "GradeLevelEnum": {
    "enum": [
     "k-2",
     "3-5",
     "6-8",
     "9-10",
     "11-12",
     "undergraduate",
     "professional"
    ],
    "type": "string",
    "description": "* `k-2` - Kindergarten \u2013 Grade 2 (ages 5\u20138)\n* `3-5` - Grades 3\u20135 (ages 8\u201311)\n* `6-8` - Grades 6\u20138 (ages 11\u201314)\n* `9-10` - Grades 9\u201310 (ages 14\u201316)\n* `11-12` - Grades 11\u201312 (ages 16\u201318)\n* `undergraduate` - Undergraduate\n* `professional` - Professional / adult learner"
   },
   "LengthModeEnum": {
    "enum": [
     "none",
     "duration",
     "fixed"
    ],
    "type": "string",
    "description": "* `none` - No set length\n* `duration` - Duration \u2014 N days from each learner's start\n* `fixed` - Fixed start and end dates"
   },
   "Lesson": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "chapter_id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "title": {
      "type": "string",
      "maxLength": 255
     },
     "position": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648
     },
     "is_published": {
      "type": "boolean"
     },
     "content_type": {
      "$ref": "#/components/schemas/ContentTypeEnum"
     },
     "content": {},
     "is_free_preview": {
      "type": "boolean"
     },
     "opens_day_offset": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "opens_time": {
      "type": "string",
      "format": "time"
     },
     "closes_day_offset": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "closes_time": {
      "type": "string",
      "format": "time"
     },
     "view_window_minutes": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "availability_state": {
      "type": "string",
      "readOnly": true
     },
     "access": {
      "type": "string",
      "readOnly": true
     },
     "updated_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    },
    "required": [
     "access",
     "availability_state",
     "chapter_id",
     "content_type",
     "id",
     "position",
     "title",
     "updated_at"
    ]
   },
   "LevelEnum": {
    "enum": [
     "beginner",
     "intermediate",
     "advanced"
    ],
    "type": "string",
    "description": "* `beginner` - Beginner\n* `intermediate` - Intermediate\n* `advanced` - Advanced"
   },
   "PaginatedAuditLogEntryList": {
    "type": "object",
    "required": [
     "count",
     "results"
    ],
    "properties": {
     "count": {
      "type": "integer",
      "example": 123
     },
     "next": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=4"
     },
     "previous": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=2"
     },
     "results": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/AuditLogEntry"
      }
     }
    }
   },
   "PaginatedCourseLearnerList": {
    "type": "object",
    "required": [
     "count",
     "results"
    ],
    "properties": {
     "count": {
      "type": "integer",
      "example": 123
     },
     "next": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=4"
     },
     "previous": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=2"
     },
     "results": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/CourseLearner"
      }
     }
    }
   },
   "PaginatedCourseList": {
    "type": "object",
    "required": [
     "count",
     "results"
    ],
    "properties": {
     "count": {
      "type": "integer",
      "example": 123
     },
     "next": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=4"
     },
     "previous": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=2"
     },
     "results": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Course"
      }
     }
    }
   },
   "PaginatedPurchaseRowList": {
    "type": "object",
    "required": [
     "count",
     "results"
    ],
    "properties": {
     "count": {
      "type": "integer",
      "example": 123
     },
     "next": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=4"
     },
     "previous": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=2"
     },
     "results": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/PurchaseRow"
      }
     }
    }
   },
   "PaginatedReplyList": {
    "type": "object",
    "required": [
     "count",
     "results"
    ],
    "properties": {
     "count": {
      "type": "integer",
      "example": 123
     },
     "next": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=4"
     },
     "previous": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=2"
     },
     "results": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Reply"
      }
     }
    }
   },
   "PaginatedStoredObjectListList": {
    "type": "object",
    "required": [
     "count",
     "results"
    ],
    "properties": {
     "count": {
      "type": "integer",
      "example": 123
     },
     "next": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=4"
     },
     "previous": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=2"
     },
     "results": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/StoredObjectList"
      }
     }
    }
   },
   "PaginatedTopicList": {
    "type": "object",
    "required": [
     "count",
     "results"
    ],
    "properties": {
     "count": {
      "type": "integer",
      "example": 123
     },
     "next": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=4"
     },
     "previous": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=2"
     },
     "results": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Topic"
      }
     }
    }
   },
   "PaginatedWebhookDeliveryList": {
    "type": "object",
    "required": [
     "count",
     "results"
    ],
    "properties": {
     "count": {
      "type": "integer",
      "example": 123
     },
     "next": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=4"
     },
     "previous": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=2"
     },
     "results": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/WebhookDelivery"
      }
     }
    }
   },
   "PaginatedWebhookEndpointList": {
    "type": "object",
    "required": [
     "count",
     "results"
    ],
    "properties": {
     "count": {
      "type": "integer",
      "example": 123
     },
     "next": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=4"
     },
     "previous": {
      "type": "string",
      "nullable": true,
      "format": "uri",
      "example": "http://api.example.org/accounts/?page=2"
     },
     "results": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/WebhookEndpoint"
      }
     }
    }
   },
   "PatchedAgentLesson": {
    "type": "object",
    "description": "Strips the learner-facing `access`/`availability_state` machinery an\nagent has no use for and never gates content by them -- an agent acting\non behalf of the creator reads/writes the full lesson, same as the\ncreator's own authoring session (`access_map` is never populated here).",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "chapter_id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "title": {
      "type": "string",
      "maxLength": 255
     },
     "position": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648
     },
     "is_published": {
      "type": "boolean"
     },
     "content_type": {
      "$ref": "#/components/schemas/ContentTypeEnum"
     },
     "content": {},
     "is_free_preview": {
      "type": "boolean"
     },
     "opens_day_offset": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "opens_time": {
      "type": "string",
      "format": "time"
     },
     "closes_day_offset": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "closes_time": {
      "type": "string",
      "format": "time"
     },
     "view_window_minutes": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "availability_state": {
      "type": "string",
      "readOnly": true
     },
     "access": {
      "type": "string",
      "readOnly": true
     },
     "updated_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "PatchedChapter": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "course_id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "title": {
      "type": "string",
      "maxLength": 255
     },
     "position": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648
     },
     "is_published": {
      "type": "boolean"
     },
     "requires_previous_chapter": {
      "type": "boolean"
     },
     "sequential_lessons": {
      "type": "boolean"
     },
     "lessons": {
      "type": "string",
      "readOnly": true
     }
    }
   },
   "PatchedCourse": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "org_id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "title": {
      "type": "string",
      "maxLength": 255
     },
     "slug": {
      "type": "string",
      "maxLength": 255,
      "pattern": "^[-a-zA-Z0-9_]+$"
     },
     "description": {},
     "thumbnail_url": {
      "oneOf": [
       {
        "type": "string",
        "format": "uri",
        "maxLength": 200
       },
       {
        "type": "string",
        "maxLength": 0
       }
      ]
     },
     "status": {
      "$ref": "#/components/schemas/CourseStatusEnum"
     },
     "visibility": {
      "$ref": "#/components/schemas/VisibilityEnum"
     },
     "level": {
      "$ref": "#/components/schemas/LevelEnum"
     },
     "grade_level": {
      "oneOf": [
       {
        "$ref": "#/components/schemas/GradeLevelEnum"
       },
       {
        "$ref": "#/components/schemas/BlankEnum"
       }
      ]
     },
     "grade_level_display": {
      "type": "string",
      "readOnly": true
     },
     "language": {
      "type": "string",
      "maxLength": 16
     },
     "category": {
      "type": "integer",
      "nullable": true
     },
     "estimated_duration_minutes": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "sales_page_blocks": {},
     "landing_title": {
      "type": "string",
      "maxLength": 255
     },
     "meta_title": {
      "type": "string",
      "maxLength": 255
     },
     "meta_description": {
      "type": "string",
      "maxLength": 512
     },
     "og_image_url": {
      "oneOf": [
       {
        "type": "string",
        "format": "uri",
        "maxLength": 200
       },
       {
        "type": "string",
        "maxLength": 0
       }
      ]
     },
     "published_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true,
      "nullable": true
     },
     "created_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updated_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "timezone": {
      "type": "string",
      "maxLength": 64
     },
     "length_mode": {
      "$ref": "#/components/schemas/LengthModeEnum"
     },
     "duration_days": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "start_date": {
      "type": "string",
      "format": "date",
      "nullable": true
     },
     "end_date": {
      "type": "string",
      "format": "date",
      "nullable": true
     },
     "schedule_basis": {
      "$ref": "#/components/schemas/ScheduleBasisEnum"
     },
     "schedule_horizon_days": {
      "type": "integer",
      "readOnly": true
     },
     "import_state": {
      "type": "string",
      "readOnly": true
     }
    }
   },
   "PatchedLesson": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "chapter_id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "title": {
      "type": "string",
      "maxLength": 255
     },
     "position": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648
     },
     "is_published": {
      "type": "boolean"
     },
     "content_type": {
      "$ref": "#/components/schemas/ContentTypeEnum"
     },
     "content": {},
     "is_free_preview": {
      "type": "boolean"
     },
     "opens_day_offset": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "opens_time": {
      "type": "string",
      "format": "time"
     },
     "closes_day_offset": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "closes_time": {
      "type": "string",
      "format": "time"
     },
     "view_window_minutes": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "availability_state": {
      "type": "string",
      "readOnly": true
     },
     "access": {
      "type": "string",
      "readOnly": true
     },
     "updated_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "PatchedWebhookEndpoint": {
    "type": "object",
    "description": "Metadata only. `secret` never appears here \u2014 see `WebhookEndpointCreateSerializer`\nfor the one response it is ever rendered in.",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "url": {
      "type": "string",
      "format": "uri",
      "maxLength": 2048
     },
     "event_types": {},
     "is_active": {
      "type": "boolean"
     },
     "created_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updated_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    }
   },
   "PlatformPlan": {
    "type": "object",
    "description": "A plan as the pricing page and the API present it.\n\nEvery field here originates in config (app_config_state.plans) and is seeded\nby `manage.py sync_platform_plans`. Nothing may hardcode a price or a limit.",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "slug": {
      "type": "string",
      "maxLength": 32
     },
     "name": {
      "type": "string",
      "maxLength": 64
     },
     "monthly_price_usd": {
      "type": "string",
      "format": "decimal",
      "pattern": "^-?\\d{0,6}(?:\\.\\d{0,2})?$"
     },
     "currency": {
      "type": "string",
      "maxLength": 3
     },
     "transaction_fee_percent": {
      "type": "string",
      "format": "decimal",
      "pattern": "^-?\\d{0,3}(?:\\.\\d{0,2})?$"
     },
     "max_courses": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "max_enrolled_learners": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648,
      "nullable": true
     },
     "custom_domain_allowed": {
      "type": "boolean"
     },
     "is_retired": {
      "type": "boolean"
     },
     "display_order": {
      "type": "integer",
      "maximum": 2147483647,
      "minimum": -2147483648
     },
     "tagline": {
      "type": "string",
      "readOnly": true
     },
     "features": {
      "type": "array",
      "items": {},
      "readOnly": true
     },
     "is_highlighted": {
      "type": "boolean",
      "readOnly": true
     }
    },
    "required": [
     "features",
     "id",
     "is_highlighted",
     "monthly_price_usd",
     "name",
     "slug",
     "tagline",
     "transaction_fee_percent"
    ]
   },
   "PurchaseRow": {
    "type": "object",
    "description": "One row of the org's revenue ledger, for the screen the payment emails link to.\n\nRead off `PlatformFeeRecord`, which is INSERT-ONLY, rather than off `Enrollment`:\na refund is a second row rather than an edit, so the ledger a creator reconciles\nagainst their Stripe balance is the one that already exists. Deriving it from\nenrolments would need the refund reconstructed, and a reconstruction that\ndisagrees with the money is worse than no screen.",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "course_id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "course_title": {
      "type": "string",
      "readOnly": true
     },
     "learner_name": {
      "type": "string",
      "readOnly": true
     },
     "learner_email": {
      "type": "string",
      "readOnly": true,
      "default": ""
     },
     "purchased_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "gross_amount": {
      "type": "string",
      "format": "decimal",
      "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$"
     },
     "fee_amount": {
      "type": "string",
      "format": "decimal",
      "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$"
     },
     "net_to_creator": {
      "type": "string",
      "format": "decimal",
      "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$"
     },
     "currency": {
      "type": "string",
      "maxLength": 3
     },
     "fee_percent_applied": {
      "type": "string",
      "format": "decimal",
      "pattern": "^-?\\d{0,3}(?:\\.\\d{0,2})?$"
     },
     "kind": {
      "type": "string",
      "readOnly": true
     }
    },
    "required": [
     "course_id",
     "course_title",
     "fee_amount",
     "fee_percent_applied",
     "gross_amount",
     "id",
     "kind",
     "learner_email",
     "learner_name",
     "net_to_creator",
     "purchased_at"
    ]
   },
   "Reply": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "body": {
      "type": "string"
     },
     "author_name": {
      "type": "string",
      "readOnly": true
     },
     "is_mine": {
      "type": "string",
      "readOnly": true
     },
     "is_course_team": {
      "type": "string",
      "readOnly": true
     },
     "edited": {
      "type": "string",
      "readOnly": true
     },
     "removed": {
      "type": "string",
      "readOnly": true
     },
     "removed_label": {
      "type": "string",
      "readOnly": true
     },
     "can_edit": {
      "type": "string",
      "readOnly": true
     },
     "created_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "reply_to": {
      "type": "string",
      "format": "uuid",
      "nullable": true
     },
     "reply_to_author": {
      "type": "string",
      "readOnly": true
     }
    },
    "required": [
     "author_name",
     "body",
     "can_edit",
     "created_at",
     "edited",
     "id",
     "is_course_team",
     "is_mine",
     "removed",
     "removed_label",
     "reply_to_author"
    ]
   },
   "ScheduleBasisEnum": {
    "enum": [
     "absolute",
     "relative"
    ],
    "type": "string",
    "description": "* `absolute` - Fixed calendar dates \u2014 everyone on the same schedule\n* `relative` - Days after each learner's start date"
   },
   "StoredObjectList": {
    "type": "object",
    "description": "One row of the Manage storage table.\n\nCarries ``course_id`` because the delete route is course-scoped: without it\nthe table would have a Delete button and no URL to send it to.",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "family": {
      "allOf": [
       {
        "$ref": "#/components/schemas/FamilyEnum"
       }
      ],
      "readOnly": true
     },
     "status": {
      "allOf": [
       {
        "$ref": "#/components/schemas/StoredObjectListStatusEnum"
       }
      ],
      "readOnly": true
     },
     "mime_type": {
      "type": "string",
      "readOnly": true
     },
     "size_bytes": {
      "type": "integer",
      "readOnly": true
     },
     "original_filename": {
      "type": "string",
      "readOnly": true
     },
     "serve_url": {
      "type": "string",
      "readOnly": true
     },
     "course_id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "course_title": {
      "type": "string",
      "readOnly": true
     },
     "created_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    },
    "required": [
     "course_id",
     "course_title",
     "created_at",
     "family",
     "id",
     "mime_type",
     "original_filename",
     "serve_url",
     "size_bytes",
     "status"
    ]
   },
   "StoredObjectListStatusEnum": {
    "enum": [
     "pending",
     "ready",
     "archived",
     "failed"
    ],
    "type": "string",
    "description": "* `pending` - Uploading\n* `ready` - Ready\n* `archived` - Archived \u2014 owner's plan lapsed\n* `failed` - Failed"
   },
   "TokenRefresh": {
    "type": "object",
    "properties": {
     "access": {
      "type": "string",
      "readOnly": true
     },
     "refresh": {
      "type": "string",
      "writeOnly": true
     }
    },
    "required": [
     "access",
     "refresh"
    ]
   },
   "Topic": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "body": {
      "type": "string"
     },
     "author_name": {
      "type": "string",
      "readOnly": true
     },
     "is_mine": {
      "type": "string",
      "readOnly": true
     },
     "is_course_team": {
      "type": "string",
      "readOnly": true
     },
     "edited": {
      "type": "string",
      "readOnly": true
     },
     "removed": {
      "type": "string",
      "readOnly": true
     },
     "removed_label": {
      "type": "string",
      "readOnly": true
     },
     "can_edit": {
      "type": "string",
      "readOnly": true
     },
     "created_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "title": {
      "type": "string",
      "maxLength": 200
     },
     "lesson": {
      "type": "string",
      "format": "uuid",
      "nullable": true
     },
     "pinned": {
      "type": "boolean",
      "readOnly": true
     },
     "locked": {
      "type": "boolean",
      "readOnly": true
     },
     "reply_count": {
      "type": "integer",
      "readOnly": true
     },
     "last_activity_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    },
    "required": [
     "author_name",
     "body",
     "can_edit",
     "created_at",
     "edited",
     "id",
     "is_course_team",
     "is_mine",
     "last_activity_at",
     "locked",
     "pinned",
     "removed",
     "removed_label",
     "reply_count",
     "title"
    ]
   },
   "VisibilityEnum": {
    "enum": [
     "public",
     "unlisted",
     "private"
    ],
    "type": "string",
    "description": "* `public` - Public\n* `unlisted` - Unlisted\n* `private` - Private"
   },
   "WebhookDelivery": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "event_type": {
      "allOf": [
       {
        "$ref": "#/components/schemas/EventTypeEnum"
       }
      ],
      "readOnly": true
     },
     "status": {
      "allOf": [
       {
        "$ref": "#/components/schemas/WebhookDeliveryStatusEnum"
       }
      ],
      "readOnly": true
     },
     "attempt_count": {
      "type": "integer",
      "readOnly": true
     },
     "next_attempt_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true,
      "nullable": true
     },
     "last_response_status": {
      "type": "integer",
      "readOnly": true,
      "nullable": true
     },
     "last_error": {
      "type": "string",
      "readOnly": true
     },
     "delivered_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true,
      "nullable": true
     },
     "created_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    },
    "required": [
     "attempt_count",
     "created_at",
     "delivered_at",
     "event_type",
     "id",
     "last_error",
     "last_response_status",
     "next_attempt_at",
     "status"
    ]
   },
   "WebhookDeliveryStatusEnum": {
    "enum": [
     "pending",
     "delivered",
     "failed"
    ],
    "type": "string",
    "description": "* `pending` - Pending\n* `delivered` - Delivered\n* `failed` - Failed"
   },
   "WebhookEndpoint": {
    "type": "object",
    "description": "Metadata only. `secret` never appears here \u2014 see `WebhookEndpointCreateSerializer`\nfor the one response it is ever rendered in.",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "url": {
      "type": "string",
      "format": "uri",
      "maxLength": 2048
     },
     "event_types": {},
     "is_active": {
      "type": "boolean"
     },
     "created_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updated_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     }
    },
    "required": [
     "created_at",
     "id",
     "updated_at",
     "url"
    ]
   },
   "WebhookEndpointCreate": {
    "type": "object",
    "description": "Adds `secret` to the OUTPUT only, on the one response that follows creation.\nThe secret is generated here, never accepted from the client.",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "readOnly": true
     },
     "url": {
      "type": "string",
      "format": "uri",
      "maxLength": 2048
     },
     "event_types": {},
     "is_active": {
      "type": "boolean"
     },
     "created_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "updated_at": {
      "type": "string",
      "format": "date-time",
      "readOnly": true
     },
     "secret": {
      "type": "string",
      "readOnly": true
     }
    },
    "required": [
     "created_at",
     "id",
     "secret",
     "updated_at",
     "url"
    ]
   }
  },
  "securitySchemes": {
   "ApiKeyAuth": {
    "type": "http",
    "scheme": "bearer",
    "bearerFormat": "ilk_<prefix>.<secret>",
    "description": "An organisation API key, sent as `Authorization: Bearer ilk_<prefix>.<secret>`. Create one in the creator studio under Organisation settings \u2192 API keys (requires an active paid plan). Each key carries per-resource scopes (none / view / edit) for courses, chapters, lessons and org_settings."
   },
   "cookieAuth": {
    "type": "apiKey",
    "in": "cookie",
    "name": "sessionid"
   }
  }
 }
}