525 lines
22 KiB
Python
525 lines
22 KiB
Python
from django.contrib.auth import get_user_model
|
|
from django.test import TestCase
|
|
from rest_framework.test import APIClient
|
|
|
|
from datetime import timedelta
|
|
|
|
from django.utils import timezone
|
|
|
|
from apps.accounts.models import Role, TeacherProfile
|
|
from apps.announcements.models import Announcement
|
|
from apps.courses.models import (
|
|
Course,
|
|
CourseStatus,
|
|
Lesson,
|
|
LessonKind,
|
|
LessonStatus,
|
|
Section,
|
|
)
|
|
from apps.enrollments.models import (
|
|
ActivityEvent,
|
|
ActivityKind,
|
|
EngagementStatus,
|
|
Enrollment,
|
|
LessonProgress,
|
|
)
|
|
from apps.payments.models import Gateway, Order, OrderStatus
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
def _teacher(phone="09120000001", approved=True):
|
|
u = User.objects.create_user(phone_number=phone, full_name="Teacher", role=Role.TEACHER)
|
|
TeacherProfile.objects.create(user=u, is_approved=approved)
|
|
return u
|
|
|
|
|
|
def _student(phone="09120000099", name="Student"):
|
|
return User.objects.create_user(phone_number=phone, full_name=name, role=Role.STUDENT)
|
|
|
|
|
|
def _course_with_lessons(instructor, *, n_lessons=3, published=True):
|
|
course = Course.objects.create(
|
|
instructor=instructor, title="C", tagline="T", description="D",
|
|
level="beginner", language="fa",
|
|
)
|
|
if published:
|
|
course.publish()
|
|
sec = Section.objects.create(course=course, title="S1", order=0)
|
|
lessons = []
|
|
for i in range(n_lessons):
|
|
l = Lesson.objects.create(
|
|
section=sec,
|
|
title=f"L{i+1}",
|
|
kind=LessonKind.VIDEO,
|
|
video_url=f"https://e.com/{i}",
|
|
status=LessonStatus.PUBLISHED,
|
|
order=i,
|
|
)
|
|
lessons.append(l)
|
|
return course, sec, lessons
|
|
|
|
|
|
# ======================================================================== ENROLL
|
|
|
|
class EnrollAPITests(TestCase):
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.teacher = _teacher()
|
|
self.student = _student()
|
|
self.course, _, _ = _course_with_lessons(self.teacher)
|
|
|
|
def test_anon_blocked(self):
|
|
r = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
|
|
self.assertEqual(r.status_code, 401)
|
|
|
|
def test_teacher_cannot_enroll(self):
|
|
self.client.force_authenticate(self.teacher)
|
|
r = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
|
|
self.assertEqual(r.status_code, 403)
|
|
|
|
def test_student_enrolls_201_then_200(self):
|
|
self.client.force_authenticate(self.student)
|
|
r1 = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
|
|
self.assertEqual(r1.status_code, 201, r1.content)
|
|
self.assertTrue(Enrollment.objects.filter(student=self.student, course=self.course).exists())
|
|
r2 = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
|
|
self.assertEqual(r2.status_code, 200) # idempotent
|
|
self.assertEqual(Enrollment.objects.filter(student=self.student, course=self.course).count(), 1)
|
|
|
|
def test_enroll_404_for_unpublished(self):
|
|
draft, _, _ = _course_with_lessons(self.teacher, published=False)
|
|
self.client.force_authenticate(self.student)
|
|
r = self.client.post(f"/api/courses/{draft.slug}/enroll/")
|
|
self.assertEqual(r.status_code, 404)
|
|
|
|
|
|
# ======================================================================== MY COURSES
|
|
|
|
class MyCoursesAPITests(TestCase):
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.teacher = _teacher()
|
|
self.student = _student()
|
|
|
|
def test_my_courses_lists_progress_and_next_lesson(self):
|
|
course, sec, lessons = _course_with_lessons(self.teacher, n_lessons=4)
|
|
e = Enrollment.objects.create(student=self.student, course=course)
|
|
# complete the first 2 lessons
|
|
from django.utils import timezone
|
|
for l in lessons[:2]:
|
|
LessonProgress.objects.create(
|
|
enrollment=e, lesson=l, completed_at=timezone.now()
|
|
)
|
|
self.client.force_authenticate(self.student)
|
|
r = self.client.get("/api/me/courses/")
|
|
self.assertEqual(r.status_code, 200, r.content)
|
|
body = r.json()
|
|
self.assertEqual(len(body), 1)
|
|
row = body[0]
|
|
self.assertEqual(row["progress_percent"], 50)
|
|
self.assertEqual(row["completed_lessons"], 2)
|
|
self.assertEqual(row["total_lessons"], 4)
|
|
self.assertEqual(row["last_lesson_id"], lessons[1].id)
|
|
self.assertEqual(row["next_lesson_id"], lessons[2].id)
|
|
|
|
|
|
class MyCourseDetailAPITests(TestCase):
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.teacher = _teacher()
|
|
self.student = _student()
|
|
self.course, self.sec, self.lessons = _course_with_lessons(self.teacher, n_lessons=2)
|
|
|
|
def test_403_when_not_enrolled(self):
|
|
self.client.force_authenticate(self.student)
|
|
r = self.client.get(f"/api/me/courses/{self.course.slug}/")
|
|
self.assertEqual(r.status_code, 403)
|
|
|
|
def test_full_content_unlocked_when_enrolled(self):
|
|
Enrollment.objects.create(student=self.student, course=self.course)
|
|
self.client.force_authenticate(self.student)
|
|
r = self.client.get(f"/api/me/courses/{self.course.slug}/")
|
|
self.assertEqual(r.status_code, 200, r.content)
|
|
body = r.json()
|
|
self.assertEqual(len(body["sections"]), 1)
|
|
lessons = body["sections"][0]["lessons"]
|
|
# both lessons present, with full video_url (no preview gating)
|
|
urls = [l["video_url"] for l in lessons]
|
|
self.assertEqual(urls, ["https://e.com/0", "https://e.com/1"])
|
|
# enrollment block
|
|
self.assertEqual(body["enrollment"]["progress_percent"], 0)
|
|
|
|
|
|
# ======================================================================== PROGRESS
|
|
|
|
class LessonProgressAPITests(TestCase):
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.teacher = _teacher()
|
|
self.student = _student()
|
|
self.course, _, self.lessons = _course_with_lessons(self.teacher, n_lessons=3)
|
|
self.enrollment = Enrollment.objects.create(student=self.student, course=self.course)
|
|
self.client.force_authenticate(self.student)
|
|
|
|
def test_complete_lesson(self):
|
|
l = self.lessons[0]
|
|
r = self.client.post(f"/api/me/lessons/{l.id}/complete/")
|
|
self.assertEqual(r.status_code, 200, r.content)
|
|
body = r.json()
|
|
self.assertIsNotNone(body["completed_at"])
|
|
self.assertEqual(body["progress_percent"], 33)
|
|
self.assertFalse(body["course_completed"])
|
|
self.assertTrue(
|
|
LessonProgress.objects.filter(enrollment=self.enrollment, lesson=l).exists()
|
|
)
|
|
|
|
def test_complete_idempotent(self):
|
|
l = self.lessons[0]
|
|
self.client.post(f"/api/me/lessons/{l.id}/complete/")
|
|
first_completed = LessonProgress.objects.get(enrollment=self.enrollment, lesson=l).completed_at
|
|
self.client.post(f"/api/me/lessons/{l.id}/complete/")
|
|
second = LessonProgress.objects.get(enrollment=self.enrollment, lesson=l).completed_at
|
|
self.assertEqual(first_completed, second)
|
|
self.assertEqual(
|
|
LessonProgress.objects.filter(enrollment=self.enrollment).count(), 1
|
|
)
|
|
|
|
def test_completing_all_marks_enrollment_completed(self):
|
|
for l in self.lessons:
|
|
self.client.post(f"/api/me/lessons/{l.id}/complete/")
|
|
self.enrollment.refresh_from_db()
|
|
self.assertIsNotNone(self.enrollment.completed_at)
|
|
|
|
def test_uncomplete_clears_enrollment_completion(self):
|
|
for l in self.lessons:
|
|
self.client.post(f"/api/me/lessons/{l.id}/complete/")
|
|
self.enrollment.refresh_from_db()
|
|
self.assertIsNotNone(self.enrollment.completed_at)
|
|
# un-complete one
|
|
r = self.client.post(f"/api/me/lessons/{self.lessons[0].id}/uncomplete/")
|
|
self.assertEqual(r.status_code, 200)
|
|
self.enrollment.refresh_from_db()
|
|
self.assertIsNone(self.enrollment.completed_at)
|
|
|
|
def test_progress_for_lesson_in_course_user_is_not_enrolled_in(self):
|
|
other_teacher = _teacher(phone="09120000050")
|
|
other_course, _, other_lessons = _course_with_lessons(other_teacher)
|
|
r = self.client.post(f"/api/me/lessons/{other_lessons[0].id}/complete/")
|
|
self.assertEqual(r.status_code, 403)
|
|
|
|
|
|
# ======================================================================== INSTRUCTOR ENROLLMENTS
|
|
|
|
class InstructorEnrollmentsTests(TestCase):
|
|
def test_instructor_sees_their_courses_enrollments(self):
|
|
client = APIClient()
|
|
teacher = _teacher()
|
|
s1 = _student(phone="09120000091")
|
|
s2 = _student(phone="09120000092")
|
|
course, _, _ = _course_with_lessons(teacher)
|
|
Enrollment.objects.create(student=s1, course=course)
|
|
Enrollment.objects.create(student=s2, course=course)
|
|
client.force_authenticate(teacher)
|
|
r = client.get(f"/api/instructor/courses/{course.id}/enrollments/")
|
|
self.assertEqual(r.status_code, 200, r.content)
|
|
self.assertEqual(len(r.json()), 2)
|
|
|
|
def test_instructor_cannot_see_others_enrollments(self):
|
|
client = APIClient()
|
|
owner = _teacher(phone="09120000010")
|
|
intruder = _teacher(phone="09120000011")
|
|
course, _, _ = _course_with_lessons(owner)
|
|
Enrollment.objects.create(student=_student(), course=course)
|
|
client.force_authenticate(intruder)
|
|
r = client.get(f"/api/instructor/courses/{course.id}/enrollments/")
|
|
self.assertEqual(r.status_code, 403)
|
|
|
|
|
|
# ======================================================================== ANNOUNCEMENTS
|
|
|
|
class AnnouncementAPITests(TestCase):
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.teacher = _teacher()
|
|
self.student = _student()
|
|
self.course, _, _ = _course_with_lessons(self.teacher)
|
|
|
|
def test_instructor_creates_announcement(self):
|
|
self.client.force_authenticate(self.teacher)
|
|
r = self.client.post(
|
|
"/api/instructor/announcements/",
|
|
{"course": self.course.id, "body": "Welcome!"},
|
|
format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 201, r.content)
|
|
a = Announcement.objects.get(course=self.course)
|
|
self.assertEqual(a.posted_by, self.teacher)
|
|
|
|
def test_instructor_cannot_post_on_others_course(self):
|
|
intruder = _teacher(phone="09120000050")
|
|
self.client.force_authenticate(intruder)
|
|
r = self.client.post(
|
|
"/api/instructor/announcements/",
|
|
{"course": self.course.id, "body": "Sneaky"},
|
|
format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 403)
|
|
|
|
def test_enrolled_student_reads_announcements(self):
|
|
Enrollment.objects.create(student=self.student, course=self.course)
|
|
Announcement.objects.create(course=self.course, body="Hello", posted_by=self.teacher)
|
|
self.client.force_authenticate(self.student)
|
|
r = self.client.get(f"/api/me/courses/{self.course.slug}/announcements/")
|
|
self.assertEqual(r.status_code, 200, r.content)
|
|
body = r.json()
|
|
self.assertEqual(len(body), 1)
|
|
self.assertEqual(body[0]["body"], "Hello")
|
|
|
|
def test_non_enrolled_student_blocked(self):
|
|
Announcement.objects.create(course=self.course, body="Hello", posted_by=self.teacher)
|
|
self.client.force_authenticate(self.student)
|
|
r = self.client.get(f"/api/me/courses/{self.course.slug}/announcements/")
|
|
self.assertEqual(r.status_code, 403)
|
|
|
|
|
|
# ============================================================ ENGAGEMENT STATUS
|
|
|
|
class EngagementStatusTests(TestCase):
|
|
def setUp(self):
|
|
self.teacher = _teacher()
|
|
self.course, _, _ = _course_with_lessons(self.teacher)
|
|
|
|
def _enroll(self, student, completed=False):
|
|
e = Enrollment.objects.create(student=student, course=self.course)
|
|
if completed:
|
|
e.completed_at = timezone.now()
|
|
e.save()
|
|
return e
|
|
|
|
def test_completed_overrides_activity(self):
|
|
s = _student(phone="09120000010")
|
|
e = self._enroll(s, completed=True)
|
|
self.assertEqual(e.engagement_status(), EngagementStatus.COMPLETED.value)
|
|
|
|
def test_active_when_recent_activity(self):
|
|
s = _student(phone="09120000011")
|
|
s.last_active_at = timezone.now() - timedelta(days=2)
|
|
s.save()
|
|
e = self._enroll(s)
|
|
self.assertEqual(e.engagement_status(), EngagementStatus.ACTIVE.value)
|
|
|
|
def test_at_risk_after_7_days(self):
|
|
s = _student(phone="09120000012")
|
|
s.last_active_at = timezone.now() - timedelta(days=10)
|
|
s.save()
|
|
e = self._enroll(s)
|
|
self.assertEqual(e.engagement_status(), EngagementStatus.AT_RISK.value)
|
|
|
|
def test_inactive_after_14_days(self):
|
|
s = _student(phone="09120000013")
|
|
s.last_active_at = timezone.now() - timedelta(days=20)
|
|
s.save()
|
|
e = self._enroll(s)
|
|
self.assertEqual(e.engagement_status(), EngagementStatus.INACTIVE.value)
|
|
|
|
def test_null_last_active_treated_as_active(self):
|
|
s = _student(phone="09120000014")
|
|
e = self._enroll(s)
|
|
self.assertEqual(e.engagement_status(), EngagementStatus.ACTIVE.value)
|
|
|
|
|
|
# ============================================================ ACTIVITY EVENTS
|
|
|
|
class ActivityEventRecordingTests(TestCase):
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.teacher = _teacher()
|
|
self.student = _student()
|
|
self.course, _, self.lessons = _course_with_lessons(self.teacher, n_lessons=2)
|
|
|
|
def test_free_enrollment_records_event(self):
|
|
# the course was created with paid_course default, but _course_with_lessons doesn't set price
|
|
# so it's free
|
|
self.client.force_authenticate(self.student)
|
|
r = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
|
|
self.assertEqual(r.status_code, 201)
|
|
events = ActivityEvent.objects.filter(course=self.course, kind=ActivityKind.ENROLLED.value)
|
|
self.assertEqual(events.count(), 1)
|
|
self.assertEqual(events.first().payload["via"], "free")
|
|
|
|
def test_lesson_completion_records_event(self):
|
|
Enrollment.objects.create(student=self.student, course=self.course)
|
|
self.client.force_authenticate(self.student)
|
|
r = self.client.post(f"/api/me/lessons/{self.lessons[0].id}/complete/")
|
|
self.assertEqual(r.status_code, 200)
|
|
e = ActivityEvent.objects.filter(kind=ActivityKind.LESSON_COMPLETED.value).first()
|
|
self.assertIsNotNone(e)
|
|
self.assertEqual(e.payload["lesson_id"], self.lessons[0].id)
|
|
|
|
def test_completing_all_lessons_records_course_completed(self):
|
|
Enrollment.objects.create(student=self.student, course=self.course)
|
|
self.client.force_authenticate(self.student)
|
|
for l in self.lessons:
|
|
self.client.post(f"/api/me/lessons/{l.id}/complete/")
|
|
completed_events = ActivityEvent.objects.filter(
|
|
kind=ActivityKind.COURSE_COMPLETED.value
|
|
)
|
|
self.assertEqual(completed_events.count(), 1)
|
|
|
|
def test_announcement_post_records_event(self):
|
|
self.client.force_authenticate(self.teacher)
|
|
self.client.post(
|
|
"/api/instructor/announcements/",
|
|
{"course": self.course.id, "body": "Welcome"},
|
|
format="json",
|
|
)
|
|
e = ActivityEvent.objects.filter(kind=ActivityKind.ANNOUNCEMENT_POSTED.value).first()
|
|
self.assertIsNotNone(e)
|
|
self.assertIn("Welcome", e.payload["preview"])
|
|
|
|
def test_paid_checkout_records_enrolled_and_paid(self):
|
|
from django.test import override_settings
|
|
|
|
paid = Course.objects.create(
|
|
instructor=self.teacher, title="Paid", tagline="T", description="D",
|
|
level="beginner", language="fa", price_toman=1000,
|
|
)
|
|
paid.publish()
|
|
with override_settings(PAYMENT_BACKEND="apps.payments.services.gateway.ConsolePaymentBackend"):
|
|
self.client.force_authenticate(self.student)
|
|
co = self.client.post(f"/api/courses/{paid.slug}/checkout/").json()
|
|
self.client.get(
|
|
f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}"
|
|
)
|
|
kinds = list(
|
|
ActivityEvent.objects.filter(course=paid).values_list("kind", flat=True)
|
|
)
|
|
self.assertIn(ActivityKind.ENROLLED.value, kinds)
|
|
self.assertIn(ActivityKind.PAID.value, kinds)
|
|
|
|
|
|
# ============================================================ ANALYTICS ENDPOINTS
|
|
|
|
class InstructorAnalyticsTests(TestCase):
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.teacher = _teacher()
|
|
self.student = _student()
|
|
self.course, _, self.lessons = _course_with_lessons(self.teacher, n_lessons=4)
|
|
|
|
def test_kpis_blocked_for_non_teacher(self):
|
|
self.client.force_authenticate(self.student)
|
|
r = self.client.get("/api/instructor/kpis/")
|
|
self.assertEqual(r.status_code, 403)
|
|
|
|
def test_kpis_returns_four_cards(self):
|
|
self.client.force_authenticate(self.teacher)
|
|
r = self.client.get("/api/instructor/kpis/")
|
|
self.assertEqual(r.status_code, 200, r.content)
|
|
body = r.json()
|
|
ids = [c["id"] for c in body]
|
|
self.assertEqual(set(ids), {"revenue", "active_students", "avg_progress", "satisfaction"})
|
|
for card in body:
|
|
self.assertIn("value", card)
|
|
self.assertIn("spark", card)
|
|
self.assertEqual(len(card["spark"]), 12)
|
|
|
|
def test_kpi_revenue_reflects_paid_orders(self):
|
|
# Create a paid order this month
|
|
Order.objects.create(
|
|
student=self.student, course=self.course,
|
|
amount_toman=1_000_000, ilo_fee_toman=150_000, instructor_share_toman=850_000,
|
|
commission_percent_snapshot=15, gateway=Gateway.CONSOLE,
|
|
status=OrderStatus.PAID, paid_at=timezone.now(),
|
|
)
|
|
self.client.force_authenticate(self.teacher)
|
|
r = self.client.get("/api/instructor/kpis/")
|
|
revenue_card = next(c for c in r.json() if c["id"] == "revenue")
|
|
self.assertEqual(revenue_card["value"], 850_000)
|
|
|
|
def test_kpi_avg_progress_from_enrollments(self):
|
|
# one enrollment with 50% progress
|
|
e = Enrollment.objects.create(student=self.student, course=self.course)
|
|
for l in self.lessons[:2]:
|
|
LessonProgress.objects.create(
|
|
enrollment=e, lesson=l, completed_at=timezone.now()
|
|
)
|
|
self.client.force_authenticate(self.teacher)
|
|
r = self.client.get("/api/instructor/kpis/")
|
|
avg_card = next(c for c in r.json() if c["id"] == "avg_progress")
|
|
self.assertEqual(avg_card["value"], 50)
|
|
|
|
def test_monthly_revenue_returns_12_months(self):
|
|
self.client.force_authenticate(self.teacher)
|
|
r = self.client.get("/api/instructor/revenue/monthly/")
|
|
self.assertEqual(r.status_code, 200, r.content)
|
|
body = r.json()
|
|
self.assertEqual(len(body), 12)
|
|
for row in body:
|
|
self.assertIn("month", row)
|
|
self.assertIn("revenue_toman", row)
|
|
self.assertIn("orders_count", row)
|
|
|
|
def test_activity_feed_lists_events_for_my_courses_only(self):
|
|
# make 2 events for my course
|
|
from apps.enrollments.services.events import record_event
|
|
|
|
record_event(course=self.course, actor=self.student, kind=ActivityKind.ENROLLED.value)
|
|
record_event(course=self.course, actor=self.student, kind=ActivityKind.LESSON_COMPLETED.value)
|
|
# make 1 event on someone else's course
|
|
other_t = _teacher(phone="09120000050")
|
|
other_course, _, _ = _course_with_lessons(other_t)
|
|
record_event(course=other_course, actor=self.student, kind=ActivityKind.ENROLLED.value)
|
|
|
|
self.client.force_authenticate(self.teacher)
|
|
r = self.client.get("/api/instructor/activity/")
|
|
self.assertEqual(r.status_code, 200)
|
|
results = r.json()["results"]
|
|
self.assertEqual(len(results), 2)
|
|
self.assertTrue(all(row["course"] == self.course.id for row in results))
|
|
|
|
def test_activity_feed_filter_by_kind(self):
|
|
from apps.enrollments.services.events import record_event
|
|
|
|
record_event(course=self.course, actor=self.student, kind=ActivityKind.ENROLLED.value)
|
|
record_event(course=self.course, actor=self.student, kind=ActivityKind.LESSON_COMPLETED.value)
|
|
self.client.force_authenticate(self.teacher)
|
|
r = self.client.get("/api/instructor/activity/?kind=enrolled")
|
|
results = r.json()["results"]
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(results[0]["kind"], "enrolled")
|
|
|
|
|
|
# ============================================================ ENGAGEMENT FILTER
|
|
|
|
class CourseEnrollmentsCRMTests(TestCase):
|
|
def test_engagement_status_in_payload_and_filter(self):
|
|
client = APIClient()
|
|
teacher = _teacher()
|
|
course, _, _ = _course_with_lessons(teacher)
|
|
|
|
# an active student
|
|
a = _student(phone="09120000091")
|
|
a.last_active_at = timezone.now()
|
|
a.save()
|
|
Enrollment.objects.create(student=a, course=course)
|
|
|
|
# an at-risk student
|
|
r_student = _student(phone="09120000092")
|
|
r_student.last_active_at = timezone.now() - timedelta(days=10)
|
|
r_student.save()
|
|
Enrollment.objects.create(student=r_student, course=course)
|
|
|
|
client.force_authenticate(teacher)
|
|
r = client.get(f"/api/instructor/courses/{course.id}/enrollments/")
|
|
self.assertEqual(r.status_code, 200)
|
|
rows = r.json()
|
|
statuses = sorted(row["engagement_status"] for row in rows)
|
|
self.assertEqual(statuses, ["active", "at_risk"])
|
|
|
|
r = client.get(f"/api/instructor/courses/{course.id}/enrollments/?engagement_status=at_risk")
|
|
rows = r.json()
|
|
self.assertEqual(len(rows), 1)
|
|
self.assertEqual(rows[0]["student_id"], r_student.id)
|