fix: reject enrollments in course runs whose enrollment window has closed - #3846
Open
Anas12091101 wants to merge 1 commit into
Open
fix: reject enrollments in course runs whose enrollment window has closed#3846Anas12091101 wants to merge 1 commit into
Anas12091101 wants to merge 1 commit into
Conversation
…osed
The enrollment window was never checked when creating an enrollment. Any
authenticated learner could POST a closed run's id and get enrolled:
- /api/v2/verified_program_enrollments/<courserun_id>/ let a learner with a
program enrollment into a closed run. When the run was not upgradable it
created a free audit enrollment; when it was upgradable it also generated a
zero-value order.
- /api/v{1,2}/enrollments/ had the same gap independently of programs.
is_enrollable was only enforced on the deferral path (courses/api.py), so
closed runs were kept out of sight by product-listing filters and a
client-side check in mit-learn rather than by the API itself.
Gate all three learner-initiated entry points on run.is_enrollable. In the
program view the check sits right after the run is loaded so it covers both
the audit and the verified branch.
An existing active enrollment is exempt: the enrollment window governs
getting into a run, while the upgrade deadline (is_upgradable) governs
changing mode once in. Without the exemption, a learner who bought the
program could no longer upgrade an audit seat after enrollment_end passed,
and idempotent retries would start failing.
Staff paths are unaffected: management commands, deferrals and order
fulfillment all call create_run_enrollments directly and bypass these
entry points, so no force override was needed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OpenAPI ChangesShow/hide changesUnexpected changes? Ensure your branch is up-to-date with |
Contributor
There was a problem hiding this comment.
Pull request overview
Adds server-side enrollment-window enforcement while preserving existing active enrollments.
Changes:
- Rejects closed course runs in v1/v2 enrollment APIs.
- Guards audit and verified program-enrollment paths.
- Adds regression tests for rejection and existing-enrollment behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
courses/serializers/v1/courses.py |
Validates enrollment availability in v1. |
courses/serializers/v2/courses.py |
Validates enrollment availability in v2. |
courses/views/v2/__init__.py |
Guards program-based enrollment creation. |
courses/views/v1/views_test.py |
Tests v1 closed-run rejection. |
courses/views/v2/views_test.py |
Tests v2 and program enrollment behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+347
to
+351
| if ( | ||
| not run.is_enrollable | ||
| and not models.CourseRunEnrollment.objects.filter( | ||
| run=run, user=user, active=True | ||
| ).exists() |
Comment on lines
+759
to
+762
| # The window only governs getting into a run in the first place. A learner | ||
| # who already holds a seat may still change mode (e.g. an audit enrollment | ||
| # upgrading because they bought the program), which is gated by | ||
| # is_upgradable further down rather than by the window. |
| A run whose enrollment window has closed must be rejected on the audit | ||
| fallback path, even for a learner enrolled in the program. | ||
|
|
||
| This is the shape reported in #12813: the run is closed and not upgradable |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What are the relevant tickets?
Closes https://github.com/mitodl/hq/issues/12828
Description (What does it do?)
We never checked whether a course run was still open for enrollment before creating one, so any logged-in learner could POST a closed run's id and get in. Three endpoints were affected:
POST /api/v2/verified_program_enrollments/<courserun_id>/— a learner holding a program enrollment got into a closed run. A non-upgradable run produced a free audit enrollment; an upgradable one also produced a zero-value order, which is messier to undo.POST /api/v2/enrollments/andPOST /api/v1/enrollments/— same gap, no program involved.is_enrollablealready existed but was only enforced on the deferral path incourses/api.py. Closed runs were kept out of reach by product-listing filters and by a client-side check in mit-learn (mitodl/mit-learn#3508), not by the API, so the endpoints stayed callable directly.This adds the check to all three entry points. In the program view it sits right after the run is loaded, before the audit/verified mode logic, so one check covers both branches. Gating only the audit branch would leave the verified branch open — and that is the branch that creates an order.
Existing enrollments are exempt. The check applies only when the learner has no active enrollment in the run:
The enrollment window controls getting into a run; the upgrade deadline (
is_upgradable) controls changing mode once in. Without the exemption, a learner who bought the program could no longer upgrade an audit seat afterenrollment_endpassed, and repeat POSTs from already-enrolled learners would return 400 instead of today's 204/201. Inactive (unenrolled) rows do not count, so re-entering a closed run stays blocked.No
forceflag needed. Management commands, deferrals and order fulfillment all callcreate_run_enrollmentsdirectly and never pass through these three entry points. That is also why the check does not live increate_run_enrollments— those callers legitimately enroll into closed runs.One correction to the issue. #3450 and #3451 widened this rather than causing it: the audit branch never checked the window, so audit-mode program learners could already get into closed runs before April 2026. What #3451 changed is that a verified learner on a closed, non-upgradable run used to hit a 500 (missing product) and now silently succeeds. This matters for the backfill — it needs to cover dates before April 2026 and the plain
/enrollments/endpoint, not just_create_course_enrollment_from_programin thecall_stack.Six tests added. No migrations, and the v2 OpenAPI spec is unchanged (the endpoint already declared a 400 response).
How can this be tested?
No working Open edX needed — every run below uses a
run_tagstarting withfake, socreate_run_enrollmentsskips the edX call.1. Create the data. Save as
/tmp/window_data.py, thendocker compose exec -T web python manage.py shell < /tmp/window_data.py:It prints the learner, the program readable id, and a ready-to-paste URL for every case:
2. Sign in at
http://mitxonline.odl.local:8013/admin/login/as the printed learner, passwordtestpass123.Use
/admin/, not the app's normal login.ApisixUserMiddlewarelogs out any session whose backend is aRemoteUserBackendwhen the APISIX header is missing, so signing in another way gives403 Authentication credentials were not providedon the API.3. Program endpoint.
DEBUGis on locally, so DRF's browsable API gives you a real POST form. Open each printedverified_program_enrollmentsURL, put["<program readable_id>"]in the content box, chooseapplication/json, click POST. Expect A → 400, B → 400 (and check no new order), C → 201 withenrollment_mode: verified, D → 204.The page loads as
HTTP 405because the endpoint is POST-only; the form still works. POST C only once — a second time returns 204 because the enrollment now exists.4. Plain endpoint. Open
/api/v2/enrollments/(loads as 200 with a POST form) and POST therun_idbodies the script printed. Expect A → 400Course run is not open for enrollment, E → 201. Same for/api/v1/enrollments/.5. Confirm the check is what is doing the work.
git stash push -- courses/views/v2/__init__.py courses/serializers/v1/courses.py courses/serializers/v2/courses.py # recreate the data, repeat steps 3-4: A and B now return 201, and B also makes an order git stash popPrefer
fetchfrom the console? Note this project renames the CSRF cookie tocsrf_mitxonline, and the header isX-CSRFTOKEN.Testing through the MIT Learn UI (optional)
The click that hits the program endpoint is the enroll CTA on the Dashboard module rows of an enrolled program (ModuleCard.tsx, useCreateVerifiedProgramEnrollment). mitodl/mit-learn#3508 disables that CTA for closed runs, so you cannot reach it by clicking in the normal case. To test through the UI, temporarily set disableEnrollment = false in ModuleCard.tsx.
Separately, ModuleCard calls getBestRun(data, { contractId }) without enrollableOnly, while DashboardCard passes enrollableOnly: true, and ModuleCard's guard is course-level (courseruns.some(...)) while the run it enrolls is chosen per-run. Those can disagree. Worth aligning on the mit-learn side; not needed for this fix.