Summary
The synchronous CCX coach grade report (ccx_grades_csv in lms/djangoapps/ccx/views.py) reads persisted course/subsection grades one student at a time, producing an N+1 query pattern that makes the download slow and worker-blocking for larger classes. The asynchronous course grade report
(CourseGradeReport in lms/djangoapps/instructor_task/tasks_helper/grades.py) already avoids this by bulk-prefetching grades for the whole cohort. This issue proposes bringing the same, existing optimization to the CCX report.
Affected component
- App:
lms/djangoapps/ccx
- View/endpoint:
ccx_grades_csv → GET /courses/{ccx_id}/ccx_grades.csv
(CCX Coach dashboard → Student Admin → “Download student grades”)
Current behavior
ccx_grades_csv iterates enrolled students with CourseGradeFactory().iter(enrolled_students, course) and, for each student, reads their persisted subsection grades individually (via SubsectionGradeFactory → bulk_read_grades). Because grades are not prefetched for the whole class, each student triggers a separate DB read. There is also no modulestore().bulk_operations(...) wrapper around the iteration.
As a result, the number of grade-related queries grows linearly with the number of enrolled learners, and the report runs entirely inside the HTTP request, holding a web worker until it completes.
Steps to reproduce
- Create a CCX with a non-trivial number of enrolled learners who have attempted graded content (so persisted grades exist).
- As the CCX coach, open Student Admin and click “Download student grades” (or call
GET /courses/{ccx_id}/ccx_grades.csv).
- Observe (e.g. with Django Debug Toolbar / query logging) that the number of grade-related queries scales with the number of enrolled learners, and that response time grows with class size.
Expected behavior
The grade-related query count should be independent of the number of enrolled learners (a single bulk read), and the download should be fast for typical class sizes — matching how CourseGradeReport behaves.
Impact
- Slow, sometimes timing-out downloads for CCX coaches on larger classes.
- One web worker held for the full duration of each synchronous download.
- Inconsistent performance characteristics between the CCX grade report and the
standard course grade report, despite reading the same persisted data.
Proposed solution
In ccx_grades_csv, before iterating over students:
- Materialize the enrolled-students queryset to a list.
- Call
prefetch_course_and_subsection_grades(course_key, users) (lms/djangoapps/grades/api) to bulk-load persisted course and subsection grades once for the whole class.
- Wrap the grade iteration in
modulestore().bulk_operations(course_key).
This mirrors CourseGradeReport._rows_for_users / _CourseGradeBulkContext and reuses existing, already-blessed helpers. The CSV output is unchanged — this is a pure performance improvement (the report already reads from the persisted grade tables; only the read is made bulk instead of per-student).
Backward compatibility / risk
- No change to report contents or format; output is byte-for-byte identical.
- No schema/data migration.
- Uses the same prefetch/bulk-operations pattern already relied on by the
asynchronous grade report.
Additional bug found: email and username columns contain a bytes repr
While working on this report I found a second, related defect in the same view that is worth fixing together with the performance issue.
Current behavior
The email and username columns of the downloaded CSV contain the Python bytes representation instead of the value, e.g.:
id,email,username,grade,HW 01,...
42,b'learner@example.com',b'learner',0.75,...
Cause
ccx_grades_csv builds each row with:
rows.append([student.id, student.email.encode('utf-8'),
student.username.encode('utf-8'),
course_grade.percent] + row_percents)
On Python 3, .encode('utf-8') returns bytes, and csv.writer serializes a bytes object using its repr() — hence the b'...' prefix. The .encode() calls are a Python 2 leftover; csv.writer handles unicode strings natively. Note that student.id and the header row are not encoded, which is why only these two columns are affected.
Steps to reproduce
As a CCX coach, go to the CCX Coach dashboard → Student Admin → "Download student grades". Open the CSV and inspect the email and username columns. Expected behavior
Both columns should contain the plain values (learner@example.com, learner), including for usernames containing non-ASCII characters.
Proposed fix
Drop the two .encode('utf-8') calls. This also covers the unicode-username case that originally motivated them, since csv.writer encodes correctly on its own.
Related (optional, could be a separate issue/PR)
ccx_grades_csv sets Content-Disposition: attachment without a filename, so every CCX downloads as a generic ccx_grades.csv. Using the platform’s existing course_filename_prefix_generator to produce a descriptive, timestamped filename (e.g. <course_prefix>_grade_report_<YYYY-MM-DD-HHMM>.csv) would make it consistent with CourseGradeReport downloads. Happy to include this or split it out.
Environment
- Open edX release: <e.g. Ulmo / master>
- Deployment: <devstack / tutor / production>
Willingness to contribute
We have implemented this change (including tests: an updated test_grades_csv and a new test asserting a single bulk prefetch covers all enrolled students) and are willing to open a PR.
Summary
The synchronous CCX coach grade report (
ccx_grades_csvinlms/djangoapps/ccx/views.py) reads persisted course/subsection grades one student at a time, producing an N+1 query pattern that makes the download slow and worker-blocking for larger classes. The asynchronous course grade report(
CourseGradeReportinlms/djangoapps/instructor_task/tasks_helper/grades.py) already avoids this by bulk-prefetching grades for the whole cohort. This issue proposes bringing the same, existing optimization to the CCX report.Affected component
lms/djangoapps/ccxccx_grades_csv→GET /courses/{ccx_id}/ccx_grades.csv(CCX Coach dashboard → Student Admin → “Download student grades”)
Current behavior
ccx_grades_csviterates enrolled students withCourseGradeFactory().iter(enrolled_students, course)and, for each student, reads their persisted subsection grades individually (viaSubsectionGradeFactory→bulk_read_grades). Because grades are not prefetched for the whole class, each student triggers a separate DB read. There is also nomodulestore().bulk_operations(...)wrapper around the iteration.As a result, the number of grade-related queries grows linearly with the number of enrolled learners, and the report runs entirely inside the HTTP request, holding a web worker until it completes.
Steps to reproduce
GET /courses/{ccx_id}/ccx_grades.csv).Expected behavior
The grade-related query count should be independent of the number of enrolled learners (a single bulk read), and the download should be fast for typical class sizes — matching how
CourseGradeReportbehaves.Impact
standard course grade report, despite reading the same persisted data.
Proposed solution
In
ccx_grades_csv, before iterating over students:prefetch_course_and_subsection_grades(course_key, users)(lms/djangoapps/grades/api) to bulk-load persisted course and subsection grades once for the whole class.modulestore().bulk_operations(course_key).This mirrors
CourseGradeReport._rows_for_users/_CourseGradeBulkContextand reuses existing, already-blessed helpers. The CSV output is unchanged — this is a pure performance improvement (the report already reads from the persisted grade tables; only the read is made bulk instead of per-student).Backward compatibility / risk
asynchronous grade report.
Additional bug found:
emailandusernamecolumns contain a bytes reprWhile working on this report I found a second, related defect in the same view that is worth fixing together with the performance issue.
Current behavior
The
emailandusernamecolumns of the downloaded CSV contain the Python bytes representation instead of the value, e.g.:id,email,username,grade,HW 01,...
42,b'learner@example.com',b'learner',0.75,...
Cause
ccx_grades_csvbuilds each row with:On Python 3, .encode('utf-8') returns bytes, and csv.writer serializes a bytes object using its repr() — hence the b'...' prefix. The .encode() calls are a Python 2 leftover; csv.writer handles unicode strings natively. Note that student.id and the header row are not encoded, which is why only these two columns are affected.
Steps to reproduce
As a CCX coach, go to the CCX Coach dashboard → Student Admin → "Download student grades". Open the CSV and inspect the email and username columns. Expected behavior
Both columns should contain the plain values (learner@example.com, learner), including for usernames containing non-ASCII characters.
Proposed fix
Drop the two .encode('utf-8') calls. This also covers the unicode-username case that originally motivated them, since csv.writer encodes correctly on its own.
Related (optional, could be a separate issue/PR)
ccx_grades_csvsetsContent-Disposition: attachmentwithout a filename, so every CCX downloads as a genericccx_grades.csv. Using the platform’s existingcourse_filename_prefix_generatorto produce a descriptive, timestamped filename (e.g.<course_prefix>_grade_report_<YYYY-MM-DD-HHMM>.csv) would make it consistent withCourseGradeReportdownloads. Happy to include this or split it out.Environment
Willingness to contribute
We have implemented this change (including tests: an updated
test_grades_csvand a new test asserting a single bulk prefetch covers all enrolled students) and are willing to open a PR.