Add export_learner_data command for tenant off-boarding backup - #756
Add export_learner_data command for tenant off-boarding backup#756ZamanChaudhary wants to merge 6 commits into
Conversation
Batches SELECTs and streams JSON writes row-by-row instead of loading whole tables into memory, since courseware_studentmodule and similar tables can be millions of rows for a real tenant. Also drops auth_userprofile.allow_certificate (a column Ulmo's schema removes entirely) so exported bundles are importable there, locks bundle directories/files down to 0700/0600 since they carry password hashes and PII, and warns when a tenant resolves users but zero courses so an empty backup doesn't look like a successful one.
Waleed-Mujahid
left a comment
There was a problem hiding this comment.
Review Council Report: PR #756 — export_learner_data
Three-lens review ran (correctness, security, openedx) plus cross-reference against a proven production tenant backup SQL script (55 tables, used for an actual Edly client off-boarding).
Bottom line: Implementation is well-engineered, but TABLE_SCOPE is ~75% incomplete vs. what a production backup proved necessary for complete tenant data export. Requesting changes.
Blocker (must fix)
| # | Finding |
|---|---|
| B1 | Missing submissions/assessment chain (ORA data silently lost) — 12 tables omitted: submissions_studentitem, submissions_submission, submissions_score, assessment_assessment, assessment_assessmentfeedback, assessment_assessmentfeedback_assessments, assessment_assessmentfeedback_options, assessment_peerworkflow, assessment_peerworkflowitem, assessment_staffworkflow, assessment_studenttrainingworkflow, assessment_studenttrainingworkflowitem, problem_builder_answer. Scope chain through student_anonymoususerid → submissions_studentitem.student_id. The export command handles student_anonymoususerid already, so the join chain is buildable. Any tenant using ORA = all learner submissions, scores, peer+staff assessments non-restorable. |
| B2 | Missing social_auth_usersocialauth (SSO login breaks on restore) — provider/uid/extra_data per user not exported. Restored users can't authenticate through social/SSO provider. |
| B3 | Missing proctoring/verification tables — proctoring_proctoredexamstudentattempt, verify_student_softwaresecurephotoverification, verify_student_manualverification, verify_student_ssoverification omitted. Proctored exam attempt records = complete data loss. |
| B4 | Missing edly_edlyuserprofile — is_blocked/is_social_user flags per user lost. After restore: blocked users unblocked, social-user flag reset. |
| B5 | Missing student_courseaccessrole — course staff/instructor/CCX coach role assignments not exported. Tenant loses all course-level access control on restore. |
Should-Fix
| # | Finding |
|---|---|
| S1 | 15 user-scoped tables missing (all easy: table, user_id, needs_course_filter=False) — student_userattribute, user_api_userpreference, user_api_usercoursetag, external_user_ids_externalid, edly_twofactorbypass, courseware_xmodulestudentinfofield, courseware_xmodulestudentprefsfield, edly_studentcourseprogress, bookmarks_bookmark, course_goals_coursegoal, milestones_usermilestone, edx_when_userdate, lti_consumer_ltiagsscore, teams_courseteammembership, django_comment_client_role_users, courseware_studentfieldoverride. No new infrastructure needed. |
| S2 | Figures analytics tables missing — figures_enrollmentdata, figures_learnercoursegrademetrics. Scope by user_id + site_id + course_id. |
| S3 | kwl/journal xblock content missing — kwl_djangoapp_kwlmodel.user and journal_djangoapp_journalmodel.user are CHAR columns, not FK ints. Production backup SQL: WHERE user IN (SELECT DISTINCT CAST(user_id AS CHAR) COLLATE utf8mb4_unicode_ci FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id). |
| S4 | Course scoping differs from production backup — production backup used course_id LIKE 'course-v1:%' (no org filter). PR uses CourseOverview.objects.filter(org__in=orgs). Cross-org enrollments silently dropped. This may be correct but is undocumented. |
| S5 | grades_visibleblocks missing — visible-blocks JSON needed for grade reconstruction after restore. |
| S6 | certificates_certificateinvalidation missing — cert invalidation records lost (FK through generated_certificate_id). |
| S7 | User resolution fallback may leak cross-tenant data — when EdlyMultiSiteAccess is empty, fallback to CourseEnrollment by course_ids can pull users from other tenants (course IDs are platform-global). Production backup never used this fallback. |
Nits
| # | Finding |
|---|---|
| N1 | student_manualenrollmentaudit included — table confirmed on Koa (has enrollment_id FK). Valid addition, just not in the original backup SQL. |
| N2 | No data checksum in MANIFEST.json — bundle integrity not verifiable downstream. |
| N3 | Default output dir under /tmp/edm_exports/ — auto-clean on many systems could delete exports. Recommend a more durable default. |
Reference: Production Backup SQL (used for actual client off-boarding)
For context, the proven SQL dump that was used for an Edly tenant data extraction:
SET @lms_domain = CONVERT('tenant.example.com' USING utf8mb4) COLLATE utf8mb4_unicode_ci;
SET @suborg_id = (SELECT eso.id FROM edly_edlysuborganization eso JOIN django_site ds ON ds.id = eso.lms_site_id WHERE ds.domain = @lms_domain LIMIT 1);
-- 1. Core user tables (user_id scope, no course filter needed)
SELECT * FROM auth_user WHERE id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM auth_userprofile WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM auth_registration WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM student_anonymoususerid WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM social_auth_usersocialauth WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM student_userattribute WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM user_api_userpreference WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM user_api_usercoursetag WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM external_user_ids_externalid WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM edly_edlyuserprofile WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM edly_twofactorbypass WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
-- 2. Enrollments & course access (user_id + course scope)
SELECT * FROM student_courseenrollment WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id) AND course_id LIKE 'course-v1:%';
SELECT * FROM student_courseenrollmentattribute WHERE enrollment_id IN (SELECT id FROM student_courseenrollment WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id) AND course_id LIKE 'course-v1:%');
SELECT * FROM student_courseaccessrole WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
-- 3. Course activity & grades (student_id/user_id + course scope)
SELECT * FROM courseware_studentmodule WHERE student_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM completion_blockcompletion WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM courseware_xmodulestudentinfofield WHERE student_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM courseware_xmodulestudentprefsfield WHERE student_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM grades_persistentcoursegrade WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM grades_persistentsubsectiongrade WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM grades_visibleblocks WHERE course_id IN (SELECT DISTINCT course_id FROM grades_persistentcoursegrade WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id));
-- 4. Certificates
SELECT * FROM certificates_generatedcertificate WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM certificates_certificateinvalidation WHERE generated_certificate_id IN (SELECT id FROM certificates_generatedcertificate WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id));
-- 5. Submissions & ORA (anonymous_user_id chain)
SELECT * FROM submissions_studentitem WHERE student_id IN (SELECT anonymous_user_id FROM student_anonymoususerid WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id));
SELECT * FROM submissions_submission WHERE student_item_id IN (SELECT id FROM submissions_studentitem WHERE student_id IN (SELECT anonymous_user_id FROM student_anonymoususerid WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id)));
SELECT * FROM submissions_score WHERE student_item_id IN (SELECT id FROM submissions_studentitem ...);
SELECT * FROM assessment_assessment WHERE submission_uuid IN (SELECT uuid FROM submissions_submission WHERE student_item_id IN (SELECT id FROM submissions_studentitem ...));
SELECT * FROM assessment_assessmentfeedback WHERE submission_uuid IN (SELECT uuid FROM submissions_submission ...);
SELECT * FROM assessment_assessmentfeedback_assessments WHERE assessmentfeedback_id IN (SELECT id FROM assessment_assessmentfeedback WHERE submission_uuid IN (...));
SELECT * FROM assessment_assessmentfeedback_options WHERE assessmentfeedback_id IN (SELECT id FROM assessment_assessmentfeedback ...);
SELECT * FROM assessment_peerworkflow WHERE submission_uuid IN (SELECT uuid FROM submissions_submission ...);
SELECT * FROM assessment_peerworkflowitem WHERE author_id IN (SELECT id FROM assessment_peerworkflow WHERE submission_uuid IN (...));
SELECT * FROM assessment_staffworkflow WHERE submission_uuid IN (SELECT uuid FROM submissions_submission ...);
SELECT * FROM assessment_studenttrainingworkflow WHERE submission_uuid IN (SELECT uuid FROM submissions_submission ...);
SELECT * FROM assessment_studenttrainingworkflowitem WHERE workflow_id IN (SELECT id FROM assessment_studenttrainingworkflow WHERE submission_uuid IN (...));
SELECT * FROM problem_builder_answer WHERE student_id IN (SELECT anonymous_user_id FROM student_anonymoususerid WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id));
-- 6. User extras
SELECT * FROM bookmarks_bookmark WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM course_goals_coursegoal WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM milestones_usermilestone WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM edx_when_userdate WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
-- 7. Verification & proctoring
SELECT * FROM verify_student_softwaresecurephotoverification WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM verify_student_manualverification WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM verify_student_ssoverification WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM proctoring_proctoredexamstudentattempt WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
-- 8. LTI, teams, forums
SELECT * FROM lti_consumer_ltiagsscore WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM teams_courseteammembership WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM django_comment_client_role_users WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
-- 9. Figures analytics
SELECT * FROM figures_enrollmentdata WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM figures_learnercoursegrademetrics WHERE user_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
-- 10. Xblock data (non-standard user column types)
SELECT * FROM kwl_djangoapp_kwlmodel WHERE user IN (SELECT DISTINCT CAST(user_id AS CHAR) COLLATE utf8mb4_0900_ai_ci FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM journal_djangoapp_journalmodel WHERE user IN (SELECT DISTINCT CAST(user_id AS CHAR) COLLATE utf8mb4_unicode_ci FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
-- 11. Progress & membership
SELECT * FROM edly_studentcourseprogress WHERE student_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM courseware_studentfieldoverride WHERE student_id IN (SELECT DISTINCT user_id FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id);
SELECT * FROM edly_edlymultisiteaccess WHERE sub_org_id = @suborg_id;The PR currently handles 14 of these 55 tables. The ORA chain (12 tables), proctoring/verification (4 tables), social auth, course roles, user preferences/attrs, and xblock data (kwl/journal) are the critical gaps.
Requesting changes per blockers B1-B5 above.
Waleed-Mujahid's review found the original export covered only 14 of the ~55 tables a real learner backup needs, and flagged that course-org filtering was silently dropping a member's out-of-tenant enrollments while an enrollment-derived user fallback could leak non-members into a tenant's export. Switches to membership-only scoping (EdlyMultiSiteAccess is now the sole tenant boundary; course-org filtering is removed from every course-scoped table) and adds the full ORA/submissions assessment chain, proctoring and ID-verification tables, social auth, course access roles, Figures analytics, and the remaining trivial user-scoped tables -- each verified against this checkout's actually-installed app versions rather than assumed. Also adds a per-file manifest checksum and moves the default output location off /tmp. Fixes a submission_uuid format mismatch found during verification: raw SQL reads of submissions_submission.uuid return the 32-char hex form Django's UUIDField stores on MySQL, while the assessment_* tables store the 36-char hyphenated form ORA2 writes via the submissions API -- without converting between them, a member's own graded submissions were silently missing from the backup.
|
Thanks for the thorough audit — the original export really did only cover a fraction of what a full learner backup needs. Here's what changed and what's still open. Fully addressed
Deliberately not done, with reasons
Every added table/column was checked against this checkout's actual pinned dependency versions rather than assumed. Happy to adjust scope on any of the "deliberately not done" items if you'd rather have them in this PR than as a fast-follow. |
Waleed-Mujahid
left a comment
There was a problem hiding this comment.
Adversarial review — PR #756 export_learner_data
Reviewed feat/export-learner-data @ 1d60ad9c (1038 lines command + 940 lines tests), read line by line, with claims verified against the Koa checkout rather than inferred. Cross-referenced against the production tenant-backup SQL from the earlier review round.
Requesting changes. Two independent problems:
- The PR solves a different problem than the one we have. The confirmed requirement is an off-boarding client asking for their learners' progress and data, delivered as CSV for non-technical staff. This command is built as an internal Koa→Ulmo restore bundle emitting per-table JSON (its own docstring: "Output bundle shape matches the Ulmo-side
import_learner_datacommand exactly"). Those two artifacts have opposite requirements, and building the second and handing it over creates both a usability problem and a data-protection problem (§1, §2). - The implementation is not safe to run against the production primary, which is where it's slated to run. One defect is a non-terminating loop that fills the disk (§3); one fails outright at real tenant scale (§4); the DB access pattern has no snapshot isolation and targets the writer (§5, §6).
The engineering care here is real — the streaming writer, the os.open mode handling, the MTI and submission_uuid findings the author caught independently are all good work. Flagging one thing about the docstrings specifically: they're unusually thorough, which makes it easy to trust them, and in at least two places (§3, §9) they confidently describe behaviour the code does not implement. That's worth calling out because it affected how this PR was reviewed the first time round.
§1 — BLOCKER: the bundle ships credentials and live third-party tokens to a departing client
Given the artifact goes to the client, the following are in it, in cleartext:
| Column | What it is |
|---|---|
auth_user.password |
password hashes |
auth_registration.activation_key |
can be used to activate accounts |
social_auth_usersocialauth.extra_data |
OAuth access + refresh tokens, plaintext JSON |
edly_twofactorbypass |
2FA bypass records |
verify_student_photoverification.photo_id_key |
key protecting the learner's government-ID photo |
auth_userprofile |
full name, year of birth, gender, mailing address, phone |
Handing a departing third party live OAuth refresh tokens for their learners' identity-provider accounts, plus password hashes, is not something we can do regardless of how well the export code is written. The file permissions work (0700/0600 created via os.open with an explicit mode, correctly avoiding the umask window — genuinely well done), but permissions protect the file on our disk, not the contents after handover.
Also absent: encryption at rest, any redaction mode, any --include-secrets gate, an audit record of who ran it / row counts / destination (stdout only), and a retention/destruction step.
Required: a client-facing export must never include password, activation_key, extra_data, photo_id_key, or edly_twofactorbypass. Default-deny, with an explicit flag if an internal-restore variant ever needs them.
§2 — BLOCKER: wrong output format. The client has asked for CSV, not JSON.
The client's stated requirement is CSV — their people are non-technical and need to open this in Excel or Sheets. This command emits JSON, and specifically a shape that is harder to consume than ordinary JSON would be.
Output is MANIFEST.json plus ~50 files at learners/<table>.json, each:
{"columns": ["id", "user_id", ...], "rows": [[1, 42, ...], [2, 43, ...]]}Row values are positional arrays keyed only by the columns header at the top of the file. To answer "what did learner X score", the client must load ~50 normalized files and self-join them on raw auth_user.id integers. Nothing but auth_user.json carries a username or email. A non-technical recipient cannot open any of this.
Note that a straight JSON→CSV swap is necessary but not sufficient: 50 CSVs that are still normalized DB tables joined on integer surrogate keys are barely more usable than 50 JSON files. What the client needs is a small number of denormalized, human-readable CSVs — one row per learner, or one row per learner-per-course — with usernames, emails, course names, grades and completion resolved inline instead of left as FK integers.
It's worse for the ORA chain, which is the actual coursework: submissions_studentitem.student_id and assessment_*.scorer_id hold md5 anonymous ids, not user ids. The client would have to join through student_anonymoususerid to resolve any of it to a person. Their learners' submitted work arrives effectively pseudonymized.
And the data the client would most expect is absent from a MySQL-only export:
- Forum/discussion content is MongoDB (
cs_comments_service) — not reachable here at all. - ORA/SGA learner file uploads live in S3.
submissions_submissionandassessment_sharedfileuploadexport the references; the objects aren't copied, so every uploaded assignment points at a file the client doesn't have.staff-graded-xblockis installed here (requirements/edx/base.txt:172), so this covers SGA. courseware_studentmodulehistoryis in a separate database (student_module_history/edxapp_csmh, perlms/envs/common.py), unreachable viaconnection— so all per-attempt history is missing.
Meanwhile the Ulmo-compat machinery is dead weight on a client deliverable, and one piece of it actively misleads: the sub_org_id → tenant_id rename emits Koa's sub-org PK under a column name that implies an Ulmo tenant id. Same for auth_user_groups.group_id. Both are plausible-looking wrong integers.
What we already have, and should use instead. Koa already produces exactly that artifact — denormalized CSV, per course, human-readable — battle-tested and exposed on the Instructor Dashboard:
calculate_grades_csv—lms/djangoapps/instructor_task/tasks.py:181— one row per learner, columns per assignment, grades resolvedcalculate_students_features_csv(student profile report) —:215— username, email, name, enrollment mode, cohortexport_ora2_data—:302— ORA submissions and scores with learners resolved, not anonymous ids- plus the problem-responses report
These already solve §2 (CSV, denormalized, readable), §1 (no credential columns), and most of §10's coverage gaps. A far smaller command that resolves the tenant's courses and drives these existing tasks gets the client a usable deliverable with none of §1–§7, and reuses code with real production mileage.
The rest applies to the export mechanics regardless of which artifact we settle on, since the same pagination/scoping code would likely be reused.
§3 — BLOCKER: non-terminating loop → disk exhaustion. Operator precedence in the keyset pagination.
_fetch_by_any_id builds a disjunctive WHERE (line 580):
where_sql = u" OR ".join(clauses) # "`a` IN (%s,...) OR `b` IN (%s,...)"_paginate appends the keyset predicate by string concatenation, without parenthesising it (lines 756-758):
batch_sql, batch_args = where_sql, list(where_args)
if last_id is not None:
batch_sql += u" AND {0} > %s".format(bt('id'))Emitted SQL is WHERE a IN (...) OR b IN (...) AND id > X. AND binds tighter than OR, so MySQL evaluates WHERE a IN (...) OR (b IN (...) AND id > X) — the first clause is unbounded on every batch after the first and re-matches from the beginning. With c1 matching id 1 and c2 matching id 2, at any batch_size:
| batch | effective WHERE | rows | last_id |
|---|---|---|---|
| 1 | c1 OR c2 |
1, 2 | 2 |
| 2 | c1 OR (c2 AND id>2) |
1 | 1 |
| 3 | c1 OR (c2 AND id>1) |
1, 2 | 2 |
| 4 | → identical to batch 2 | … | … |
batch is never empty, so if not batch: return never fires. The generator yields duplicates forever and _write_table_json streams each straight to disk, so the output file grows until the filesystem fills. On the LMS host that takes the LMS down with it.
Affected — every _fetch_by_any_id call with two non-empty pairs on a table with an id column:
assessment_assessment(submission_uuidORscorer_id) — line 660assessment_peerworkflow(submission_uuidORstudent_id) — line 674assessment_peerworkflowitem(scorer_idORauthor_id) — line 684assessment_staffworkflow(submission_uuidORscorer_id) — line 690assessment_studenttrainingworkflow(submission_uuidORstudent_id) — line 707
Trigger: a member who both submitted ORA work and peer/staff-graded. That's every real ORA course — i.e. precisely the case the disjunction was added to support in the last review round.
The test suite cannot catch this. PaginateTests (tests 681-743) only ever passes where_sql='1=1', never a disjunction, and cursor.fetchall.side_effect is a fixed list terminating in [], so the mock can't express non-termination. The suite's own docstring concedes the gap (tests 11-15): "none of the mocked-cursor tests below run the raw-SQL path against a real MySQL instance end-to-end — the backticked identifiers, information_schema introspection, and the keyset ORDER BY … LIMIT pagination SQL built in _paginate are never executed for real."
Fix: batch_sql = u"({0})".format(where_sql) before appending, in both branches; add an iteration cap that raises; add a test asserting the emitted SQL parenthesises the disjunction.
§4 — BLOCKER: unbounded IN (...) binds with no deduplication
Every id list is bound as one flat IN (%s, %s, …), one placeholder per element, and no sink is deduplicated. Worst case is visible_blocks_hashes (line 426-427), which appends one ~100-char hash per subsection-grade row: 10k learners × 50 subsections ≈ 500k entries ≈ a 50 MB single statement, past max_allowed_packet (commonly 4–64 MB) → MySQL server has gone away. visible_blocks_hash is heavily shared across learners, so a set() would shrink it by orders of magnitude; the code uses a list.
Same shape on user_ids (every TABLE_SCOPE query and every --dry-run COUNT(*)), str_user_ids, enrollment_ids (one per enrollment), anon_ids (one per user×course), student_item_ids, submission_uuids, cert_ids, photo_verification_ids.
The docstring flags this as a "known scaling caveat" for the ORA seed lists only (lines 108-112); it applies identically to user_ids in the very first query. Related: because these sinks are unbounded in-memory lists, commit 91720892 ("stream export to avoid OOM") batches the SELECTs but reintroduces the OOM risk via hundreds of MB of accumulated ids.
Fix: chunk every IN bind (~1000/chunk, union results); make every sink a set().
§5 — BLOCKER on the primary: no snapshot isolation, so the bundle is inconsistent by construction
~50 tables, each in its own autocommit transaction, over hours on a real tenant. No transaction.atomic(), no START TRANSACTION WITH CONSISTENT SNAPSHOT, no --single-transaction equivalent — nothing pins a read view.
- A certificate generated after
certificates_generatedcertificateis written but beforegrades_persistentcoursegrade→ grade present, cert missing (or the reverse). - An enrollment created between
student_courseenrollmentandcourseware_studentmodule→ studentmodule rows referencing an enrollment absent from the bundle. - The capture chains are worse:
enrollment_ids,cert_ids,anon_ids,photo_verification_idsare snapshots from time T, then used to query dependent tables at T + hours.
The keyset-vs-OFFSET docstring (lines 738-742) reasons carefully about exactly this hazard within one table, then it goes unaddressed across tables — which is where it actually damages a backup.
Fix: wrap the export in one transaction.atomic() (InnoDB's default REPEATABLE READ then gives a stable read view), or run off a restored snapshot.
§6 — BLOCKER on the primary: reads the writer, ignoring this repo's own read-replica convention
from django.db import connection (line 158) is DATABASES['default'] — the primary. Koa already has the plumbing:
read_replicainDATABASES—lms/envs/common.py:1342edx_django_utils.db.read_replica.ReadReplicaRouter—lms/envs/common.py:819read_replica_or_default()/use_read_replica_if_available()—common/djangoapps/util/query.py- already used by an Edly command in this repo —
openedx/core/djangoapps/catalog/management/commands/cache_programs.py:75
Streaming millions of courseware_studentmodule rows with KB-sized state blobs off the primary evicts the InnoDB buffer pool and raises latency for a live tenant; stack §5's long transaction on it and you also get history-list growth and purge lag.
Fix: --database, defaulting to read_replica_or_default(); connections[alias].cursor() throughout.
§7 — BLOCKER: no pre-flight table check, and ALL_TABLES — which is exactly that list — is dead code
ALL_TABLES (lines 285-291) is never referenced anywhere in the command (the test file imports it only to assert it has no duplicates). Meanwhile ~50 table names are hardcoded with no existence check: when one is absent, _columns() returns [], columns_sql becomes the empty string, and SELECT FROM \t` WHERE …raisesProgrammingError` mid-export — after N files are written, with the in-flight file left as truncated invalid JSON (§8).
A green --dry-run doesn't protect against this. _dry_run covers TABLE_SCOPE, STRING_CAST_TABLES and the membership table only; it explicitly skips ENROLLMENT_LINKED_TABLES, all 16 ORA_CHAIN_TABLES, the verify_student child, cert invalidation and visible blocks, printing "depends on captured ids from a real run" instead. So the dry run can't detect a missing table in the largest, riskiest part of the export, and gives no size estimate for it either — most of the value of a dry run before a one-shot production job.
Fix: validate all of ALL_TABLES against information_schema up front and fail with the complete missing list before creating any file. Extend --dry-run to cover the chained tables.
§8 — HIGH: mid-job cancel leaves a corrupt bundle indistinguishable from a good one; no atomic finalize, no resume
_write_table_json streams {"columns": …, "rows": [, then rows, then ]}. Ctrl-C / OOM-kill / pod eviction mid-table leaves that file ending mid-array with no closing ]}, while every earlier file looks complete.
Writing MANIFEST.json last is the right instinct, but nothing enforces it as a contract: no "complete": true, no .partial directory, no temp-then-rename, no documented requirement that a consumer reject a manifest-less bundle. Compounding:
- Re-runs mint a new timestamped directory, so partial multi-GB bundles accumulate with no cleanup — disk pressure on the LMS host.
- With an explicit
--output-dir,_open_bundle_fileusesos.O_TRUNC(line 890): a re-run silently overwrites a previous good bundle, and since the manifest is written last, a re-run that then fails leaves neither a valid old bundle nor a new one. No guard on a non-empty target. - No
--resume; a multi-hour export restarts from zero.
Fix: write into <dir>.partial/, each table <table>.json.tmp → os.replace(), os.replace() the directory on success; "complete": true in the manifest; refuse a non-empty target without --force; --resume skipping tables whose recorded checksum matches.
§9 — HIGH: two silent-success paths, and a docstring that contradicts the code
A collapsed chain reports success. If a seed list captures nothing, _fetch_by_any_id short-circuits to iter(()) (line 579), so all 16 ORA tables write valid 0 rows files, checksums are computed, the manifest is written, and the success banner prints. 0 rows is indistinguishable from "genuinely empty" — total silent loss of the ORA chain presents as a clean run. Add post-export invariants (e.g. submissions_studentitem > 0 while submissions_submission == 0 → warn + non-zero exit) and record seed-list sizes in the manifest.
--batch-size 0 produces an empty backup that reports success. No validation on the argument: LIMIT 0 makes if not batch: return fire on the first iteration of every table → every file 0 rows, manifest written, checksums over empty files, success banner. Negative values throw a raw SQL error. Reject < 1.
MTI docstring is inverted. Lines 733-740 claim keyset pagination "transparently covers Django multi-table-inheritance child tables (e.g. verify_student_softwaresecurephotoverification, assessment_teamstaffworkflow) whose primary key is a *_ptr_id column rather than a plain id." The code tests only if 'id' in columns (line 752); photoverification_ptr_id and staffworkflow_ptr_id are not id, so both MTI children take the OFFSET branch — the branch the same docstring calls non-atomic and unsafe. The two tables claimed safe are exactly the two on the unsafe path, and test_offset_fallback_when_no_id_column (tests 708-714) repeats the claim. Detect the real PK via information_schema.key_column_usage and keyset on it.
§10 — MEDIUM
- Binary data is corrupted silently, and the checksum certifies the corruption.
_json_defaultdoesvalue.decode('utf-8', errors='replace')(line 902), permanently substituting U+FFFD for undecodable bytes; the sha256 is then computed over the already-corrupted output, so integrity verification will confirm a corrupted bundle. Base64-encode with a type marker, or raise. _json_defaultis missinguuid.UUID(andtimedelta). If the driver returns aUUIDobject forsubmissions_submission.uuid, it raisesTypeErrormid-write, leaving a truncated file._hex_uuid_to_canonicalwould also fail on aUUIDobject, sinceuuid.UUID(hex=…)requires a string.- OFFSET branch orders by every column (line 774), including TEXT. That's a filesort, and
max_sort_length(default 1024 bytes) truncates the sort key — so rows sharing a long prefix in an earlier text column sort in undefined order and OFFSET pagination can skip or duplicate. Order by the PK. - Undocumented naive-UTC assumption. Raw SQL bypasses Django's timezone handling, MySQL returns naive datetimes,
isoformat()emits no offset. Any consumer parsing these as local time shifts every timestamp in the bundle. Document it. - Index usability / runtime.
WHERE student_id IN (200k values) AND id > X ORDER BY id LIMIT 1000againstcourseware_studentmodulewill very likely drive off the PK and filter — re-scanning a multi-million-row table once per 1000-row batch, thousands of times, on the primary (§6). Outer-loop over user chunks (~500) so thestudent_idindex is usable, paginate within. assessment_assessmentfeedback_assessmentsis not reference data. It's excluded as an "M2M through-table" alongside rubric/criterion definitions, but its rows are per-learner join records — which assessments a learner's feedback covers. Excluding it orphans every exportedassessment_assessmentfeedbackrow. The dismissal holds forassessmentfeedbackoption(course-authored) and not for the feedback↔assessments join.student_manualenrollmentauditis scoped only byenrollment_id, which is nullable — rows withenrollment_id IS NULLare silently dropped. It carriesenrolled_email, which would scope them.student_userstandingis missing, so a disabled/banned account restores as fully active — the same class of finding as the acceptedis_blockedblocker. Model atcommon/djangoapps/student/models.py:403. Also absent:student_courseenrollmentallowed,student_usersignupsource,student_languageproficiency,student_socialllink,entitlements_courseentitlement,student_loginfailures,bulk_gradesscore overrides, andwiki_*(django-wiki is installed → learner-authored content)._schema_stateswallows every exception (except Exception: return {}), so the manifest's only schema-compatibility signal can vanish silently.MANIFEST.jsonitself carries no checksum or signature — the per-file digests detect transport corruption, not tampering.- No confirmation gate or audit trail on a PII-extracting production command: no "about to export N users' PII, confirm" prompt, no
--yes, no logger record of operator/row counts/destination. - Default output path lands under
MEDIA_ROOT(_resolve_bundle_dir;MEDIA_ROOT = '/edx/var/edxapp/media/'atlms/envs/common.py:1477). In standard Open edX deployments nginx serves/media/from that root, which would exposehttps://<tenant>/media/edm_exports/<slug>_<ts>/learners/auth_user.json. The0700mode most likely prevents it since nginx runs as a different uid thanedxapp— meaning the protection is incidental rather than designed. I could not confirm the nginx config from this workspace, so please verify against the deployment repo before any run. Commit91720892moved the default off bare/tmpfor a good reason; MEDIA_ROOT is the wrong destination. - Nits:
self.batch_sizeassigned inhandle()rather than__init__(pylint W0201);six.text_typeon a Python-3-only checkout;--dry-runnever prints the resolved output directory.
Suggested path forward
Given the deliverable is client-facing CSV, I'd rather redirect this than harden it:
- Ship the client a CSV bundle built on the existing instructor reports (
calculate_grades_csv,calculate_students_features_csv,export_ora2_data, problem responses). A thin management command that resolves the tenant's courses and drives those tasks avoids §1–§7 entirely and reuses code with production mileage. Add the S3 ORA/SGA uploads and, if in scope, forum content — both absent here and both likely expected. If any coverage genuinely isn't reachable through the existing reports, the gap is worth a purpose-built denormalized CSV writer — but it should be scoped to that gap, not a 50-table raw dump. - Confirm with legal/compliance what the client is contractually owed before defining scope. That determines whether forum content, uploaded files, and per-attempt history are in or out — none of which this PR covers.
- If we still want the Ulmo restore bundle, keep it as a separate internal-only command, fix §3–§9, and gate merge on an actual round-trip restore into an Ulmo test tenant. The current test plan's round-trip checkbox is unchecked, so there's no evidence yet that the bundle restores — and independent of that, restoring raw
auth_user.idvalues into a populated Ulmo needs a username-keyed remap. Note the anonymous-id consequence specifically:AnonymousUserIdhas nounique_togetheron (user, course_id) (common/djangoapps/student/models.py:130-146) andanonymous_id_for_usersalts the digest withSECRET_KEY(:171), so on a target with a differentSECRET_KEYthe first post-restore access callsget_or_createwith a freshly computed digest, creates a second row, and every restoredsubmissions_studentitem.student_id/assessment_*.scorer_idis orphaned — learner ORA history silently reads as absent. Same failure shape as the ORA assessment/rubric remap issue we hit before.
Happy to pair on either direction. If the CSV route is acceptable I can sketch that command.
Redirects PR #756's off-boarding deliverable per the lead's review (pullrequestreview-4805493785): the client need is a human-readable CSV for non-technical staff, not a machine-shaped Koa->Ulmo JSON restore bundle. Removes export_learner_data.py (and its tests) and adds export_tenant_reports_csv, which produces per-course and tenant-wide summary CSVs (grades, profiles, enrollments, problem responses, ORA2) by calling the five existing instructor-report generator functions directly in-process, bypassing their Celery task wrappers via the same test seam this codebase's own test suite already uses (runner._get_current_task), rather than submitting real Celery tasks -- avoiding queue contention with live student-facing actions and the stuck-task-reservation risk a real submission path would carry. Fixes two scoping gaps inherent to reusing per-course report generators: resolves tenant membership strictly via EdlyMultiSiteAccess rather than course enrollment (avoids sweeping in non-members), and unions org-filtered courses with each member's actual enrollments (avoids silently dropping a member's out-of-tenant-org courses). Strips `meta` (an unvalidated per-app JSON blob) from profile exports by default, and excludes the may-enroll report (pending invites with no account to scope by) unless explicitly requested.
|
Thanks for this review — it was right that the JSON bundle was the wrong artifact, and pushing on that reshaped this PR significantly. Here's where every finding in this review landed. Fully addressed by the redirect
Still open, honestly not addressed yet
New gaps introduced by the redirect itself, not in your original review
Given the scope of this change I'd appreciate a fresh pass whenever you have time, rather than treating the earlier approval as still covering this. |
Waleed-Mujahid
left a comment
There was a problem hiding this comment.
Fresh pass on 6e14d13. The redirect was the right call — the raw-SQL pagination, information_schema introspection, and 55-table scope-guessing problems are genuinely gone, not papered over, and reusing the platform's own generators is what retired them. Your closure comment was accurate on almost everything.
Keeping this short and only to things that either break something or hand the client wrong data. I've dropped the rest of what I found (bind-list sizes, snapshot-isolation caveats, duplicate per-course files, manifest polish) — none of it stops this working.
Blockers
1. grades_summary.csv has no grade in it. GRADES_SUMMARY_COLUMNS (export_tenant_reports_csv.py:267) omits 'Grade', but CourseGradeReport._grades_header (tasks_helper/grades.py:497) always emits ["Grade"] first, at a fixed offset — only the per-assignment columns after it vary. So the one file a non-technical operator opens to answer "how far did our learners get" has enrollment track and certificate status and zero scores. One-word fix, but it's the whole point of the deliverable.
2. --output-dir chmods a path you didn't create. _make_private_dir (:734-742) does an unconditional os.chmod(path, 0o700), and handle() passes --output-dir through verbatim (:729). --output-dir /edx/var/edxapp/media sets MEDIA_ROOT to 0700 and nginx stops serving every course asset on that box. Only chmod a directory this run created.
3. An interrupted run loses two whole reports and can't be resumed. _DictCsvSink (:375-421) touches the filesystem only in close(), and close() only runs on the normal loop exit at :595. Ctrl-C or an OOM-kill at course 40 of 60 leaves problem_responses.csv and ora2_responses.csv nonexistent (not partial), no MANIFEST.json, and no record of which courses finished — so the only recovery is a full rerun. try/finally around the course loop so the sinks close and the manifest lands with status: incomplete plus the completed-course list would cover this. This is the §8 you flagged as open; I'm raising it to blocking because buffering the two biggest sinks made it worse than the per-table files it replaced.
4. Grade-report error rows are dropped silently. _invoke_report:813 filters out anything ending _err, but CourseGradeReport._upload (grades.py:488-491) uploads two CSVs — grade_report and grade_report_err, the latter holding [user.id, username, error] for every learner CourseGradeFactory failed to grade (grades.py:694). Those learners are absent from the export and the course still reports {'status': 'success', 'rows': N}. The count is already in hand and thrown away: _invoke_report returns the progress dict carrying task_progress.failed (grades.py:475) and _run_grades:971 discards it with result, _ =. Write the error rows to a __grades_errors.csv and put the count in the manifest.
Worth noting no test can catch this one: no fake in the test file emits an _err upload, so the path is invisible by construction — same shape as PaginateTests only ever passing where_sql='1=1' last time.
5. Memory is uncapped for the whole run. _no_max_problem_responses_limit (:304) sets MAX_PROBLEM_RESPONSES_COUNT = None. I checked and it can't crash — grades.py:906 and the decrement both guard if max_count is not None, and the restore is in a finally. But that cap is the only thing bounding this report, and lifting it stacks three live copies: _build_student_data's course-wide list (grades.py:895-968), format_dictlist's copy (:1001), then _DictCsvSink holding every course's rows for the entire tenant until the run ends. ora2 starts worse — collect_ora2_data does list(...) over the whole course then one Assessment query per submission (edx-ora2 data.py:560-578). Make the lift opt-in (--max-problem-responses, defaulting to the platform cap) rather than automatic, so a big tenant doesn't OOM halfway through with no resume path.
Cheap, would do before it leaves the building
- CSV formula injection.
QUOTE_ALLdoesn't stop Excel evaluating a cell that starts=,+,-, or@, andname/goals/mailing_address/free-text answers are all learner-controlled. The premise here is "a non-technical operator opens this in Excel," so prefix a'on those. Three lines. The instructor dashboard has the same hole, but that CSV doesn't leave the company. --as-userisn't checked for staff.ProblemResponses._build_student_datacallsget_course_blocks(user, usage_key)(grades.py:906), which transforms the tree for that user — a non-staff operator gets unreleased/hidden blocks pruned and their responses silently missing, still reported as success.if not user.is_staff: raise CommandError(...)is enough.- Two known-and-fine-if-stated gaps, worth a line in the manifest rather than code:
enrolled_students_featuresfiltersis_active=1(instructor_analytics/basic.py:102-105) while the grade report passesinclude_inactive=True(grades.py:535-538), so an unenrolled learner lands ingrades_summary.csvwith a Student ID and no row inlearner_profile.csv; and ORA file attachments aren't exported at all (that'supload_ora2_submission_files,misc.py:476, which this doesn't call) — fine if the client's ask is text responses, worth confirming if it isn't.
On the ora2 gap you flagged
Pulled the schema for you from edly-io/edx-ora2@develop-koa (openassessment/data.py:618-633):
Submission ID | Location | Problem Name | Item ID | [Username] | Anonymized Student ID | Date/Time Response Submitted | Response | ...
Your content-based detection lands correctly on it — Submission ID is a UUID, Location a usage key, Item ID an int PK, so they're all disqualified, and the identity columns are the two you'd want. The false positive I went looking for (a scorer column) doesn't exist: scorer identities are embedded inside the Assessment Details text cell (data.py:579), not a column. Username only appears when ENABLE_ORA_USERNAMES_ON_DATA_EXPORT is on (data.py:375-380), which is off by default. So you can name the column directly now and keep the content check as a sanity assert.
On skipping Celery
Agreed, and there's a sharper reason than the ones in the description: these route to HIGH_MEM_QUEUE via GRADES_DOWNLOAD_ROUTING_KEY (lms/envs/common.py:3252), shared with live instructor-dashboard work, and #5's memory profile would OOM-kill a worker holding unrelated tasks. The one thing you gave up is the durable audit trail on the failure path — which is exactly what fixing #3 restores, without bringing the queue back.
Blocking on 1–5. Everything else above is a judgement call I'm happy to leave to you.
Fixes the 5 blockers, 2 low-severity issues, and 2 documentation gaps from pullrequestreview-4807539504 (Waleed-Mujahid): grades_summary.csv was missing the actual grade column; --output-dir could chmod a pre-existing shared directory; an interrupted run left no manifest and silently lost the two buffered report types; grade-report error rows (learners CourseGradeFactory failed to grade) were dropped with no signal; the problem-responses memory cap was lifted automatically instead of via an explicit --max-problem-responses flag. Also fixes CSV formula injection (a leading =/+/-/@ is now escaped) and adds a staff check for --as-user, since ProblemResponses silently prunes content a non-staff operator can't see. Also closes out the three previously-disclosed gaps from the CSV redirect: the command's own tenant/course/user queries now route through read_replica_or_default() (matching the cache_programs.py precedent), the five upstream report generators are deliberately left on the primary DB and documented as such (CourseGradeReport has a confirmed read-then-write path that would be unsafe against a lagging replica), and Command.handle()/--dry-run now have test coverage. Hardens handle()'s failure/interruption reporting throughout: the manifest write, each sink's close(), and per-course report outcomes are now all individually isolated so one failure can't mask another or produce a false "success" exit when something was actually lost. manifest['status'] distinguishes complete / complete_with_errors / incomplete, courses_with_errors and courses_needing_review roll up per-course problems to the top level, and the final banner reflects what actually happened instead of printing unconditionally. Verified via a standalone harness (no working Django/Celery/devstack in this environment) -- the real Django test suite has not been run and should be a gate before merge.
|
Thanks for this — every one of the 5 blockers, the 2 non-blocking items, and both documentation gaps are fixed.
Also closed the three gaps disclosed after the last redirect: the command's own queries now go through One honest caveat: none of this has run under a real Django test environment yet — no working devstack in the environment this was built in — so that's still the next real gate before merge, not something I'm claiming is done. |
Waleed-Mujahid
left a comment
There was a problem hiding this comment.
Checked 576a2d1e against real upstream source rather than taking the closure comment at its word. All five blockers are genuinely fixed, and two of them I could only confirm by reading the generators they depend on:
- 1 (missing Grade) —
'Grade'is inGRADES_SUMMARY_COLUMNSat index 3. Worth noting why theheader.index(col)validation at:1569is safe: all ten of those columns come out ofCourseGradeReport._success_headers(tasks_helper/grades.py:441-450) unconditionally — onlyCohort Name, the experiment-partition columns, andTeam Nameare conditional, and none of those are in your allowlist. So there's no course shape that makes this raise. - 4 (dropped error rows) —
_error_headers()returns exactly["Student ID", "Username", "Error"](grades.py:452), so_filter_rows_by_column(error_header, ..., 'Student ID', ...)matches the real header rather than landing in thegrades_errors_export_errorbranch on every run with a failure. That was the way this fix could have been quietly useless; it isn't. - 2 — chmod is now inside the
if not os.path.exists. 3 —try/finallycloses the sinks and the manifest lands asincomplete. 5 — opt-in via--max-problem-responses,_null_context()by default so FEATURES is untouched.
On the tests: the specific blind spot from last round is closed. Last time no fake emitted an _err upload, so blocker 4's code path was invisible by construction — test_grade_report_err_rows_are_captured_and_failure_count_recorded now emits both uploads from one call, and test_grades_error_row_failure_does_not_flip_a_successful_course_to_error covers the isolation. test_open_sinks_partial_failure_still_closes_and_records_the_already_opened_sinks asserting sink._file.closed on the two that opened is the right shape too.
Dropping my block. Three conditions before merge, in order:
1. Run the tests. This is the real gate and you already named it. 3,680 lines that have never executed. I couldn't run them either (no Koa devstack here), so I cleared what's clearable statically instead — the results are worth having before you spend devstack time:
read_replicais absent fromlms/envs/test.py:185'sDATABASES, which only definesdefaultandstudent_module_history. Soread_replica_or_default()returns'default'under test settings. That matters a lot: had test settings kept the alias, every.using(read_replica_or_default())call would open a second connection that can't see aTestCase's uncommitted fixtures, and roughly every DB-touching test in this file would have failed for a reason that has nothing to do with your logic.ReadReplicaRoutingTestsis fine as written because it asserts the call, not the alias.EdlySubOrganizationFactory/EdlyMultiSiteAccessFactoryexist atopenedx/features/edly/tests/factories.py:29,65.from student.tests.factories import ...(not thecommon.djangoapps.prefix) matches all eight sibling files inopenedx/features/edly/tests/.tests/__init__.pyis present at the PR head. Both files compile clean.
No known import blocker left — but that's static analysis, not a green run.
2. --dry-run is broken as documented. The --as-user/is_staff gate (:737-746) sits above the if dry_run: branch (:755), and DEFAULT_REPORTS includes problem_responses — so the module docstring's own first Usage line, manage.py export_tenant_reports_csv <slug> --dry-run, exits with a CommandError. You hit this and documented the workaround in the test instead of fixing it (test_dry_run_writes_nothing's comment: "without this, --dry-run with no --as-user would raise CommandError before ever reaching the dry-run branch"). A test comment explaining why the happy path can't be tested as documented is the signal to move the branch. --dry-run writes nothing and calls no generator, so it has no business needing an operator identity — move the if dry_run: block above the gate.
3. Don't default the output under MEDIA_ROOT. _resolve_output_dir falls back to <MEDIA_ROOT>/edm_exports/... = /edx/var/edxapp/media/, which nginx serves at /media/. The 0700 dirs and 0600 files probably hold if nginx runs as a different user than edxapp, but "probably, depending on deployment" isn't the property you want on a directory holding a whole tenant's learner PII, and the timestamp in the path is guessable within a range. Default somewhere outside the web root, or make --output-dir required.
One thing that isn't a blocker but is worth hearing: the code got better this round and the prose got worse. The module docstring is 361 lines against ~1,090 lines of actual code, and the finally block carries 40+ lines of comment explaining its own control flow, including comments about what the code used to do and which review round changed it. Rationale that deep belongs in the commit message — that's where someone doing git blame in a year will look for it, and it doesn't have to be re-read by everyone who opens the file. The code reads well enough now that most of it isn't earning its space.
Last thing, procedural: @muhammadali286's empty approval from 2026-07-29 is still on this PR. With my block dropped, that's now a second approval on a branch whose tests have never run — worth making sure nobody merges on it before condition 1 is done.
|
Thanks for the approval, and for catching these two on the deeper pass — both are fixed now:
On your first condition — actually running the ~3,680 lines of tests on real devstack — that's still open on my end and isn't something I can simulate away; I've confirmed as much of it as this environment allows (a real Django 2.2.24 venv probe for the required-argument behavior, plus the standalone harness), but a genuine devstack run is still the real gate before merge. Left the prose/comment-bloat note as-is for now since you flagged it as non-blocking — happy to do a pass on that separately if useful. |
Addresses pullrequestreview-4816365056 (Waleed-Mujahid, approved with 2 pre-merge conditions). The --as-user/problem_responses staff check ran before the --dry-run early return, so the command's own documented usage example (--dry-run with default flags, no --as-user) raised CommandError instead of doing a dry run -- a dry run never touches an operator identity or calls a report generator, so it has no business being gated by that check. Reordered so --dry-run always short-circuits first. Also makes --output-dir a required argument instead of falling back to EDM_EXPORT_DIR (never defined anywhere in this codebase's settings) or, failing that, a directory under MEDIA_ROOT -- which nginx serves publicly at /media/ in this Koa deployment, meaning a tenant's full learner-PII export could land under the public web root by default. There's no safe default to fall back to here, so the fix removes the guesswork rather than picking a different unverified default.
Updated*
Ticket
EDLYPRODUCT-8334
export_tenant_reports_csv
Summary
export_tenant_reports_csvis a Django management command that generates a human-readable CSV export of a single tenant's data — grades, learner profiles, course enrollments, problem responses, and ORA2 (open response assessment) data — for use during tenant off-boarding.It's intended for an operator (support, compliance, or the customer's own admin) who needs a complete, correctly-scoped snapshot of everything a tenant's learners did on the platform, in a format that's readable without any tooling — just CSV files and a manifest.
Why it exists
When a tenant leaves the platform, someone needs to hand them (or archive) their learner data before it's deleted. That data lives across five different report types the LMS already knows how to generate — but only for one course at a time, only through the instructor dashboard, and with no concept of "give me everything for this tenant, and only this tenant." This command automates that: run every relevant report for every course the tenant's members touch, filter it down to just their data, and package it into a single, audited export.
What it does, step by step
EdlySubOrganizationfor the given slug and its associated org(s).EdlyMultiSiteAccessfor that tenant — this, not course/org matching, is what defines "belongs to this tenant."grades→CourseGradeReport(per-learner grade breakdown)profiles→upload_students_csv(learner profile fields: name, email, language, location, etc.)enrollments→ derived from the same profile report (enrollment mode, course-varying fields)problem_responses→ProblemResponses(every learner's answer to every problem)ora2→upload_ora2_data(open-response assessment submissions and scores)may_enroll→upload_may_enroll_csv(opt-in only; not part of the default set)grades_summary.csv,learner_profile.csv,course_enrollments.csvper_course/directory with one file per course per report type, for anyone who needs course-level detail<course>__grades_errors.csv) for rows the underlying report itself flagged as failedMANIFEST.json— the run's own audit record (see below)How the report generation actually runs
The five report generators above are the exact same functions Studio's instructor dashboard uses — but normally they run as background Celery tasks, tracked by an
InstructorTaskdatabase row, and their progress is polled from the UI.This command doesn't do that. It patches the generators' task-context plumbing (
runner._get_current_taskand each module'supload_csv_to_report_storebinding) so they run synchronously, in the same process as the management command itself, with no Celery task ever submitted. The upload calls that would normally push a CSV to the InstructorTask's report store are captured directly instead and routed into this command's own sinks.Why not just submit real Celery tasks? Submitting five report tasks per course, for potentially hundreds of courses, would compete for space on a production instructor-task queue and risk stuck task reservations. Running everything in-process, one course at a time, keeps the whole export self-contained and predictable — at the cost of it being fully synchronous (see Limitations).
Data source: primary vs. replica
The command's own lookups — tenant resolution, membership, course-list resolution — are routed through a read replica (
read_replica_or_default()) where one is configured, to keep this reporting workload off the primary database.The five report generators themselves are not routed to the replica; they run against the primary as they normally would. This is deliberate:
CourseGradeReportperforms a read-then-write sequence internally that would be unsafe against replica lag (a write could be based on stale data). Routing the command's own queries to the replica while leaving the actual report logic untouched avoids introducing a new failure mode into code this command doesn't own.Safety behavior
--output-diris required. There is no default export location — anywhere under the platform's public media root would be served by nginx, so the operator must consciously choose a private destination every run.metaprofile JSON blob is excluded unless--include-fieldsexplicitly asks for it (and confirms with--allow-meta-field). Themay_enrollreport, which can include people who never actually enrolled, is excluded from the default report set.=,+,-, or@is prefixed to prevent it from being interpreted as a formula if the CSV is later opened in a spreadsheet app.problem_responsesrequires--as-user. That report needs an acting staff identity to resolve course-block access; a non-staff or missing identity is rejected up front instead of silently producing an incomplete export.Interruption and failure handling
If the run is interrupted partway through (Ctrl-C, OOM-kill, an unexpected exception), the command still writes whatever manifest and CSV data it can before exiting, and marks the manifest status
incompleteso it's never mistaken for a clean run. A course-level report failure doesn't stop the rest of the export — it's recorded against that course and the run continues, ending with acomplete_with_errorsstatus if anything failed. The manifest is the single source of truth for "did this actually work" — the process exit code and stdout banner both reflect it.The manifest
MANIFEST.json, written into the output directory alongside the CSVs, records:{ "slug": "acme", "operator": "jane.doe", "generated_at": "...", "reports_requested": ["grades", "profiles", "enrollments"], "courses": { "course-v1:AcmeX+CS101+2026": { "grades": {"status": "success", "rows": 42}, "profiles": {"status": "success", "rows": 42} } }, "courses_completed": [...], "courses_with_errors": [...], "courses_needing_review": [...], "known_gaps": {...}, "status": "complete" }statusis one ofcomplete,complete_with_errors, orincomplete— the one field to check before trusting an export is done.Usage
slug(positional)EdlySubOrganizationslug--output-dir--reports--as-userproblem_responsesrequested--include-fields--allow-meta-fieldmetafield--skip-course--ora2-identity-column--max-problem-responses--dry-runLimitations