Build these pages in order. Each page depends on the previous one.
APIs to use (from API_PUBLIC.md):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| Login | POST | /accounts/login/ |
Authenticate user, returns JWT cookies + {role, name, is_active} |
| Forgot Password | POST | /accounts/forgot-password/ |
Send OTP to email (always returns 200) |
| Verify OTP | POST | /accounts/verify-otp/ |
Verify 6-digit code, get reset_token |
| Reset Passwor d | POST | /accounts/reset-password/ |
Set new password with OTP + reset_token |
Flow:
POST /accounts/login/{role, name} in React state, redirect to Teachers Page (if role=siteowner)Required: Login form, Forgot Password modal/flow.
APIs to use (from API_PUBLIC.md):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| Verify email | POST | /accounts/verify-email/request/ |
Request OTP for email verification |
| Confirm email | POST | /accounts/verify-email/confirm/ |
Verify OTP, get verification_token |
| Check field availability | GET | /accounts/check-field/?field=username&value=john |
Real-time check if username/email/phone is taken |
| Verify email | POST | /accounts/verify-email/request/ |
Request OTP for email verification |
| Confirm email | POST | /accounts/verify-email/confirm/ |
Verify OTP, get verification_token |
| Register | POST | /accounts/student/register/ |
Create student account (pending approval) |
3-Step Registration Flow:
Step 1: POST /verify-email/request/ { email }
→ OTP sent to email
Step 2: POST /verify-email/confirm/ { email, otp }
→ Returns { verified, email, verification_token }
→ Save verification_token in React state
Step 3: POST /accounts/student/register/ { verification_token, gmail, ...all fields }
→ Server validates token matches gmail
→ Creates student if valid
Step 3 form fields:
verification_token (hidden field — from Step 2 response)gmail (must match the email verified in Step 2)username (unique)password + password_confirmname_ar (Arabic name)name_en (English name)phone_number (Egyptian format: 010/011/012/015 + 8 digits)father_number, mother_number (parent phones)school_type (select from dropdown)grade (select from dropdown)division (select from dropdown, filters by school_type + grade)school_name (text input)birth_date (date picker)gender (male/female)governorate (select from dropdown)area (select from dropdown, filters by governorate)Dropdown data: Fetch from Settings endpoints (see Page 6).
Business rules:
gmail in Step 3 must match the email verified in Step 2.verification_token is single-use and expires in 30 minutes.password_confirm must match passwordThis is the main landing page after login. Shows all teachers as cards.
APIs to use (from API_SITEOWNER.md):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| List teachers | GET | /accounts/teachers/ |
Paginated list with search, filter |
| Create teacher | POST | /accounts/teachers/ |
Create new teacher account |
Teacher Card shows:
name)subject_name)grade_names — array of strings)courses_count)students_count)is_active)Teacher List Filters:
?all=true for all)Teacher Create Form fields:
username, passwordname, phone, secondary_phone, gmailgender (male/female — required)subject (ID from subjects list)grades (array of grade IDs — filtered by selected subject)is_active (checkbox, default true)biography, facebook (optional)Grade filtering logic:
The GET /accounts/subjects/ response includes grade_ids for each subject. When the user selects a subject, filter the grades multi-select to only show grades whose IDs are in subject.grade_ids:
const subject = subjects.find(s => s.id === selectedSubjectId);
const validGrades = allGrades.filter(g => subject.grade_ids.includes(g.id));
If no subject is selected, disable the grades field with "Select a subject first".
Backend validation: If a grade outside the subject's range is submitted, the API returns 400 with {"grades": ["Subject 'Math' is not available for grade '1st Secondary'."]}.
Success response: Returns full teacher profile with ID. Use this to navigate to the new teacher's detail.
Clicking on a teacher card or the edit button from the Teachers Page.
APIs to use:
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| Get teacher | GET | /accounts/teachers/<id>/ |
Full teacher detail with subject/grades, bunny_collection_id |
| Update teacher | PATCH | /accounts/teachers/<id>/ |
Update teacher fields (including bunny_collection_id and optional password) |
| Create collection | POST | /accounts/teachers/<id>/create-collection/ |
Auto-create a Bunny collection and assign it |
| List assistants | GET | /accounts/teacher/assistants/?teacher=<id> |
View assistants for this teacher |
Teacher Detail sections:
bunny_collection_id. Editable.bunny_collection_id (if assigned). Button to assign a collection: PATCH /accounts/teachers/<id>/ with { "bunny_collection_id": "..." }. Or create one via POST /accounts/teachers/<id>/create-collection/.Course Create Form (inline):
name (text — will be validated as unique per teacher+grade)grade (select from dropdown — teacher must teach this grade)subject (select from dropdown — required; teacher must teach this subject, and the subject must be available for the selected grade)description (textarea, optional)is_active (checkbox, default true)Success: After creating a course, the page should show a success message and optionally navigate to the Teacher Courses Page.
List all courses for a specific teacher. Accessed via "View Courses" button on teacher card.
APIs to use:
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| List courses | GET | /courses/?teacher=<id> |
Paginated list of courses for this teacher |
| Get course | GET | /courses/<id>/ |
Full course detail |
| Update course | PATCH | /courses/<id>/ |
Update course name/description/active |
| Delete course | DELETE | /courses/<id>/ |
Delete empty course (no enrollments/topics/lectures) |
Course Card shows:
topic_count in response)total_lectures in response)Course Edit Form:
name, subject, description, is_active (all editable)teacher, grade (these are locked after creation)Course Delete rules:
Manage all student registrations. Approve/decline/suspend students. View full profile with enrollments, per-course balances, and transaction history per course.
APIs to use:
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| List students | GET | /accounts/students/ |
Paginated list with search, sort, filters |
| Get student (with enrollments) | GET | /accounts/students/<id>/ |
Full student detail + enrollments[] array (course, teacher, status, balance) |
| Update student (status + profile) | PATCH | /accounts/students/<id>/ |
Update status + profile fields in one call |
| Transaction history | GET | /payments/transactions/?student=X&course=Y |
Full ledger for a specific student + course (requires both params) |
Student List Filters:
status (verified/pending/declined/suspended)grade, school_typeStudent Table columns:
Student Detail view (3 sections):
Section 1 — Profile Info:
enrollments[] array shows all courses the student is enrolled in with per-course balance, teacher name, status, enrollment date.status_history field.Section 2 — Status Management:
Status change buttons (with reason field):
PATCH {status: "declined", is_active: false, reason: "..."} — reason requiredPATCH {status: "suspended_temporary", is_active: false, reason: "..."} — reason requiredPATCH {status: "suspended_permanent", is_active: false, reason: "..."} — reason requiredPATCH {status: "verified", is_active: true, reason: "..."} — reason optionalStatus transition rules:
pending → only "Verify" or "Decline"verified → only "Suspend" (cannot decline or set to pending)declined → only "Verify" (cannot suspend)suspended → only "Verify" (reactivate)Profile edit rules (student self-service):
verified → Cannot editpending → Cannot editdeclined → Can edit — after saving, status resets to pendingsuspended_temporary → Cannot editsuspended_permanent → Blocked from loginLogin behavior by status:
pending → receives {status: "pending"} — show "pending approval" pageverified → Normal logindeclined → receives {status: "declined"} — show reason + prompt to edit profilesuspended_temporary → receives {status: "suspended_temporary"} — show reasonsuspended_permanent → Blocked — 401 "Your account has been permanently suspended."Section 3 — Transaction History (per course):
GET /payments/transactions/?student=<id>&course=<id>?start_date=YYYY-MM-DD, ?end_date=YYYY-MM-DD, ?code=ABC123, ?transaction_type=code_redeemed|lecture_purchase?page_size=25)Default view: Show pending students first (sort by most recent).
Display all reference data for preview. No CRUD operations.
APIs to use (from API_PUBLIC.md):
| Action | Endpoint | Purpose |
|---|---|---|
| List grades | GET | /accounts/grades/ |
| List school types | GET | /accounts/school-types/ |
| List divisions | GET | /accounts/divisions/ |
| List subjects | GET | /accounts/subjects/ |
| List governorates | GET | /accounts/governorates/ |
| List areas | GET | /accounts/areas/ |
| List video security | GET | /accounts/video-security/ |
Note: All these endpoints support ?all=true to bypass pagination and get complete lists for dropdowns.
No static files are served by the backend. The frontend should handle all assets (images, icons, logos) on its own.
1. POST /accounts/login/ with {username, password}
2. Server sets httpOnly cookies: access_token (30min), refresh_token (7d)
3. Server returns JSON: {role: "siteowner", name: "Admin", is_active: true}
4. Frontend stores {role, name} in React context/state
5. All subsequent requests automatically send cookies (browser handles this)
6. When access_token expires → POST /accounts/token/refresh/ (reads refresh cookie)
7. POST /accounts/logout/ to clear cookies
Note: Since cookies are httpOnly, the frontend CANNOT read the JWT. Session restoration works via GET /accounts/me/ which reads the cookie and returns {role, name, is_active}.
All API errors follow this pattern:
{"error": "Human-readable error message"}
HTTP Status codes:
200 — Success201 — Created400 — Bad request (validation error)401 — Not authenticated or invalid credentials403 — Not authorized (wrong role)404 — Not found500 — Server errorAnonymous browsing is served by dedicated public endpoints — GET /courses/ and GET /courses/<id>/ now require authentication in all cases (even with ?teacher=), so the public pages must use these:
| Action | Endpoint | Returns |
|---|---|---|
| Platform stats (landing hero) | GET /accounts/public/stats/ |
{teacher_count, course_count, subject_count} — no student data |
Public course list (catalog, filters subject/teacher/grade + search) |
GET /courses/public/ |
names, descriptions, covers, teacher/grade/subject, topic_count, total_lectures — no prices, no videos |
| Public course detail (SEO course page) | GET /courses/public/<id>/ |
topics + lecture names only, topic pictures — no prices, no video IDs |
| Public teacher list | GET /accounts/public/teachers/ |
includes courses_count (active courses) |
Rules for the public pages: hide any prices/lecture counts behind login; the "teacher → their courses" drill-down on the public teacher page uses GET /courses/public/?teacher=<id>; a logged-in student's version of the same pages uses the authenticated endpoints (/courses/, /courses/by-subject/<id>/, /courses/<id>/preview/), which are eligibility-filtered and include eligibility.
Build these in order after Phase 1 is complete.
IMPORTANT: All pages in Phase 2 apply to both Teacher and Assistant roles unless noted. The only exception is the Assistants management page — assistants can view coworkers but cannot create/delete.
Dashboards (Course Dashboard, Teacher Dashboard) are built last — after all features exist.
Teacher logs in → POST /accounts/login/ → JWT cookies set → GET /accounts/me/ → redirects to My Courses.
Page layout: Grid of course cards, each showing:
| Action | Method | Endpoint |
|---|---|---|
| My courses | GET | /courses/ |
Purpose: The core page where teachers build their course content hierarchy. Teacher spends most time here.
Navigation: Select course → Topics list → Click topic → Lectures list → Click lecture → Lecture Detail
Page layout (left-to-right hierarchy):
Course: Chemistry 3rd Secondary
┌────────────────────────────────────────────────┐
│ Unit 1: Chemical Reactions [+ Topic] │
│ ├── Lecture 1: Intro [3 videos] │
│ ├── Lecture 2: Balancing [2 videos] │
│ └── + Add Lecture │
│ │
│ Unit 2: Organic Chemistry │
│ ├── Lecture 3: Hydrocarbons [0 videos] │
│ └── + Add Lecture │
└────────────────────────────────────────────────┘
Topic CRUD:
| Action | Endpoint |
|---|---|
| Create | POST /courses/topics/ { course, name, description, order } |
| Update | PUT /courses/topics/<id>/ |
| Delete | DELETE /courses/topics/<id>/ (blocked if has lectures) |
Lecture CRUD:
| Action | Endpoint |
|---|---|
| Create | POST /courses/lectures/ { topic, name, price, discount, available_days, description } |
| Update | PUT /courses/lectures/<id>/ (blocked if is_visible=false & active purchases exist) |
| Delete | DELETE /courses/lectures/<id>/ (blocked if any purchases exist) |
Click a lecture → Lecture Detail page with sub-tabs:
← Back to Content
Lecture: Intro to Chemical Reactions
Price: 50 EGP | Discount: 10 EGP | Available: 30 days
[ Videos ] [ Purchases ] [ Homework ] [ Quiz ] [ Materials ] [ Prerequisites ]
Each sub-tab is built when its corresponding page below is completed.
Video tab (built here):
| Action | Endpoint |
|---|---|
| List videos | GET /courses/videos/?lecture=<id> |
| Create video | POST /courses/videos/ { lecture, name, order } |
| Get video | GET /courses/videos/<id>/ |
| Delete video | DELETE /courses/videos/<id>/ |
| Create Bunny upload | POST /courses/videos/<id>/create-upload/ |
Bunny Upload Flow (TUS Direct Upload):
bunny_video_id=null, status="Pending")POST .../create-upload/ → returns TUS credentials
create-upload/ rejects with 409. Wait for their upload to finish.tus-js-client:
https://video.bunnycdn.com/tusuploadAuthorizationSignature, AuthorizationExpire, VideoId, LibraryIdPOST /courses/videos/<id>/upload-complete/ after TUS onSuccess — faster status detection, but not requiredGET /courses/videos/<id>/ every 5 seconds — self-healing sync (backend checks Bunny API on each poll)is_active=true, teacher sees "Ready" badgeTUS handles pause/resume natively — findPreviousUploads() + resumeFromPreviousUpload()
stores progress in localStorage and resumes at the exact byte.
Use 5 MB chunks (chunkSize: 5 * 1024 * 1024) and add 300ms delay between chunks
via onChunkComplete to prevent WiFi saturation.
Note: The old POST /upload/ proxy endpoint has been removed (returns 410 Gone).
Video List UI Table:
# │ Name │ Status │ Actions
──┼───────────────┼───────────────────────────────────┼────────────
1 │ Intro │ ✅ Ready │ [Preview] [Delete]
2 │ Types │ ⬆️ ████████░░ 80% Uploading │ [Pause] [Cancel]
3 │ Graphs │ 📝 Pending (not uploaded yet) │ [Upload] [Delete]
4 │ Advanced │ ❌ Error — encoding failed │ [Retry] [Delete]
Playback Preview (teacher): GET /courses/videos/<id>/play/ returns Bunny Stream HLS URL. Teacher can preview own videos.
Student Preview toggle: Add ?as_student=true to any course/topic/lecture detail endpoint to see exactly what students see (only active topics, visible lectures, active videos).
Purpose: See ALL enrolled students for this lecture — who bought it, who didn't, their homework/quiz scores, watch progress, and reopen expired purchases.
Single API call: GET /courses/lectures/<id>/students-progress/
Reopen purchase: PATCH /courses/purchases/<id>/reopen/ (no body, 1 day, max 2)
The students-progress/ endpoint returns everything in one response (see API_TEACHER.md §8 for full response).
Pagination: Supports ?page= and ?page_size= (default 50, max 200). Response includes count, next, previous, results. Add "Load More" / page navigation when next is not null.
Top-level arrays:
| Field | Use |
|---|---|
homeworks[] |
Lists ALL homeworks in this lecture: {id, title, total} |
quizzes[] |
Lists ALL quizzes in this lecture: {id, title, total, max_attempts} |
Per-student homeworks[] and quizzes[] always match the length and order of these top-level arrays. Empty array = no assessments of that type.
UI Layout (summary table):
← Back to Lecture Detail
Lecture: Intro to Reactions
Course: Chemistry 3rd Secondary — Teacher: Dr Hany
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total enrolled: 25 students | Purchased: 20 | Not purchased: 5
[Search by student name or code...] (client-side filter)
Student │ Code │ Bought │ Paid │ Watch │ Actions
──────────────┼─────────┼──────────┼──────────┼────────────┼─────────────
Ahmed Ali │ 1234567 │ ✅ Yes │ 40 EGP │ 2/3 (67%) │ [View]
Sara Ahmed │ 7654321 │ ❌ No │ — │ — │ [View]
Mohamed Omar │ 1122334 │ ✅ Yes │ 40 EGP │ 3/3 (100%) │ ✅ Active
Nora Hassan │ 9988776 │ ✅ Yes │ 40 EGP │ 0/3 (0%) │ [View]
Khaled Ali │ 5544332 │ ✅ Yes │ 40 EGP │ 1/3 (33%) │ 🔴 Expired [Reopen]
Page 1 of 2 — [Next ►]
Each column explained:
| Column | Source |
|---|---|
| Student | student_name |
| Code | student_code (7-digit code) |
| Bought | is_purchased → ✅ Yes / ❌ No |
| Paid | purchase.amount_paid (frozen snapshot, never changes) |
| Watch | watch.watched_count / watch.total_videos (watch.percentage%) |
| Actions | Based on state (see render logic below) |
Render logic:
| State | Display |
|---|---|
Not purchased (is_purchased=false) |
Gray row, "Not purchased" badge, [View] button |
Purchased + Active (is_expired=false) |
Green "Active" badge, [View] button |
Purchased + Expired + can reopen (is_expired=true, can_reopen=true) |
Red "Expired" badge, [Reopen] button (no [View] — reopen is the primary action) |
Purchased + Expired + max reopens (can_reopen=false) |
Red "Expired (max reopens)" text, [View] button |
No videos (total_videos=0) |
Watch column shows "—" |
[View] button opens a modal/drawer with full student detail (see below). [Reopen] opens a confirmation dialog (see reopen flow below).
Reopen flow:
expires_at), reopens used (reopen_logs.length), remaining (2 - logs.length), sessions_used will be reset to 0PATCH /courses/purchases/<id>/reopen/ (no body)students-progress/ for that pageexpires_at = max(expiry, now) + 1 day, extra_days resets to 0) — reopening a lecture expired long ago actually works now. is_expired recalculates, can_reopen updates, sessions_used is reset to 0 so the student can start new viewing sessionsWhen the teacher clicks [View] on any row, a modal/drawer opens showing everything about that student for this lecture. No additional API call needed — all data is already in the students-progress/ response.
Modal layout:
┌─────────────────────────────────────────────────────────────────┐
│ Student: Ahmed Ali (1234567) [X] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ── Purchase Info ──────────────────────────────────────────── │
│ Status: Purchased ✅ │
│ Paid: 40.00 EGP │
│ Purchased: July 1, 2026 │
│ Expires: August 2, 2026 (1 reopen used, 1 remaining) │
│ Watched: 2/3 videos (67%) │
│ │
│ ── Homeworks ──────────────────────────────────────────────── │
│ Week 1 HW │ ✅ Submitted │ Score: 2/3 │ Jul 1, 2026 │
│ Week 2 HW │ ❌ Not done │ — │ — │
│ │
│ ── Quizzes ────────────────────────────────────────────────── │
│ Quiz 1 │ ❌ Not done │ — │ Attempt 0/1 │
│ │
└─────────────────────────────────────────────────────────────────┘
Modal sections:
| Section | Content | Data Source |
|---|---|---|
| Student header | Name + code | student_name, student_code |
| Purchase Info | Status, paid amount, dates, reopen info, sessions used/remaining | purchase object |
| Homeworks | Table: title, submitted?, score/total, submission date | homeworks[] per-student + homeworks[] top-level for total |
| Quizzes | Table: title, submitted?, score/total, attempt#/max | quizzes[] per-student + quizzes[] top-level for total/max_attempts |
Homework table columns:
| Column | Source |
|---|---|
| Title | homeworks[i].title |
| Status | "✅ Submitted" if submitted=true, "❌ Not done" if false |
| Score | score / total from top-level homeworks[i].total (null = "—") |
| Submission date | If submitted, fetch from GET /learning/homework-submissions/<submission_id>/ or show "—" |
Quiz table columns:
| Column | Source |
|---|---|
| Title | quizzes[i].title |
| Status | "✅ Submitted" if submitted=true, "❌ Not done" if false |
| Score | score / total from top-level quizzes[i].total (null = "Not graded" if submitted, or "—") |
| Attempt | attempt / max_attempts (from quizzes[i].max_attempts) — quizzes are multi-attempt; exams are single |
Empty states inside modal:
Empty states for the main table:
Purpose: Create bubble-sheet homework with questions. View submissions with per-question results.
Standalone management page: Table of all homeworks across courses with filters (?lecture=, ?is_published=).
Lecture tab: Same component, pre-filtered by ?lecture=<id>.
| Action | Endpoint |
|---|---|
| List | GET /learning/homeworks/ |
| Create | POST /learning/homeworks/ |
| Get | GET /learning/homeworks/<id>/ |
| Update | PUT /learning/homeworks/<id>/ |
| Delete | DELETE /learning/homeworks/<id>/ |
| List submissions | GET /learning/homeworks/<id>/submissions/ |
| Submission detail | GET /learning/homework-submissions/<id>/ |
Create form:
Title: [Week 1 Homework______________]
Description: [________________]
Published: [ ] (toggle)
Show grades to students: [ ] (toggle — when on, students see correct answers after submission)
Bubble Questions:
Q1: Choices [4 ▼] | Answer [A] | Explanation (optional): [_________] | Points: [1]
Q2: Choices [5 ▼] | Answer [B] | Explanation (optional): [_________] | Points: [2]
+ Add Question
Bubble Question fields:
order — question numberchoices_count — number of choices (2–8)correct_answer — e.g. "A" or "A,C"answer_explanation — shown after submission if show_grades=truepoints — 1–20 (default 1, max 20; enforced by the backend)Request body:
{
"lecture": 5,
"title": "Week 1 HW - Chapter 1",
"description": "Chapter 1 exercises",
"is_published": false,
"bubble_questions": [
{"order": 1, "choices_count": 4, "correct_answer": "A", "answer_explanation": "Optional", "points": 1},
{"order": 2, "choices_count": 5, "correct_answer": "A,C", "answer_explanation": "", "points": 2}
]
}
Submissions table (teacher view):
Student │ Code │ Score │ Status │ Submitted
─────────────┼─────────┼───────┼────────┼──────────────
Ahmed Ali │ S-001 │ 2/3 │ Graded │ 2026-07-08
Sara Ahmed │ S-002 │ 3/3 │ Graded │ 2026-07-07
Click a submission → per-question results:
Q1: Student answered B | Correct: A | ❌ Wrong | Explanation: Because...
Q2: Student answered B | Correct: B | ✅ Correct
Purpose: Create quizzes with standalone questions (text + image + choices). View submissions, manually grade written answers, release scores/answers.
| Action | Endpoint |
|---|---|
| List | GET /learning/quizzes/ |
| Create | POST /learning/quizzes/ { lecture, title, settings } |
| Get | GET /learning/quizzes/<id>/ (with questions) |
| Update | PUT /learning/quizzes/<id>/ |
| Delete | DELETE /learning/quizzes/<id>/ |
| Update question image | PATCH /learning/questions/<id>/ (multipart — image field) |
| Update question text | PATCH /learning/questions/<id>/ (JSON — text, question_type, points_override) |
| List submissions | GET /learning/quizzes/<id>/results/ |
| Grade written | POST /learning/quiz-submissions/<id>/grade-written/ |
| Release scores | POST /learning/quizzes/<id>/release-scores/ |
| Release answers | POST /learning/quizzes/<id>/release-answers/ |
| Start quiz | POST /learning/quizzes/<id>/start/ |
| Resume quiz | GET /learning/quizzes/<id>/resume/ |
| Submit quiz | POST /learning/quizzes/<id>/submit/ |
| Save draft answer | PATCH /learning/quiz-answers/<answer_id>/save-draft/ |
| Student quiz history | GET /learning/quizzes/ |
| Delete submission (retake) | DELETE /learning/quiz-submissions/<pk>/delete/ |
Settings form (5 fields only):
{
"timer_minutes": 30,
"score_visibility": "immediate",
"answers_visibility": "immediate",
"question_order": "fixed",
"max_attempts": 1
}
| Field | Values | Default | Description |
|---|---|---|---|
timer_minutes |
0–1440 | 0 | 0 = no limit. Max 1440 (24h). |
score_visibility |
immediate, manual |
immediate |
manual = teacher must release scores |
answers_visibility |
immediate, manual |
immediate |
manual = teacher must release correct answers |
question_order |
fixed, random |
fixed |
Shuffle question order per student |
max_attempts |
1+ | 1 | Enforced for quizzes — how many attempts each student gets. When used up, the teacher must delete submissions to free a slot. (Exams ignore this — always single-attempt.) |
Multi-attempt enforcement (quizzes): Students can start a new quiz attempt while one is in progress (the backend returns the existing in-progress attempt), and can submit up to max_attempts times. When all attempts are used, POST /start/ returns 400 "You have used all your allowed attempts...". The teacher frees a slot by deleting a submission via DELETE /learning/quiz-submissions/<pk>/delete/. Exams remain single-attempt — delete the submission to allow a retake.
Note on is_active: This field exists on the model for frontend hide/show toggling but is not enforced by the backend. Only is_published gates student access. Set is_published=true when the quiz is ready for students.
Questions: Standalone only (inline text + MCQ choices + written).
Images: NOT uploadable via JSON during inline creation. Upload question images separately via PATCH /learning/questions/<id>/ with multipart/form-data (image field). The response always returns the image as a full URL.
Sending questions inline during create/update:
{
"lecture": 5,
"title": "Quiz Title",
"settings": { ... },
"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}
]
}
]
}
CAUTION: Sending questions on update (PUT/PATCH /quizzes/<id>/) upserts by question id: existing questions (with matching id) are updated in-place, new questions (no id) are created, questions absent from the payload are deleted. For auto-save without risking deletion, use individual PATCH /questions/<id>/ instead.
Limits: a quiz can have max 300 questions (create and update — 400 otherwise); each question max 20 points (
points_override, null = 1).
Purpose: Create exams independent of lectures. Can be used as prerequisites.
| Action | Endpoint |
|---|---|
| List | GET /learning/exams/ |
| Create | POST /learning/exams/ { course, title, open_date, close_date, settings } |
| Get | GET /learning/exams/<id>/ |
| Update | PUT /learning/exams/<id>/ |
| Delete | DELETE /learning/exams/<id>/ |
| Start exam | POST /learning/exams/<id>/start/ |
| Resume exam | GET /learning/exams/<id>/resume/ |
| Submit exam | POST /learning/exams/<id>/submit/ |
| List submissions | GET /learning/exams/<id>/results/ |
| Grade written | POST /learning/exam-submissions/<id>/grade-written/ |
| Release scores | POST /learning/exams/<id>/release-scores/ |
| Release answers | POST /learning/exams/<id>/release-answers/ |
| Student exam history | GET /learning/exams/ |
| Delete submission (retake) | DELETE /learning/exam-submissions/<pk>/delete/ |
Settings: Same as quizzes (5 fields) plus AFTER_CLOSE visibility option (exams have open_date/close_date).
Note: Created at course level, not lecture level. Appears in student's Exams page and can gate lecture access via prerequisites.
Single-attempt enforcement (exams only): Exams allow exactly 1 attempt. Teachers can delete a submission via DELETE /learning/exam-submissions/<pk>/delete/ to let a student retake. (Quizzes are multi-attempt — see Page 5.)
| Action | Endpoint |
|---|---|
| Upload | POST /materials/ (multipart: lecture, title, file) |
| List | GET /materials/?lecture=<id> |
| Get | GET /materials/<id>/ |
| Update | PUT /materials/<id>/ |
| Delete | DELETE /materials/<id>/ |
Accepted formats: PDF, JPG, JPEG, PNG — Max size: 20 MB
Write access: Teachers and assistants only (students and siteowners are read-only)
File uploads get a unique UUID filename — no overwrites
Purpose: Gate lecture video access behind quiz or exam passing scores.
| Action | Endpoint |
|---|---|
| Get available assessments (for dropdown) | GET /courses/lectures/<id>/available-assessments/ |
| List | GET /courses/lectures/<id>/prerequisites/ |
| Add | POST /courses/lectures/<id>/prerequisites/ { content_type, content_id, passing_score } |
| Get detail | GET /courses/lectures/<id>/prerequisites/<prereq_id>/ |
| Update | PUT|PATCH /courses/lectures/<id>/prerequisites/<prereq_id>/ |
| Remove | DELETE /courses/lectures/<id>/prerequisites/<prereq_id>/ |
Valid types: quiz, exam
The "available assessments" endpoint returns all quizzes and exams for a lecture in one call — so the prerequisite picker dropdown can be populated with a single request instead of three:
{
"quizzes": [{ "id": 5, "title": "Quiz 1", "lecture_id": 5, "lecture_name": "Intro to Reactions" }],
"exams": [{ "id": 2, "title": "Midterm Exam" }]
}
Prerequisite picker UI:
Current prerequisites for "Intro to Reactions":
- Quiz #1 (must score >= 50%) [Remove]
- Quiz #2 (must score >= 70%) [Remove]
+ Add Prerequisite
Type: [Quiz ▼] ID: [____] Passing Score: [50]%
Purpose: Manage everything about every student in a course. Approve/reject enrollments, view balances, purchases, attendance, homework/quiz/exam grades.
Flow Overview:
1. Load page → GET /courses/enrollments/?course=X&status=approved → list of approved students + balances
2. Tabs: Pending | Approved | Rejected
3. Click student → expanded row with per-lecture purchases + homework/quiz/video status
4. Click student → see full details in expanded row (balance, purchases, activity from enrollment list + transactions)
Page layout:
Course: Chemistry 3rd Secondary
Pending (3) | Approved (24) | Rejected (2)
── Pending Tab ──────────────────────────────────────
Gets data from: GET /courses/enrollments/?course=X&status=pending
Student │ Code │ Requested │ Actions
─────────────┼─────────┼──────────────┼────────────────────────────
Ali Hassan │ S-010 │ 2026-07-07 │ [Approve] [Reject]
Nora Ahmed │ S-011 │ 2026-07-06 │ [Approve] [Reject]
── Approved Tab ──────────────────────────────────────
Gets data from: GET /courses/enrollments/?course=X&status=approved
(returns: student_id, student_name, student_code, balance, enrolled_at)
Student │ Code │ Balance │ Actions
─────────────┼─────────┼──────────┼────────────────────────────
Ahmed Ali │ S-001 │ 200 EGP │ [Report] [Reopen] [History]
▶ Expanded (per-lecture data, fetched on expand):
Data source: GET /courses/enrollments/?course=X&status=approved (enrollment list with balance)
- Lecture 1: Intro → Bought Jul 1 → Expires Jul 31 → Watch 2/4 → [Reopen]
- Lecture 2: Types → Bought Jul 3 → Expires Aug 2 → Watch 1/4 → [Reopen]
- Quiz 1: 80% ✅
- Homework 1: 2/3
── Full Report (modal / new page) ───────────────────
Data source: GET /payments/transactions/?student=X
(one API call returns everything: student info, enrollment, balance,
ALL lectures (bought + not bought) with per-lecture progress:
videos watched, homework submitted, quiz taken, purchases, etc.)
── Balance History (modal) ─────────────────────────
Data source: GET /payments/transactions/?student=X
Shows every transaction with balance_before → balance_after:
code redemptions (shows code, amount, balance snapshots),
lecture purchases (shows lecture name, amount deducted, balance snapshots)
Endpoint Reference Table:
| Action | Endpoint | Notes |
|---|---|---|
| Enrolled students + balances | GET /courses/enrollments/?course=X&status=approved |
Main list for the approved tab |
| Pending enrollments | GET /courses/enrollments/?course=X&status=pending |
Pending tab data |
| Approved enrollments | GET /courses/enrollments/?course=X&status=approved |
Full enrollment details |
| Rejected enrollments | GET /courses/enrollments/?course=X&status=rejected |
Rejected tab data |
| All pending (across courses) | GET /courses/enrollments/?status=pending |
Dashboard badge showing total pending |
| Approve single | POST /courses/enrollments/approve/ |
Body: { "response_note": "string" } — accepts pending AND rejected rows (re-approval) |
| Reject single | POST /courses/enrollments/reject/ |
Body: { "response_note": "string" } (reason required) — pending only |
| Approve bulk | POST /courses/enrollments/approve/ |
Body: { "enrollment_ids": [...], "action": "approve\|reject" } |
| Block student from course | POST /courses/enrollments/<id>/block/ |
Body: { "reason": "..." } (required) — approved enrollments only; student loses access to videos/purchases/course page (403 {blocked: true, reason}) |
| Unblock student | POST /courses/enrollments/<id>/unblock/ |
No body — restores access |
| Per-lecture student progress | GET /courses/enrollments/?course=X&status=approved |
ALL students for ONE lecture: purchases, homework, quizzes, videos |
| Student transactions | GET /payments/transactions/?student=X |
ONE student for ALL lectures: everything |
| Balance transaction history | GET /payments/transactions/?student=X |
Full ledger: balance_before, balance_after, code details, purchase details |
| Lightweight activity log | ||
| Reopen lecture access | PATCH /courses/purchases/<id>/reopen/ |
No body — restores ~1 day of access from now (max 2 reopens, resets sessions) |
| Delete quiz submission (allow retake) | DELETE /learning/quiz-submissions/<pk>/delete/ |
Teacher deletes submission; student can start again |
| Delete exam submission (allow retake) | DELETE /learning/exam-submissions/<pk>/delete/ |
Same as quiz |
| Homework submission detail | GET /learning/homework-submissions/<id>/ |
Per-question bubble answers |
| Quiz submission detail | GET /learning/quiz-submissions/<id>/ |
MCQ choices, written answers |
| Exam submission detail | GET /learning/exam-submissions/<id>/ |
Same as quiz |
Expanded Row Data Flow:
GET /payments/transactions/ for a specific lecturepurchased: false → show [Buy] prompt insteadFull Report Data Flow:
GET /payments/transactions/?student=XGET /payments/transactions/?student=X (balance_before → balance_after per action)Purpose: Full report of everything about a student within a course.
| Action | Endpoint |
|---|---|
| Per-student course report | GET /courses/<course_id>/students/<student_id>/lectures/ — ALL visible lectures with purchase status, homework/quiz scores, per-video watch progress |
Response includes: student info, ALL lectures (bought + not bought) with per-lecture homework/quiz/video progress, purchases (with sessions/expiry + can_reopen), quiz submissions, homework submissions.
Balance history (separate): GET /payments/transactions/?student=X — full ledger with balance_before and balance_after for every code redemption and lecture purchase.
Purpose: Manage assistants who share all course management capabilities.
| Action | Endpoint |
|---|---|
| List | GET /accounts/teacher/assistants/ |
| Create | POST /accounts/teacher/assistants/ { username, password, name, phone, gmail, gender } |
| Get | GET /accounts/teacher/assistant/<id>/ |
| Update | PATCH /accounts/teacher/assistant/<id>/ |
| Delete | DELETE /accounts/teacher/assistant/<id>/ (must be deactivated first) |
Note: Assistant creation is teacher-only. Assistants can view other assistants but cannot create/delete.
Purpose: Generate, track, and export recharge codes. Codes are money — every action is audited.
Roles: SiteOwner (all courses), Teacher (own courses only). Assistant can view their teacher's codes.
Page Layout (3 views):
┌──────────────────────────────────────────────────────────────────┐
│ Analytics Cards: Total Batches | Codes | Valid | Used | │
│ Blacklisted | Revenue Collected │
├──────────────────────────────────────────────────────────────────┤
│ Filter Bar: [Teacher ▼] [Course ▼] [Clear] Showing 12 batches │
├──────────────────────────────────────────────────────────────────┤
│ Batch List Table: │
│ Batch ID │ Course │ Teacher │ Codes │ Value │ Status │ Notes │
│ Batch #1 │ Chem │ Dr Hany │ 100 │ 50 │ ████░░ │ Center │
│ Batch #2 │ Phys │ Omar │ 50 │ 100 │ ██░░░░ │ School │
├──────────────────────────────────────────────────────────────────┤
│ Pagination │
└──────────────────────────────────────────────────────────────────┘
Click a batch row → View individual codes in that batch:
┌──────────────────────────────────────────────────────────────────┐
│ ← Back to Batches │
│ Batch #1 — Chemistry 3rd Secondary — Dr Hany Hassanin │
│ Codes: 100 | Value: 50 EGP | Redeemed Value: 17,500 EGP │
├──────────────────────────────────────────────────────────────────┤
│ Code (masked) │ Value │ Status │ Used By │ Used At │
│ X7K9 XXXX… │ 50 │ ✅ Used │ Ahmed / أحمد │ 2026-07-14 │
│ A1B2 XXXX… │ 50 │ 🟢 Valid │ — │ — │
└──────────────────────────────────────────────────────────────────┘
Click "History" → Full audit modal with date/type/code filters.
Click "Blacklist" → OTP verification → irreversible blacklist.
APIs for the Page:
| Action | Method | Endpoint | Where to find | Purpose |
|---|---|---|---|---|
| Analytics dashboard | GET | /payments/codes/analytics/ |
API_SITEOWNER.md §5 | Returns total_batches, total_codes, valid/used/blacklisted counts + values (no revenue split — cuts were removed) |
| Batch list (paginated) | GET | /payments/codes/batches/ |
API_SITEOWNER.md §5 | Grouped by batch_id, filters: ?teacher=X, ?course=X |
| Batch detail | GET | /payments/codes/batches/<uuid:batch_id>/ |
API_SITEOWNER.md §5 | Individual codes inside a batch with their status |
| Generate codes | POST | /payments/codes/ |
API_SITEOWNER.md §5 | Bulk create (1-1000). Body: {course, value, notes, count} |
| Code summary | GET | /payments/codes/summary/?course=X |
API_SITEOWNER.md §5 | Aggregate totals: generated, redeemed, blacklisted, values |
| Code audit history | GET | /payments/codes/history/ |
API_SITEOWNER.md §5 | Unified feed of redemptions + blacklists. Response also includes analytics block with total, redeemed, blacklisted, and revenue counts computed from the full filtered dataset (respects all query parameters). Filters: ?start_date=, ?end_date=, ?type=redeemed|blacklisted, ?search=X7K9, ?teacher=X, ?course=X. Paginated (20/page). Newest first. |
| Export all codes | GET | /payments/codes/export/ |
API_SITEOWNER.md §5 | Download ALL visible codes as Excel (no ?batch= needed). Supports ?teacher=X, ?course=X. Columns: Code, Value, Status, Course, Teacher, Student, Redemption Date, Blacklist Date, etc. |
| Batch detail export | GET | /payments/codes/export/?batch=<uuid> |
API_SITEOWNER.md §5 | Download .xlsx with full code details for a specific batch |
| Batch blacklist codes | POST | /payments/codes/batch-blacklist/ |
API_SITEOWNER.md §5 | Body: { code_ids: [...], otp: "..." }. Blacklists multiple codes with one OTP. Only valid codes affected — used/blacklisted skipped. |
| Request blacklist OTP | POST | /payments/codes/<pk>/request-blacklist-otp/ |
API_SITEOWNER.md §5 | SiteOwner only. Sends OTP to their gmail |
| Request batch blacklist OTP | POST | /payments/codes/request-blacklist-otp/ |
API_SITEOWNER.md §5 | SiteOwner only. Same OTP works for single and batch blacklist. |
| Blacklist code | POST | /payments/codes/<pk>/blacklist/ |
API_SITEOWNER.md §5 | SiteOwner only. One-way irreversible. Requires 6-digit OTP |
| Transaction history | GET | /payments/transactions/?transaction_type=code_redeemed |
API_SITEOWNER.md §6 | Full audit log with date filters. Filter by ?start_date=, ?end_date=, ?code= |
Analytics Cards (from GET /codes/analytics/):
| Card | Field | Description |
|---|---|---|
| Total Batches | total_batches |
Number of distinct batch_id values |
| Total Codes | total_codes |
Total codes across all batches |
| Valid | valid_count |
Codes not yet redeemed or blacklisted |
| Used | used_count |
Successfully redeemed codes |
| Blacklisted | blacklisted_count |
Permanently deactivated by SiteOwner |
| Revenue Collected | used_value |
Total EGP of all redeemed codes |
Note: Teacher's Cut / SiteOwner's Cut cards were removed from the backend —
GET /payments/codes/analytics/returns only the 8 fields above. Thevideo_security.price_per_studentplatform-fee split is no longer computed.
Code Model Fields:
| Field | Description |
|---|---|
code |
Auto-generated 16-char random code (XXXX-XXXX-XXXX-XXXX) |
value |
1–1000 EGP. Set at creation, never editable |
status |
valid → used or blacklisted (both terminal) |
batch_id |
UUID grouping codes from the same bulk generation |
batch_name |
Human-readable name ("Dr Hany Hassanin Batch #1"), per-teacher sequential counter |
expires_at |
Auto-set to 1 year from creation, not editable |
notes |
Optional text (e.g. "Batch for Center X") |
created_by |
SiteOwner or Teacher who generated it |
used_by |
Student who redeemed it (null if unused) |
used_at |
When it was redeemed |
blacklisted_by |
SiteOwner who blacklisted it |
blacklisted_at |
When it was blacklisted |
Code Status Flow:
Valid ──→ Redeemed (used) — Student self-redeem or Teacher recharge
Valid ──→ Blacklisted — SiteOwner only, OTP required, irreversible
Business Rules:
select_for_update() — no double-redemption possibleBalanceTransaction (source of truth for finances)"{TeacherName} Batch #{n}" where n is a per-teacher sequential counterExcel Export Columns (from GET /codes/export/?batch=<uuid>):
Code | Value (EGP) | Status | Course | Redeemed By | Redeemed At | Blacklisted By | Blacklisted At | Created At | Expires At | Notes
Page Flow for SiteOwner:
1. Page loads → GET /payments/codes/analytics/ → populate analytics cards (Total Batches, Codes, Valid, Used, Blacklisted, Revenue Collected)
2. GET /payments/codes/batches/ → populate batch table (paginated, 20 per page)
3. Teacher dropdown → GET /payments/codes/batches/?teacher=X → filter batches
4. Course dropdown (dependent on teacher) → GET /payments/codes/batches/?course=X → filter batches
5. Click batch row → GET /payments/codes/batches/<uuid>/ → show individual codes with `code_masked` ("ABDC XXXX XXXX XXXX") by default, "Reveal" button to show full code
6. Click "Export" on batch → GET /payments/codes/export/?batch=<uuid> → download .xlsx
7. Click "Export All" → GET /payments/codes/export/ → download all codes .xlsx
8. Click "History" → open modal → GET /payments/codes/history/ with default date range (last 30 days) → populate history table with filters
9. In History modal: filter by type, code search, date range → refetch or client-filter
10. Click "Generate Batch" → POST /payments/codes/ → modal with course/count/value/notes
11. Select one or more codes via checkboxes → Click "Blacklist Selected" → POST /codes/request-blacklist-otp/ → OTP modal
12. Enter OTP → POST /codes/batch-blacklist/ with { code_ids: [...] } → codes permanently blacklisted
Page Flow for Teacher (same as above, but:
When all features exist, build a single page that unifies everything into 4 tabs:
┌────────────────────────────────────────────────────────────┐
│ ← Chemistry 3rd Secondary │
│ │
│ ┌──────────┬─────────────────────┬──────────┬───────────┐ │
│ │ 📊 │ 👥 │ 📚 │ 📋 │ │
│ │Overview │ Students │ Content │ Reports │ │
│ │ (Stats) │ Same as Page 9 │ Same as │ Same as │ │
│ │ │ (embedded) │ Page 2 │ Page 10 │ │
│ │ │ │ (embedded)│ (embedded)│ │
│ └──────────┴─────────────────────┴──────────┴───────────┘ │
└────────────────────────────────────────────────────────────┘
Tab 1 — Overview: GET /courses/<id>/ — enrolled/pending/rejected counts, topic/lecture/video counts.
For deeper analytics: GET /courses/<id>/analytics/ — per-topic and per-lecture purchase breakdowns with revenue.
Purpose: Landing page after teacher login. Aggregated stats across all courses, plus action items that need the teacher's attention. Single call to GET /courses/teacher/dashboard/ replaces multiple API calls.
GET /courses/teacher/dashboard/ Response:
{
"summary": {
"total_courses": 3,
"active_courses": 3,
"total_students": 24,
"pending_enrollments": 4,
"total_purchases": 18,
"total_revenue": "720.00",
"outstanding_cut": "160.00"
},
"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 }
]
}
},
"recent_enrollments": [
{ "id": 1, "student_name": "Ahmed Ali / أحمد", "student_code": "1234567", "course_name": "Chemistry 3rd Secondary 2027", "status": "pending", "enrolled_at": "2026-07-30T10:00:00Z" }
],
"recent_purchases": [
{ "id": 1, "student_name": "Ahmed Ali / أحمد", "student_code": "1234567", "lecture_name": "Lecture 1", "course_name": "Chemistry 3rd Secondary 2027", "amount_paid": "40.00", "purchased_at": "2026-07-30T11:00:00Z" }
],
"courses": [
{ "id": 28, "name": "Chemistry 3rd Secondary 2027", "is_active": true, "enrolled_count": 10, "pending_count": 2, "purchase_count": 8, "revenue": "320.00" }
]
}
Page layout:
Dashboard — Welcome back, Dr Hany
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[Summary cards]
Total Courses: 3 (2 active) │ Students: 24 │ Purchases: 18 │ Revenue: 720 EGP
[Actions Needed] ← NEW — teacher's to-do list
┌─ Pending Enrollments (4) ──────────────────────────────────┐
│ Ahmed Ali / أحمد (1234567) — Chemistry 3rd Sec 2027 │
│ requested Jul 30 [Approve] [Reject] │
└────────────────────────────────────────────────────────────┘
┌─ Ungraded Written Answers (5) ─────────────────────────────┐
│ Quiz: Important Quiz (Lecture 1, Chemistry) — 3 pending │
│ → link to quiz written-answers review │
│ Exam: Midterm (Chemistry) — 2 pending │
│ → link to exam written-answers review │
└────────────────────────────────────────────────────────────┘
[Recent Activity]
Recent Enrollments (last 10) — table: student, code, course, status, date
Recent Purchases (last 10) — table: student, code, lecture, course, amount, date
[Course Breakdown]
Course card grid — name, active badge, enrolled/pending counts,
purchase count, revenue, "Open" → Content Management
Field usage guide:
| Section | Frontend use |
|---|---|
summary |
Summary cards at top: total_courses (+ active_courses subtitle like "3 active"), total_students, pending_enrollments (also shown as badge count on the Actions card), total_purchases, total_revenue |
actions_needed.pending_enrollments[] |
Approve/Reject queue. Render each item with student_name, student_code, course_name, enrolled_at. Clicking [Approve] / [Reject] calls POST /courses/enrollments/approve/ or POST /courses/enrollments/reject/ with {"enrollment_ids": [enrollment_id]} — then re-fetch the dashboard. Empty array → show "No pending enrollments ✅" |
actions_needed.ungraded_written |
Grading to-do. Show total_pending badge. Each quizzes[] item links to GET /learning/quizzes/<quiz_id>/written-answers/; each exams[] item links to GET /learning/exams/<exam_id>/written-answers/. pending_count is the number of answers to grade for that assessment. Empty quizzes/exams arrays → show "All written answers graded ✅" |
recent_enrollments[] |
Recent activity feed/table (last 10, any status — show status badge: pending/approved/rejected) |
recent_purchases[] |
Recent revenue feed/table — show amount_paid + purchased_at |
courses[] |
Per-course cards: name, is_active badge, enrolled_count + pending_count (badge if > 0), purchase_count, revenue. Card click → My Courses / Content Management page |
Business rules for the frontend:
actions_needed.pending_enrollments is the full list (not capped at 10) — the card can scroll or show all. Count badge = summary.pending_enrollments.POST /courses/enrollments/approve/ {enrollment_ids: [...], response_note?} — 200 response {processed, total_requested, errors}.amount_paid and revenue are strings (decimal) — format, don't parse as floats.Purpose (SiteOwner): Manual platform-cut invoicing. Siteowner sees per-teacher lecture volume (filterable by month/date range), creates an invoice with a date range + cut per lecture, and tracks unpaid/paid/cancelled invoices. Purpose (Teacher): see their own invoices (owed/settled) — transparency.
APIs (SiteOwner — all under /payments/cuts/):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| Overview | GET | /payments/cuts/overview/?teacher=&search=&start=&end= |
Per-teacher total/purchased lectures, outstanding/paid totals, per-course breakdown; rows include profile_picture; ?teacher=<id> embeds lectures[] + lectures_count (teacher detail — no separate call needed). Teachers/assistants are auto-scoped to their own teacher (?teacher=/?search= ignored) |
| Itemized preview | GET | /payments/cuts/lectures/?teacher=&search=&start=&end=&course= |
Individual purchases in range (verify before invoicing) |
| List invoices | GET | /payments/cuts/invoices/?teacher=&status=&search=&start=&end= |
Paginated invoice list |
| Create invoice | POST | /payments/cuts/invoices/ |
{teacher, start_date, end_date, cut_per_lecture, discount, note?} → 201 with computed lectures_count, gross_total, total_owed |
| Invoice detail | GET | /payments/cuts/invoices/<id>/ |
Single invoice |
| Update invoice | PATCH | /payments/cuts/invoices/<id>/ |
Edit cut/discount (blocked when paid), note, paid_note, status (paid sets paid_at) |
| Cancel invoice | DELETE | /payments/cuts/invoices/<id>/ |
Soft-cancel (status → cancelled) |
API (Teacher):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| My invoices | GET | /payments/cuts/my/?search= |
Teacher's own unpaid/paid invoices |
SiteOwner page layout (/siteowner/cuts):
Filter bar: [Teacher ▼] [Search...] [Start date] [End date] [Clear]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Summary cards: Total Lectures | Purchased Lectures | Outstanding | Collected
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Overview table (per teacher):
Teacher │ Lectures │ Purchased │ Outstanding │ Paid │ Actions
Dr Hany │ 42 │ 320 │ 6400 EGP │ 1200 │ [Create Invoice] [▸ Expand]
▸ per-course rows: course name, grade, lectures, purchased, revenue
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Invoices table:
Teacher │ Period │ Count │ Cut/Lec │ Total │ Status │ Actions
Dr Hany │ Aug 1–31, 2026 │ 320 │ 20 EGP │ 6400 EGP │ Unpaid │ [Mark Paid] [Cancel] [View]
Create Invoice modal: teacher (pre-filled), start/end dates, cut_per_lecture, discount, note. Live preview: "N lectures in range × X EGP − discount = Y EGP" (N fetched from /cuts/lectures/?teacher=&start=&end=; validate discount ≤ gross client-side, server enforces too). Overlapping-period and discount>gross errors (400) shown inline.
Mark Paid flow: optional Payment Note field → PATCH {status: "paid", paid_note}. Reverting to unpaid clears the note.
Invoices table: teacher | period | count | cut/lecture | discount | gross | total | status badge | paid note | actions.
Teacher page (/teacher/cuts): read-only table of their invoices — period, lectures count, cut, discount, gross/total owed, status badge, note, paid note. Empty state "No invoices yet."
Business rules (backend-enforced):
lectures_count is a snapshot at creation; editing the cut/discount never re-counts.total_owed = max(0, (cut × count) − discount); gross_total = cut × count (read-only).DELETE soft-cancels; paid → unpaid/cancelled clears paid_at and paid_note.?search= is case-insensitive partial: overview → teacher name; lectures → student name/code/lecture/course; invoices → teacher name/note; cuts/my/ → note.Build in dependency order. Pages without dependencies can be built in parallel.
Dependency graph:
Early (independent):
Page 1: Profile
Page 2: Recharge Codes
Page 3: Transaction History
Chain (build in order):
Page 4: Course Preview
↓
Page 5: My Enrollments
↓
Page 6: Course Lectures
↓
Page 7: Purchase Lecture
↓
Page 8: Lecture Player
Independent (can build anytime after Chain):
Page 9: Homework Bubble Sheet
Page 10: Quiz Taking
Page 12: My Lectures (aggregate — all purchased lectures)
Page 13: My Homeworks (aggregate — all available homeworks)
Page 14: My Quizzes (aggregate — all available quizzes)
Last (build after everything):
Page 11: Student Dashboard
Student authentication behavior by status:
After login (POST /accounts/login/), the response includes status for students:
pending → Show "pending approval" message — can't access courses yetverified → Normal login — full accessdeclined → Receive {status: "declined"} — show reason + prompt to edit profile (editing resets to pending)suspended_temporary → Receive {status: "suspended_temporary"} — show reasonsuspended_permanent → Blocked — 401 "Your account has been permanently suspended."Purpose: View profile + change password + edit profile if declined. No student-page dependencies — can be built immediately after Phase 1.
APIs to use (from API_STUDENT.md):
| Action | Method | Endpoint | Roles |
|---|---|---|---|
| Get profile | GET | /accounts/profile/me/ |
Student only |
| Update profile | PATCH | /accounts/profile/me/ |
Student only (declined only) |
| Change password | POST | /accounts/change-password/ |
Any authenticated |
GET /accounts/profile/me/ Response:
{
"id": 1, "user_id": 1, "username": "ahmed123",
"student_code": "1234567",
"name_ar": "أحمد", "name_en": "Ahmed",
"full_name": "Ahmed / أحمد",
"phone_number": "01234567890",
"father_number": "01112345678",
"mother_number": "01087654321",
"school_type": 1, "school_type_name": "Public School",
"grade": 3, "grade_name": "3rd Secondary",
"division": 2, "division_name": "Scientific",
"school_name": "Al-Haram Secondary School",
"birth_date": "2008-05-15",
"gender": "male",
"gmail": "ahmed@gmail.com",
"governorate": 1, "governorate_name": "Cairo",
"area": 5, "area_name": "Nasr City",
"status": "declined",
"is_active": false,
"can_access_course": false,
"status_history": [
{
"from_status": "pending",
"to_status": "declined",
"reason": "Missing father's phone number. Please re-submit with correct info.",
"changed_by": "Admin � Username of the admin who made the change",
"created_at": "2026-07-15T10:00:00Z"
}
],
"created_at": "2026-06-01T10:00:00Z",
"updated_at": "2026-07-18T10:00:00Z"
}
PATCH /accounts/profile/me/ Request: Same fields as GET (all optional for PATCH). Can use JSON or multipart. Only sends if status === 'declined'.
POST /accounts/change-password/ Request:
{
"old_password": "String (Required) — Current password",
"new_password": "String (Required) — Minimum 8 characters",
"new_password_confirm": "String (Required) — Must match new_password"
}
POST /accounts/change-password/ Response — 200 OK:
{
"message": "Password changed successfully. Please log in again."
}
Error Responses:
| Status | Endpoint | Condition |
|---|---|---|
| 403 | PATCH profile | Verified / pending / suspended profiles cannot edit |
| 400 | PATCH profile | Email exists, area/governorate mismatch |
| 400 | Change password | Missing fields, passwords don't match, too short, wrong old password |
| 400 | Change password | Weak password (common, entirely numeric, too similar to username) |
Page layout:
declined → shows reason from status_history in a warning card with edit promptstatus === 'declined'| Status | Can edit profile? | Can change password? |
|---|---|---|
verified |
❌ | ✅ |
pending |
❌ | ✅ |
declined |
✅ (resets to pending) | ✅ |
suspended_temporary |
❌ | ✅ |
suspended_permanent |
❌ Blocked from login | ❌ |
Populates from: profile endpoint
Purpose: Redeem physical voucher codes to add balance to a specific course.
APIs to use (from API_STUDENT.md):
| Action | Method | Endpoint | Roles |
|---|---|---|---|
| My balances | GET | /payments/balance/ |
Student only |
| Course balance | GET | /courses/<id>/balance/ |
Student only |
| Redeem code | POST | /payments/codes/redeem/ |
Student only |
POST /payments/codes/redeem/ Request:
{"code": "X7K9-M2P4-QR1W-L5D8", "course": 1}
POST /payments/codes/redeem/ Response — 200 OK:
{
"detail": "Code redeemed successfully. Added 50.00 EGP to your Chemistry balance.",
"new_balance": "200.00",
"transaction_id": 42
}
Error Responses:
| Status | Condition |
|---|---|
| 400 | Code already used / expired / wrong course |
| 403 | Not enrolled and approved |
| 404 | Invalid code |
Page layout:
new_balance shown, balance list refreshesPopulates from: balance endpoint for course list + balances; redeem for code application
Purpose: View full ledger of all code redemptions and lecture purchases with per-course balance tracking.
API: GET /payments/transactions/ — auto-filtered to the logged-in student. Paginated.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
page |
Integer | Page number (default: 1) |
page_size |
Integer | Items per page (default: 50) |
transaction_type |
String | code_redeemed or lecture_purchase |
search |
String | Search in description |
start_date / end_date |
String | Date range filter |
Response:
{
"count": 12,
"results": [
{
"course_name": "Chemistry 3rd Secondary",
"teacher_name": "Dr Hany Hassanin",
"teacher_picture": "https://.../profile_pictures/drhany.jpg",
"transaction_type": "code_redeemed",
"transaction_type_display": "Code Redeemed",
"amount": "50.00",
"balance_before": "100.00",
"balance_after": "150.00",
"description": "Code X7K9-M2P4-QR1W-L5D8",
"created_at": "2026-07-19T10:00:00Z"
}
]
}
Page layout:
| Date | Type | Teacher | Description | Performed by | Amount | Balance After |
|---|---|---|---|---|---|---|
| Jul 19 | Redeemed ✅ | Dr Hany | Code X7K9-... | Teacher name | +50.00 | 150.00 (was 100) |
| Jul 18 | Purchased 🔶 | Dr Hany | Intro to Reactions | Student | −40.00 | 100.00 (was 140) |
Populates from: transactions endpoint
Purpose: Preview a course before enrolling — shows full structure with topics, lectures, prices, and teacher info. Available to any authenticated user.
APIs to use (from API_STUDENT.md):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| Preview course | GET | /courses/<id>/preview/ |
Topics + lectures with prices |
| Enroll in course | POST | /courses/enrollments/enroll/ |
Request enrollment |
| Cancel enrollment | DELETE | /courses/enrollments/<id>/cancel/ |
Cancel pending request |
GET /courses/<id>/preview/ Response:
{
"id": 1, "name": "Chemistry 3rd Secondary",
"description": "Full course covering chemical reactions...",
"cover_picture": "https://.../cover.jpg",
"teacher": {"id": 5, "name": "Dr Hany", "profile_picture": "..."},
"grade": {"id": 3, "name": "3rd Secondary"},
"subject": {"id": 2, "name": "Chemistry"},
"topic_count": 5, "total_lectures": 20,
"topics": [
{
"id": 1, "name": "Chemical Reactions",
"lecture_count": 4,
"picture": "https://.../topics/pictures/chemical.jpg",
"lectures": [
{
"id": 10, "name": "Intro to Reactions",
"price": "50.00", "final_price": "40.00",
"discount": "10.00", "available_days": 30,
"video_count": 2
}
]
}
]
}
Page layout:
description field is for internal use — not shown to students)eligibility: {eligible, reason: 'grade'|'division'|'school_type'|null}. If eligible: false, hide the "Enroll for Free" button and show the reason (e.g., "This course is for another grade/division"). Course lists (/courses/, /courses/by-subject/<id>/) are already filtered server-side to eligible courses, so this mostly guards direct links.POST /courses/enrollments/enroll/ with {"course": <id>}Populates from: preview endpoint for course data; enroll for enrollment
Purpose: View all enrolled courses with teacher info, topics/lectures count. Each card links to the course lectures page.
APIs to use (from API_STUDENT.md):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| My enrollments | GET | /courses/enrollments/ |
All student enrollments (auto-filtered). Use ?status=approved for approved only |
GET /courses/enrollments/ Response (student view):
[
{
"id": 1, "student": 1,
"student_name": "Ahmed / ????", "student_code": "1234567",
"course_name": "Chemistry 3rd Secondary",
"grade_name": "3rd Secondary",
"teacher_name": "Dr Hany",
"subject_name": "Chemistry",
"status": "approved",
"status_display": "Approved",
"balance": "150.00",
"cover_picture": "https://.../covers/chem.jpg",
"topic_count": 5,
"total_lectures": 20,
"enrolled_at": "2026-07-01T10:00:00Z",
"responded_by": null,
"responded_by_name": null,
"responded_at": null,
"response_note": null
}
]
Note: cover_picture, topic_count, total_lectures are only non-null for approved enrollments.
Page layout:
cover_picture)topic_count, total_lectures)Populates from: enrollments endpoint with ?status=approved
Purpose: View all topics inside an enrolled course. Each topic is a clickable square card that navigates to the lecture player or purchase page.
APIs to use (from API_STUDENT.md):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| Course lectures | GET | /courses/<id>/lectures/ |
Topics + lectures with purchase status |
GET /courses/<id>/lectures/ Response:
{
"course": {"id": 1, "name": "Chemistry 3rd Secondary"},
"topics": [
{
"id": 1, "name": "Chemical Reactions", "order": 1,
"lectures": [
{
"id": 10, "name": "Intro to Reactions",
"price": "50.00", "final_price": "40.00",
"order": 1, "video_count": 2,
"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,
"has_active_session": true,
"active_session": {
"id": 1, "purchase": 5,
"started_at": "2026-07-26T10:00:00Z",
"expires_at": "2026-07-26T16:00:00Z",
"is_active": true,
"remaining_seconds": 18000
}
}
}
]
}
]
}
Page layout:
Populates from: lectures endpoint — topics with lecture purchase status
Purpose: View lecture details and purchase access by deducting from course balance.
APIs to use (from API_STUDENT.md):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| Lecture detail | GET | /courses/lectures/<id>/ |
Full lecture info with videos, counts, prerequisites |
| Buy lecture | POST | /courses/purchases/buy/ |
Deduct balance and grant access |
| Course balance | GET | /courses/<id>/balance/ |
Current balance |
POST /courses/purchases/buy/ Request:
{"lecture": 10}
Response — 201 Created:
{
"id": 5, "lecture_name": "Intro to Reactions",
"topic_name": "Chemical Reactions",
"course_name": "Chemistry 3rd Secondary",
"teacher_name": "Dr Hany",
"purchased_at": "2026-07-18T10:00:00Z",
"expires_at": "2026-08-17T10:00:00Z",
"amount_paid": "40.00",
"is_expired": false
}
Error Responses:
| Status | Condition |
|---|---|
| 403 | Not enrolled/approved |
| 400 | Already purchased |
| 400 | Insufficient balance |
Page layout:
lecture.descriptionvideo_count)max_watch_count) � shown as "X viewing sessions included"available-assessments endpoint)LecturePrerequisite model):
final_price (with price strikethrough if discounted)available_days — shown as "Access for X days after purchase"Populates from: courses/lectures/<id>/ for lecture detail; buy endpoint; balance endpoint
Purpose: Watch purchased lecture videos, access materials, view quizzes and homeworks. Entry points: Course Lectures page (Page 6) and My Lectures page (Page 12).
APIs to use (from API_STUDENT.md):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| Check session | GET | /courses/purchases/<pk>/active-session/ |
Check if 6h viewing window is active |
| Start session | POST | /courses/purchases/<pk>/start-watching/ |
Start a 6h viewing session (consumes 1 from max) |
| Lecture detail | GET | /courses/lectures/<id>/ |
Lecture info with videos list, counts |
| Playback URL | GET | /courses/videos/<id>/play/ |
Signed Bunny Stream URL |
| List progress | GET | /courses/progress/ |
All video watch progress across lectures |
| Update progress | POST | /courses/progress/update/ |
Save watch progress |
| Video progress detail | GET | /courses/progress/<video_id>/ |
Progress for a specific video |
| List materials | GET | /materials/ |
Study materials for purchased lectures |
| Quizzes list | GET | /learning/quizzes/ |
Quiz submissions for this lecture |
| Homeworks list | GET | /learning/homeworks/ |
Homework submissions |
GET /courses/videos/<id>/play/ Response:
{
"playback_url": "https://vz-...b-cdn.net/{guid}/playlist.m3u8"
}
Access Rules:
| Check | Blocked? |
|---|---|
| Not purchased | 403 |
| Expired | 403 |
| No active viewing session | 403 |
| Prerequisite not passed | 403 |
Session-based viewing (new flow): Instead of per-video watch counting, the system uses 6-hour viewing sessions. The frontend must check session status before showing the player:
GET /courses/purchases/<pk>/active-session/ to check session status| State | Condition | UI |
|---|---|---|
| No active session + watches remaining | has_active_session: false, sessions_remaining > 0 |
Show "Start watching" button with remaining count (e.g., "3 sessions remaining"). Click ? POST /courses/purchases/<pk>/start-watching/ ? creates 6h session ? redirects to player |
| Active session | has_active_session: true, session.is_active: true |
Show "Resume watching" with countdown timer (remaining time from session.remaining_seconds). Player is accessible. |
| No watches remaining | sessions_remaining: 0 |
Show "No watches remaining. Contact your teacher." message with a disabled button. |
GET /active-session/ again to refresh state.GET /active-session/ restores the session state � no need to re-start.Page layout:
Progress tracking (important � read carefully):
The system tracks two independent values. Both must reach >= 90% for is_completed:
| Field | What it tracks | Prevents | Sent when |
|---|---|---|---|
progress_seconds |
Furthest playback position reached | Scrubbing ahead to skip content | Every update (including regular intervals) |
cumulative_watch_seconds |
Wall-clock time spent genuinely playing | Watching only 30% on repeat | Milestones only (25%, 50%, 75%, 90%) |
Frontend logic (client-side):
// Track cumulative watch time in localStorage (survives page refreshes)
const STORAGE_KEY = `watch_time_${videoId}`;
let cumulativeSeconds = Number(localStorage.getItem(STORAGE_KEY)) || 0;
let lastTick = Date.now();
videoPlayer.on('timeupdate', () => {
const now = Date.now();
const elapsed = (now - lastTick) / 1000; // Real seconds passed
if (!videoPlayer.paused) {
cumulativeSeconds += elapsed;
localStorage.setItem(STORAGE_KEY, Math.round(cumulativeSeconds));
}
lastTick = now;
});
Milestone detection � send cumulative_watch_seconds only 4-6 times total per video:
const DURATION = 600; // video length in seconds
const MILESTONES = [0.25, 0.50, 0.75, 0.90];
let lastReportedMilestone = -1;
function checkMilestone() {
const milestone = Math.floor(cumulativeSeconds / DURATION / 0.25);
if (milestone > lastReportedMilestone && milestone < MILESTONES.length) {
lastReportedMilestone = milestone;
fetch('/courses/progress/update/', {
method: 'POST',
body: JSON.stringify({
video: videoId,
progress_seconds: Math.round(videoPlayer.currentTime), // current position
cumulative_watch_seconds: Math.round(cumulativeSeconds), // cumulative time (milestone)
duration_seconds: DURATION
})
});
}
}
On tab close (beforeunload): Send one final update with the latest cumulative_watch_seconds.
Example: 10-min video (600s), student watches normally:
| Time (real) | Event | progress_seconds sent |
cumulative_watch_seconds sent |
|---|---|---|---|
| 0:00 | Page load | � | � |
| 0:30 | Regular update | 30 | � |
| 2:30 | Milestone hit (25%) | 150 | 150 ? |
| 5:00 | Regular update | 300 | � |
| 5:00 | Milestone hit (50%) | 300 | 300 ? |
| 7:30 | Regular update | 450 | � |
| 7:30 | Milestone hit (75%) | 450 | 450 ? |
| 9:00 | Milestone hit (90%) | 540 | 540 ? (is_completed = True) |
Total requests per video: ~6 (4 milestones + 2-3 regular position updates + 1 on close)
Populates from: active-session endpoint (on load), start-watching endpoint (to start session), play endpoint (after session active), materials endpoint, quizzes/ and homeworks/ endpoints
Purpose: Answer bubble-sheet homework — no question text, just A/B/C/D choices in a compact grid. Opened from Lecture Player's homework card.
APIs to use (from API_STUDENT.md):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| Homework detail | GET | /learning/homeworks/<id>/ |
Bubble questions (only order + choices_count) |
| Save draft | PATCH | /learning/homeworks/<id>/draft/ |
Auto-save answers |
| Load draft | GET | /learning/homeworks/<id>/draft/ |
Restore saved answers |
| Submit | POST | /learning/homeworks/<id>/submit/ |
Submit for auto-grading |
| View results | GET | /learning/homework-submissions/<submission_id>/ |
Per-question results |
GET /learning/homeworks/<id>/ Response (student view):
{
"id": 1, "lecture": 10,
"title": "Week 1 HW",
"description": "Chapter 1 exercises",
"bubble_questions": [
{"id": 1, "order": 1, "choices_count": 4},
{"id": 2, "order": 2, "choices_count": 5}
]
}
Note: HomeworkBubbleQuestion has NO text field — only order, choices_count, correct_answer, points.
POST /learning/homeworks/<id>/submit/ Request:
{
"bubble_answers": [
{"bubble_question_id": 1, "selected_choice": "A"},
{"bubble_question_id": 2, "selected_choice": "B,D"}
]
}
Response — 200 OK:
{
"detail": "Homework submitted and auto-graded.",
"submission_id": 10, "score": "2.00", "status": "graded"
}
Page layout (compact — no scroll):
# | Answer (selected choice letter) | A | B | C | DHomeworkBubbleQuestion model (only order + choices_count)Populates from: homeworks/<id> for questions; draft for save/load; submit for grading; homework-submissions/<id> for results
Purpose: Take a timed quiz one question at a time with navigator, auto-save drafts, and view detailed results after submission.
APIs to use (from API_STUDENT.md):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| Quiz detail | GET | /learning/quizzes/<id>/ |
Quiz info (settings visible, questions hidden until started) |
| Start quiz | POST | /learning/quizzes/<id>/start/ |
Create attempt, get questions + submission_id |
| Resume quiz | GET | /learning/quizzes/<id>/resume/ |
Restore active attempt with saved answers |
| Save draft | PATCH | /learning/quiz-answers/<answer_id>/save-draft/ |
Auto-save per answer |
| Submit quiz | POST | /learning/quizzes/<id>/submit/ |
Submit all answers |
| View results | GET | /learning/quiz-submissions/<id>/ |
Per-question results |
POST /learning/quizzes/<id>/start/ Response:
{
"submission_id": 15,
"timer_minutes": 30, "started_at": "2026-07-18T10:00:00Z",
"questions": [
{
"answer_id": 50, "quiz_question_id": 10,
"text": "What is 2+2?", "image": null,
"question_type": "mcq_single", "points": 2,
"choices": [{"id": 100, "text": "4", "order": 1}, {"id": 101, "text": "5", "order": 2}]
}
]
}
POST /learning/quizzes/<id>/submit/ Request:
{
"answers": [
{"quiz_question_id": 10, "choice_ids": [100], "written_answer": ""}
]
}
Response — 200 OK:
{
"detail": "Quiz submitted successfully.", "submission_id": 15,
"score": "5.00", "score_visible": true, "answers_visible": true
}
Settings fields (from QuizSettings):
| Field | Values | Description |
|---|---|---|
timer_minutes |
0–1440 | Time limit. 0 = no limit. |
max_attempts |
1+ | Max attempts allowed (quizzes only — exams are always single-attempt) |
score_visibility |
immediate / manual | When score is shown to student |
answers_visibility |
immediate / manual | When correct answers are shown |
question_order |
fixed / random | Order of questions |
Page flow:
timer_minutes > 0)PATCH /learning/quiz-answers/<answer_id>/save-draft/ on answer changeGET /learning/quizzes/<id>/resume/ → restore timer, questions, saved answersPopulates from: quizzes/<id> for info; start for beginning; resume for recovery; save-draft for auto-save; submit for grading; quiz-submissions/<id> for results
Purpose: Landing page after student login. Shows enrolled courses and quick links to all features. Uses the consolidated dashboard endpoint � replaces 5 separate API calls.
APIs to use (from API_STUDENT.md):
| Action | Method | Endpoint | Roles |
|---|---|---|---|
| Dashboard | GET | /courses/student/dashboard/ |
Student only |
GET /courses/student/dashboard/ Response:
{
"enrolled_courses": [
{
"id": "integer",
"name": "string",
"cover_picture": "string (URL) | null",
"teacher_name": "string",
"teacher_picture": "string (URL) | null",
"grade_name": "string",
"balance": "string (decimal)",
"total_lectures": "integer",
"purchased_lectures": "integer"
}
],
"my_lectures": {
"total": "integer",
"active": "integer",
"expired": "integer"
},
"nearly_expired_lectures": [
{
"purchase_id": "integer",
"lecture_id": "integer",
"lecture_name": "string",
"topic_name": "string",
"course_name": "string",
"expires_at": "datetime (ISO 8601)",
"remaining_hours": "integer"
}
],
"homeworks": {
"total": "integer",
"submitted": "integer",
"graded": "integer",
"pending": "integer"
},
"quizzes": {
"total": "integer",
"not_started": "integer",
"in_progress": "integer",
"submitted": "integer"
}
}
Page layout:
my_lectures, "View All" button ? My Lectures pagehomeworks, "View All" button ? My Homeworks pagequizzes, "View All" button ? My Quizzes pageenrolled_courses[] (cover thumbnail, course name, teacher name, balance, teacher picture)nearly_expired_lectures[] with remaining hours countdownPopulates from: /courses/student/dashboard/ (single call � replaces 5 separate API calls)
Purpose: View all purchased lectures across all enrolled courses. Filter and navigate to watch. APIs to use (from API_STUDENT.md �14):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| My lectures | GET | /courses/my-lectures/ |
All purchased lectures across courses with content counts |
GET /courses/my-lectures/ Response:
[
{
"purchase_id": 1, "lecture_id": 10, "lecture_name": "Intro to Reactions",
"topic_id": 1, "topic_name": "Chemical Reactions",
"course_id": 1, "course_name": "Chemistry 3rd Secondary",
"teacher_name": "Dr Hany",
"price": "40.00",
"purchased_at": "2026-07-01T10:00:00Z",
"expires_at": "2026-07-31T10:00:00Z",
"effective_expiry": "2026-07-31T10:00:00Z",
"is_expired": false,
"extra_days": 0,
"max_watch_count": 4,
"sessions_used": 1,
"sessions_remaining": 3,
"has_active_session": false,
"video_count": 3,
"materials_count": 2,
"homeworks_count": 1,
"quizzes_count": 1
}
]
Page layout:
Populates from: my-lectures endpoint (single call � no more iteration across courses)
Purpose: View all available homeworks across all purchased lectures. Start homework or view submission results.
APIs to use (from API_STUDENT.md �14):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| My homeworks | GET | /learning/homeworks/ |
All homeworks for purchased lectures with submission status |
GET /learning/homeworks/ Response (paginated � use /learning/homeworks/?all=true for non-paginated):
[
{
"homework_id": 1,
"title": "Week 1 HW",
"description": "Chapter 1 exercises",
"is_published": true,
"show_grades": true,
"total_points": 3,
"lecture_id": 10,
"lecture_name": "Intro to Reactions",
"topic_id": 1,
"topic_name": "Chemical Reactions",
"course_id": 1,
"course_name": "Chemistry 3rd Secondary",
"teacher_name": "Dr Hany",
"is_submitted": true,
"submission_id": 5,
"score": "2.00",
"status": "graded",
"submitted_at": "2026-07-18T10:00:00Z"
}
]
Page layout:
Populates from: homeworks/ endpoint (single call � no merging needed)
Purpose: View all available quizzes across all purchased lectures. Start, resume, or view quiz results.
APIs to use (from API_STUDENT.md �14):
| Action | Method | Endpoint | Purpose |
|---|---|---|---|
| My quizzes | GET | /learning/quizzes/ |
All quizzes for purchased lectures with status |
GET /learning/quizzes/ Response (paginated � use /learning/quizzes/?all=true for non-paginated):
[
{
"quiz_id": 1,
"title": "Quiz 1",
"description": "Chapter 1 quiz",
"total_points": "10.00",
"settings": {
"timer_minutes": 10,
"score_visibility": "immediate",
"answers_visibility": "immediate"
},
"lecture_id": 10,
"lecture_name": "Intro to Reactions",
"topic_id": 1,
"topic_name": "Chemical Reactions",
"course_id": 1,
"course_name": "Chemistry 3rd Secondary",
"teacher_name": "Dr Hany",
"quiz_status": "submitted",
"submission_id": 15,
"score": "5.00",
"score_visible": true,
"answers_visible": true,
"started_at": "2026-07-18T10:00:00Z",
"submitted_at": "2026-07-18T10:30:00Z"
}
]
Page layout:
Populates from: quizzes/ endpoint (single call � 3 statuses included, no merging needed)