EduTrack API Changelog

Last Updated: August 12, 2026

This document tracks all changes made to the API, organized by feature area.


Verified-Student Gating + Enrollment Block/Unblock + Reopen Fix + Quiz Caps (Aug 12, 2026)

1. Non-verified students can no longer enroll, buy, or be approved

2. Block / unblock approved students per course (with audit trail)

New Enrollment fields: is_blocked, block_reason, blocked_by, blocked_at, unblocked_by, unblocked_at (migration courses/0021).

Endpoint Permission Body Behavior
POST /courses/enrollments/<pk>/block/ Teacher/Assistant (own course) or SiteOwner {reason} (required) Only approved enrollments; sets is_blocked=True + reason + who/when
POST /courses/enrollments/<pk>/unblock/ same Clears the block (records who/when)

Access gating while blocked (403 with {error, blocked: true, reason}):

EnrollmentSerializer now returns is_blocked, block_reason, blocked_by_name, blocked_at, unblocked_by_name, unblocked_at.

3. Re-approve rejected enrollments

POST /courses/enrollments/approve/ now accepts enrollments with status pending OR rejected (previously pending-only). reject/ still only touches pending requests. This lets teachers approve a student who was previously rejected (e.g. after fixing a data issue).

4. Reopen semantics fixed (expired lectures)

PATCH /courses/purchases/<pk>/reopen/ previously added extra_days += 1 to the frozen original expiry — reopening a lecture expired 10 days ago still left it expired. Now:

5. Quiz questions capped at 300

QuizCreateUpdateSerializer rejects questions payloads with more than 300 items (400, "Maximum of 300 questions allowed per quiz.") on create AND update. (Homeworks already had a 300-question cap.)

6. Small fixes

7. Cut overview accessible to teachers/assistants (scoped)

GET /payments/cuts/overview/ permission changed from SiteOwner-only to IsSiteOwnerOrTeacherOrAssistant. Teachers/assistants are always forced to their own teacher's data — the ?teacher= param and ?search= are ignored for them (their own row is returned regardless of the params). lectures/, invoices/, and invoice detail remain SiteOwner-only; cuts/my/ remains Teacher-only.

Files changed: courses/models.py (+0021 migration), courses/views.py, courses/serializers.py, courses/urls.py, courses/tests.py, balance/views.py, balance/tests.py, quizzes/serializers.py, exams/views.py, learning/base.py, docs.


Bunny Stream — Migration to Library 725542 (Aug 2026)

The app was built against the old Bunny status scheme, old webhook signature, and the old library (712312). This commit migrates everything to the current official scheme, verified live against the real API and the current Bunny docs.

New credentials (.env — gitignored, not committed)

Var Old New
BUNNY_STREAM_LIBRARY_ID 712312 725542
BUNNY_STREAM_API_KEY old new full-access key
BUNNY_STREAM_READ_ONLY_API_KEY old new read-only key
BUNNY_STREAM_HOSTNAME vz-855cb49f-e03.b-cdn.net vz-2fe271ec-aba.b-cdn.net
BUNNY_STREAM_TOKEN_AUTH_KEY NEW — Token authentication key from Security tab (used only if embed token auth is ever enabled)

backend/settings.py defaults updated to 725542 / vz-2fe271ec-aba.b-cdn.net (a missing env var can no longer silently fall back to the old library) + BUNNY_STREAM_TOKEN_AUTH_KEY added (empty default). Also added Postgres CONN_MAX_AGE=300 + CONN_HEALTH_CHECKS.

New official status scheme (0–10)

Per the current Bunny webhook/API docs: 0 Queued, 1 Processing, 2 Encoding, 3 Finished, 4 Resolution finished, 5 Failed, 6 PresignedUploadStarted, 7 PresignedUploadFinished, 8 PresignedUploadFailed, 9 CaptionsGenerated, 10 TitleOrDescriptionGenerated.

Behavior changes

Embed player (playback model)

Library 725542 uses MediaCage Basic DRM → embed-only playback (direct HLS / third-party players disabled by Bunny). The student player is Bunny's embed iframe: https://player.mediadelivery.net/embed/725542/{video_id}.

Docs: BUNNY_STREAM_FRONTEND.md fully synced (status table, poll switch, embed playback, webhook v1, lifecycle). docs/CHANGELOG.md updated. docs/API_STUDENT.md play response note clarified.

Files changed: backend/settings.py, courses/bunny.py, courses/views.py, courses/serializers.py, courses/models.py, docs/BUNNY_STREAM_FRONTEND.md, docs/CHANGELOG.md, .env.example (token key documented).


Video Play Endpoint — Signed Embed URL (Aug 2026)

GET /courses/videos/<id>/play/ now returns the embed iframe URL in addition to the raw playlist URL:

{ "playback_url": "…/playlist.m3u8", "embed_url": "https://player.mediadelivery.net/embed/725542/{guid}?token=…&expires=…", "token": "… | null", "expires_at": 1721400000, "expires_in": 7200 }

Files changed: courses/views.py (VideoPlayView), docs/API_STUDENT.md, docs/BUNNY_STREAM_FRONTEND.md, docs/CHANGELOG.md.


Students Progress — Video Meta + Per-Student Watch Details (Aug 2026)

GET /courses/lectures/<pk>/students-progress/ now exposes per-video watch details (purely additive — nothing renamed/removed):

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

Files changed: courses/views.py (LectureStudentsProgressView), courses/serializers.py (LectureVideoSerializer, StudentVideoProgressSerializer, StudentWatchInfoSerializer.videos, LectureStudentsProgressSerializer.videos), courses/tests.py (7 new tests), docs/API_TEACHER.md.


Course Eligibility Gating + Public SEO Catalog + Public Stats (Aug 2026)

1. GET /courses/ is now auth-only in ALL cases (?teacher= included)

2. eligibility field in course responses

CourseListSerializer, CourseSerializer, and GET /courses/<id>/preview/ now return eligibility: {eligible: bool, reason: 'grade'|'division'|'school_type'|null} only for authenticated students (null otherwise). The frontend can hide "Enroll" and show the reason pre-submit.

3. Public SEO endpoints (no auth, no prices/videos, no student data)

Endpoint Purpose
GET /courses/public/ Public course list — filters subject/teacher/grade + search; returns {id, name, description, cover_picture, teacher, grade, subject, topic_count, total_lectures} (no prices/videos)
GET /courses/public/<pk>/ Public course detail — topics + lecture names only (includes topic picture); no prices, no video IDs, no Bunny data
GET /accounts/public/stats/ Landing-page stats: {teacher_count, course_count, subject_count}student counts intentionally excluded

4. Public teacher list gains courses_count

GET /accounts/public/teachers/ now annotates and returns courses_count (number of active courses per teacher, 0 default).

Tests: courses/tests.py + accounts/tests.py — replaced the old "public with teacher filter" test with auth-required + eligibility tests, added public list/detail (no prices), public stats, and courses_count tests.

Files changed: courses/views.py, courses/serializers.py, courses/urls.py, accounts/views.py, accounts/serializers.py, accounts/urls.py, courses/tests.py, accounts/tests.py, docs/API_PUBLIC.md, docs/API_OVERVIEW.md, docs/API_STUDENT.md, docs/CHANGELOG.md.


Question Points Capped at 20 (Aug 2026)

Per-question points are now capped at 20 for all three assessment types:

Model Field Rule
Quiz/Exam question (BaseQuestion.points_override) nullable integer null → 1 point; max 20
Homework bubble question (HomeworkBubbleQuestion.points) integer, default 1 max 20

Tests: 13 new (5 quiz + 4 exam + 4 homework): model-level reject/accept (20 OK, 21 rejected), default 1, API inline reject at 21 / accept at 20.

Files changed: learning/base_app/models.py, homeworks/models.py, quizzes/serializers.py, exams/serializers.py, migrations (quizzes/0010, exams/0005, homeworks/0009), quizzes/tests.py, exams/tests.py, homeworks/tests.py, docs/API_TEACHER.md, docs/FRONTEND_BUILD_ORDER.md, docs/CHANGELOG.md.






Cut Invoice System (SiteOwner-Facing, Aug 2026)

A manual, invoice-based platform-cut system. No rule engine — the siteowner decides the cut per lecture on every invoice.

Concept

  1. Siteowner opens a Cuts page → sees per-teacher total lectures, lectures purchased, and per-course breakdown (course, grade, lectures, purchases, purchase revenue), filterable by date range.
  2. Siteowner creates an invoice: teacher + date range + cut per lecture (their choice).
  3. Backend snapshots lectures_count = purchases in the range at creation; total_owed = cut_per_lecture × lectures_count.
  4. Invoice shows what the teacher owes. Siteowner can mark paid / cancel (soft). Teachers see their own invoices.

New model: CutInvoice (balance/models.py)

Field Purpose
teacher FK → TeacherProfile Who owes
start_date, end_date Invoice period (inclusive)
lectures_count Frozen snapshot of purchases in range at creation
cut_per_lecture Cut entered by the siteowner (editable while unpaid)
total_owed cut_per_lecture × lectures_count (recomputed on cut edit)
status unpaid (default) / paid / cancelled
paid_at, note, created_by Audit + context

Key behaviors:

New endpoints (all under /payments/cuts/)

Method Path Permission Purpose
GET /payments/cuts/overview/ SiteOwner (teachers/assistants scoped since Aug 12) Per-teacher + per-course summary (teacher, start, end params)
GET /payments/cuts/lectures/ SiteOwner Itemized purchases for preview (teacher required, start, end, course optional)
GET/POST /payments/cuts/invoices/ SiteOwner List (filters: teacher, status, start, end) / create
GET/PATCH/DELETE /payments/cuts/invoices/<pk>/ SiteOwner Detail / edit cut·note·status / soft-cancel
GET /payments/cuts/my/ Teacher Teacher's own invoices (unpaid + paid; cancelled hidden)

Overview response shape (per teacher):

{ "teacher_id": 5, "teacher_name": "Dr Hany", "total_lectures": 42, "purchased_count": 320, "outstanding_total": "6400.00", "paid_total": "1200.00", "courses": [ { "course_id": 28, "course_name": "Chemistry 3rd Secondary", "grade_name": "3rd Secondary", "total_lectures": 24, "purchased_count": 210, "purchased_revenue": "8400.00" } ] }

Invoice create body: {teacher, start_date, end_date, cut_per_lecture, note?} → 201 with computed lectures_count / total_owed.

Invoice PATCH rules: cut_per_lecture (recompute, blocked when paid) | note | status (paid → sets paid_at; unpaid/cancelled → clears it). teacher/start_date/end_date are locked.

Teacher dashboard

GET /courses/teacher/dashboard/ summary now includes outstanding_cut (string decimal — sum of the teacher's unpaid invoices).

Admin

CutInvoiceAdmin registered in balance/admin.py — fully read-only (add/delete disabled).

Tests

47 new tests in balance/tests.py: model math + snapshot semantics, overview correctness + per-course breakdown, range/both-required/invalid-date/404 cases, full role-permission matrix (anonymous/teacher/assistant/student/siteowner × every endpoint), invoice lifecycle (create math, zero purchases, start>end, negative cut, overlap/contained/adjacent/redo-after-cancel, patch cut vs frozen count, note-only patch, mark paid + paid_at, revert + clear, cut-edit-when-paid blocked, teacher/period change blocked, soft delete, list filters).

Files changed: balance/models.py, balance/serializers.py, balance/views.py, balance/urls.py, balance/admin.py, balance/tests.py, balance/migrations/0013_cutinvoice.py, courses/views.py (TeacherDashboardView), docs.


Cut Endpoints — Search Support (Aug 2026)

All cut-invoice list endpoints now support a ?search= parameter (case-insensitive, partial):

Endpoint ?search= matches
GET /payments/cuts/overview/ teacher name
GET /payments/cuts/lectures/ student name (en/ar), student code, lecture name, course name
GET /payments/cuts/invoices/ teacher name, note
GET /payments/cuts/my/ note

?search= combines with all existing filters (teacher, status, start/end, course). The overview remains unpaginated by design (one row per teacher — the frontend computes the summary cards from the full response); lectures/invoices/my stay paginated.

17 new tests cover: search by each field, case-insensitivity, partial match, no-match → empty, and search combined with range/status/course filters.

Files changed: balance/views.py, balance/tests.py, docs.


Cut Invoices — Discount, Paid Note, Gross Total, Overview Detail Mode (Aug 2026)

New invoice fields

Field Type Purpose
discount Decimal(10,2), default 0 Amount subtracted from the gross total (writable while unpaid)
paid_note TextField, blank Optional note recorded when the invoice is marked paid
gross_total read-only (property) cut_per_lecture × lectures_count — before discount

New math: total_owed = max(0, (cut_per_lecture × lectures_count) − discount). gross_total is always exposed; lectures_count remains a frozen snapshot.

Validation: discount cannot be negative and cannot exceed the gross total — enforced at create AND on every cut/discount edit (400 otherwise). Editing discount while status='paid' is blocked (same lock as the cut).

Paid lifecycle: status: 'paid' sets paid_at (and saves paid_note from the same PATCH); reverting to unpaid/cancelled (or DELETE) clears paid_at and paid_note.

Overview consolidation (kills 2 API calls)

GET /payments/cuts/overview/:

Invoice response now: ... , cut_per_lecture, discount, gross_total, total_owed, status, note, paid_note, paid_at, ...

Files changed: balance/models.py (+migration 0014), balance/serializers.py, balance/views.py, balance/admin.py, balance/tests.py (20 new tests), docs.


Multi-Attempt Quizzes Restored (Aug 2026)

Supersedes the older "attempt_number Removed" and "Single-Attempt + Submission Deletion" sections below. Those sections describe behavior that was reverted and are kept only for history.

Current attempt policy (authoritative)

Assessment Attempt policy Enforced by
Quiz Multi-attempt — up to settings.max_attempts submitted attempts (default 1) QuizStartView counts submitted attempts and blocks when max_attempts is reached
Exam Single-attempt — one submission per student, ever ExamStartView blocks if ANY submission exists (unique_together exam, student)

Model changes

POST /learning/quizzes/<id>/start/ behavior (new)

Scenario Response
An in-progress attempt exists (started, not submitted) 400{"detail": "You already have an active quiz attempt. Resuming it.", "submission_id": X, "status": "in_progress"} — the frontend should call GET /resume/
Submitted attempts < settings.max_attempts 200 — creates a new attempt (attempt_number = submitted + 1) and returns questions
Submitted attempts >= settings.max_attempts 400"You have used all your allowed attempts for this quiz. If you need to retake, ask your teacher to delete your previous submissions."

settings.max_attempts is active again (quizzes only)

Teacher retake flows

Files changed: quizzes/models.py, quizzes/views.py (QuizStartView), quizzes/migrations/0008/0009


Course Creation Now Requires subject

Files changed: courses/serializers.py (CourseCreateUpdateSerializer), courses/views.py


Course List & Detail — Auth Required (Expired-Session Leak Fix)

⚠️ PARTIALLY SUPERSEDED (Aug 2026): A later commit made GET /courses/ auth-only in ALL cases — the ?teacher= public path no longer exists. Anonymous browsing now uses GET /courses/public/ + GET /courses/public/<pk>/. See Course Eligibility Gating + Public SEO Catalog + Public Stats.

Files changed: courses/views.py (CourseListCreateView, CourseDetailView)


student_name_ar Added to Teacher-Facing Responses

Added the Arabic student name to:

Files changed: quizzes/views.py, quizzes/serializers.py, exams/serializers.py, homeworks/views.py, homeworks/serializers.py


Code Analytics — Teacher/SiteOwner Cuts Removed

GET /payments/codes/analytics/ now returns exactly 8 fields — the revenue-split calculation was removed:

{ "total_batches": 45, "total_codes": 5000, "valid_count": 3000, "valid_value": "150000.00", "used_count": 1800, "used_value": "90000.00", "blacklisted_count": 200, "blacklisted_value": "10000.00" }

No teachers_cut / siteowner_cut / revenue fields. The frontend should remove those analytics cards.

Files changed: balance/views.py (CodeAnalyticsView)


Other Fixes (July–Aug 2026)


Teacher Dashboard — Expanded with Actions Needed

GET /courses/teacher/dashboard/ — New expanded endpoint

New consolidated dashboard endpoint that returns summary stats, action items, recent activity, and per-course breakdown in a single response.

The old URL GET /courses/dashboard/teacher/ was removed — use GET /courses/teacher/dashboard/.

Response structure (new):

Section Fields What's new
summary total_courses, active_courses, total_students, pending_enrollments, total_purchases, total_revenue Added active_courses, pending_enrollments, total_purchases
actions_needed pending_enrollments[], ungraded_written New — action items requiring teacher attention
recent_enrollments {id, student_name, student_code, course_name, status, enrolled_at} Added student_code
recent_purchases {id, student_name, student_code, lecture_name, course_name, amount_paid, purchased_at} Added student_code, course_name
courses {id, name, is_active, enrolled_count, pending_count, purchase_count, revenue} Renamed from course_breakdown; added is_active, pending_count, renamed student_countenrolled_count

New actions_needed section:

{ "actions_needed": { "pending_enrollments": [ { "enrollment_id": 1, "student_id": 10, "student_name": "Ahmed Ali / أحمد", "student_code": "1234567", "course_id": 28, "course_name": "Chemistry 3rd Secondary 2027", "enrolled_at": "2026-07-30T10:00:00Z" } ], "ungraded_written": { "total_pending": 5, "quizzes": [ { "quiz_id": 94, "quiz_title": "Important Quiz", "lecture_name": "Lecture 1", "course_name": "...", "pending_count": 3 } ], "exams": [ { "exam_id": 2, "exam_title": "Midterm", "course_name": "...", "pending_count": 2 } ] } } }

Authentication: Teacher, Assistant (ownership-scoped)

Files changed: courses/views.py (TeacherDashboardView), courses/urls.py


Prerequisites Rework — Quiz/Exam Only, Same-Lecture Validation

LecturePrerequisite changes

Before: Could set homework, quiz, or exam from any lecture as a prerequisite. Students might need to buy an extra lecture just to access a prerequisite assessment.

After:

Change Detail
homework choice removed Prerequisites can only be quizzes or exams
Same-lecture validation Prerequisite quizzes must belong to the same lecture being gated
Exams remain course-level Exams can still be used as prerequisites for any lecture in the course

New prerequisite flow for teachers:

  1. Create a quiz inside the lecture you want to gate
  2. Write questions that test prerequisite knowledge from earlier lectures
  3. Set that quiz as the prerequisite with a passing_score
  4. Student buys the lecture → takes the quiz → if they know the material, they pass → videos unlock
  5. No extra purchases needed — the prerequisite assessment is in the same lecture

Error if trying to use a quiz from a different lecture:

{"non_field_errors": ["Prerequisite quiz must belong to the same lecture. Quizzes from other lectures cannot be used as prerequisites."]}

API changes:


Multi-Identifier Login (Email, Gmail, Phone)

POST /accounts/login/ — Login with username, email, gmail, or student phone

Before: Only accepted username (the User.username field). Students who forgot their username couldn't log in even though they knew their email.

After: The username field now accepts:

  1. Username (direct match)
  2. Email (User.email)
  3. Gmail (any profile's gmail field — Student, Teacher, Assistant, SiteOwner)
  4. Phone number (StudentProfile.phone_number — unique field, students only)

Backend resolves the input to the actual username before authenticating. No frontend changes needed — same {"username": "...", "password": "..."} request body.

Files changed: accounts/serializers.py


Student Dashboard Consolidated Endpoint

GET /courses/student/dashboard/

New consolidated endpoint that returns all student dashboard data in a single response, replacing 5 separate API calls:

Previously Required Calls Now Replaced By
GET /courses/enrollments/?status=approved GET /courses/student/dashboard/
GET /payments/balance/ GET /courses/student/dashboard/
GET /courses/my-lectures/ GET /courses/student/dashboard/
GET /learning/homeworks/ GET /courses/student/dashboard/
GET /learning/quizzes/ GET /courses/student/dashboard/

Response includes: enrolled_courses[] (with per-course balance, teacher info, purchased lecture count), my_lectures stats (total/active/expired), nearly_expired_lectures[] (lectures expiring soon with remaining hours), homeworks stats (total/submitted/graded/pending), and quizzes stats (total/not_started/in_progress/submitted).

Authentication: Student only

Files changed: courses/views.py, courses/serializers.py, courses/urls.py


API Consolidation — Removed Duplicate Endpoints

Removed Endpoints

The following duplicate student-only endpoints have been removed. Their data is now available through the existing list endpoints (which now include student-specific submission fields).

Removed Endpoint Replacement
GET /learning/quizzes/my/ GET /learning/quizzes/ (now includes quiz_status, submission_id, score, score_visible for students)
GET /learning/quizzes/all/ GET /learning/quizzes/ (same enhanced response)
GET /learning/homeworks/my/ GET /learning/homeworks/ (now includes is_submitted, submission_id, score, status, submitted_at for students)
GET /learning/homeworks/all/ GET /learning/homeworks/ (same enhanced response)
GET /learning/exams/my/ GET /learning/exams/ (now includes quiz_status, submission_id, score, score_visible for students)

Enhanced List Endpoints

GET /learning/quizzes/ — NEW fields for students: quiz_status (not_started|in_progress|submitted), submission_id, score, score_visible

GET /learning/homeworks/ — NEW fields for students: question_count, is_submitted, submission_id, score, status (submitted|graded), submitted_at

GET /learning/exams/ — NEW fields for students: quiz_status (not_started|in_progress|submitted), submission_id, score, score_visible

Files changed: learning/views.py, learning/serializers.py, learning/urls.py


Quiz/Exam Question Editing — Auto-Regrade on Changes

PUT/PATCH /learning/quizzes/<id>/ and /learning/exams/<id>/

Before: Blocked editing questions entirely if any submissions existed. Teachers had to delete the quiz and recreate it.

After: Teachers can freely edit questions, add new ones, or remove them. All existing submissions are auto-regraded with the new data.

Change Effect on existing submissions
Change correct answer on MCQ ✅ Regraded with new correct answer
Change points_override ✅ Regraded with new points
Change question text ✅ No effect on scores
Add a new question ✅ Existing subs get 0 for it, max score increases
Remove a question ✅ Question + answers deleted, max score decreases, scores recalculated
Change question_type (MCQ ↔ Written) Blocked if submissions exist

New error for blocked question_type changes:

{"non_field_errors": ["Cannot change question type after submissions exist. Delete and recreate the question instead."]}

Files changed: quizzes/serializers.py, exams/serializers.py


Student Dashboard Endpoints (My Lectures, Homeworks, Quizzes)

GET /courses/my-lectures/

New endpoint that returns ALL purchased lectures across all enrolled courses in a single response. No more per-course iteration. Includes expiry status, active session indicator, content counts (videos, materials, homeworks, quizzes), and session tracking.

GET /learning/homeworks/all/

New endpoint that returns ALL homeworks for all purchased lectures across all courses in a single response. Shows submission status and score if graded. Includes course/lecture/topic context for each homework — no more merging data from multiple endpoints.

GET /learning/quizzes/all/

New endpoint that returns ALL quizzes for all purchased lectures across all courses in a single response. Shows quiz status (not_started/in_progress/submitted), score if visible, and settings. Includes full course/lecture/topic context.

All three endpoints are Student-only (require student_profile).


Written Answers Review Endpoint

GET /learning/quizzes/<pk>/written-answers/ + GET /learning/exams/<pk>/written-answers/

New endpoints for teachers to review and grade written questions in bulk.

Returns ALL written answers across ALL students who submitted, grouped by question:

{ "quiz_id": 94, "quiz_title": "Important Quiz", "total_questions": 2, "total_graded": 1, "total_pending": 1, "written_questions": [ { "question_id": 341, "question_text": "What is...?", "max_score": 5, "answers": [ {"answer_id": 501, "student_name": "Shahd...", "written_answer": "CO2", "current_score": 3, "max_score": 5, "is_graded": true, "feedback": "Correct formula...", "graded_by": "Dr Hany"} ] } ] }

Teachers can see all pending written answers in one view, then grade each using the existing POST /.../grade-written/ endpoint.

Files changed: quizzes/views.py, exams/views.py, quizzes/urls.py, exams/urls.py


Un-Release Endpoints (Scores + Answers)

4 new endpoints to reverse score/answer releases

Endpoint Method What it does
/learning/quizzes/<pk>/unrelease-scores/ POST Sets is_score_released=False on all submissions — hides scores
/learning/quizzes/<pk>/unrelease-answers/ POST Sets are_answers_released=False on all submissions — hides correct answers
/learning/exams/<pk>/unrelease-scores/ POST Same for exam submissions
/learning/exams/<pk>/unrelease-answers/ POST Same for exam submissions

All require Teacher/Assistant with course ownership. Returns 204 No Content.

Before: Scores/answers could only be released (one-way). No way to revoke. After: Teachers can release, then unrelease, then release again as needed.

Behavior by visibility mode

Mode Release has effect? Unrelease has effect?
immediate ❌ Ignored (always visible) ❌ Ignored (always visible)
manual ✅ Students see scores/answers ✅ Students lose access
after_close (exam only) ✅ If close_date hasn't passed ✅ If close_date hasn't passed

attempt_number Removed

⚠️ SUPERSEDED (Aug 2026): Multi-attempt quizzes were restored — attempt_number is back on QuizSubmission. See Multi-Attempt Quizzes Restored. This section describes the (reverted) single-attempt era.

The attempt_number field has been fully removed from the system. Since single-attempt is now the only supported mode, the field was redundant.

Changes:


Single-Attempt + Submission Deletion

⚠️ PARTIALLY SUPERSEDED (Aug 2026): Quizzes are multi-attempt again (max_attempts enforced); exams remain single-attempt. The delete-submission endpoints below are still the retake mechanism. See Multi-Attempt Quizzes Restored.

Single-Attempt Enforcement (Quizzes + Exams)

Before: max_attempts setting controlled how many times a student could start/submit a quiz or exam (default 1, configurable up to N). The start endpoints checked attempt count against max_attempts.

After: Students can only start a quiz/exam ONCE. No multiple attempts.

Scenario Before After
Student starts quiz, submits, tries to start again Allowed if max_attempts > 1 Blocked — "already started" error
Student starts quiz, never submits, tries to start again Blocked — already has an active attempt Blocked — same (any submission exists)
max_attempts set to 3 Student could attempt 3 times Still only 1 attemptmax_attempts is deprecated

New Delete Endpoints (Teacher/Assistant)

Allow teachers to delete student submissions, enabling retakes:

Method Endpoint Description
DELETE /learning/quiz-submissions/<pk>/delete/ Delete a quiz submission. Student can then start and submit again.
DELETE /learning/exam-submissions/<pk>/delete/ Delete an exam submission. Same behavior.

Authorization: Teacher or Assistant only, must own the course.

Success Response — 204 No Content

Error Responses:

Status Condition
403 Not the teacher/assistant of the course

max_attempts Field — Deprecated

The max_attempts field still exists in the QuizSettings and exam settings models/serializers for backward compatibility, but its value is effectively always 1. The backend no longer reads this field to gate access. Setting it to any value has no effect — students always get exactly 1 attempt.


Video Progress Tracking — Dual-Value Completion

POST /courses/progress/update/ & VideoWatchProgress model

Before: progress_seconds tracked only the furthest playhead position. A student could scrub to 90% in 5 seconds and get is_completed without actually watching. Frontend sent updates every 5-30 seconds.

After: Added cumulative_watch_seconds field. is_completed now requires both conditions:

New response fields:

Request changes:

Why: Accurate completion tracking without hammering the backend. A 10-min video requires the student to genuinely play >= 9 minutes AND reach >= 9 minutes position to complete.


Balance Endpoint — Now Shows All Enrolled Courses

GET /payments/balance/ — Shows 0 balance for new students

Before: Only returned courses where a CourseBalance record existed (i.e., the student had redeemed a code or received a recharge). Newly approved students with no transactions saw {"count": 0, "results": []}.

After: Returns ALL approved-enrolled courses. Courses without a CourseBalance record show "balance": "0.00" and "id": null.

{"id": null, "course": 1, "course_name": "Chemistry", "balance": "0.00", ...} {"id": 5, "course": 2, "course_name": "Physics", "balance": "150.00", ...}

Why: A student with 0 balance should see their enrolled courses listed, not an empty page.


Single-Session Enforcement (Students Only)

POST /accounts/login/ — Immediate invalidation of previous sessions

Before: Logging in from a new device did NOT invalidate the previous device's tokens. A student could share their credentials and both devices would work simultaneously.

After: Added token_version field to the User model. Each student login increments token_version and embeds it in both the access and refresh tokens. Every API request verifies the token's version matches the user's current version.

Scenario Before After
Student A logs in, shares credentials with B Both watch simultaneously B logs in → A's next API request gets immediate 401
A's refresh token cookie Works for 7 days Rejected immediately — version mismatch
A tries to refresh after B logs in Succeeds (gets new tokens) 401 — refresh endpoint checks version too
A's already-loaded video player Keeps playing via cached Bunny URL Keeps playing until player reloads or session expires (Bunny URL is unsigned, but can't get new URLs)

Affected files:

New error response for invalidated sessions:

{"error": "Session invalidated by a new login on another device."}

Session-Based Watch Counting

New Model: LectureViewingSession

A model that tracks 6-hour viewing windows for a purchased lecture.

Field Type Description
purchase FK → PurchasedLecture The purchased lecture this session belongs to
student FK → StudentProfile The student who started this session
started_at DateTime When the session started (auto-now)
expires_at DateTime When the session expires (started_at + 6 hours)

Properties: is_active (true if now < expires_at), remaining_seconds (positive int, 0 if expired)

SESSION_DURATION_HOURS = 6

New Fields on PurchasedLecture

Field Type Description
sessions_used PositiveIntegerField (default=0) How many 6-hour viewing sessions have been consumed

max_watch_count (default=4) is now interpreted as maximum viewing sessions, not maximum per-video watches.

Removed Model Logic

VideoWatchProgress.update_progress() — removed all watch_count/session increment logic. Now only tracks progress_seconds, duration_seconds, and is_completed. No more 5-minute gap session detection.


New Endpoints

POST /courses/purchases/<pk>/start-watching/

If there's an active session (within 6h), returns existing session. Otherwise creates a new 6h session and increments sessions_used.

Success Response — 200 (existing session):

{ "session": { "id": 1, "purchase": 5, "started_at": "...", "expires_at": "...", "is_active": true, "remaining_seconds": 21540 }, "sessions_used": 1, "sessions_remaining": 3, "message": "Resuming existing session." }

Success Response — 201 (new session):

{ "session": { "id": 2, "purchase": 5, "started_at": "...", "expires_at": "...", "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 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...", "sessions_used": 4, "sessions_remaining": 0}

GET /courses/purchases/<pk>/active-session/

Returns the current active session status for a purchase. No session is created — this is read-only.

Response — Active session:

{ "has_active_session": true, "session": { "id": 1, "purchase": 5, "started_at": "...", "expires_at": "...", "is_active": true, "remaining_seconds": 12000 }, "sessions_used": 1, "sessions_remaining": 3 }

Response — No active session:

{ "has_active_session": false, "is_expired": false, "sessions_used": 1, "sessions_remaining": 3 }

Response — Purchase expired:

{ "has_active_session": false, "is_expired": true, "error": "Your access to this lecture has expired." }

Modified Endpoints

GET /courses/videos/<pk>/play/

Removed old check: SUM(VideoWatchProgress.watch_count) >= purchase.max_watch_count

Added check: must have an active LectureViewingSession (expires_at > now)

If no active session → 403 with {"error": "No active viewing session. Click \"Start watching\" to begin a 6-hour session.", "sessions_used": 1, "sessions_remaining": 3}

POST /courses/progress/update/

Removed old check: SUM(watch_count) >= max_watch_count

Added same session check as the play endpoint — requires an active LectureViewingSession.

PATCH /courses/purchases/<pk>/reopen/

Instead of resetting VideoWatchProgress.watch_count to 0

Now resets purchase.sessions_used to 0 and deletes all LectureViewingSession records for that purchase.

GET /courses/<id>/lectures/

Purchase object now includes:

Field Type Description
max_watch_count Integer Maximum viewing sessions (default: 4)
sessions_used Integer How many sessions have been consumed
sessions_remaining Integer Remaining sessions (max - used)
has_active_session Boolean Whether a 6-hour window is currently open
active_session Object LectureViewingSession data (if active)

Serializer Changes


General Concept

Instead of per-video session tracking (which was unreliable with internet cuts, page refreshes), the system now uses per-entry session tracking:

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

Homework

GET /learning/homeworks/<id>/submissions/ — Complete Rewrite

Before: Only returned students who actually submitted the homework.

{ "count": 2, "results": [ { "id": 1, "student_name": "Mariam...", "score": "7.00", "status": "graded", "submitted_at": "..." } ] }

After: Returns ALL students who purchased the lecture, with their submission status. Students who bought but haven't submitted are now included.

{ "homework_id": 21, "homework_title": "Homework 1", "lecture_name": "Lecture 1", "course_name": "Chemistry", "total_purchased": 5, "total_submitted": 2, "count": 5, "results": [ { "student_id": 78, "student_name": "Mariam...", "is_purchased": true, "submitted": true, "submission_id": 1, "score": "7.00" }, { "student_id": 79, "student_name": "Shahd...", "is_purchased": true, "submitted": false, "submission_id": null, "score": null } ] }

Added filters: ?submitted=true|false, ?search=name_or_code

Why: Teachers need to see who bought the lecture but hasn't done the homework — not just who already submitted.


GET /learning/quizzes/<id>/results/ — Complete Rewrite

Before: Only returned students who submitted a quiz attempt.

After: Returns ALL students who purchased the lecture, with 3 quiz statuses:

quiz_status Meaning
not_started Bought the lecture, never opened the quiz
in_progress Started the quiz (has submission_id) but didn't submit (submitted_at is null)
submitted Completed and submitted

Added filters: ?quiz_status=not_started|in_progress|submitted, ?search=name_or_code

Added meta: total_purchased, total_submitted, total_in_progress

Why: Quizzes have 3 states — students can start a quiz and walk away without finishing. Teachers need to see who's stuck in progress.


Lecture Content Details

New fields added to GET /courses/lectures/<id>/ (Lecture detail serializer)

Field Type Description
materials_count Integer Number of active study materials for this lecture
homeworks_count Integer Number of published homeworks
quizzes_count Integer Number of published quizzes
prerequisites Array List of {content_type, content_name, passing_score}

Same fields added to GET /courses/<id>/lectures/ (Student's course lectures)

Added materials_count, homeworks_count, quizzes_count to each lecture in the student's enrolled course view.

Why: The purchase page needs to show students what a lecture contains (X videos, Y materials, Z quizzes) before they buy. Previously this required separate API calls; now it's included in the lecture detail response.


Permissions Changed

GET /courses/lectures/<id>/available-assessments/

Before: [IsTeacherOrAssistant] — teachers only.

After: [IsAuthenticated] — students can now see published assessments. Teachers/assistants still see all. Ownership check now only applies to teacher/assistant roles.

Why: The purchase page needs to show students whether a lecture has quizzes and homework.

GET /courses/lectures/<id>/prerequisites/

Before: [IsTeacherOrAssistant] — no student access.

After: GET requests now allowed for any authenticated user. POST/PUT/DELETE remain teacher-only.

Why: Students need to see prerequisites before buying a lecture.


Purchase Validation

POST /courses/purchases/buy/ — Added active purchase check

Scenario Before After
Never purchased ✅ Creates purchase ✅ Same
Purchased + still active ✅ Creates duplicate purchase (charged twice!) Blocked — returns 400 with "already purchased and active"
Purchased + expired ✅ Creates new purchase ✅ Same (re-buy allowed)
Teacher reopens expired purchase ✅ Grants +1 day ✅ Same

Why: Students could buy the same lecture multiple times while it's still active, losing money each time. Now they can only re-buy after expiry expires.


Removed Endpoints

GET /courses/enrolled/

Before: Separate student-only endpoint returning approved courses with course details.

After: Removed. Use GET /courses/enrollments/?status=approved instead — it now returns cover_picture, topic_count, total_lectures for approved enrollments, plus all the existing enrollment fields.

Why: Duplicated the enrollments endpoint. The serializer was enhanced to include course details instead.


Enrollment Serializer Enhancements

New Field Type When non-null
course Integer Always (the course ID)
cover_picture String (URL) Always
topic_count Integer For approved enrollments
total_lectures Integer For approved enrollments
subject_name String Always

Also: cover_picture used to return null for non-approved enrollments. Now returns the URL for all statuses.


Balance & Transactions

GET /payments/balance/ — Added teacher picture

Added teacher_picture (URL) to each balance entry, sourced from course.teacher.profile_picture.

GET /payments/transactions/ — Added teacher name and picture

Added teacher_name and teacher_picture to each transaction entry.

Why: The transaction history UI needs to show the teacher's name and profile picture alongside each transaction.


Course & Topic Images

GET /courses/<id>/preview/ — Added topic picture

Each topic now returns picture (URL) from topic.picture.

GET /courses/<id>/lectures/ — Added topic picture

Each topic in the student's enrolled course view now returns picture (URL).


Profile Serializer

Removed email field from student profile

The StudentProfileSerializer no longer returns email (from User.email). Students only see gmail (from StudentProfile.gmail) — the field they entered during registration and used for password reset.

Why: These were duplicate fields with the same value from different models, confusing students.


Student-Facing API Documentation

All three missing endpoints were added to API_STUDENT.md:

Endpoint Previously documented in
GET /courses/lectures/<id>/ Only in API_TEACHER.md
GET /courses/lectures/<id>/available-assessments/ Only in API_TEACHER.md
GET /courses/lectures/<id>/prerequisites/ Only in API_TEACHER.md

Overview Matrix Updated

The following missing endpoints were added to API_OVERVIEW.md:

Endpoint Role
GET /courses/lectures/<id>/ All authenticated
GET /courses/purchases/ All authenticated
GET /courses/progress/ Student
POST /courses/progress/update/ Student
GET /courses/progress/<video_id>/ Student

Code History Endpoint

GET /payments/codes/history/ — Fixed performed_by name resolution

Before: performed_by returned the user's username (e.g., "Marmar999999").

After: Uses the same logic as the balance transaction serializer:


Codes List Endpoint

GET /payments/codes/ — Added search by code

Added SearchFilter with search_fields = ['code']. Now supports ?search=KTMD to filter codes by partial match.


Students Progress Endpoint

GET /courses/lectures/<id>/students-progress/ — Added filters

Parameter Description
?search= Filter by student name (en/ar) or student code
?is_purchased=true/false Filter by purchase status

Before: All enrolled students returned — no search or purchase filtering.