Files
training-software/reports/tests/test_reports.py
Paperclip CTO a27663e6fc
Some checks failed
CI / Tests (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / OpenAPI Schema (push) Has been cancelled
feat(TRA-244): Reporting and CSV export for completion, progress, and quiz attempts
- 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>
2026-05-07 09:31:31 +02:00

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