- CompletionReportView + CSV: enrollment-level completion status, filterable by course_id, org_id, date_from, date_to - ProgressReportView + CSV: page-level dwell-time aggregates, same filters - AttemptReportView + CSV: quiz attempt scores, pass/fail, timestamps; org filter joins through Enrollment.org_id; course filter traverses quiz -> page -> lesson -> module -> course - Streaming CSV responses with _EchoWriter to avoid buffering large exports - pytest test suite covering filters, aggregation accuracy, and CSV format Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
222 lines
8.3 KiB
Python
222 lines
8.3 KiB
Python
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
from django.utils.timezone import now
|
|
from rest_framework import status
|
|
from rest_framework.test import APIClient
|
|
|
|
from accounts.tests.factories import AccountUserFactory
|
|
from cms.tests.factories import CourseFactory, LessonFactory, ModuleFactory, PageFactory
|
|
from tracking.models import Enrollment, PageProgress
|
|
|
|
|
|
@pytest.fixture
|
|
def auth_client():
|
|
user = AccountUserFactory()
|
|
client = APIClient()
|
|
client.force_authenticate(user=user)
|
|
return client
|
|
|
|
|
|
def _make_enrollment(user=None, course=None, org_id=None, completed=False):
|
|
if user is None:
|
|
user = AccountUserFactory()
|
|
if course is None:
|
|
course = CourseFactory()
|
|
if org_id is None:
|
|
org_id = uuid.uuid4()
|
|
e = Enrollment.objects.create(
|
|
user=user,
|
|
course=course,
|
|
org_id=org_id,
|
|
completed_at=now() if completed else None,
|
|
)
|
|
return e
|
|
|
|
|
|
# ── Completion Report ──────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.django_db
|
|
class TestCompletionReport:
|
|
url = "/api/v1/reports/completion/"
|
|
|
|
def test_unauthenticated_returns_401(self):
|
|
resp = APIClient().get(self.url)
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
|
|
|
def test_returns_all_enrollments(self, auth_client):
|
|
_make_enrollment()
|
|
_make_enrollment(completed=True)
|
|
resp = auth_client.get(self.url)
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert len(resp.data) >= 2
|
|
|
|
def test_filter_by_course(self, auth_client):
|
|
course = CourseFactory()
|
|
_make_enrollment(course=course)
|
|
_make_enrollment() # different course
|
|
resp = auth_client.get(self.url, {"course_id": str(course.id)})
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert len(resp.data) == 1
|
|
assert resp.data[0]["course_title"] == course.title
|
|
|
|
def test_filter_by_org_id(self, auth_client):
|
|
org = uuid.uuid4()
|
|
_make_enrollment(org_id=org)
|
|
_make_enrollment() # different org
|
|
resp = auth_client.get(self.url, {"org_id": str(org)})
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert len(resp.data) == 1
|
|
assert resp.data[0]["org_id"] == str(org)
|
|
|
|
def test_completion_flag_reflects_completed_at(self, auth_client):
|
|
user = AccountUserFactory()
|
|
course = CourseFactory()
|
|
org = uuid.uuid4()
|
|
_make_enrollment(user=user, course=course, org_id=org, completed=True)
|
|
resp = auth_client.get(self.url, {"course_id": str(course.id)})
|
|
assert resp.data[0]["is_complete"] is True
|
|
assert resp.data[0]["completed_at"] is not None
|
|
|
|
def test_incomplete_enrollment_flag_false(self, auth_client):
|
|
user = AccountUserFactory()
|
|
course = CourseFactory()
|
|
org = uuid.uuid4()
|
|
_make_enrollment(user=user, course=course, org_id=org, completed=False)
|
|
resp = auth_client.get(self.url, {"course_id": str(course.id)})
|
|
assert resp.data[0]["is_complete"] is False
|
|
assert resp.data[0]["completed_at"] is None
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestCompletionReportCSV:
|
|
url = "/api/v1/reports/completion/csv/"
|
|
|
|
def test_returns_csv_content_type(self, auth_client):
|
|
_make_enrollment()
|
|
resp = auth_client.get(self.url)
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert "text/csv" in resp["Content-Type"]
|
|
|
|
def test_csv_header_row(self, auth_client):
|
|
resp = auth_client.get(self.url)
|
|
content = b"".join(resp.streaming_content).decode()
|
|
assert "enrollment_id" in content
|
|
assert "user_email" in content
|
|
assert "is_complete" in content
|
|
|
|
def test_csv_has_data_rows(self, auth_client):
|
|
_make_enrollment(completed=True)
|
|
resp = auth_client.get(self.url)
|
|
content = b"".join(resp.streaming_content).decode()
|
|
lines = [l for l in content.strip().splitlines() if l]
|
|
assert len(lines) >= 2 # header + at least one data row
|
|
|
|
|
|
# ── Progress Report ────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.django_db
|
|
class TestProgressReport:
|
|
url = "/api/v1/reports/progress/"
|
|
|
|
def test_returns_page_progress_rows(self, auth_client):
|
|
enrollment = _make_enrollment()
|
|
page = PageFactory()
|
|
PageProgress.objects.create(
|
|
enrollment=enrollment,
|
|
page=page,
|
|
accumulated_seconds=120,
|
|
is_complete=True,
|
|
)
|
|
resp = auth_client.get(self.url)
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert any(r["accumulated_seconds"] == 120 for r in resp.data)
|
|
|
|
def test_filter_by_course(self, auth_client):
|
|
course = CourseFactory()
|
|
enrollment = _make_enrollment(course=course)
|
|
lesson = LessonFactory(module=ModuleFactory(course=course))
|
|
page = PageFactory(lesson=lesson)
|
|
PageProgress.objects.create(enrollment=enrollment, page=page, accumulated_seconds=60)
|
|
|
|
other_enrollment = _make_enrollment() # different course
|
|
other_page = PageFactory()
|
|
PageProgress.objects.create(enrollment=other_enrollment, page=other_page, accumulated_seconds=30)
|
|
|
|
resp = auth_client.get(self.url, {"course_id": str(course.id)})
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert len(resp.data) == 1
|
|
assert resp.data[0]["course_title"] == course.title
|
|
|
|
def test_csv_export(self, auth_client):
|
|
enrollment = _make_enrollment()
|
|
page = PageFactory()
|
|
PageProgress.objects.create(enrollment=enrollment, page=page, accumulated_seconds=45)
|
|
resp = auth_client.get("/api/v1/reports/progress/csv/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
content = b"".join(resp.streaming_content).decode()
|
|
assert "accumulated_seconds" in content
|
|
|
|
|
|
# ── Attempt Report ─────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.django_db
|
|
class TestAttemptReport:
|
|
url = "/api/v1/reports/attempts/"
|
|
|
|
def _make_attempt(self, user=None, is_passed=True, score=85.0):
|
|
from quizzes.models import Quiz, QuizAttempt, AttemptStatus
|
|
if user is None:
|
|
user = AccountUserFactory()
|
|
quiz = Quiz.objects.create(title="Test Quiz")
|
|
attempt = QuizAttempt.objects.create(
|
|
quiz=quiz,
|
|
user=user,
|
|
attempt_number=1,
|
|
seed=42,
|
|
score_percent=score,
|
|
is_passed=is_passed,
|
|
status=AttemptStatus.SUBMITTED,
|
|
submitted_at=now(),
|
|
)
|
|
return attempt
|
|
|
|
def test_returns_attempt_rows(self, auth_client):
|
|
self._make_attempt()
|
|
resp = auth_client.get(self.url)
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert len(resp.data) >= 1
|
|
|
|
def test_score_and_pass_flag_present(self, auth_client):
|
|
user = AccountUserFactory()
|
|
self._make_attempt(user=user, is_passed=False, score=45.0)
|
|
resp = auth_client.get(self.url)
|
|
row = next(r for r in resp.data if r["user_email"] == user.email)
|
|
assert row["score_percent"] == 45.0
|
|
assert row["is_passed"] is False
|
|
|
|
def test_filter_by_org_id(self, auth_client):
|
|
org = uuid.uuid4()
|
|
user = AccountUserFactory()
|
|
course = CourseFactory()
|
|
Enrollment.objects.create(user=user, course=course, org_id=org)
|
|
self._make_attempt(user=user)
|
|
|
|
other_user = AccountUserFactory()
|
|
self._make_attempt(user=other_user)
|
|
|
|
resp = auth_client.get(self.url, {"org_id": str(org)})
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
emails = [r["user_email"] for r in resp.data]
|
|
assert user.email in emails
|
|
assert other_user.email not in emails
|
|
|
|
def test_csv_export(self, auth_client):
|
|
self._make_attempt()
|
|
resp = auth_client.get("/api/v1/reports/attempts/csv/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
content = b"".join(resp.streaming_content).decode()
|
|
assert "quiz_title" in content
|
|
assert "score_percent" in content
|