Last Updated: August 12, 2026
This document tracks all changes made to the API, organized by feature area.
POST /courses/enrollments/enroll/ → 403 "Your account is pending approval. You cannot enroll until an administrator verifies your account." when the student's status ≠ verified.POST /courses/purchases/buy/ → 403 "Your account must be verified before purchasing lectures." when not verified.POST /courses/enrollments/approve/ (and reject→approve re-approval) skips enrollments whose student isn't verified: the row is reported in errors[] with "Student account is not verified yet." and stays pending.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}):
GET /courses/videos/<id>/play/ (student branch)POST /courses/purchases/buy/GET /courses/<id>/lectures/ (student enrolled-course view)GET /courses/my-lectures/ — blocked-course lectures are excluded from the listEnrollmentSerializer now returns is_blocked, block_reason, blocked_by_name, blocked_at, unblocked_by_name, unblocked_at.
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).
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:
expires_at = max(effective_expiry, now) + 1 day, and extra_days = 0.LectureReopenLog audit entry still written; still max 2 reopens; still resets sessions_used + deletes viewing sessions.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.)
calculate_submission_score() — prevents a 500 when many high-point questions overflow the Decimal field.exams/views.py missing get_object_or_404 import fixed.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.
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.
.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.
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.
courses/bunny.py: new constants + READY_STATUSES {3,4,9,10}, IN_PROGRESS_STATUSES {0,1,2,6,7}, FAILED_STATUSES {5,8}.courses/serializers.py: BUNNY_STATUS_MAP rewritten to the new display labels (Queued/Processing/Encoding/Finished/Ready/Failed/Uploading/Uploaded/UploadFailed/…).courses/models.py: Video.bunny_status help_text updated (cosmetic)._sync_bunny_status (status polling): skips terminal states; activates on 3/4/9/10, deactivates on 5/8 (was: hardcoded status == 4).VideoListCreateView.list: "pending to poll" filter uses IN_PROGRESS_STATUSES (no longer polls already-failed videos).VideoCreateUploadView: finalization/in-progress conflict checks use READY_STATUSES/IN_PROGRESS_STATUSES.VideoUploadCompleteView: writes 7 (PresignedUploadFinished) instead of old 1.BunnyWebhookView) — rewritten to the v1 spec:
X-BunnyStream-Signature-Version: v1 + X-BunnyStream-Signature-Algorithm: hmac-sha256 + X-BunnyStream-Signature = lowercase hex HMAC-SHA256 over the exact raw body using the Read-Only API key (constant-time hmac.compare_digest). This matches the current official procedure exactly.X-Bunny-Signature (full API key) kept as a fallback for an old-configured library.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}.
courses.bunny.generate_embed_url(guid, expires_in=7200) → unsigned embed URL, or signed with ?token=SHA256_HEX(BUNNY_STREAM_TOKEN_AUTH_KEY + guid + expires)&expires= when the token key is configured (inert until the library's Embed view token authentication is enabled).generate_tus_credentials() unchanged — already matches the current spec.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).
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
}
embed_url is signed (SHA256_HEX(BUNNY_STREAM_TOKEN_AUTH_KEY + guid + expires)) when the token key is configured; otherwise token/expires_at are null and the embed URL is unsigned.expires_in = max(active_session.remaining_seconds + 600, 3600) — a shared embed URL dies with the student's 6-hour session window (15-min buffer so a viewer mid-video isn't cut off at the window end).expires_in = 7200).Files changed: courses/views.py (VideoPlayView), docs/API_STUDENT.md, docs/BUNNY_STREAM_FRONTEND.md, docs/CHANGELOG.md.
GET /courses/lectures/<pk>/students-progress/ now exposes per-video watch details (purely additive — nothing renamed/removed):
videos meta (same pattern as homeworks/quizzes): [{id, name, order}] — all videos in the lecture, ordered by order.watch.videos: one entry per lecture video, aligned to the meta order, for every row — never-watched videos use defaults:{
"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
}
progress_percentage = cumulative_watch_seconds / duration_seconds × 100, capped at 100, rounded to 1 decimal, 0 when duration is null (null-safe).VideoWatchProgress; the per-video map is built in one query, so there's no N+1.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.
GET /courses/ is now auth-only in ALL cases (?teacher= included)CourseListCreateView.get_permissions → IsAuthenticated for every GET (previously ?teacher= was public). Anonymous browsing moved to the new public endpoints below.Enrollment.clean() via the new course_eligibility() helper in courses/serializers.py).GET /courses/topics/, GET /courses/videos/, GET /courses/videos/<id>/ now require authentication (locks anonymous topic/video leaks; writes still teacher-only).eligibility field in course responsesCourseListSerializer, 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.
| 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 |
courses_countGET /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.
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 |
MaxValueValidator(20), message "Points per question cannot exceed 20.") — applies to Django admin and all serializer flows automatically.QuizQuestionInlineSerializer/ExamQuestionInlineSerializer.points_override now max_value=20) — POST/PUT/PATCH of a question with points_override > 20 returns 400.points > 20 in homework create/update also returns 400.effective_points/total_points math unchanged (min 1 when unset).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.
A manual, invoice-based platform-cut system. No rule engine — the siteowner decides the cut per lecture on every invoice.
lectures_count = purchases in the range at creation; total_owed = cut_per_lecture × lectures_count.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:
compute_totals() counts purchases and sets totals — CREATE time only.recompute_total() recomputes total_owed from the existing snapshot — editing the cut never re-counts (purchases made after the invoice snapshot are NOT pulled in).start ≤ new_end AND end ≥ new_start). Cancelled invoices don't block (a period can be re-invoiced after cancellation). Adjacent periods are allowed.cut_per_lecture is frozen once status='paid' (PATCH → 400; revert to unpaid first).DELETE is a soft cancel (status → cancelled, paid_at cleared, row kept)./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.
GET /courses/teacher/dashboard/ summary now includes outstanding_cut (string decimal — sum of the teacher's unpaid invoices).
CutInvoiceAdmin registered in balance/admin.py — fully read-only (add/delete disabled).
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.
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.
| 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.
GET /payments/cuts/overview/:
profile_picture (absolute URL or null) — the frontend no longer needs a separate teachers fetch for avatars.?teacher=<id> the single row additionally embeds lectures_count and lectures[] — per-lecture rows (lecture_id, lecture_name, topic_id/topic_name, course_id/course_name, grade_name, price, final_price, purchased_count, purchased_revenue). Purchase counts respect the start/end range. The frontend's teacher-detail page reads this directly.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.
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.
| 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) |
QuizSubmission.attempt_number (PositiveInteger, default 1) restored.unique_together = ['quiz', 'student', 'attempt_number'] restored on QuizSubmission.ExamSubmission remains unique_together = ['exam', 'student'] (no attempt_number).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)max_attempts in QuizSettings is read by the backend again to gate quiz starts (default 1, any positive integer).max_attempts in exam settings is ignored — exams are always single-attempt.DELETE /learning/quiz-submissions/<pk>/delete/) to free one attempt slot, or delete all to reset to attempt #1. An in-progress attempt must be deleted to start fresh.DELETE /learning/exam-submissions/<pk>/delete/).QuizResultsView shows one row per student (all purchasers); for multi-attempt quizzes the attempt shown follows the submission ordering (-submitted_at, -started_at).Files changed: quizzes/models.py, quizzes/views.py (QuizStartView), quizzes/migrations/0008/0009
subjectPOST /courses/ now requires a subject field (Subject ID). Previously the subject was auto-derived from the teacher.subject is returned in all course serializers (subject_name too).Files changed: courses/serializers.py (CourseCreateUpdateSerializer), courses/views.py
⚠️ 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 usesGET /courses/public/+GET /courses/public/<pk>/. See Course Eligibility Gating + Public SEO Catalog + Public Stats.
GET /courses/ (no ?teacher= param) now requires authentication. With an expired token this returns 401 instead of silently returning all teachers' active courses. Public listings still work via ?teacher=<id> (used by public teacher pages) and GET /courses/by-subject/<id>/.GET /courses/<id>/ now requires authentication (was fully public). The public course preview uses the separate GET /courses/<id>/preview/ endpoint.Files changed: courses/views.py (CourseListCreateView, CourseDetailView)
student_name_ar Added to Teacher-Facing ResponsesAdded the Arabic student name to:
GET /learning/quizzes/<id>/results/ rows (student_name_ar)GET /learning/quizzes/<id>/results/ submission detail serializer (QuizSubmissionSerializer)GET /learning/exams/<id>/results/ (ExamSubmissionSerializer)GET /learning/homeworks/<id>/submissions/ rows (student_name_ar)GET /learning/homework-submissions/<id>/ (HomeworkSubmissionDetailSerializer)Files changed: quizzes/views.py, quizzes/serializers.py, exams/serializers.py, homeworks/views.py, homeworks/serializers.py
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)
UnboundLocalError in the lecture reopen endpoint fixed.http://localhost:3000, http://127.0.0.1:3000, and https://online.edutrackeg.com (plus matching CSRF_TRUSTED_ORIGINS).openpyxl pinned to 3.1.5 (stable with Python 3.10 + codes export).TeacherProfile.subjects ManyToMany replaces the single-subject FK. Teacher create/update use subjects: [ids]. Public/owner serializers expose subjects_detail / subject_names.seed_curriculum command is idempotent.GET /courses/teacher/dashboard/ — New expanded endpointNew 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 — useGET /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_count → enrolled_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 }
]
}
}
}
actions_needed.pending_enrollments — full list (not capped) of pending enrollments, newest first, with student + course context for quick approve/reject.actions_needed.ungraded_written — counts written answers with written_grade=null on submitted quiz/exam submissions. Each quiz/exam appears once with its pending_count. Empty quizzes: [] / exams: [] means nothing to grade.Authentication: Teacher, Assistant (ownership-scoped)
Files changed: courses/views.py (TeacherDashboardView), courses/urls.py
LecturePrerequisite changesBefore: 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:
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:
GET /courses/lectures/<pk>/available-assessments/ no longer returns homeworksVideoPlayView no longer checks homework prerequisitesPOST /accounts/login/ — Login with username, email, gmail, or student phoneBefore: 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:
User.email)gmail field — Student, Teacher, Assistant, SiteOwner)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
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
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) |
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
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
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).
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
| 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.
| 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 |
⚠️ SUPERSEDED (Aug 2026): Multi-attempt quizzes were restored —
attempt_numberis back onQuizSubmission. 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.
attempt_number removed from BaseSubmission model (affects QuizSubmission and ExamSubmission)unique_together constraintsQuizStartView and ExamStartView no longer set attempt_number when creating submissionsattempt_number⚠️ PARTIALLY SUPERSEDED (Aug 2026): Quizzes are multi-attempt again (
max_attemptsenforced); exams remain single-attempt. The delete-submission endpoints below are still the retake mechanism. See Multi-Attempt Quizzes Restored.
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.
QuizStartView and ExamStartView now check if ANY submission exists for the student+quiz/exam. If yes, block with:
{"detail": "You have already started this quiz/exam. If you need to retake, ask your teacher to delete your previous submission."}
| 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 attempt — max_attempts is deprecated |
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 — DeprecatedThe 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.
POST /courses/progress/update/ & VideoWatchProgress modelBefore: 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:
progress_seconds >= duration * 0.9 (must reach the end — prevents watching only the beginning on repeat)cumulative_watch_seconds >= duration * 0.9 (must genuinely watch enough — prevents scrubbing to the end)New response fields:
cumulative_watch_seconds — total wall-clock seconds played (not paused/buffering)position_percentage — percentage based on progress_seconds (furthest position)Request changes:
progress_seconds — still required, still the current playback positioncumulative_watch_seconds — new, optional, send only on milestones (25%/50%/75%/90%)localStorage — survives page refreshesWhy: 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.
GET /payments/balance/ — Shows 0 balance for new studentsBefore: 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.
POST /accounts/login/ — Immediate invalidation of previous sessionsBefore: 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:
accounts/models.py — added token_version fieldaccounts/views.py — LoginView increments + embeds version; TokenRefreshView checks versionaccounts/authentication.py — CookieJWTAuthentication checks version on every API callNew error response for invalidated sessions:
{"error": "Session invalidated by a new login on another device."}
LectureViewingSessionA 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
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.
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.
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."
}
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) |
LectureViewingSessionSerializer: fields [id, purchase, started_at, expires_at, is_active, remaining_seconds]PurchasedLectureSerializer now includes sessions_usedStudentPurchaseInfoSerializer now includes sessions_usedVideoWatchProgressSerializer no longer returns watch_count (still exists on model but unused)Instead of per-video session tracking (which was unreliable with internet cuts, page refreshes), the system now uses per-entry session tracking:
max_watch_count (default 4)sessions_used to 0)GET /learning/homeworks/<id>/submissions/ — Complete RewriteBefore: 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 RewriteBefore: 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.
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} |
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.
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.
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.
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.
| 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.
GET /payments/balance/ — Added teacher pictureAdded teacher_picture (URL) to each balance entry, sourced from course.teacher.profile_picture.
GET /payments/transactions/ — Added teacher name and pictureAdded 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.
GET /courses/<id>/preview/ — Added topic pictureEach topic now returns picture (URL) from topic.picture.
GET /courses/<id>/lectures/ — Added topic pictureEach topic in the student's enrolled course view now returns picture (URL).
email field from student profileThe 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.
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 |
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 |
GET /payments/codes/history/ — Fixed performed_by name resolutionBefore: performed_by returned the user's username (e.g., "Marmar999999").
After: Uses the same logic as the balance transaction serializer:
student role → "Student"teacher role → teacher_profile.nameassistant role → assistant_profile.nameuser.usernameGET /payments/codes/ — Added search by codeAdded SearchFilter with search_fields = ['code']. Now supports ?search=KTMD to filter codes by partial match.
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.