Version: 1.3 Date: July 16, 2026 Target Audience: Next.js Frontend Developers (Teacher & Assistant Views) Backend: Django REST Framework
Note: This API documentation applies to both Teacher and Assistant roles unless explicitly stated. Assistants have access to the same Course Dashboard, content management, enrollments, quizzes, homeworks, and materials as teachers. The only Assistant restriction is they cannot create or delete assistants (view-only).
The API uses JWT tokens stored in HTTP-Only cookies.
POST /api/accounts/login/. The server sets two cookies:
access_token — short-lived (30 minutes)refresh_token — long-lived (7 days)Authorization header needed.Authorization: Bearer <token> header.POST /api/accounts/token/refresh/. The server reads the refresh token from the cookie, generates new tokens, and blacklists the old refresh token.POST /api/accounts/logout/. The server blacklists the refresh token and clears both cookies.Inactive User Blocking:
| Status | Condition | Response Body |
|---|---|---|
401 Unauthorized |
Not authenticated (missing/invalid token) | {"detail": "Authentication credentials were not provided."} |
401 Unauthorized |
Token expired | {"error": "Invalid or expired refresh token"} |
403 Forbidden |
Insufficient permissions | {"detail": "You do not have permission to perform this action."} |
404 Not Found |
Object does not exist | {"detail": "Not found."} or {"error": "String"} |
500 Internal Server Error |
Unexpected server error | {"detail": "Internal server error"} |
List endpoints that support pagination return this wrapper by default:
{
"count": "Integer",
"next": "String (URL) | null",
"previous": "String (URL) | null",
"results": "Array[Object]"
}
Bypass pagination by adding ?all=true to get all results in a single response:
{
"count": "Integer",
"results": "Array[Object]"
}
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
page |
Integer | Page number (default: 1) |
page_size |
Integer | Items per page (default: 50, max: 200) |
all |
String | Set to true to bypass pagination |
These endpoints allow a teacher to manage their own assistants.
Description: List all assistants belonging to the authenticated teacher.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active)
Success Response — 200 OK:
{
"count": "Integer",
"next": "String (URL) | null",
"previous": "String (URL) | null",
"results": [
{
"id": "Integer",
"user_id": "Integer",
"username": "String",
"name": "String",
"phone": "String",
"gmail": "String",
"gender": "String (male|female) | null",
"profile_picture": "String (URL) | null",
"is_active": "Boolean",
"created_at": "DateTime (ISO 8601)",
"teacher": "Integer — Teacher ID"
}
]
}
Description: Create a new assistant for the authenticated teacher.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active)
Content-Type: multipart/form-data
Request Body:
{
"username": "String (Required)",
"password": "String (Required) — Minimum 8 characters",
"name": "String (Required)",
"phone": "String (Required)",
"gmail": "String (Required) — Must be globally unique",
"gender": "String (Optional) — male|female",
"profile_picture": "File (Optional) — image/jpeg|image/png|image/webp"
}
GET /accounts/teacher/assistant/<id>/Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active) (must own the assistant)
PUT/PATCH /accounts/teacher/assistant/<id>/`Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active) (must own the assistant)
DELETE /accounts/teacher/assistant/<id>/Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active) (must own the assistant)
Success Response — 204 No Content
Description: List courses taught by the authenticated teacher.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active)
Success Response — 200 OK: Array of courses.
{
"count": "Integer",
"next": "String (URL) | null",
"previous": "String (URL) | null",
"results": [
{
"id": "Integer",
"name": "String",
"teacher": "Integer",
"teacher_name": "String",
"grade": "Integer",
"grade_name": "String",
"subject": "Integer",
"subject_name": "String",
"description": "String | null",
"cover_picture": "String (URL) | null",
"is_active": "Boolean",
"topic_count": "Integer",
"enrolled_count": "Integer - Number of approved students",
"pending_count": "Integer - Number of pending requests",
"rejected_count": "Integer - Number of rejected requests",
"created_at": "DateTime (ISO 8601)"
}
]
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
403 |
Not a teacher | {"detail": "You do not have permission..."} |
404 |
Teacher profile not found | {"detail": "Teacher profile not found."} |
GET /courses/<id>/analytics/Description: Returns enrollment and purchase analytics for a specific course. Teachers/assistants can only view their own courses.
Authentication: Teacher, Assistant (must own the course)
Success Response — 200 OK:
{
"course_id": "Integer",
"course_name": "String",
"enrollment_stats": {
"total_enrolled": "Integer — Approved enrollments",
"pending": "Integer",
"rejected": "Integer"
},
"purchase_stats": {
"total_purchases": "Integer",
"total_revenue": "String (Decimal)"
},
"topics_stats": [
{
"topic_id": "Integer",
"topic_name": "String",
"lecture_count": "Integer",
"purchase_count": "Integer",
"revenue": "String (Decimal)"
}
],
"lectures_stats": [
{
"lecture_id": "Integer",
"lecture_name": "String",
"topic_id": "Integer",
"topic_name": "String",
"price": "String (Decimal)",
"final_price": "String (Decimal)",
"purchase_count": "Integer",
"revenue": "String (Decimal)"
}
]
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
401 |
Not authenticated | {"detail": "Authentication credentials were not provided."} |
403 |
Not the course owner | {"error": "You can only view analytics for your own courses."} |
404 |
Course not found | {"detail": "Not found."} |
GET /courses/enrollments/?course=X&status=approvedDescription: List all approved students enrolled in a specific course, with each student's balance for this course only (not their total across all courses).
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Success Response — 200 OK:
{
"count": "Integer",
"results": [
{
"id": "Integer",
"student": "Integer",
"student_name": "String (en / ar)",
"student_code": "String",
"phone_number": "String — Student's phone number",
"father_number": "String — Father's phone number",
"mother_number": "String — Mother's phone number",
"course": "Integer",
"course_name": "String",
"grade_name": "String",
"teacher_name": "String",
"subject_name": "String | null",
"status": "String (pending|approved|rejected)",
"status_display": "String (Pending|Approved|Rejected)",
"balance": "String (Decimal) — Current balance for this course",
"cover_picture": "String (URL) | null — Only for approved enrollments",
"topic_count": "Integer | null — Only for approved enrollments",
"total_lectures": "Integer | null — Only for approved enrollments",
"enrolled_at": "DateTime (ISO 8601)",
"responded_by": "Integer | null",
"responded_by_name": "String | null",
"responded_at": "DateTime | null",
"response_note": "String | null"
}
]
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
404 |
Course not found | {"detail": "Not found."} |
GET /courses/<id>/topics/Description: List topics for a specific course (convenience shortcut for GET /courses/topics/?course=<id>).
Authentication: Any authenticated user
Success Response — 200 OK: Same format as GET /courses/topics/.
Description: List all topics.
Authentication: Any authenticated user
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
course |
Integer | Filter by course ID |
is_active |
Boolean | Filter by active status |
search |
String | Search by name, description, or course name |
ordering |
String | order, created_at, name |
Success Response — 200 OK:
{
"count": "Integer",
"next": "String (URL) | null",
"previous": "String (URL) | null",
"results": [
{
"id": "Integer",
"course": "Integer",
"course_name": "String",
"name": "String",
"description": "String | null",
"picture": "String (URL) | null",
"order": "Integer",
"is_active": "Boolean",
"lecture_count": "Integer",
"created_at": "DateTime (ISO 8601)",
"updated_at": "DateTime (ISO 8601)"
}
]
}
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active)
Content-Type: application/json
Request Body:
{
"course": "Integer (Required) — Course ID",
"name": "String (Required)",
"description": "String (Optional)",
"order": "Integer (Optional) — Default: 0",
"is_active": "Boolean (Optional) — Default: true"
}
GET /courses/topics/<id>/Authentication: Any authenticated user
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
as_student |
String | Set to true to preview what a student sees (hides inactive topics, invisible lectures) |
Success Response — 200 OK: Single topic with nested lectures.
{
"id": "Integer",
"course": "Integer",
"course_name": "String",
"name": "String",
"description": "String | null",
"order": "Integer",
"is_active": "Boolean",
"lectures": [
{
"id": "Integer",
"topic": "Integer",
"topic_name": "String",
"course_name": "String",
"teacher_name": "String",
"name": "String",
"description": "String | null",
"price": "Decimal",
"discount": "Decimal — Flat discount amount in EGP (not percentage)",
"final_price": "Decimal — Price after subtracting discount (never below 0)",
"formatted_price": "String",
"available_days": "Integer",
"is_visible": "Boolean",
"picture": "String (URL) | null",
"order": "Integer",
"videos": "Array[Object]",
"video_count": "Integer",
"created_at": "DateTime (ISO 8601)",
"updated_at": "DateTime (ISO 8601)"
}
],
"created_at": "DateTime (ISO 8601)",
"updated_at": "DateTime (ISO 8601)"
}
PUT/PATCH /courses/topics/<id>/`Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active)
DELETE /courses/topics/<id>/Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active)
Authentication: Any authenticated user
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
topic |
Integer | Filter by topic ID |
is_visible |
Boolean | Filter by visibility |
search |
String | Search by name, description, or topic name |
ordering |
String | order, price, created_at, name |
Success Response — 200 OK: Array of lectures.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active)
Content-Type: multipart/form-data
Request Body:
{
"topic": "Integer (Required) — Topic ID",
"name": "String (Required)",
"description": "String (Optional)",
"price": "Decimal (Required)",
"discount": "Decimal (Optional) — Default: 0.00",
"available_days": "Integer (Required) — Days of access after purchase (1-365)",
"is_visible": "Boolean (Optional) — Default: true",
"picture": "File (Optional) — image/jpeg|image/png|image/webp",
"order": "Integer (Optional) — Default: 0"
}
Success Response — 201 Created: Single lecture.
GET /courses/lectures/<id>/Authentication: Any authenticated user
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
as_student |
String | Set to true to preview what a student sees (hides inactive videos) |
Success Response — 200 OK: Single lecture with nested videos.
{
"id": "Integer",
"topic": "Integer",
"topic_name": "String",
"course_name": "String",
"teacher_name": "String",
"name": "String",
"description": "String | null",
"price": "Decimal",
"discount": "Decimal",
"final_price": "Decimal",
"formatted_price": "String",
"available_days": "Integer",
"is_visible": "Boolean",
"picture": "String (URL) | null",
"order": "Integer",
"videos": [
{
"id": "Integer",
"lecture": "Integer",
"name": "String",
"bunny_video_id": "String | null — Bunny Stream GUID",
"bunny_status": "Integer | null — 0=Created, 1=Uploaded, 2=Processing, 3=Transcoding, 4=Finished, 5=Error",
"bunny_status_display": "String — Human-readable status (Pending, Created, Uploaded, Finished, etc.)",
"thumbnail_url": "String | null — Bunny auto-generated thumbnail URL (https://{cdn}/{id}/thumbnail.jpg)",
"order": "Integer",
"is_active": "Boolean",
"created_at": "DateTime (ISO 8601)",
"updated_at": "DateTime (ISO 8601)"
}
],
"video_count": "Integer",
"materials_count": "Integer � Number of active study materials",
"homeworks_count": "Integer � Number of published homeworks",
"quizzes_count": "Integer � Number of published quizzes",
"prerequisites": [
{
"content_type": "String (quiz|exam)",
"content_name": "String",
"passing_score": "Decimal"
}
],
"created_at": "DateTime (ISO 8601)",
"updated_at": "DateTime (ISO 8601)"
}
PUT/PATCH /courses/lectures/<id>/`Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active)
DELETE /courses/lectures/<id>/Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active)
All videos are hosted on Bunny Stream. The upload uses TUS direct upload — the backend
generates time-limited credentials server-side, and the frontend uploads directly to Bunny Stream
using tus-js-client. No video bytes ever pass through the backend.
1. Teacher creates a video record → POST /courses/videos/
2. Frontend calls `POST /courses/videos/<id>/create-upload/` → returns TUS credentials
(library_id, signature, expiration_time). API key stays server-side.
3. Frontend uploads directly to Bunny using tus-js-client:
→ endpoint: https://video.bunnycdn.com/tusupload
→ headers: AuthorizationSignature, AuthorizationExpire, VideoId, LibraryId
→ Zero backend CPU/RAM/bandwidth used
4. Bunny processes the video and calls the webhook when done
5. Frontend polls `GET /courses/videos/<id>/` until a ready status (3/4)
Authentication: Any authenticated user
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
lecture |
Integer | Filter by lecture ID |
bunny_status |
Integer | Filter by encoding status |
is_active |
Boolean | Filter by active status |
search |
String | Search by name |
ordering |
String | order, created_at |
Success Response — 200 OK:
{
"count": "Integer",
"results": [
{
"id": "Integer",
"lecture": "Integer",
"name": "String",
"bunny_video_id": "String | null — Bunny Stream GUID",
"bunny_status": "Integer | null — 0=Created, 1=Uploaded, 2=Processing, 3=Transcoding, 4=Finished, 5=Error",
"bunny_status_display": "String — Human-readable status (Pending, Created, Uploaded, Finished, etc.)",
"thumbnail_url": "String | null — Bunny auto-generated thumbnail URL (https://{cdn}/{id}/thumbnail.jpg)",
"order": "Integer",
"is_active": "Boolean",
"created_at": "DateTime (ISO 8601)",
"updated_at": "DateTime (ISO 8601)"
}
]
}
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active)
Content-Type: application/json
Request Body:
{
"lecture": "Integer (Required) — Lecture ID",
"name": "String (Required)",
"order": "Integer (Optional) — Default: 0",
"is_active": "Boolean (Optional) — Default: true"
}
Success Response — 201 Created: Single video object (same structure as list item).
GET /courses/videos/<id>/Authentication: Any authenticated user
Success Response — 200 OK: Single video object.
POST /courses/videos/<id>/create-upload/Description: Creates the video entry on Bunny Stream and returns TUS credentials
for direct browser-to-Bunny upload. The backend creates the Bunny entry (API key stays
server-side) and generates a time-limited SHA256 signature. The frontend uses these
credentials with tus-js-client to upload directly to Bunny — zero backend bandwidth.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active) (must own the lecture's course)
Request Body: None
Success Response — 201 Created:
{
"video_id": "Integer — Local video ID",
"bunny_video_id": "String — Bunny Stream GUID",
"library_id": "Integer — Bunny Stream library ID (725542)",
"expiration_time": "Integer — Unix timestamp (seconds) when credentials expire",
"signature": "String — SHA256 hex signature for TUS AuthorizationSignature header"
}
⚠️
expiration_timeis in SECONDS — JavaScript'sDate.now()returns milliseconds. Passexpiration_timedirectly to theAuthorizationExpireheader without multiplying by 1000.
PUT/PATCH /courses/videos/<id>/Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active)
Description: Update video metadata. When name is changed, the backend also updates the
title on Bunny Stream so the Bunny dashboard stays in sync.
Request Body:
{
"name": "String (Optional) — New video title",
"order": "Integer (Optional)",
"is_active": "Boolean (Optional)"
}
Success Response — 200 OK: Updated video object (same structure as list item).
Note: If the Bunny update fails, the local DB is still updated and the error is logged.
DELETE /courses/videos/<id>/Description: Deletes the video from both the local database and Bunny Stream. If the Bunny deletion fails (network issue), the local record is still removed and the error is logged.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active)
POST /courses/enrollments/approve/Description: Approve pending enrollments. Supports both single and bulk approval. Rejected enrollments can also be re-approved (the approval action accepts pending AND rejected rows; rejection only accepts pending).
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Content-Type: application/json
Request Body:
{
"enrollment_ids": "[Integer] (required) — IDs of pending/rejected enrollments to approve",
"response_note": "String (optional) — Note for the student"
}
Success Response — 200 OK:
{
"processed": "Integer — Number successfully approved",
"total_requested": "Integer",
"errors": "[{enrollment_id, error}]"
}
Verified-student gate: enrollments whose student account is not verified (pending/declined/suspended) are skipped — they appear in
errors[]as"Student account is not verified yet."and staypending. Verify the student in the SiteOwner Students page first.
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
403 |
Not course owner | {"error": "You can only manage enrollments for your own courses"} |
400 |
Not pending | {"error": "Cannot approve enrollment with status: X"} |
404 |
Enrollment not found | {"error": "Enrollment not found"} |
POST /courses/enrollments/reject/Description: Reject pending enrollments. Supports both single and bulk rejection.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Content-Type: application/json
Request Body:
{
"enrollment_ids": "[Integer] (required) — IDs of pending enrollments to reject",
"response_note": "String (optional) — Note for the student"
}
Success Response — 200 OK:
{
"processed": "Integer — Number successfully rejected",
"total_requested": "Integer",
"errors": "[{enrollment_id, error}]"
}
Error Responses: Same as approve endpoint.
POST /courses/enrollments/<pk>/block/Description: Temporarily block an approved student from the whole course. While blocked, the student gets 403 {error, blocked: true, reason} on video playback, purchases, and the course-lectures page, and their lectures disappear from my-lectures/.
Authentication: Teacher, Assistant (own course), SiteOwner
Content-Type: application/json
Request Body:
{
"reason": "String (required) — Why the student is blocked"
}
Success Response — 200 OK:
{
"message": "Student blocked from the course.",
"is_blocked": true
}
Error Responses:
| Status | Condition |
|---|---|
400 |
Reason missing |
400 |
Enrollment not approved |
403 |
Not the course owner |
404 |
Enrollment not found |
POST /courses/enrollments/<pk>/unblock/Description: Remove the block from a student in a course (records who/when).
Authentication: Teacher, Assistant (own course), SiteOwner
Success Response — 200 OK:
{
"message": "Student unblocked.",
"is_blocked": false
}
Error Responses: Same as block endpoint (minus the reason check).
Enrollment response fields (new):
is_blocked,block_reason,blocked_by_name,blocked_at,unblocked_by_name,unblocked_at— available inGET /courses/enrollments/and the enroll response.
GET /courses/lectures/<id>/students-progress/Description: Returns ALL enrolled students for a lecture's course in a single response. For each student: purchase status, homework score, quiz score, and watch progress. Non-buyers are included (shows is_purchased: false). This replaces the need for 4+ separate API calls.
One response = everything you need for the Purchases tab.
Authentication: Teacher, Assistant
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
page |
Integer | Page number (default: 1) |
page_size |
Integer | Items per page (default: 50, max: 200) |
search |
String | Filter by student name (en/ar) or student code — case-insensitive partial match |
is_purchased |
Boolean | true = only students who bought this lecture, false = only those who haven't |
Error Responses:
| Status | Condition |
|---|---|
403 |
Not course owner |
404 |
Lecture not found |
Success Response — 200 OK:
{
"lecture_id": 10,
"lecture_name": "Intro to Reactions",
"topic_name": "Unit 1: Chemical Reactions",
"course_name": "Chemistry 3rd Secondary",
"teacher_name": "Dr Hany",
"total_videos": 3,
"total_students": 25,
"purchased_count": 20,
"homeworks": [
{"id": 1, "title": "Week 1 HW", "total": 3},
{"id": 2, "title": "Week 2 HW", "total": 5}
],
"quizzes": [
{"id": 1, "title": "Quiz 1", "total": 10, "max_attempts": 1}
],
"videos": [
{"id": 30, "name": "Video 1", "order": 1},
{"id": 31, "name": "Video 2", "order": 2},
{"id": 32, "name": "Video 3", "order": 3}
],
"count": 25,
"next": "https://.../students-progress/?page=2&page_size=50",
"previous": null,
"results": [
{
"student_id": 1,
"student_name": "Ahmed Ali",
"student_name_ar": "أحمد علي",
"student_code": "1234567",
"is_purchased": true,
"purchase": {
"id": 5,
"amount_paid": "40.00",
"purchased_at": "2026-07-01T10:00:00Z",
"expires_at": "2026-07-31T10:00:00Z",
"extra_days": 2,
"is_expired": false,
"reopened_by_name": "Dr Hany",
"reopened_at": "2026-07-28T10:00:00Z",
"reopen_logs": [
{"reopened_by": "Dr Hany", "reopened_at": "2026-07-28T10:00:00Z"},
{"reopened_by": "Dr Hany", "reopened_at": "2026-08-01T10:00:00Z"}
],
"max_watch_count": 4,
"sessions_used": 1,
"can_reopen": false
},
"homeworks": [
{"homework_id": 1, "title": "Week 1 HW", "submitted": true, "score": "2.00", "submission_id": 10},
{"homework_id": 2, "title": "Week 2 HW", "submitted": false, "score": null, "submission_id": null}
],
"quizzes": [
{"quiz_id": 1, "title": "Quiz 1", "submitted": false, "score": null}
],
"watch": {
"watched_count": 2,
"total_videos": 3,
"completed_count": 1,
"percentage": 67,
"videos": [
{
"video_id": 30,
"progress_seconds": 540,
"cumulative_watch_seconds": 520,
"duration_seconds": 600,
"is_completed": true,
"last_watched_at": "2026-08-11T09:00:00Z",
"progress_percentage": 86.7
},
{
"video_id": 31,
"progress_seconds": 0,
"cumulative_watch_seconds": 0,
"duration_seconds": null,
"is_completed": false,
"last_watched_at": null,
"progress_percentage": 0
},
{
"video_id": 32,
"progress_seconds": 300,
"cumulative_watch_seconds": 300,
"duration_seconds": 600,
"is_completed": false,
"last_watched_at": "2026-08-10T15:30:00Z",
"progress_percentage": 50.0
}
]
}
}
]
}
Field Reference — Top Level:
| Field | Description |
|---|---|
lecture_id / lecture_name |
The lecture this data belongs to (once, not per-row) |
topic_name / course_name / teacher_name |
Context info |
total_videos |
Number of videos in this lecture |
total_students |
Number of approved-enrolled students |
purchased_count |
How many of those students bought this lecture |
homeworks |
Array of all homeworks for this lecture: {id, title, total} |
quizzes |
Array of all quizzes for this lecture: {id, title, total, max_attempts} |
videos |
Array of all videos in this lecture: {id, name, order} — same length/order as each row's watch.videos |
count |
Total number of students (for pagination) |
next / previous |
Pagination links |
Per-student fields (results[]):
| Field | Description |
|---|---|
student_id / student_name / student_name_ar / student_code |
Student identity (English name + Arabic name + code) |
is_purchased |
Whether this student bought the lecture |
purchase |
Full purchase object (null if not purchased) |
purchase.sessions_used |
How many viewing sessions have been consumed |
purchase.can_reopen |
true if the purchase is expired AND has fewer than 2 reopens |
homeworks[] |
Per-student array matching homeworks meta: each has homework_id, title, submitted, score, submission_id |
quizzes[] |
Per-student array matching quizzes meta: each has quiz_id, title, submitted, score |
watch.watched_count |
Sum of watch counts across all videos in this lecture |
watch.total_videos |
How many videos are in the lecture |
watch.completed_count |
How many videos are completed (>= 90% watched) |
watch.percentage |
watched_count / total_videos * 100 |
watch.videos[] |
Per-video array matching the top-level videos meta (one entry per video, same order): video_id, progress_seconds, cumulative_watch_seconds, duration_seconds (nullable), is_completed (bool), last_watched_at (ISO or null), progress_percentage (0–100, cumulative/duration, 1 decimal, null-safe → 0) |
Business Rules:
can_reopen is only true when is_expired=true AND reopen_logs.count < 2. Non-expired purchases always get can_reopen: false.amount_paid is a snapshot from the time of purchase — never changes, even if the lecture price changes later.homeworks[], quizzes[], and videos[] at the top level define what content exists for this lecture. The per-student arrays always match the same length and order — iterate over them together.homeworks is []. Same for quizzes and videos. A student with no progress rows still gets one watch.videos entry per video with zero/null defaults.PATCH /courses/purchases/<id>/reopen/Description: Teacher/Assistant extends a student's access to an expired lecture by adding 1 extra day. Reopen is only available when the purchase is expired and hasn't reached the max reopen limit.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active) (lecture course owner), Assistant
Request Body: None (always grants exactly 1 extra day).
Success Response — 200 OK: Updated purchased lecture.
{
"id": "Integer",
"student": "Integer",
"lecture": "Integer",
"lecture_name": "String",
"topic_name": "String",
"course_name": "String",
"teacher_name": "String",
"purchased_at": "DateTime (ISO 8601)",
"expires_at": "DateTime (ISO 8601)",
"amount_paid": "Decimal",
"extra_days": "Integer — Total extra days granted (cumulative)",
"is_expired": "Boolean",
"reopened_by": "Integer | null",
"reopened_by_name": "String | null",
"reopened_at": "DateTime | null",
"reopen_logs": [
{
"reopened_by": "String | null",
"reopened_at": "DateTime"
}
],
"max_watch_count": "Integer - Maximum viewing sessions (default: 4)",
"sessions_used": "Integer - Viewing sessions consumed"
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
403 |
Not course owner | {"error": "You can only extend access for lectures in your own courses"} |
400 |
Max reopen limit reached | {"error": "Maximum reopen limit (2) reached for this purchase."} |
404 |
Purchase not found | {"detail": "Not found."} |
Business Rules:
expires_at = max(effective_expiry, now) + 1 day and extra_days = 0. Reopening an expired lecture restores access for ~1 day from now (not from the frozen original expiry); reopening an active purchase extends it by 1 day from the current expiry.can_reopen is true (from students-progress/ response).is_expired field uses expires_at + extra_days to determine if access is still valid — it always reflects the current real expiry.| # | Endpoint | Method | Who |
|---|---|---|---|
| 9.1 | /learning/exams/ |
GET | Teacher, Assistant |
| 9.2 | /learning/exams/ |
POST | Teacher, Assistant |
| 9.3 | /learning/exams/<id>/ |
GET | Teacher, Assistant |
| 9.4 | /learning/exams/<id>/ |
PUT/PATCH | Teacher, Assistant |
| 9.5 | /learning/exams/<id>/ |
DELETE | Teacher, Assistant |
| 9.6 | /learning/exams/<id>/start/ |
POST | Student |
| 9.7 | /learning/exams/<id>/submit/ |
POST | Student |
| 9.8 | /learning/exams/<id>/resume/ |
GET | Student |
| 9.9 | /learning/exams/<id>/results/ |
GET | Teacher, Assistant |
| 9.10 | /learning/exam-submissions/<id>/ |
GET | Student (own), Teacher, Assistant |
| 9.11 | /learning/exams/<pk>/written-answers/ |
GET | Teacher, Assistant |
| 9.12 | /learning/exam-submissions/<id>/grade-written/ |
POST | Teacher, Assistant |
| 9.13 | /learning/exams/<id>/release-scores/ |
POST | Teacher, Assistant |
| 9.14 | /learning/exams/<id>/unrelease-scores/ |
POST | Teacher, Assistant |
| 9.15 | /learning/exams/<id>/release-answers/ |
POST | Teacher, Assistant |
| 9.16 | /learning/exams/<id>/unrelease-answers/ |
POST | Teacher, Assistant |
| 9.17 | /learning/exam-choices/<id>/ |
PATCH/PUT | Teacher, Assistant |
| 9.18 | /learning/exam-submissions/<pk>/delete/ |
DELETE | Teacher, Assistant |
Same settings as quizzes (see 11.1 reference) with one addition: exams have an after_close option for both score_visibility and answers_visibility (values: immediate, after_close, manual). When set to after_close, scores/answers become visible automatically after the exam's close_date passes. Key differences:
max_attempts is ignored for exams — exams are always single-attempt.open_date and close_date directly on the Exam model, NOT in settings.| Field | Type | Description |
|---|---|---|
open_date |
DateTime (optional) | Students cannot start the exam before this date |
close_date |
DateTime (optional) | Students cannot start the exam after this date |
Description: List exams. Teachers see exams for their own courses. Assistants see exams for their teacher's courses.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
course |
Integer | Filter by course ID |
is_active |
Boolean | Filter by active status |
is_published |
Boolean | Filter by published status |
Success Response — 200 OK:
{
"count": "integer",
"results": [
{
"id": "integer",
"course": "integer",
"course_name": "string",
"title": "string",
"description": "string",
"is_active": "boolean",
"is_published": "boolean",
"total_points": "integer",
"question_count": "integer",
"open_date": "datetime (ISO 8601) | null",
"close_date": "datetime (ISO 8601) | null",
"created_at": "datetime (ISO 8601)"
}
]
}
Description: Create a new exam with settings and optional inline questions. Questions can be created inline with choices during the same request (same pattern as quizzes).
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Access Control:
Request Body (with inline questions):
{
"course": "integer (required) — Course ID",
"title": "string (required) — Exam title",
"description": "string (optional)",
"is_active": "boolean (optional, default: true)",
"is_published": "boolean (optional, default: false)",
"open_date": "datetime (ISO 8601, optional) — Students cannot start before this date.",
"close_date": "datetime (ISO 8601, optional) — Students cannot start after this date.",
"settings": {
"timer_minutes": "integer (optional, default: 0) — 0 = unlimited, max 1440.",
"score_visibility": "string (optional, default: 'immediate') — 'immediate' or 'manual'",
"answers_visibility": "string (optional, default: 'immediate') — 'immediate' or 'manual'",
"question_order": "string (optional, default: 'fixed')",
"max_attempts": "integer (optional, default: 1)"
},
"questions": [
{
"text": "What is 2+2?",
"question_type": "mcq_single",
"order": 1,
"points_override": 2,
"choices": [
{"text": "4", "is_correct": true, "order": 1},
{"text": "5", "is_correct": false, "order": 2}
]
},
{
"text": "Explain gravity.",
"question_type": "written",
"order": 2,
"points_override": 5,
"choices": []
}
]
}
Inline Question Fields:
| Field | Type | Description |
|---|---|---|
id |
Integer | Existing question ID to update (omit or null for new questions) |
text |
String | Question text (required) |
question_type |
String | mcq_single, mcq_multiple, or written (default: mcq_single) |
order |
Integer | Display order (default: 0) |
points_override |
Integer | Points for this question (null = 1). Max: 20 |
image |
File | Question image (optional, multipart/form-data only) |
choices |
Array | List of choice objects (required for MCQ types) |
Each Choice:
| Field | Type | Description |
|---|---|---|
id |
Integer | Existing choice ID to update (omit or null for new choices) |
text |
String | Choice text (required) |
is_correct |
Boolean | Whether this is correct (default: false) |
order |
Integer | Display order (default: 0) |
image |
File | Choice image (optional, multipart/form-data only) |
Notes:
questions field is optional. If omitted, the exam is created with no questions.questions is provided, existing questions are matched by id and updated in-place. New questions (no id) are created. Questions present in the DB but absent from the payload are deleted. Existing submissions are auto-regraded with the new data. Changing question_type (MCQ ↔ written) is blocked once submissions exist — delete and recreate the question instead.is_correct: true for MCQ questions.multipart/form-data using the image field on questions and choices. When using JSON content-type, omit the image field (the existing image is preserved on update).Success Response — 201 Created: Same structure as GET detail.
Description: Retrieve an exam with all questions, choices, and settings.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Access Control: Teachers can only view exams for their own courses. Others get 404.
Success Response — 200 OK:
{
"id": "integer",
"course": "integer",
"course_name": "string",
"title": "string",
"description": "string",
"is_active": "boolean",
"is_published": "boolean",
"open_date": "datetime (ISO 8601) | null",
"close_date": "datetime (ISO 8601) | null",
"settings": {
"timer_minutes": "integer",
"score_visibility": "string",
"answers_visibility": "string",
"question_order": "string",
"max_attempts": "integer"
},
"questions": [
{
"id": "integer",
"order": "integer",
"points_override": "integer | null",
"effective_points": "integer",
"question_type": "string (mcq_single|mcq_multiple|written)",
"text": "string",
"image": "string (URL) | null",
"standalone_choices": [
{
"id": "integer",
"text": "string",
"image": "string (URL) | null",
"is_correct": "boolean",
"order": "integer"
}
]
}
],
"total_points": "integer",
"created_by": "integer",
"created_at": "datetime (ISO 8601)"
}
Description: Update exam metadata, open/close dates, and/or settings.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Access Control: Teachers can only update exams in their own courses. Others get 404.
Request Body: Same as POST (all fields optional for PATCH).
Success Response — 200 OK: Updated exam object.
Description: Delete an exam and all its submissions, answers, and grades.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Access Control: Teachers can only delete exams in their own courses. Others get 404.
Success Response — 204 No Content
Description: Start an exam attempt. Works identically to quiz start. Creates a submission, returns questions in randomized order if enabled. Timer starts counting.
Authentication: Student
Access Checks:
| Check | Blocked? |
|---|---|
| Exam exists, is active, and is published | 404 |
| Student is enrolled and approved in the course | 403 |
Current time is before open_date |
403 |
Current time is after close_date |
403 |
| Student has already started the exam (any submission exists) | 400 |
Request Body: None
Success Response — 200 OK:
{
"submission_id": "integer",
"timer_minutes": "integer",
"started_at": "datetime (ISO 8601)",
"questions": [
{
"answer_id": "integer",
"question_id": "integer",
"question_text": "string",
"question_image": "string (URL) | null",
"question_type": "string (mcq_single|mcq_multiple|written)",
"points": "integer",
"choices": [
{"id": "integer", "text": "string", "image": "string (URL) | null", "order": "integer"}
]
}
]
}
Description: Resume an active (not yet submitted) exam attempt. Returns remaining time, all questions with choices, and any previously saved draft answers. If the timer has expired, timer_remaining will be 0.
Authentication: Student
Success Response — 200 OK:
{
"submission_id": "integer",
"timer_minutes": "integer",
"timer_remaining": "number | null",
"started_at": "datetime (ISO 8601)",
"questions": [
{
"answer_id": "integer",
"question_id": "integer",
"question_text": "string",
"question_image": "string (URL) | null",
"question_type": "string",
"points": "integer",
"choices": [
{"id": "integer", "text": "string", "image": "string (URL) | null", "order": "integer"}
],
"saved_choice_ids": "array[integer]",
"saved_written_answer": "string"
}
]
}
Error Responses:
| Status | Condition |
|---|---|
| 400 | No active attempt found — student must start the exam first |
| 404 | Exam not found or not available |
Description: Submit answers for an active exam attempt. Same grading logic as quizzes. Auto-grades MCQ, stores written for manual grading.
Authentication: Student
Request Body:
{
"answers": [
{
"question_id": "integer (required)",
"choice_ids": "array[integer] — For MCQ questions",
"written_answer": "string — For written questions"
}
]
}
Success Response — 200 OK:
{
"detail": "Exam submitted successfully.",
"submission_id": "integer",
"score": "string (decimal) | null",
"score_visible": "boolean",
"answers_visible": "boolean"
}
Description: List all submissions for an exam with per-answer details, scores, and grading status.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Success Response: Same structure as quiz results (see 11.10).
Description: View a single exam submission with per-answer results.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant, Student (own submissions only)
Description: Manually grade a written answer. Works identically to quiz written grading.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Request Body:
{
"answer_id": "integer (required)",
"score_override": "string (decimal, required)",
"feedback": "string (optional)"
}
Success Response — 200 OK:
{
"detail": "Written answer graded successfully.",
"new_score": "string (decimal)"
}
Description: Returns all written question answers across all students who submitted, grouped by question. One-stop view for teachers to review all pending written answers before grading.
Authentication: Teacher, Assistant (ownership-scoped)
Success Response — 200 OK:
{
"exam_id": "integer",
"exam_title": "string",
"total_questions": "integer",
"total_graded": "integer",
"total_pending": "integer",
"written_questions": [
{
"question_id": "integer",
"question_text": "string",
"image": "string (URL) | null",
"max_score": "integer",
"answers": [
{
"answer_id": "integer",
"submission_id": "integer — Used to POST to /grade-written/ endpoint",
"student_id": "integer",
"student_name": "string",
"student_code": "string",
"written_answer": "string",
"current_score": "number",
"max_score": "integer",
"is_graded": "boolean",
"feedback": "string",
"graded_by": "string | null",
"graded_at": "datetime (ISO 8601) | null"
}
]
}
]
}
Business Rules:
is_correct returns "corrected" if teacher graded it, "not_corrected" if notis_correct returns boolean true/false as beforeError Responses:
| Status | Condition | Response Body |
|---|---|---|
403 |
Not course owner | {"error": "You can only view written answers for your own courses."} |
404 |
Exam not found | {"detail": "Not found."} |
Description: Release scores for all submissions (for manual score_visibility).
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Request Body: None
Success Response — 200 OK:
{
"detail": "Scores released for X submissions."
}
Description: Release correct answers for all submissions (for manual answers_visibility).
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Request Body: None
Success Response — 200 OK:
{
"detail": "Answers released for X submissions."
}
Description: Delete an exam submission to allow a student to retake the exam. The student can then start and submit again as if it was their first attempt. Exams are single-attempt, so this is the only way to give a student an exam retake.
Authentication: Teacher, Assistant (must own the course)
Access Control:
Request Body: None
Success Response — 204 No Content
Error Responses:
| Status | Condition |
|---|---|
| 403 | Not the course owner |
| 404 | Submission not found |
Business Rules:
Description: Update an individual exam choice (upload/delete image, update text/order/correctness). Same pattern as quiz choices.
Authentication: Teacher, Assistant
Content-Type: multipart/form-data (for image upload) or application/json (for text-only updates)
Request Body (multipart):
| Field | Type | Description |
|---|---|---|
text |
String | Choice text |
is_correct |
Boolean | Whether this is the correct choice |
order |
Integer | Display order |
image |
File | Image file to upload (omit to keep existing, empty file to delete) |
Success Response — 200 OK: Updated choice object.
Error Responses:
| Status | Condition |
|---|---|
| 403 | Not the course owner |
| 404 | Choice not found |
Homework uses the Bubble Sheet model. Teachers create questions with a correct answer and optional explanation. Students see question numbers with A/B/C/D choices, submit their answers, and get auto-graded immediately.
Description: List homeworks.
Authentication: Any authenticated user
Query Parameters:
lecture — Filter by lecture IDis_published — true or falseSuccess Response — 200 OK:
{
"count": "integer",
"results": [
{
"id": "integer",
"lecture": "integer",
"lecture_name": "string",
"title": "string — Homework title (required)",
"description": "string",
"is_published": "boolean",
"show_grades": "boolean — When true, students see correct answers after submission",
"total_points": "integer",
"created_at": "datetime (ISO 8601)"
}
]
}
Description: Create a homework with inline bubble questions.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Access Control:
403.Request Body:
{
"lecture": "integer - Lecture ID",
"title": "string — Homework title (required)",
"description": "string - Description",
"is_published": "boolean",
"show_grades": "boolean - When true, students see correct answers after submission",
"bubble_questions": [
{"order": 1, "choices_count": 4, "correct_answer": "A", "answer_explanation": "Optional explanation", "points": 1},
{"order": 2, "choices_count": 5, "correct_answer": "B,D", "answer_explanation": "", "points": 2}
]
}
Notes:
bubble_questions is optional — questions can be added/updated later via PUT/PATCH with the same body format.choices_count: number of answer choices (4 = A/B/C/D, 5 = A/B/C/D/E, up to 8).points: points per question, 1–20 (max 20; default 1).correct_answer: single answer "A" or multiple answers "A,C". No partial credit — all must match.answer_explanation: optional field shown to students after submission (only if show_grades=true).Description: Retrieve a homework with all bubble questions (teacher view).
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Success Response — 200 OK:
{
"id": "integer",
"lecture": "integer",
"lecture_name": "string",
"course_name": "string",
"title": "string — Homework title (required)",
"description": "string",
"is_published": "boolean",
"total_points": "integer",
"bubble_questions": [
{
"id": "integer",
"order": "integer",
"choices_count": "integer",
"correct_answer": "string",
"answer_explanation": "string",
"points": "integer"
}
],
"created_at": "datetime (ISO 8601)"
}
Description: Update a homework. Send bubble_questions to replace all existing questions.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Request Body: Same as POST (all fields optional for PATCH). Send bubble_questions: [] to remove all questions.
Description: Delete a homework and all its submissions.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Description: Returns ALL students who purchased the lecture, with their homework submission status. Includes both students who submitted and those who haven't. Does NOT include students who didn't purchase the lecture.
Authentication: Teacher, Assistant (ownership-scoped)
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
search |
String | Filter by student name or code (case-insensitive partial match) |
submitted |
Boolean | true = only submitted, false = only not submitted |
purchased |
Boolean | true (default) = only students who bought the lecture. Set to false to exclude purchasers (edge case) |
page |
Integer | Page number (default: 1) |
page_size |
Integer | Items per page (default: 50, max: 200) |
Success Response � 200 OK:
{
"homework_id": "integer",
"homework_title": "string",
"lecture_name": "string",
"course_name": "string",
"total_purchased": "integer � Total students who bought this lecture",
"total_submitted": "integer � Total submissions for this homework",
"count": "integer",
"next": "string (URL) | null",
"previous": "string (URL) | null",
"results": [
{
"student_id": "integer",
"student_name": "string",
"student_name_ar": "string",
"student_code": "string",
"is_purchased": "boolean",
"purchase_id": "integer",
"submitted": "boolean",
"submission_id": "integer | null",
"score": "string (decimal) | null",
"status": "string (submitted|graded) | null",
"submitted_at": "datetime (ISO 8601) | null"
}
]
}
Business rules:
?submitted=false to find students who bought but haven't done the homework?submitted=true to see only students who already submittedGET /learning/homework-submissions/<submission_id>/Description: View a specific submission with per-question results.
Authentication: Student (own submissions), Teacher, Assistant
Success Response — 200 OK:
{
"id": "integer",
"homework": "integer",
"homework_title": "string",
"student": "integer",
"student_name": "string",
"student_name_ar": "string",
"student_code": "string",
"score": "string (decimal)",
"status": "string",
"submitted_at": "datetime (ISO 8601)",
"total_points": "integer",
"bubble_answers": [
{
"bubble_question": "integer",
"question_order": "integer",
"selected_choice": "string",
"correct_answer": "string",
"answer_explanation": "string",
"is_correct": "boolean",
"points_earned": "string (decimal)"
}
]
}
Notes:
answer_explanation and correct_answer are always shown to teachers/assistants.show_grades=true.| # | Endpoint | Method | Who |
|---|---|---|---|
| 11.1 | /learning/quizzes/ |
GET | Any authenticated |
| 11.2 | /learning/quizzes/ |
POST | Teacher, Assistant |
| 11.3 | /learning/quizzes/<id>/ |
GET | Any authenticated |
| 11.4 | /learning/quizzes/<id>/ |
PUT/PATCH | Teacher, Assistant |
| 11.5 | /learning/quizzes/<id>/ |
DELETE | Teacher, Assistant |
| 11.6 | /learning/quizzes/<id>/start/ |
POST | Student |
| 11.7 | /learning/quizzes/<id>/resume/ |
GET | Student |
| 11.8 | /learning/quizzes/<id>/submit/ |
POST | Student |
| 11.9 | /learning/quiz-answers/<id>/save-draft/ |
PATCH | Student |
| 11.10 | /learning/quizzes/<id>/results/ |
GET | Teacher, Assistant |
| 11.11 | /learning/quizzes/<pk>/written-answers/ |
GET | Teacher, Assistant |
| 11.12 | /learning/quiz-submissions/<id>/ |
GET | Student (own), Teacher, Assistant |
| 11.13 | /learning/quiz-submissions/<id>/grade-written/ |
POST | Teacher, Assistant |
| 11.14 | /learning/quizzes/<id>/release-scores/ |
POST | Teacher, Assistant |
| 11.15 | /learning/quizzes/<id>/unrelease-scores/ |
POST | Teacher, Assistant |
| 11.16 | /learning/quizzes/<id>/release-answers/ |
POST | Teacher, Assistant |
| 11.17 | /learning/quizzes/<id>/unrelease-answers/ |
POST | Teacher, Assistant |
| 11.18 | /learning/quiz-choices/<id>/ |
PATCH/PUT | Teacher, Assistant |
| 11.19 | /learning/quiz-submissions/<pk>/delete/ |
DELETE | Teacher, Assistant |
These settings apply to all quiz creation/update endpoints:
| Field | Type | Default | Description |
|---|---|---|---|
timer_minutes |
Integer | 0 | Time limit in minutes. 0 = no time limit. Max: 1440 (24 hours). |
score_visibility |
String | immediate |
immediate = show score right after submit. manual = teacher must release scores. |
answers_visibility |
String | immediate |
immediate = show correct answers right after submit. manual = teacher must release answers. |
question_order |
String | fixed |
fixed = questions appear in their order field. random = shuffled for each student. |
max_attempts |
Integer | 1 | Enforced for quizzes — maximum number of attempts a student gets. When all attempts are used, the teacher must delete submissions (DELETE /learning/quiz-submissions/<pk>/delete/) to free a slot. |
Description: List quizzes. Teachers see quizzes in their own courses. Students see only published quizzes for lectures they purchased. SiteOwner sees all.
Authentication: Any authenticated user
Access Control:
| Role | What they see |
|---|---|
| Teacher | Quizzes in their own courses (lecture__topic__course__teacher) |
| Assistant | Quizzes in their assigned teacher's courses |
| Student | Only published quizzes (is_published=True) for lectures they purchased |
| SiteOwner | All quizzes |
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
lecture |
Integer | Filter by lecture ID |
is_active |
Boolean | Filter by active status |
is_published |
Boolean | Filter by published status |
Success Response — 200 OK:
{
"count": "integer",
"results": [
{
"id": "integer",
"lecture": "integer",
"lecture_name": "string",
"title": "string",
"description": "string",
"is_active": "boolean",
"is_published": "boolean",
"total_points": "integer",
"question_count": "integer",
"settings": {
"timer_minutes": "integer",
"score_visibility": "string (immediate|manual)",
"answers_visibility": "string (immediate|manual)",
"question_order": "string (fixed|random)",
"max_attempts": "integer"
},
"created_at": "datetime (ISO 8601)"
}
]
}
Description: Create a new quiz with settings and optional inline questions. Questions can be created inline with choices during the same request.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Access Control:
Request Body (with inline questions):
{
"lecture": "integer (required) — Lecture ID",
"title": "string (required) — Quiz title",
"description": "string (optional) — Description",
"is_active": "boolean (optional, default: true)",
"is_published": "boolean (optional, default: false) — Must be true for students to see the quiz.",
"settings": {
"timer_minutes": "integer (optional, default: 0) — Time limit. 0 = unlimited, max 1440.",
"score_visibility": "string (optional, default: 'immediate') — 'immediate' or 'manual'.",
"answers_visibility": "string (optional, default: 'immediate') — 'immediate' or 'manual'.",
"question_order": "string (optional, default: 'fixed') — 'fixed' or 'random'.",
"max_attempts": "integer (optional, default: 1)"
},
"questions": [
{
"text": "What is 2+2?",
"question_type": "mcq_single",
"order": 1,
"points_override": 2,
"choices": [
{"text": "4", "is_correct": true, "order": 1},
{"text": "5", "is_correct": false, "order": 2}
]
},
{
"text": "Explain gravity.",
"question_type": "written",
"order": 2,
"points_override": 5,
"choices": []
}
]
}
Inline Question Fields:
| Field | Type | Description |
|---|---|---|
id |
Integer | Existing question ID to update (omit or null for new questions) |
text |
String | Question text (required for each question) |
question_type |
String | mcq_single, mcq_multiple, or written (default: mcq_single) |
order |
Integer | Display order (default: 0) |
points_override |
Integer | Points for this question (null = 1). Max: 20 |
image |
File | Question image (optional, multipart/form-data only) |
choices |
Array | List of choice objects (required for MCQ types) |
Each Choice:
| Field | Type | Description |
|---|---|---|
id |
Integer | Existing choice ID to update (omit or null for new choices) |
text |
String | Choice text (required) |
is_correct |
Boolean | Whether this is correct (default: false) |
order |
Integer | Display order (default: 0) |
image |
File | Choice image (optional, multipart/form-data only) |
Notes:
questions field is optional. If omitted, the quiz is created with no questions.questions is provided, existing questions and choices are matched by id and updated in-place. New items (no id) are created. Items present in the DB but absent from the payload are deleted. Existing submissions are auto-regraded with the new data. Changing question_type (MCQ ↔ written) is blocked once submissions exist — delete and recreate the question instead.image is not in the payload, the existing image stays). This applies to both quiz and exam choices.is_correct: true for MCQ questions.Multipart form data with JSON string fields:
When including images in the same request, use Content-Type: multipart/form-data and send settings and questions as JSON strings:
| Field | Format | Example |
|---|---|---|
lecture |
Plain value | 17 |
title |
Plain value | "My Quiz" |
settings |
JSON.stringify(...) |
{"timer_minutes":30,"score_visibility":"immediate",...} |
questions |
JSON.stringify([...]) |
[{"text":"Q1","choices":[...]}] |
questions[0].image |
Binary file | Uploaded file |
questions[0].choices[0].image |
Binary file | Uploaded file |
Both settings and questions are parsed from JSON string automatically by the backend.
Uploading question/choice images:
| Step | Action | Endpoint | Format |
|---|---|---|---|
| 1 | Create quiz with text-only inline questions | POST /learning/quizzes/ |
JSON or multipart |
| 2 | Upload image to a question | PATCH /learning/questions/<id>/ |
multipart/form-data |
| 3 | Upload/delete image on a choice | PATCH /learning/quiz-choices/<id>/ |
multipart/form-data |
| 4 | Read question with image URL | GET /learning/quizzes/<id>/ |
JSON (returns URL) |
The image field on both questions and choices is returned as a full URL in all responses (e.g. "https://.../media/quiz_questions/photo.jpg"). Upload the file using the dedicated PATCH endpoint with content type multipart/form-data — the image field accepts a binary file, not a URL string.
⚠️ CRITICAL: Never send the image URL back as JSON. If you call
PATCH /learning/questions/<id>/orPATCH /learning/quiz-choices/<id>/withContent-Type: application/jsonand include"image": "https://..."in the body, the backend cannot parse a URL string as a file upload and will clear the existing image from storage.How to avoid losing the image:
- To update text only (keep existing image): Send
PATCHwithContent-Type: application/jsonand body{"text": "New text"}— omit theimagefield entirely. The existing image is preserved automatically.- To update the image: Send
PATCHwithContent-Type: multipart/form-dataand include the file as theimagefield. Text fields can be included in the same request.- To remove the image: Send multipart with
imageset to an empty file.
Success Response — 201 Created:
{
"id": "integer",
"lecture": "integer",
"title": "string",
"description": "string",
"is_active": true,
"is_published": false,
"settings": { "timer_minutes": 30, "score_visibility": "immediate", ... },
"created_at": "datetime (ISO 8601)",
"updated_at": "datetime (ISO 8601)"
}
Error Responses:
| Status | Condition |
|---|---|
| 400 | timer_minutes exceeds 1440 |
| 403 | Not a teacher/assistant of the lecture's course |
| 404 | Lecture not found |
Description: Retrieve a quiz with all its questions, choices, and settings.
Authentication: Any authenticated user
Access Control:
| Role | What they see |
|---|---|
| Teacher/Assistant | Full quiz: all questions, choices, correct answers, settings |
| Student (has started) | Full quiz: questions, choices (without is_correct), saved answers |
| Error Responses:** |
| Status | Condition |
|---|---|
| 400 | No active attempt found (start the quiz first) |
| 400 | Quiz already submitted |
| 400 | Timer expired (is_timed_out) |
| 400 | All attempts used (max_attempts reached) — delete a submission to free a slot |
Description: Auto-save individual answer choices while the student is working. These saved answers are restored when the student resumes the quiz via GET /resume/. Does NOT submit the quiz — student must call /submit/ separately.
Authentication: Student
Request Body:
{
"choice_ids": "array[integer] — Selected choice IDs (for MCQ questions)",
"written_answer": "string — Written answer text (for written questions)"
}
Success Response — 200 OK:
{
"saved": true,
"answer_id": "integer"
}
Description: Returns ALL students who purchased the lecture, with their quiz submission status. Three states: not_started, in_progress (started but not submitted), submitted. Does NOT include students who didn't purchase the lecture.
Authentication: Teacher, Assistant (ownership-scoped)
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
quiz_status |
String | Filter: not_started, in_progress, or submitted |
search |
String | Filter by student name or code (case-insensitive partial match) |
page |
Integer | Page number (default: 1) |
page_size |
Integer | Items per page (default: 50, max: 200) |
Success Response — 200 OK:
{
"quiz_id": "integer",
"quiz_title": "string",
"lecture_name": "string",
"course_name": "string",
"total_points": "string (decimal) — Maximum possible score",
"total_purchased": "integer — Total students who bought this lecture",
"total_submitted": "integer — Students who submitted",
"total_in_progress": "integer — Students who started but didn't submit",
"count": "integer",
"next": "string (URL) | null",
"previous": "string (URL) | null",
"results": [
{
"student_id": "integer",
"student_name": "string",
"student_name_ar": "string",
"student_code": "string",
"phone_number": "string — Student's phone number",
"father_number": "string | null — Father's phone number",
"mother_number": "string | null — Mother's phone number",
"is_purchased": "boolean",
"purchase_id": "integer",
"quiz_status": "string (not_started|in_progress|submitted)",
"submission_id": "integer | null",
"score": "string (decimal) | null — Raw score value",
"score_display": "string | null — Formatted as '47 / 50'",
"total_points": "string (decimal) — Maximum possible score",
"started_at": "datetime (ISO 8601) | null",
"submitted_at": "datetime (ISO 8601) | null",
"time_taken": "string | null — e.g. '5m 23s'",
"is_score_visible": "boolean | null — Whether score is released to student",
"are_answers_visible": "boolean | null — Whether correct answers are released"
}
]
}
Business rules:
?quiz_status=not_started to find students who haven't attempted?quiz_status=in_progress to find students who started but didn't finish?quiz_status=submitted to see only completed attempts-submitted_at, -started_at)GET /learning/quiz-submissions/<submission_id>/Description: View a single submission's details with per-answer results.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant, Student (own submissions only)
Access Control:
Success Response: Same structure as a single item in GET /results/.
Description: Manually grade a written answer for a quiz submission. Overrides the default 0 points. The submission's total score is recalculated automatically.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Access Control: Teachers can only grade quizzes in their own courses.
Request Body:
{
"answer_id": "integer (required) — The QuizAnswer ID to grade",
"score_override": "string (decimal, required) — Points to award (cannot be negative)",
"feedback": "string (optional) — Feedback text shown to student"
}
Success Response — 200 OK:
{
"detail": "Written answer graded successfully.",
"new_score": "string (decimal) — Updated total score for the submission"
}
Description: Returns all written question answers across all students who submitted, grouped by question. One-stop view for teachers to review all pending written answers before grading.
Authentication: Teacher, Assistant (ownership-scoped)
Success Response — 200 OK:
{
"quiz_id": "integer",
"quiz_title": "string",
"total_questions": "integer — How many written questions exist",
"total_graded": "integer — How many answers have been graded",
"total_pending": "integer — How many answers still need grading",
"written_questions": [
{
"question_id": "integer",
"question_text": "string",
"image": "string (URL) | null",
"max_score": "integer",
"answers": [
{
"answer_id": "integer",
"submission_id": "integer — Used to POST to /grade-written/ endpoint",
"student_id": "integer",
"student_name": "string",
"student_code": "string",
"written_answer": "string",
"current_score": "number — 0 if not graded, otherwise teacher-assigned score",
"max_score": "integer",
"is_graded": "boolean",
"feedback": "string — Teacher's feedback (empty if not graded)",
"graded_by": "string | null — Username of the grader",
"graded_at": "datetime (ISO 8601) | null"
}
]
}
]
}
Business Rules:
is_correct returns "corrected" if teacher graded it, "not_corrected" if notis_correct returns boolean true/false as beforesubmission_id to call POST /grade-written/ with the selected answerError Responses:
| Status | Condition | Response Body |
|---|---|---|
403 |
Not course owner | {"error": "You can only view written answers for your own courses."} |
404 |
Quiz not found | {"detail": "Not found."} |
Description: Release scores for all submissions of a quiz. Only affects quizzes with score_visibility=manual. After release, students can see their scores in the quiz results.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Request Body: None
Success Response — 200 OK:
{
"detail": "Scores released for 15 submissions."
}
Description: Release correct answers for all submissions of a quiz. Only affects quizzes with answers_visibility=manual. After release, students can see which choices were correct.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Request Body: None
Success Response — 200 OK:
{
"detail": "Answers released for 15 submissions."
}
Description: Update an individual quiz choice (upload/delete image, update text/order/correctness). Same pattern as question image updates. Send image as a file via multipart/form-data to upload; send empty file to delete the image. When sending JSON, the image field is ignored (preserves existing image).
Authentication: Teacher, Assistant
Content-Type: multipart/form-data (for image upload) or application/json (for text-only updates)
Request Body (multipart):
| Field | Type | Description |
|---|---|---|
text |
String | Choice text |
is_correct |
Boolean | Whether this is the correct choice |
order |
Integer | Display order |
image |
File | Image file to upload (omit to keep existing, empty file to delete) |
Success Response — 200 OK: Updated choice object.
Error Responses:
| Status | Condition |
|---|---|
| 403 | Not the course owner |
| 404 | Choice not found |
Description: Delete a quiz submission to allow a student to retake the quiz. The student can then start and submit again. Quizzes are multi-attempt (up to settings.max_attempts), so deleting one submission frees one attempt slot; deleting all submissions resets the student to attempt #1.
Authentication: Teacher, Assistant (must own the course)
Access Control:
Request Body: None
Success Response — 204 No Content
Error Responses:
| Status | Condition |
|---|---|
| 403 | Not the course owner |
| 404 | Submission not found |
Business Rules:
max_attempts.Study materials are PDF or image files attached to lectures. Only teachers and assistants can create, update, or delete them. Students and siteowners are read-only.
Description: Upload a new PDF or image study material.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Content-Type: multipart/form-data
Request Body:
{
"lecture": "Integer - Lecture ID",
"title": "String - Material title",
"file": <PDF_OR_IMAGE_FILE>
}
Validation:
.exe with a .pdf extension is rejected. Allowed: %PDF, JPEG (FFD8), PNG (89504E47), WEBP (RIFF...WEBP).Success Response — 201 Created:
{
"id": "Integer",
"lecture": "Integer — Lecture ID",
"lecture_name": "String — Lecture name",
"course_name": "String — Course name",
"title": "String — Material title",
"file": "String (URL) — File download URL",
"file_url": "String (URL) — Absolute URL to the file",
"is_active": "Boolean — Whether this material is active",
"created_by": "Integer — User ID of the creator",
"created_by_name": "String — Creator's username",
"created_at": "DateTime (ISO 8601)",
"updated_at": "DateTime (ISO 8601)"
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
400 |
File too large | {"file": ["File too large. Size should not exceed 20 MB."]} |
400 |
Invalid file type | {"file": ["File extension 'xyz' is not allowed. Allowed extensions are: pdf, jpg, jpeg, png."]} |
403 |
Insufficient role (student or siteowner) | {"detail": "You do not have permission to perform this action."} |
Description: List study materials.
Authentication: Any authenticated user
Query Parameters:
lecture — Filter by lecture IDis_active — true or falseData isolation by role:
| Role | What they see |
|---|---|
| Student | Only active materials for lectures they've purchased |
| Teacher | All materials in their own courses |
| Assistant | All materials in their assigned teacher's courses |
| SiteOwner | All materials |
| Other | No materials |
Description: Retrieve a single material.
Authentication: Any authenticated user
Data isolation:
404 if they haven't purchased the material's lecture404 if the material belongs to another teacher's course404 if the material belongs to a different teacherDescription: Update a material (replace file, change title, etc.).
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Data isolation: Teachers can only update materials in their own courses. Assistants can only update their assigned teacher's materials. Others get 403 or 404.
Description: Delete a material.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant
Data isolation: Teachers can only delete materials in their own courses. Assistants can only delete their assigned teacher's materials. Others get 403 or 404.
Codes list: GET /payments/codes/ with filters ?course=X, ?status=valid|used|blacklisted, ?batch=<uuid>. Now includes code_masked field.
Codes summary: GET /payments/codes/summary/?course=X — aggregate stats (total generated, redeemed, blacklisted, values).
Codes export: GET /payments/codes/export/?batch=<uuid> — download batch as Excel sheet.
Export all: GET /payments/codes/export/ — download ALL visible codes (with Teacher Name + Student Code columns).
Codes history: GET /payments/codes/history/ — unified audit log (redemptions + blacklists), filters: ?start_date=, ?end_date=, ?type=, ?search=, paginated, newest first.
Codes analytics: GET /payments/codes/analytics/ — dashboard stats (total_batches, total_codes, used/valid/blacklisted counts + values).
Batch list: GET /payments/codes/batches/ — grouped by batch_id, filters ?teacher=X, ?course=X.
Batch detail: GET /payments/codes/batches/<uuid>/ — individual codes with code_masked, redeemed_value.
Description: Teacher/Assistant redeems an existing recharge code on behalf of a student, adding its value to the student's course balance. The student must be enrolled and approved in the course. The transaction is recorded with performed_by set to the teacher/assistant, so the ledger shows who recharged.
Authentication: Teacher, Assistant
Request Body:
{
"student": "Integer — Student ID (required)",
"course": "Integer — Course ID (required)",
"code": "String — Recharge code (required, e.g. 'X7K9-M2P4-QR1W-L5D8')"
}
Success Response — 200 OK:
{
"detail": "Code X7K9-M2P4-QR1W-L5D8 redeemed for Ahmed. Added 50.00 EGP.",
"new_balance": "150.00",
"transaction_id": 42,
"code": "X7K9-M2P4-QR1W-L5D8",
"value": "50.00",
"recharged_by": "drhany"
}
Error Responses:
| Status | Condition |
|---|---|
| 400 | Missing required fields (student, course, code) |
| 400 | Student not enrolled/approved in the course |
| 400 | Code already used / blacklisted / expired / course deactivated |
| 403 | Not the teacher of the course |
| 404 | Student, course, or code not found |
Description: Full balance transaction ledger for students. Shows every balance change with before and after snapshots. Includes code redemptions (with code, value, balance_before, balance_after) and lecture purchases (with lecture name, amount, balance_before, balance_after). Transactions are immutable — never editable or deletable.
Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant (sees students enrolled in their courses), Student (sees own), SiteOwner (sees all)
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
student |
Integer | Filter by student ID |
course |
Integer | Filter by course ID |
transaction_type |
String | Filter: code_redeemed or lecture_purchase |
code |
String | Search by recharge code (partial match, e.g. ?code=ABC123) |
search |
String | Search in description text |
source_type |
String | Filter source: recharge_code, purchased_lecture, teacher_add |
start_date |
String | Filter by date range start (YYYY-MM-DD) |
end_date |
String | Filter by date range end (YYYY-MM-DD) |
ordering |
String | created_at or -created_at (default: newest first) |
Success Response — 200 OK:
{
"count": "Integer",
"results": [
{
"id": "Integer",
"student": "Integer",
"student_name": "String (name_en / name_ar)",
"course": "Integer",
"course_name": "String",
"teacher_name": "String",
"teacher_picture": "String (URL) | null",
"transaction_type": "String (code_redeemed|lecture_purchase)",
"transaction_type_display": "String (Code Redeemed|Lecture Purchased)",
"amount": "String (Decimal)",
"balance_before": "String (Decimal)",
"balance_after": "String (Decimal)",
"source_type": "String",
"source_type_display": "String",
"source_id": "Integer",
"description": "String",
"metadata": {
"code": "String — The code string (for code_redeemed)",
"lecture_name": "String — Lecture name (for lecture_purchase)"
},
"performed_by": "Integer | null",
"performed_by_name": "String | null",
"created_at": "DateTime (ISO 8601)"
}
]
}
Description: Get aggregated stats for the teacher's dashboard, including summary metrics, action items (pending enrollments + ungraded written answers), recent activity, and per-course breakdown.
Note: This is the expanded teacher dashboard endpoint.
Authentication: Teacher, Assistant (ownership-scoped)
Success Response — 200 OK:
{
"summary": {
"total_courses": "Integer",
"active_courses": "Integer",
"total_students": "Integer",
"pending_enrollments": "Integer",
"total_purchases": "Integer",
"total_revenue": "String (Decimal)",
"outstanding_cut": "String (Decimal) — Sum of the teacher's unpaid cut invoices"
},
"actions_needed": {
"pending_enrollments": [
{
"enrollment_id": "Integer",
"student_id": "Integer",
"student_name": "String (en / ar)",
"student_code": "String",
"course_id": "Integer",
"course_name": "String",
"enrolled_at": "DateTime (ISO 8601)"
}
],
"ungraded_written": {
"total_pending": "Integer",
"quizzes": [
{
"quiz_id": "Integer",
"quiz_title": "String",
"lecture_name": "String",
"course_name": "String",
"pending_count": "Integer"
}
],
"exams": [
{
"exam_id": "Integer",
"exam_title": "String",
"course_name": "String",
"pending_count": "Integer"
}
]
}
},
"recent_enrollments": [
{
"id": "Integer",
"student_name": "String (en / ar)",
"student_code": "String",
"course_name": "String",
"status": "String (pending|approved|rejected)",
"enrolled_at": "DateTime (ISO 8601)"
}
],
"recent_purchases": [
{
"id": "Integer",
"student_name": "String (en / ar)",
"student_code": "String",
"lecture_name": "String",
"course_name": "String",
"amount_paid": "String (Decimal)",
"purchased_at": "DateTime (ISO 8601)"
}
],
"courses": [
{
"id": "Integer",
"name": "String",
"is_active": "Boolean",
"enrolled_count": "Integer",
"pending_count": "Integer",
"purchase_count": "Integer",
"revenue": "String (Decimal)"
}
]
}
Field Reference:
| Section | Field | Description |
|---|---|---|
summary |
total_courses |
Total number of the teacher's courses |
summary |
active_courses |
Number of courses with is_active=true |
summary |
total_students |
Total approved enrollments across all courses |
summary |
pending_enrollments |
Total pending enrollment requests across all courses |
summary |
total_purchases |
Total lecture purchases across all courses |
summary |
total_revenue |
Sum of amount_paid across all purchases (string decimal) |
summary |
outstanding_cut |
Sum of the teacher's unpaid cut invoices' total_owed (string decimal) |
actions_needed.pending_enrollments |
— | All pending enrollments (newest first) with student + course info for quick approve/reject |
actions_needed.ungraded_written |
total_pending |
Total ungraded written answers (quizzes + exams) |
actions_needed.ungraded_written |
quizzes[] |
Per-quiz pending counts: {quiz_id, quiz_title, lecture_name, course_name, pending_count} |
actions_needed.ungraded_written |
exams[] |
Per-exam pending counts: {exam_id, exam_title, course_name, pending_count} |
recent_enrollments |
— | Last 10 enrollments (any status), newest first |
recent_purchases |
— | Last 10 lecture purchases, newest first |
courses |
— | Per-course summary: {id, name, is_active, enrolled_count, pending_count, purchase_count, revenue} |
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
403 |
Not teacher/assistant | {"error": "Only teachers and assistants can access this dashboard"} |
Business Rules:
pending_enrollments in actions_needed is the full list (not limited to 10) — the frontend can show the count badge and link to the Students page.ungraded_written counts written answers with written_grade=null on submitted quiz/exam submissions. Each quiz/exam is listed once with its pending_count; empty lists (quizzes: [], exams: []) mean nothing to grade.recent_enrollments and recent_purchases are capped at 10 items each.Description: Returns the authenticated teacher's own cut invoices (unpaid + paid). Cancelled invoices are hidden. Teachers cannot see other teachers' invoices, and no one else (siteowner/assistant/student) can access this endpoint.
Authentication: Teacher
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
search |
String | Invoice note (case-insensitive partial) |
page / page_size |
Integer | Pagination (50/page) |
Success Response — 200 OK (paginated):
Teachers/assistants can also call the cut overview — it is automatically scoped to their own teacher's data (?teacher= and ?search= are ignored; the response contains exactly one row for their teacher). Invoice management (lectures/, invoices/, detail) remains SiteOwner-only.
{
"count": 2,
"results": [
{
"id": 12,
"teacher": 5,
"teacher_name": "Dr Hany Hassanin",
"start_date": "2026-08-01",
"end_date": "2026-08-31",
"lectures_count": 320,
"cut_per_lecture": "20.00",
"discount": "10.00",
"gross_total": "6400.00",
"total_owed": "6390.00",
"status": "unpaid",
"note": "August cut",
"paid_note": "",
"paid_at": null,
"created_by": 1,
"created_at": "2026-08-08T10:00:00Z",
"updated_at": "2026-08-08T10:00:00Z"
}
]
}
Error Responses:
| Status | Condition |
|---|---|
| 403 | Not a teacher (siteowner/assistant/student) |
GET /courses/<course_id>/students/<student_id>/lectures/Description: Per-student report for one course: every visible lecture with purchase status, full purchase info (incl. can_reopen + reopen logs), homework/quiz status, and per-video watch progress.
Authentication: Teacher, Assistant (must own the course)
Success Response — 200 OK:
{
"student_id": 1,
"student_name": "Ahmed Ali",
"course_id": 28,
"course_name": "Chemistry 3rd Secondary",
"lectures": [
{
"lecture_id": 10,
"lecture_name": "Intro to Reactions",
"topic_name": "Unit 1: Chemical Reactions",
"order": 1,
"is_purchased": true,
"purchase": {
"id": 5,
"purchased_at": "2026-07-01T10:00:00Z",
"expires_at": "2026-07-31T10:00:00Z",
"effective_expiry": "2026-07-31T10:00:00Z",
"is_expired": false,
"amount_paid": "40.00",
"extra_days": 0,
"max_watch_count": 4,
"sessions_used": 1,
"sessions_remaining": 3,
"can_reopen": false,
"reopened_by_name": null,
"reopen_logs": []
},
"homeworks": [
{"homework_id": 1, "title": "Week 1 HW", "total_points": 3, "submitted": true, "submission_id": 10, "score": "2.00", "status": "graded"}
],
"quizzes": [
{"quiz_id": 1, "title": "Quiz 1", "total_points": 10, "submitted": true, "submission_id": 15, "score": "8.00", "quiz_status": "submitted", "score_visible": true}
],
"videos": [
{"video_id": 30, "name": "Video 1", "order": 1, "is_completed": true, "progress_percentage": "86.7", "position_percentage": "96.7", "duration_seconds": 600}
]
}
]
}
Notes:
purchase is null when the student hasn't bought the lecture.quiz_status: not_started | in_progress | submitted; score is hidden unless score_visible.progress_percentage = cumulative/duration; position_percentage = furthest position/duration (both '0.00' when never watched).Error Responses:
| Status | Condition |
|---|---|
403 |
Not the course owner ({"error": "Not your course"}) |
404 |
Course or student not found |
Lecture prerequisites gate video access behind assessment scores. A student cannot play a lecture's videos until they achieve the passing_score on the specified quiz or exam.
When the teacher is setting prerequisites for a lecture, they need to know what assessments exist for that lecture. This single endpoint returns all available quizzes and exams in one call — so the frontend doesn't need separate API calls.
Why this endpoint exists: Without it, the frontend would have to call
GET /learning/quizzes/?lecture=XandGET /learning/exams/?course=Xseparately and merge the results. This endpoint exists specifically for the prerequisite picker UI and should not be deleted.
GET /courses/lectures/<id>/available-assessments/Description: Returns all quizzes and exams available for a lecture, grouped by type. Teachers/assistants see all assessments for their courses. Students see only published assessments for purchased lectures.
Authentication: Any authenticated user
Success Response — 200 OK:
{
"quizzes": [
{ "id": 5, "title": "Quiz 1", "lecture_id": 5, "lecture_name": "Intro to Reactions" }
],
"exams": [
{ "id": 2, "title": "Midterm Exam" }
]
}
Notes:
quizzes are filtered by lecture=<id> (the given lecture)exams are filtered by the course the lecture belongs to (exams are course-level)lecture_id and lecture_name so the teacher can identify which lecture each assessment belongs toid and titleGET /courses/lectures/<id>/prerequisites/Description: List all prerequisites for a lecture. Includes the lecture name and the resolved content name for display.
Authentication: Any authenticated user (students can read, only teachers/assistants can create/update/delete)
Success Response — 200 OK:
{
"count": "Integer",
"results": [
{
"id": 1,
"lecture": 5,
"lecture_name": "Chapter 1 — Introduction",
"content_type": "quiz",
"content_id": 12,
"passing_score": "50.00",
"content_name": "Quiz 1 — Chapter 1",
"created_at": "2026-07-17T23:00:00Z"
}
]
}
Fields:
| Field | Type | Description |
|---|---|---|
id |
Integer | Prerequisite ID |
lecture |
Integer | Lecture ID |
lecture_name |
String | Lecture name (resolved from lecture FK) |
content_type |
String | quiz or exam |
content_id |
Integer | ID of the homework, quiz, or exam |
passing_score |
Decimal | Minimum score required to pass (default 50.00) |
content_name |
String | Title of the homework/quiz/exam (resolved from content_type + content_id) |
created_at |
DateTime | ISO 8601 timestamp |
POST /courses/lectures/<id>/prerequisites/Description: Add a prerequisite to a lecture. Student must pass this quiz or exam before accessing the lecture's videos.
Authentication: Teacher, Assistant (ownership-scoped)
Request Body:
{
"content_type": "String (Required) — quiz|exam",
"content_id": "Integer (Required) — ID of the homework or quiz",
"passing_score": "Decimal (Optional, default: 50.0) — Minimum score required to pass"
}
Success Response — 201 Created:
{
"id": 1,
"lecture": 5,
"lecture_name": "Chapter 1 — Introduction",
"content_type": "quiz",
"content_id": 12,
"passing_score": "50.00",
"content_name": "Quiz 1 — Chapter 1",
"created_at": "2026-07-17T23:00:00Z"
}
GET /courses/lectures/<id>/prerequisites/<prereq_id>/Description: Get a single prerequisite by ID.
Authentication: Teacher, Assistant (ownership-scoped)
Success Response — 200 OK:
Same response shape as the list endpoint.
PUT|PATCH /courses/lectures/<id>/prerequisites/<prereq_id>/Description: Update a prerequisite. You can only change passing_score. To change content_type or content_id, delete and recreate the prerequisite.
Authentication: Teacher, Assistant (ownership-scoped)
Request Body (partial update allowed):
{
"passing_score": "75.00"
}
Success Response — 200 OK:
{
"id": 1,
"lecture": 5,
"lecture_name": "Chapter 1 — Introduction",
"content_type": "quiz",
"content_id": 12,
"passing_score": "75.00",
"content_name": "Quiz 1 — Chapter 1",
"created_at": "2026-07-17T23:00:00Z"
}
Note: content_type and content_id are ignored on update. If you send them, they won't be applied. Delete and recreate instead.
DELETE /courses/lectures/<id>/prerequisites/<prereq_id>/Description: Remove a prerequisite from a lecture.
Authentication: Teacher, Assistant (ownership-scoped)
Success Response — 204 No Content
When a quiz or exam is deleted, all LecturePrerequisite records that reference it are automatically deleted via database signals. This prevents dangling prerequisites that could never be passed.
What happens when content changes:
| Event | Effect on prerequisites |
|---|---|
| Quiz/Exam deleted | Prerequisite is auto-deleted (signal) |
| Title changed | content_name in response updates automatically (resolved live) |
| Scoring changed | No effect — each prerequisite has its own passing_score independent of the assessment |
| Lecture deleted | Prerequisites cascade-deleted (on_delete=CASCADE) |
Each lecture enforces a maximum viewing session limit (default: 4). Instead of per-video tracking, the system uses 6-hour viewing sessions:
max_watch_count (default 4)sessions_used to 0 and deletes all LectureViewingSession records.sessions_used, sessions_remaining, has_active_session, and active_session per purchase.