EduTrack Online - Teacher & Assistant API

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).


Authentication

The API uses JWT tokens stored in HTTP-Only cookies.

Inactive User Blocking:

Common Errors

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"}

Pagination

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

1. Assistant Management

These endpoints allow a teacher to manage their own assistants.


GET /accounts/teacher/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" } ] }

POST /accounts/teacher/assistants/

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


2. My Courses


GET /courses/

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."}

3. Course Students


GET /courses/enrollments/?course=X&status=approved

Description: 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."}

4. Course Content Management


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/.


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)" } ] }

POST /courses/topics/

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)


5. Lectures


GET /courses/lectures/

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.


POST /courses/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)


6. Videos

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)

GET /courses/videos/

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)" } ] }

POST /courses/videos/

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_time is in SECONDS — JavaScript's Date.now() returns milliseconds. Pass expiration_time directly to the AuthorizationExpire header 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)


7. Enrollments


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 stay pending. 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 in GET /courses/enrollments/ and the enroll response.


8. Lecture Students Progress (Single Endpoint)


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:


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:


9. Exams


Index

# 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

Settings Reference

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:

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

9.1 GET /learning/exams/

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)" } ] }

9.2 POST /learning/exams/

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:

Success Response — 201 Created: Same structure as GET detail.


9.3 GET /learning/exams//

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)" }

9.4 PUT/PATCH /learning/exams//

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.


9.5 DELETE /learning/exams//

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


Student Exam Flow


9.6 POST /learning/exams//start/

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"} ] } ] }

9.7 GET /learning/exams//resume/

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

9.8 POST /learning/exams//submit/

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" }

Teacher Review & Grading


9.9 GET /learning/exams//results/

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).


9.9 GET /learning/exam-submissions//

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)


9.10 POST /learning/exam-submissions//grade-written/

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)" }

9.11 GET /learning/exams//written-answers/

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:

Error 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."}

9.12 POST /learning/exams//release-scores/

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." }

9.13 POST /learning/exams//release-answers/

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." }

9.18 DELETE /learning/exam-submissions//delete/

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:


9.17 PATCH/PUT /learning/exam-choices//

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

10. Homeworks

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.


GET /learning/homeworks/

Description: List homeworks.

Authentication: Any authenticated user

Query Parameters:

Success 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)" } ] }

POST /learning/homeworks/

Description: Create a homework with inline bubble questions.

Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant

Access Control:

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:


GET /learning/homeworks/{id}/

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)" }

PUT/PATCH /learning/homeworks/{id}/

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.


DELETE /learning/homeworks/{id}/

Description: Delete a homework and all its submissions.

Authentication: Any authenticated user (teachers see own, siteowner sees all, students see active), Assistant


GET /learning/homeworks/{id}/submissions/

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:


GET /learning/homework-submissions/{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:


11. Quizzes


Index

# 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

Settings Reference

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.


11.1 GET /learning/quizzes/

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)" } ] }

11.2 POST /learning/quizzes/

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:

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>/ or PATCH /learning/quiz-choices/<id>/ with Content-Type: application/json and 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:

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

11.3 GET /learning/quizzes//

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

11.9 PATCH /learning/quiz-answers/<answer_id>/save-draft/

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" }


Teacher Review & Grading


11.10 GET /learning/quizzes//results/

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:


11.11 GET /learning/quiz-submissions//

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/.


11.12 POST /learning/quiz-submissions//grade-written/

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" }

11.13 GET /learning/quizzes//written-answers/

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:

Error 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."}

11.14 POST /learning/quizzes//release-scores/

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." }

11.14 POST /learning/quizzes//release-answers/

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." }

11.15 PATCH/PUT /learning/quiz-choices//

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

11.16 DELETE /learning/quiz-submissions//delete/

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:




12. Study Materials

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.


POST /materials/

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:

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."}

GET /materials/

Description: List study materials.

Authentication: Any authenticated user

Query Parameters:

Data 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

GET /materials/{id}/

Description: Retrieve a single material.

Authentication: Any authenticated user

Data isolation:


PUT /materials/{id}/

Description: 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.


DELETE /materials/{id}/

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.


13. Balance


Student Activity History



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.

POST /payments/recharge/

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

GET /payments/transactions/

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)" } ] }

14. Dashboard


GET /courses/teacher/dashboard/

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:


15. My Cut Invoices

GET /payments/cuts/my/

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):

GET /payments/cuts/overview/ (teacher-scoped)

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)

16. Student Report

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:

Error Responses:

Status Condition
403 Not the course owner ({"error": "Not your course"})
404 Course or student not found

17. Lecture Prerequisites

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=X and GET /learning/exams/?course=X separately 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:


GET /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


Data Integrity — Orphan Cleanup

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)

18. Session-Based Viewing Limits

Each lecture enforces a maximum viewing session limit (default: 4). Instead of per-video tracking, the system uses 6-hour viewing sessions:

  1. Student clicks "Start watching" → 1 session consumed from max_watch_count (default 4)
  2. A 6-hour window opens → all videos in the lecture are freely accessible
  3. Within 6 hours → the student can watch any video, refresh the page, experience internet cuts — all fine
  4. After 6 hours → must click "Start watching" again → consumes another session
  5. If all 4 sessions used → must contact the teacher for a reopen