Version: 1.3 Date: July 16, 2026 Target Audience: Next.js Frontend Developers (Student Features) Backend: Django REST Framework
GET /courses/by-subject/<subject_id>/](#get-coursesby-subjectsubject_id)GET /courses/<id>/preview/](#get-coursesidpreview)POST /courses/enrollments/<id>/cancel/](#post-coursesenrollmentsidcancel)GET /courses/<id>/lectures/](#get-coursesidlectures)GET /courses/lectures/<id>/](#get-courseslecturesid)GET /courses/lectures/<id>/available-assessments/](#get-courseslecturesidavailable-assessments)GET /courses/lectures/<id>/prerequisites/](#get-courseslecturesidprerequisites)GET /courses/videos/<id>/play/](#get-coursesvideosidplay)GET /courses/progress/<video_id>/](#get-coursesprogressvideo_id)POST /learning/homeworks/<id>/submit/](#post-learninghomeworksidsubmit)POST /learning/quizzes/<id>/start/](#post-learningquizzesidstart)POST /learning/quizzes/<id>/submit/](#post-learningquizzesidsubmit)GET /learning/quiz-submissions/<id>/](#get-learningquiz-submissionsid)POST /learning/exams/<id>/start/](#post-learningexamsidstart)POST /learning/exams/<id>/submit/](#post-learningexamsidsubmit)GET /learning/exam-submissions/<id>/](#get-learningexam-submissionsid)Description: Resume an active (not yet submitted) exam attempt. Returns remaining time, questions, and saved draft answers.
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. Start the exam first. |
| 404 | Exam not found or not available. |
Description: Get the current student's own profile.
Authentication: Student
Success Response — 200 OK:
{
"id": "Integer",
"user_id": "Integer",
"username": "String",
"student_code": "String",
"name_ar": "String",
"name_en": "String",
"full_name": "String",
"phone_number": "String",
"father_number": "String | null",
"mother_number": "String | null",
"school_type": "Integer | null",
"school_type_name": "String | null",
"grade": "Integer | null",
"grade_name": "String | null",
"division": "Integer | null",
"division_name": "String | null",
"school_name": "String | null",
"birth_date": "Date (YYYY-MM-DD)",
"gender": "String (male|female)",
"gmail": "String",
"governorate": "Integer",
"governorate_name": "String | null",
"area": "Integer",
"area_name": "String | null",
"status": "String",
"is_active": "Boolean",
"can_access_course": "Boolean",
"status_history": [
{
"changed_by": "String | null — Username of the admin who made the change",
"from_status": "String | null",
"to_status": "String",
"reason": "String | null",
"created_at": "DateTime (ISO 8601)"
}
],
"created_at": "DateTime (ISO 8601)",
"updated_at": "DateTime (ISO 8601)"
}
Description: Update the current student's own profile. Only works when status='declined' — other statuses are blocked.
Authentication: Student
Content-Type: multipart/form-data or application/json
Request Body: All fields from the profile are optional for PATCH. Same validation rules as registration apply.
Success Response — 200 OK: Same structure as GET. On success, if the student was declined, their status auto-resets to pending and is_active becomes false — the SiteOwner must review and re-activate.
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
403 |
Profile is verified | {"error": "Cannot update a verified profile. Contact an administrator."} |
403 |
Profile is pending or suspended | {"error": "Cannot update profile while account is in this state."} |
400 |
Email exists (other user) | {"gmail": ["This email is already associated with an account."]} |
400 |
Area/governorate mismatch | {"area": ["The selected area does not belong..."]} |
400 |
Missing required fields for state | Same as registration validation errors |
Description: Allows an authenticated user to change their password. Requires the current password for verification. Invalidates all refresh tokens on success.
Authentication: Any authenticated user
Content-Type: application/json
Request Body:
{
"old_password": "String (Required) — Current password",
"new_password": "String (Required) — Minimum 8 characters",
"new_password_confirm": "String (Required) — Must match new_password"
}
Success Response — 200 OK:
{
"message": "Password changed successfully. Please log in again."
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
400 |
Missing fields | {"error": "All password fields are required"} |
400 |
Passwords don't match | {"error": "New passwords do not match"} |
400 |
Password too short | {"error": "Password must be at least 8 characters"} |
400 |
Weak password | {"error": "This password is too common."} or {"error": "This password is entirely numeric."} |
400 |
Wrong old password | {"error": "Current password is incorrect"} |
401 |
Not authenticated | {"detail": "Authentication credentials were not provided."} |
Business Rules:
GET /courses/by-subject/<subject_id>/Description: List courses for a specific subject. Auto-filtered by the authenticated student's grade, division, and school type. Only active courses are returned.
Authentication: Any authenticated user
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
teacher |
Integer | Filter by teacher ID |
search |
String | Search by name, description, or teacher name |
ordering |
String | created_at, name, -created_at (default: -created_at) |
Success Response — 200 OK:
{
"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",
"eligibility": {
"eligible": "Boolean — Whether this student may enroll (grade/school_type/division match)",
"reason": "String | null — 'grade' | 'division' | 'school_type' when not eligible"
},
"created_at": "DateTime (ISO 8601)"
}
]
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
401 |
Not authenticated | {"detail": "Authentication credentials were not provided."} |
Business Rules:
grade, school_type (via subject), and division (via subject).eligibility is present only for authenticated students (null otherwise).GET /courses/<id>/preview/Description: Preview a course before enrolling. Shows topics and lectures with prices, but NO video URLs. Available to any authenticated user.
Authentication: Any authenticated user
Success Response — 200 OK:
{
"id": "Integer",
"name": "String",
"description": "String | null",
"cover_picture": "String (URL) | null",
"teacher": {
"id": "Integer",
"name": "String",
"profile_picture": "String (URL) | null"
},
"grade": {
"id": "Integer",
"name": "String"
},
"subject": {
"id": "Integer",
"name": "String"
},
"topic_count": "Integer",
"total_lectures": "Integer",
"eligibility": {
"eligible": "Boolean — Whether this student may enroll (grade/school_type/division match)",
"reason": "String | null — 'grade' | 'division' | 'school_type' when not eligible"
},
"topics": [
{
"id": "Integer",
"name": "String",
"description": "String | null",
"order": "Integer",
"lecture_count": "Integer",
"picture": "String (URL) | null",
"lectures": [
{
"id": "Integer",
"name": "String",
"description": "String | null",
"price": "String — Decimal as string",
"final_price": "String — Decimal as string",
"discount": "String — Decimal as string",
"available_days": "Integer",
"order": "Integer",
"video_count": "Integer"
}
]
}
],
"created_at": "DateTime (ISO 8601)"
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
401 |
Not authenticated | {"detail": "Authentication credentials were not provided."} |
404 |
Course not found | {"error": "Course not found"} |
Description: Student requests enrollment in a course. Creates a PENDING enrollment.
Authentication: Student
Content-Type: application/json
Request Body:
{
"course": "Integer (Required) — Course ID"
}
Success Response — 201 Created:
{
"id": "Integer",
"student": "Integer",
"student_name": "String",
"student_code": "String",
"course": "Integer",
"course_name": "String",
"grade_name": "String",
"teacher_name": "String",
"status": "String — pending",
"status_display": "String — Pending",
"is_blocked": "Boolean",
"block_reason": "String | null",
"blocked_by_name": "String | null",
"blocked_at": "DateTime | null",
"unblocked_by_name": "String | null",
"unblocked_at": "DateTime | null",
"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 |
|---|---|---|
400 |
No student profile | {"detail": "Student profile not found."} |
403 |
Account not verified | {"detail": "Your account is pending approval. You cannot enroll until an administrator verifies your account."} |
400 |
Already enrolled | {"detail": "You are already enrolled or have a pending request for this course."} |
400 |
Grade mismatch | {"detail": "String"} |
Business Rules:
403.Description: Student views their own enrollments. Returns all statuses (pending/approved/rejected). For approved enrollments, includes course cover picture and topic/lecture counts.
Authentication: Student
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
status |
String | Filter: pending, approved, rejected |
Success Response — 200 OK:
[
{
"id": "Integer",
"student": "Integer",
"student_name": "String (en / ar)",
"student_code": "String",
"phone_number": "String",
"father_number": "String | null",
"mother_number": "String | null",
"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) — '0.00' if no balance",
"cover_picture": "String (URL) | null",
"topic_count": "Integer | null — Only for approved enrollments",
"total_lectures": "Integer | null — Only for approved enrollments",
"is_blocked": "Boolean — Whether the teacher blocked this student from the course",
"block_reason": "String | null — Why (when blocked)",
"blocked_by_name": "String | null",
"blocked_at": "DateTime | null",
"unblocked_by_name": "String | null",
"unblocked_at": "DateTime | null",
"enrolled_at": "DateTime (ISO 8601)",
"responded_by": "Integer | null",
"responded_by_name": "String | null",
"responded_at": "DateTime | null",
"response_note": "String | null"
}
]
Blocked courses: when
is_blockedistrue, the student cannot watch videos, buy lectures, or open the course — API calls return403with{blocked: true, reason}. The block is per-course and is set/removed by the teacher.
POST /courses/enrollments/<id>/cancel/Description: Student cancels their own pending enrollment. The enrollment is deleted.
Authentication: Student
Success Response — 200 OK:
{
"message": "Enrollment cancelled successfully"
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
403 |
Not own enrollment | {"error": "You can only cancel your own enrollments"} |
400 |
Not pending | {"error": "Cannot cancel enrollment with status: X"} |
404 |
Enrollment not found | {"error": "Enrollment not found"} |
GET /courses/<id>/lectures/Description: Get lectures for a course the student is enrolled in. Shows purchase status for each lecture.
Authentication: Student (must be approved enrolled)
Success Response — 200 OK:
{
"course": {
"id": "Integer",
"name": "String"
},
"topics": [
{
"id": "Integer",
"name": "String",
"picture": "String (URL) | null",
"order": "Integer",
"lectures": [
{
"id": "Integer",
"name": "String",
"description": "String | null",
"price": "String — Decimal as string",
"final_price": "String — Decimal as string",
"discount": "String — Decimal as string",
"available_days": "Integer",
"order": "Integer",
"video_count": "Integer",
"materials_count": "Integer",
"homeworks_count": "Integer",
"quizzes_count": "Integer",
"is_purchased": "Boolean",
"purchase": {
"id": "Integer",
"purchased_at": "DateTime (ISO 8601)",
"expires_at": "DateTime (ISO 8601)",
"effective_expiry": "DateTime (ISO 8601)",
"is_expired": "Boolean",
"extra_days": "Integer",
"amount_paid": "String — Decimal as string",
"max_watch_count": "Integer — Maximum viewing sessions (default: 4)",
"sessions_used": "Integer — Viewing sessions consumed",
"sessions_remaining": "Integer — Remaining sessions",
"has_active_session": "Boolean — Whether a 6-hour window is open",
"active_session": "Object — LectureViewingSession data (if active)"
}
}
]
}
]
}
Note: The
purchaseobject is only included ifis_purchasedistrue. When present, it includesmax_watch_count,sessions_used,sessions_remaining,has_active_session, andactive_session(if a 6-hour viewing window is open).
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
400 |
No student profile | {"error": "Student profile not found"} |
403 |
Blocked from course | {"error": "You have been blocked from this course.", "blocked": true, "reason": "…"} |
403 |
Not enrolled | {"error": "You are not enrolled in this course"} |
404 |
Course not found | {"error": "Course not found"} |
GET /courses/lectures/<id>/Description: Get full details for a single lecture, including videos list, materials count, homework/quiz counts, and prerequisites. Students can access this for any lecture in a course they're enrolled in.
Authentication: Student (must be approved enrolled in the course)
Success Response — 200 OK:
{
"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",
"available_days": "Integer",
"is_visible": "Boolean",
"picture": "String (URL) | null",
"order": "Integer",
"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"
}
],
"videos": [
{
"id": "Integer",
"name": "String",
"order": "Integer",
"is_active": "Boolean",
"thumbnail_url": "String | null"
}
],
"created_at": "DateTime (ISO 8601)",
"updated_at": "DateTime (ISO 8601)"
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
400 |
No student profile | {"error": "Student profile not found"} |
403 |
Not enrolled | {"error": "You are not enrolled in this course"} |
404 |
Lecture not found | {"error": "Lecture not found"} |
GET /courses/lectures/<id>/available-assessments/Description: Returns all quizzes and exams available for a lecture. Students see only published assessments. Teachers see all.
Authentication: Any authenticated user (students see only published)
Success Response — 200 OK:
{
"quizzes": [
{ "id": "Integer", "title": "String", "lecture_id": "Integer", "lecture_name": "String" }
],
"exams": [
{ "id": "Integer", "title": "String" }
]
}
Error Responses:
| Status | Condition |
|---|---|
404 |
Lecture not found |
GET /courses/lectures/<id>/prerequisites/Description: List all prerequisites for a lecture. Shows what quiz or exam must be passed before accessing the lecture's videos.
Authentication: Any authenticated user (students can read)
Success Response — 200 OK:
{
"count": "Integer",
"results": [
{
"id": "Integer",
"lecture": "Integer",
"lecture_name": "String",
"content_type": "String (quiz|exam)",
"content_id": "Integer",
"passing_score": "Decimal",
"content_name": "String",
"created_at": "DateTime (ISO 8601)"
}
]
}
Error Responses:
| Status | Condition |
|---|---|
404 |
Lecture not found |
Description: Student purchases a lecture. Deducts the lecture's final price from the student's course balance.
Authentication: Student
Content-Type: application/json
Request Body:
{
"lecture": "Integer (Required) — Lecture ID"
}
Success Response — 201 Created:
{
"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)",
"effective_expiry": "DateTime (ISO 8601)",
"amount_paid": "Decimal",
"extra_days": "Integer",
"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 |
|---|---|---|
400 |
No student profile | {"detail": "Student profile not found."} |
403 |
Account not verified | {"detail": "Your account must be verified before purchasing lectures."} |
403 |
Blocked from course | {"detail": "You have been blocked from this course.", "blocked": true, "reason": "…"} |
403 |
Not enrolled/approved | {"detail": "You must be enrolled and approved in this course before purchasing lectures."} |
400 |
Already purchased and active | {"detail": "You have already purchased this lecture and it's still active."} |
400 |
Insufficient balance | {"detail": "Insufficient balance. You need X more."} |
Business Rules:
403 with blocked: true and the teacher's reason).final_price is deducted from the student's course balance.expires_at is calculated as purchased_at + available_days.Description: Student views their own purchased lectures.
Authentication: Student
Success Response — 200 OK: Array of purchased lectures.
POST /courses/purchases/<pk>/start-watching/Description: Start a 6-hour viewing session for a purchased lecture. If there's already an active session (within 6h), returns the existing session. If not, creates a new 6h session and increments sessions_used.
Authentication: Student (must own the purchase)
Request Body: None
Success Response — 200 OK (existing session resumed):
{
"session": {
"id": 1,
"purchase": 5,
"started_at": "2026-07-26T10:00:00Z",
"expires_at": "2026-07-26T16:00:00Z",
"is_active": true,
"remaining_seconds": 21540
},
"sessions_used": 1,
"sessions_remaining": 3,
"message": "Resuming existing session."
}
Success Response — 201 Created (new session):
{
"session": {
"id": 2,
"purchase": 5,
"started_at": "2026-07-26T10:00:00Z",
"expires_at": "2026-07-26T16:00:00Z",
"is_active": true,
"remaining_seconds": 21599
},
"sessions_used": 2,
"sessions_remaining": 2,
"message": "Session started. You have 6 hours to watch all videos."
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
400 |
No student profile | {"error": "Student profile not found."} |
403 |
Purchase expired | {"error": "Your access to this lecture has expired."} |
403 |
All sessions used | {"error": "You have used all your viewing sessions. Contact your teacher to reopen access.", "sessions_used": 4, "sessions_remaining": 0} |
404 |
Purchase not found | {"detail": "Not found."} |
Business Rules:
max_watch_count (default 4).GET /courses/purchases/<pk>/active-session/Description: Check for an active viewing session for a purchased lecture. Read-only — no session is created. Frontend uses this to decide which button to show: "Start watching" (no active session) or "Resume watching" (active session).
Authentication: Student (must own the purchase)
Success Response — 200 OK (active session exists):
{
"has_active_session": true,
"session": {
"id": 1,
"purchase": 5,
"started_at": "2026-07-26T10:00:00Z",
"expires_at": "2026-07-26T16:00:00Z",
"is_active": true,
"remaining_seconds": 12000
},
"sessions_used": 1,
"sessions_remaining": 3
}
Success Response — 200 OK (no active session, purchase still active):
{
"has_active_session": false,
"is_expired": false,
"sessions_used": 1,
"sessions_remaining": 3
}
Success Response — 200 OK (purchase expired):
{
"has_active_session": false,
"is_expired": true,
"error": "Your access to this lecture has expired."
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
400 |
No student profile | {"error": "Student profile not found."} |
404 |
Purchase not found | {"detail": "Not found."} |
GET /courses/videos/<id>/play/Description: Get a playback URL for a video. Enforces purchase check, expiry, active viewing session check, and prerequisites before returning the URL. Library 725542 uses MediaCage Basic DRM → the actual playback happens in Bunny's embed iframe; use embed_url. playback_url is the direct HLS manifest, kept for preview/dev contexts.
Authentication: Student (must have purchased the lecture)
Request Body: None
Success Response — 200 OK:
{
"playback_url": "String — HLS playlist URL (e.g. https://cdn/{guid}/playlist.m3u8)",
"embed_url": "String — Bunny embed iframe URL, e.g. https://player.mediadelivery.net/embed/725542/{guid} (?token=…&expires=… when token auth is enabled)",
"token": "String | null — Embed token (SHA256_HEX) when token auth is configured, else null",
"expires_at": "Integer | null — Unix seconds the embed token expires (null when no token)",
"expires_in": "Integer — Seconds the URL is valid for"
}
Token expiry (students): the embed token is tied to the student's active 6-hour viewing session —
expires_in = max(session_remaining + 15 min, 1 hour). A shared embed URL dies with the session window, so a copied link cannot be reused after the student's session ends. Teachers/assistants/siteowner previews get a flat 2 hours.
Access Rules:
| Check | Blocked? |
|---|---|
| Lecture not purchased | 403 |
| Purchase expired | 403 |
| No active viewing session | 403 |
| Prerequisite quiz not passed | 403 |
| Prerequisite exam not passed | 403 |
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
403 |
Blocked from course | {"error": "You have been blocked from this course.", "blocked": true, "reason": "…"} |
403 |
Not purchased | {"error": "You have not purchased this lecture."} |
403 |
Expired | {"error": "Your access to this lecture has expired."} |
403 |
No active session | {"error": "No active viewing session. Click \"Start watching\" to begin a 6-hour session.", "sessions_used": 1, "sessions_remaining": 3} |
403 |
Prerequisite not met | {"error": "You must pass the quiz (ID X) before accessing this video."} |
404 |
Video not found/not active | {"error": "Video is not available."} |
Description: List the student's video watch progress.
Authentication: Student
Success Response — 200 OK:
{
"count": "Integer",
"next": "String (URL) | null",
"previous": "String (URL) | null",
"results": [
{
"id": "Integer",
"student": "Integer",
"student_name": "String",
"student_code": "String",
"video": "Integer",
"video_name": "String",
"lecture_name": "String",
"course_name": "String",
"progress_seconds": "Integer",
"cumulative_watch_seconds": "Integer",
"duration_seconds": "Integer | null",
"progress_percentage": "Decimal — Based on cumulative_watch_seconds",
"position_percentage": "Decimal — Based on progress_seconds (furthest position)",
"is_completed": "Boolean",
"last_watched_at": "DateTime (ISO 8601)",
"platform_data": "Object | null"
}
]
}
Description: Update watch progress for a video. Requires an active viewing session (see start-watching endpoint).
Authentication: Student
Content-Type: application/json
Request Body:
{
"video": "Integer (Required) — Video ID",
"progress_seconds": "Integer (Required) — Current playback position (furthest point reached)",
"cumulative_watch_seconds": "Integer (Optional) — Cumulative wall-clock seconds genuinely watched while playing",
"duration_seconds": "Integer (Optional) — Total video duration",
"platform_data": "Object (Optional) — Platform-specific data"
}
Success Response — 200 OK:
{
"id": "Integer",
"student": "Integer",
"video": "Integer",
"progress_seconds": "Integer",
"cumulative_watch_seconds": "Integer",
"duration_seconds": "Integer | null",
"progress_percentage": "Decimal — Based on cumulative_watch_seconds",
"position_percentage": "Decimal — Based on progress_seconds (furthest position)",
"is_completed": "Boolean",
"last_watched_at": "DateTime (ISO 8601)",
"platform_data": "Object | null"
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
400 |
Missing video | {"video": ["This field is required."]} |
403 |
Lecture not purchased | {"detail": "You must purchase this lecture to track progress."} |
403 |
No active session | {"error": "No active viewing session. Click \"Start watching\" to begin a 6-hour session.", "sessions_used": 1, "sessions_remaining": 3} |
Business Rules:
progress_seconds — furthest playback position reached (only increases). Prevents cheating by skipping ahead.cumulative_watch_seconds — cumulative wall-clock time spent playing (only increases). Prevents cheating by scrubbing to the end.is_completed requires both values to reach >= 90% of duration_seconds. A student cannot complete a video by scrubbing or by watching only the first 30% repeatedly.cumulative_watch_seconds ONLY on milestones (25%, 50%, 75%, 90%) — not every request. The frontend tracks cumulative time locally via localStorage and reports it in batches.GET /courses/progress/<video_id>/Description: Get progress for a specific video.
Authentication: Student
Success Response — 200 OK: Single progress object.
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
404 |
Progress not found | {"detail": "Not found."} |
Description: Get all course balances for the authenticated student. Returns one entry per approved-enrolled course, with the student's available balance. Newly approved students with no transaction history are included with balance: "0.00".
Authentication: Student
Success Response — 200 OK:
[
{
"id": "Integer | null — null if no CourseBalance record exists (balance is 0)",
"student": "Integer",
"student_name": "String",
"student_code": "String",
"course": "Integer",
"course_name": "String",
"teacher_name": "String",
"teacher_picture": "String (URL) | null",
"balance": "String — Decimal as string (e.g. '150.00' or '0.00')",
"updated_at": "DateTime (ISO 8601) | null"
}
]
Business Rules:
CourseBalance record return balance: "0.00" and id: nullCourseBalance record is created and id becomes non-nullGET /courses/<id>/balance/Description: Get a student's balance for a specific course. Returns a single balance value for the given course.
Authentication: Student (must be enrolled in the course)
Success Response — 200 OK:
{
"course_id": 1,
"course_name": "Chemistry 3rd Secondary",
"balance": "150.00"
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
401 |
Not authenticated | {"detail": "Authentication credentials were not provided."} |
404 |
Course not found | {"error": "Course not found"} |
Description: Student redeems a physical voucher code to add balance to a specific course. Codes are course-specific and can only be used once.
Authentication: Student (must be approved enrolled in the course)
Request Body:
{
"code": "String (Required) — Recharge code (e.g. 'X7K9-M2P4-QR1W-L5D8')",
"course": "Integer (Required) — Course ID"
}
Success Response — 200 OK:
{
"detail": "String - Success message",
"new_balance": "String (Decimal) - New balance",
"transaction_id": "Integer"
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
400 |
Code already used | {"detail": "This code has already been used."} |
400 |
Code expired | {"detail": "This code has expired."} |
400 |
Wrong course | {"detail": "This code cannot be used for this course."} |
403 |
Not enrolled | {"detail": "You must be enrolled and approved..."} |
404 |
Invalid code | {"detail": "Invalid code."} |
Business Rules:
XXXX-XXXX-XXXX-XXXX (16 random chars, cryptographically secure).BalanceTransaction ledger entry.Description: Full balance transaction ledger for the logged-in student. Shows every balance change with before and after snapshots. Auto-filtered to the authenticated student.
Authentication: Student (auto-filtered to own transactions)
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
course |
Integer | Filter by course ID |
transaction_type |
String | Filter: code_redeemed or lecture_purchase |
search |
String | Search in description text |
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) |
page |
Integer | Page number |
page_size |
Integer | Items per page (default: 50) |
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 — 'Student' for self-purchases, teacher name for code redemptions",
"created_at": "DateTime (ISO 8601)"
}
]
}
Homework uses the Bubble Sheet model. Students see question numbers with A/B/C/D choices, select answers, and submit. All questions are auto-graded immediately.
Description: List homeworks. For students, returns homeworks for purchased lectures including submission status, score, and grade visibility.
Authentication: Student
Success Response — 200 OK:
{
"count": "integer",
"results": [
{
"id": "integer",
"lecture": "integer",
"lecture_name": "string",
"title": "string",
"description": "string",
"is_published": "boolean",
"show_grades": "boolean",
"total_points": "integer",
"question_count": "integer",
"is_submitted": "boolean",
"submission_id": "integer | null",
"score": "string (decimal) | null",
"status": "string (submitted|graded) | null",
"submitted_at": "datetime (ISO 8601) | null",
"created_at": "datetime (ISO 8601)"
}
]
}
GET /learning/homeworks/<id>/Description: Get the homework detail (bubble questions with choices count). Student sees only question number and choices count — no correct answers.
Authentication: Student (must have purchased the lecture)
Access Control:
404.Success Response — 200 OK:
{
"id": "integer",
"lecture": "integer",
"lecture_name": "string",
"title": "string — Homework title",
"description": "string",
"bubble_questions": [
{"id": "integer", "order": "integer", "choices_count": "integer"},
{"id": "integer", "order": "integer", "choices_count": "integer"}
]
}
PATCH /learning/homeworks/<id>/draft/Description: Auto-save homework answers as a draft. Students can close the page and come back — their choices are restored.
Request Body:
{
"answers": {
"1": "A",
"2": "B",
"3": "A,C"
}
}
Success Response — 200 OK:
{
"answers": {"1": "A", "2": "B", "3": "A,C"},
"updated_at": "datetime (ISO 8601)"
}
GET /learning/homeworks/<id>/draft/Description: Load the saved draft answers. Returns empty object if no draft exists.
Success Response — 200 OK:
{
"answers": {"1": "A", "2": "B", "3": "A,C"},
"updated_at": "datetime (ISO 8601)"
}
POST /learning/homeworks/<id>/submit/Description: Submit bubble sheet answers. Auto-graded immediately.
Authentication: Student
Access Control:
Request Body:
{
"bubble_answers": [
{"bubble_question_id": "integer", "selected_choice": "A"},
{"bubble_question_id": "integer", "selected_choice": "C"},
{"bubble_question_id": "integer", "selected_choice": "A,C"}
]
}
Success Response — 200 OK:
{
"detail": "Homework submitted and auto-graded.",
"submission_id": "integer",
"score": "string (decimal)",
"status": "string (submitted|graded)"
}
GET /learning/homework-submissions/<id>/Description: View submission results with per-question details.
Access Control:
correct_answer and answer_explanation if the teacher enabled show_grades on the homework.Authentication: Student (own submissions), Teacher, Assistant
Success Response — 200 OK:
{
"id": "integer",
"homework": "integer",
"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 | null — Only shown to students if show_grades=true",
"answer_explanation": "string | null — Only shown to students if show_grades=true",
"is_correct": "boolean",
"points_earned": "string (decimal)"
}
]
}
| # | Endpoint | Method | Who |
|---|---|---|---|
| 11.1 | /learning/quizzes/ |
GET | Student |
| 11.2 | /learning/quizzes/<id>/ |
GET | Any authenticated |
| 11.3 | /learning/quizzes/<id>/start/ |
POST | Student |
| 11.4 | /learning/quizzes/<id>/resume/ |
GET | Student |
| 11.5 | /learning/quiz-answers/<id>/save-draft/ |
PATCH | Student |
| 11.6 | /learning/quizzes/<id>/submit/ |
POST | Student |
| 11.7 | /learning/quiz-submissions/<id>/ |
GET | Student (own) |
Description: List quizzes. For students, returns quizzes for purchased lectures including quiz status, submission info, score, and visibility.
Authentication: Student
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 — Number of allowed attempts (quizzes are multi-attempt)"
},
"quiz_status": "string (not_started|in_progress|submitted)",
"submission_id": "integer | null",
"score": "string (decimal) | null",
"score_visible": "boolean",
"created_at": "datetime (ISO 8601)"
}
]
}
Description: View quiz details. What you see depends on whether you've started the quiz.
Access Control:
| Status | What you see |
|---|---|
| Not started (no purchase or no attempt) | id, title, description, total_points, question_count, settings — questions are hidden |
| Has started (active or submitted attempt) | Full quiz with questions, choices, your saved/submitted answers |
| Not purchased the lecture | 404 Not Found |
Authentication: Any authenticated user
Notes:
correct_answer (is_correct) on choices is NEVER shown to students. Teachers only.QuizStudentDetailSerializer which omits quiz_questions but still includes settings and question_count.Description: Start a quiz attempt. Timer starts counting from this moment. The returned submission_id is used for submitting answers and saving drafts. Quizzes are multi-attempt — one attempt per student is allowed up to settings.max_attempts (default 1).
Authentication: Student
Access Checks (all must pass):
| Check | Failure response |
|---|---|
| You must have purchased the lecture | 403 |
| You must be enrolled and approved in the course | 403 |
Quiz must be published (is_published=true) |
404 |
Attempts remaining (submitted attempts < max_attempts) |
400 |
Request Body: None
Success Response — 200 OK:
{
"submission_id": "integer — Save this! Required for submit and draft endpoints",
"timer_minutes": "integer — Time limit. 0 means no time limit.",
"started_at": "datetime (ISO 8601) — When this attempt began",
"questions": [
{
"answer_id": "integer — ID to use when saving drafts or submitting",
"quiz_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"}
]
}
]
}
Error Responses:
| Status | Body |
|---|---|
| 403 | {"detail": "You must purchase this lecture to take the quiz."} |
| 403 | {"detail": "You must be enrolled in this course."} |
| 400 | {"detail": "You already have an active quiz attempt. Resuming it.", "submission_id": X, "status": "in_progress"} — call GET /resume/ instead |
| 400 | {"detail": "You have used all your allowed attempts for this quiz. If you need to retake, ask your teacher to delete your previous submissions."} |
| 404 | {"detail": "Quiz not found or not available."} |
Description: Resume an active quiz attempt (one that hasn't been submitted yet). Returns remaining time, questions, and any previously saved draft answers. Use this when the page is reloaded or the student returns to the quiz.
Authentication: Student
Success Response — 200 OK:
{
"submission_id": "integer",
"timer_minutes": "integer — Original time limit",
"timer_remaining": "number | null — Minutes remaining. Null if no timer. Can be 0 if expired.",
"started_at": "datetime (ISO 8601)",
"questions": [
{
"answer_id": "integer",
"quiz_question_id": "integer",
"question_text": "string",
"question_image": "string (URL) | null",
"question_type": "string",
"points": "integer",
"choices": [
{"id": "integer", "text": "string", "order": "integer"}
],
"saved_choice_ids": "array[integer] — Previously saved MCQ choices (empty array if none)",
"saved_written_answer": "string — Previously saved written answer (empty string if none)"
}
]
}
Error Responses:
| Status | Condition |
|---|---|
| 400 | No active attempt found — you must start the quiz first |
| 404 | Quiz not found or not available |
Description: Save individual answer choices while the student is working. This is the auto-save endpoint — call it periodically (every 5-10 seconds) to preserve progress. Saved answers are restored when you resume the quiz. This does NOT submit the quiz — you must call /submit/ separately.
Authentication: Student
Request Body:
{
"choice_ids": "array[integer] — Selected choice IDs (for mcq_single or mcq_multiple questions)",
"written_answer": "string — Written answer text (for written questions)"
}
Success Response — 200 OK:
{
"saved": true,
"answer_id": "integer"
}
Description: Submit quiz answers for auto-grading. MCQ questions are graded immediately. Written questions receive 0 points until the teacher manually grades them.
Grading Rules:
| Question Type | Correct | Points |
|---|---|---|
mcq_single |
Exactly the correct choice(s) selected | Full points |
mcq_single |
Wrong choice(s) selected | 0 |
mcq_multiple |
All correct choices selected AND no extras | Full points |
mcq_multiple |
Subset of correct choices selected (no extras) | Partial credit (proportional) |
mcq_multiple |
Any incorrect choice selected | 0 |
written |
Auto-graded | 0 until teacher grades manually |
Timer Behavior: If the timer has expired (elapsed time >= timer_minutes), the submission is auto-submitted and marked is_timed_out with score 0; the submit request returns 400 {"detail": "Time limit exceeded. Quiz auto-submitted with score 0."}. The student must submit BEFORE time runs out.
Authentication: Student
Request Body:
{
"answers": [
{
"quiz_question_id": "integer (required)",
"choice_ids": "array[integer] — Selected choice IDs (for mcq_single and mcq_multiple)",
"written_answer": "string — Written answer text (for written questions)"
}
]
}
Success Response — 200 OK:
{
"detail": "Quiz submitted successfully.",
"submission_id": "integer",
"score": "string (decimal) | null — Null if score_visibility=manual and not released",
"score_visible": "boolean",
"answers_visible": "boolean"
}
Error Responses:
| Status | Condition |
|---|---|
| 400 | No active attempt found (start the quiz first) |
| 400 | Quiz already submitted |
| 400 | Time limit exceeded (auto-submitted, is_timed_out=true) |
Description: View a specific submission with per-answer results. Shows selected choices, whether correct, and points earned.
Authentication: Student (own submissions only)
Access Control:
correct_answer and correct_standalone_choice_texts are hidden unless answers_visibility allows them.Success Response — 200 OK:
{
"id": "integer",
"quiz": "integer",
"quiz_title": "string",
"student": "integer",
"student_name": "string",
"student_name_ar": "string",
"student_code": "string",
"score": "string (decimal) | null",
"started_at": "datetime (ISO 8601)",
"submitted_at": "datetime (ISO 8601) | null",
"is_timed_out": "boolean",
"score_visible": "boolean",
"answers_visible": "boolean",
"answers": [
{
"quiz_question": "integer",
"question_text": "string",
"selected_standalone_choice_texts": "array[string]",
"correct_standalone_choice_texts": "array[string] — Only shown if answers_visible=true",
"written_answer": "string",
"is_correct": "boolean | string | null — For MCQ: true/false. For written: 'corrected' or 'not_corrected'.",
"points_earned": "integer | float — e.g. 1 instead of '1.00'. Float if non-integer.",
"feedback": "string | null — Teacher's feedback for written questions (null if not graded or not a written question).",
"display_order": "integer"
}
]
}
| # | Endpoint | Method | Who |
|---|---|---|---|
| 12.1 | /learning/exams/ |
GET | Student |
| 12.2 | /learning/exams/<id>/start/ |
POST | Student |
| 12.3 | /learning/exams/<id>/resume/ |
GET | Student |
| 12.4 | /learning/exams/<id>/submit/ |
POST | Student |
| 12.5 | /learning/exam-submissions/<id>/ |
GET | Student (own) |
Description: List exams. For students, returns exams for their enrolled courses including quiz status, submission info, score, and visibility.
Authentication: Student
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",
"quiz_status": "string (not_started|in_progress|submitted)",
"submission_id": "integer | null",
"score": "string (decimal) | null",
"score_visible": "boolean",
"created_at": "datetime (ISO 8601)"
}
]
}
Description: Start an exam attempt. Works identically to quiz start. Timer begins, questions are returned. The exam must be within open_date and close_date to start. Exams are single-attempt — a student can start an exam only once, ever.
Authentication: Student
Access Checks:
| Check | Failure response |
|---|---|
| Must be enrolled and approved in the course | 403 |
Current time must be after open_date (if set) |
403 |
Current time must be before close_date (if set) |
403 |
| Must not have already started this exam | 400 |
Request Body: None
Error Responses:
| Status | Body |
|---|---|
| 403 | {"detail": "You must be enrolled in this course."} |
| 403 | {"detail": "This exam is not yet open."} |
| 403 | {"detail": "This exam has closed."} |
| 400 | {"detail": "You have already started this exam. If you need to retake, ask your teacher to delete your previous submission."} |
| 404 | {"detail": "Exam not found or not available."} |
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: Submit exam answers. Same grading logic as quizzes. Auto-grades MCQ, stores written for manual grading. Timer rules apply — if time expires, submission is rejected.
Authentication: Student
Request Body:
{
"answers": [
{
"question_id": "integer (required)",
"choice_ids": "array[integer] — Selected choice IDs (for MCQ)",
"written_answer": "string — Written answer text (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: View a specific exam submission with per-answer results.
Authentication: Student (own submissions only)
Access Control:
score_visibility / answers_visibility settings.Success Response: Same structure as quiz submission detail (see 11.7).
Description: Get all study materials for lectures the student has purchased.
Authentication: Student
Success Response — 200 OK:
[
{
"id": "integer - Unique identifier",
"lecture": "integer - Lecture ID",
"lecture_name": "String - Lecture name",
"title": "String - Material title",
"file_url": "String (URL) - File URL",
"is_active": "boolean - Whether this item is active",
"created_at": "DateTime (ISO 8601)"
}
]
These endpoints power the student dashboard pages — a unified view of all purchased lectures, homeworks, and quizzes across all enrolled courses.
GET /courses/my-lectures/Description: Returns ALL purchased lectures across all enrolled courses for the student. Includes expiry status, active session indicator, content counts (videos, materials, homeworks, quizzes), and session tracking.
Authentication: Student (requires student_profile)
Success Response — 200 OK:
[
{
"purchase_id": "integer",
"lecture_id": "integer",
"lecture_name": "string",
"topic_id": "integer",
"topic_name": "string",
"topic_picture": "string (URL) | null",
"lecture_picture": "string (URL) | null",
"course_id": "integer",
"course_name": "string",
"teacher_name": "string",
"price": "string (decimal)",
"purchased_at": "datetime (ISO 8601)",
"expires_at": "datetime (ISO 8601)",
"effective_expiry": "datetime (ISO 8601)",
"is_expired": "boolean",
"extra_days": "integer",
"max_watch_count": "integer",
"sessions_used": "integer",
"sessions_remaining": "integer",
"has_active_session": "boolean",
"video_count": "integer",
"materials_count": "integer",
"homeworks_count": "integer",
"quizzes_count": "integer"
}
]
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
400 |
No student profile | {"error": "Student profile not found"} |
401 |
Not authenticated | {"detail": "Authentication credentials were not provided."} |
Blocked courses are excluded: lectures belonging to a course where the student's approved enrollment is blocked by the teacher do not appear in this list.
GET /learning/homeworks/Description: List homeworks with submission status. For students, shows all homeworks for purchased lectures including whether submitted, score, and grade visibility. (Replaces the removed /homeworks/all/ endpoint.)
Authentication: Student
Success Response — 200 OK:
{
"count": "integer",
"results": [
{
"id": "integer",
"lecture": "integer",
"lecture_name": "string",
"title": "string",
"description": "string",
"is_published": "boolean",
"show_grades": "boolean",
"total_points": "integer",
"question_count": "integer",
"is_submitted": "boolean",
"submission_id": "integer | null",
"score": "string (decimal) | null",
"status": "string (submitted|graded) | null",
"submitted_at": "datetime (ISO 8601) | null",
"created_at": "datetime (ISO 8601)"
}
]
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
400 |
No student profile | {"error": "Student profile not found"} |
401 |
Not authenticated | {"detail": "Authentication credentials were not provided."} |
GET /learning/quizzes/Description: List quizzes with student progress status. For students, shows all quizzes for purchased lectures with quiz status (not_started/in_progress/submitted), submission info, and score visibility. (Replaces the removed /quizzes/all/ endpoint.)
Authentication: Student
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",
"answers_visibility": "string",
"question_order": "string",
"max_attempts": "integer"
},
"quiz_status": "string (not_started|in_progress|submitted)",
"submission_id": "integer | null",
"score": "string (decimal) | null",
"score_visible": "boolean",
"created_at": "datetime (ISO 8601)"
}
]
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
400 |
No student profile | {"error": "Student profile not found"} |
401 |
Not authenticated | {"detail": "Authentication credentials were not provided."} |
GET /courses/student/dashboard/Description: One consolidated student landing page endpoint. Returns enrolled courses with balances, lecture stats, nearly-expired lectures, homework stats, and quiz stats in a single response. Replaces 5 separate API calls.
Authentication: Student
Success Response — 200 OK:
{
"enrolled_courses": [
{
"id": "integer",
"name": "string",
"cover_picture": "string (URL) | null",
"teacher_name": "string",
"teacher_picture": "string (URL) | null",
"grade_name": "string",
"balance": "string (decimal)",
"total_lectures": "integer",
"purchased_lectures": "integer"
}
],
"my_lectures": {
"total": "integer",
"active": "integer",
"expired": "integer"
},
"nearly_expired_lectures": [
{
"purchase_id": "integer",
"lecture_id": "integer",
"lecture_name": "string",
"topic_name": "string",
"course_name": "string",
"expires_at": "datetime (ISO 8601)",
"remaining_hours": "integer"
}
],
"homeworks": {
"total": "integer",
"submitted": "integer",
"graded": "integer",
"pending": "integer"
},
"quizzes": {
"total": "integer",
"not_started": "integer",
"in_progress": "integer",
"submitted": "integer"
}
}
Error Responses:
| Status | Condition | Response Body |
|---|---|---|
400 |
No student profile | {"error": "Student profile not found"} |
401 |
Not authenticated | {"detail": "Authentication credentials were not provided."} |
Documentation generated by OpenCode AI Agent Project: EduTrack Online Backend Last Updated: July 12, 2026