Skip to content

fix: reject enrollments in course runs whose enrollment window has closed - #3846

Open
Anas12091101 wants to merge 1 commit into
mainfrom
anas/enforce-enrollment-window-on-enrollment-apis
Open

fix: reject enrollments in course runs whose enrollment window has closed#3846
Anas12091101 wants to merge 1 commit into
mainfrom
anas/enforce-enrollment-window-on-enrollment-apis

Conversation

@Anas12091101

@Anas12091101 Anas12091101 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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/ and POST /api/v1/enrollments/ — same gap, no program involved.

is_enrollable already existed but was only enforced on the deferral path in courses/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:

if not run.is_enrollable and not CourseRunEnrollment.objects.filter(
    run=run, user=request.user, active=True
).exists():

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 after enrollment_end passed, 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 force flag needed. Management commands, deferrals and order fulfillment all call create_run_enrollments directly and never pass through these three entry points. That is also why the check does not live in create_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_program in the call_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_tag starting with fake, so create_run_enrollments skips the edX call.

1. Create the data. Save as /tmp/window_data.py, then docker compose exec -T web python manage.py shell < /tmp/window_data.py:

from datetime import timedelta
from uuid import uuid4

import reversion
from django.contrib.contenttypes.models import ContentType
from mitol.common.utils import now_in_utc

from courses.factories import (
    CourseRunEnrollmentFactory,
    CourseRunFactory,
    ProgramEnrollmentFactory,
)
from ecommerce.models import Product
from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE

# Factory readable_id sequences restart every shell session, so ids are built
# here: keeps this re-runnable and clear of whatever is in your dev database.
TOKEN = uuid4().hex[:8]
now = now_in_utc()
past, long_past, future = (
    now - timedelta(days=30), now - timedelta(days=90), now + timedelta(days=365),
)


def add_product(obj, price=10):
    with reversion.create_revision():  # without a revision, no order can be made
        Product.objects.create(
            price=price, is_active=True, object_id=obj.id,
            content_type=ContentType.objects.get_for_model(obj),
        )


pe = ProgramEnrollmentFactory.create(
    enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, active=True,
    program__readable_id=f"program-v1:wintest-{TOKEN}",
    program__title=f"Window test {TOKEN}",
)
learner, program = pe.user, pe.program
learner.set_password("testpass123")
learner.is_staff = True  # so you can sign in at /admin/
learner.save()
add_product(program, price=100)


def make_run(slug, *, closed, in_program=True):
    run = CourseRunFactory.create(
        course__readable_id=f"course-v1:wintest-{TOKEN}+{slug}",
        course__title=f"Window test {TOKEN} {slug}",
        courseware_id=f"course-v1:wintest-{TOKEN}+{slug}+3T2026",
        run_tag=f"fake{slug}", live=True, start_date=past, end_date=future,
        enrollment_start=long_past,
        enrollment_end=past if closed else future, upgrade_deadline=future,
    )
    if in_program:
        program.add_requirement(run.course)
    return run


runs = {
    "A": make_run("A", closed=True),                  # closed, not upgradable
    "B": make_run("B", closed=True),                  # closed, upgradable
    "C": make_run("C", closed=False),                 # open
    "D": make_run("D", closed=True),                  # closed, already enrolled
    "E": make_run("E", closed=False, in_program=False),  # open, outside program
}
add_product(runs["B"])
add_product(runs["C"])
CourseRunEnrollmentFactory.create(
    user=learner, run=runs["D"], active=True, edx_enrolled=True,
    enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE,
)

BASE = "http://mitxonline.odl.local:8013"
want = {
    "A": "400  audit branch (the reported shape)",
    "B": "400  verified branch, and no order",
    "C": "201  verified (control)",
    "D": "204  no-op (exemption)",
    "E": "201  control, plain endpoint only",
}
print(f"\nlearner {learner.email} / testpass123\nprogram {program.readable_id}\n")
for slug, run in runs.items():
    print(f"{slug} enrollable={str(run.is_enrollable):<5} "
          f"upgradable={str(run.is_upgradable):<5} -> {want[slug]}")
    if slug != "E":
        print(f"  {BASE}/api/v2/verified_program_enrollments/{run.courseware_id}/")
    print(f'  {BASE}/api/v2/enrollments/  body: {{"run_id": {run.id}}}')

It prints the learner, the program readable id, and a ready-to-paste URL for every case:

A enrollable=False upgradable=False -> 400  audit branch (the reported shape)
B enrollable=False upgradable=True  -> 400  verified branch, and no order
C enrollable=True  upgradable=True  -> 201  verified (control)
D enrollable=False upgradable=False -> 204  no-op (exemption)
E enrollable=True  upgradable=False -> 201  control, plain endpoint only

2. Sign in at http://mitxonline.odl.local:8013/admin/login/ as the printed learner, password testpass123.

Use /admin/, not the app's normal login. ApisixUserMiddleware logs out any session whose backend is a RemoteUserBackend when the APISIX header is missing, so signing in another way gives 403 Authentication credentials were not provided on the API.

3. Program endpoint. DEBUG is on locally, so DRF's browsable API gives you a real POST form. Open each printed verified_program_enrollments URL, put ["<program readable_id>"] in the content box, choose application/json, click POST. Expect A → 400, B → 400 (and check no new order), C → 201 with enrollment_mode: verified, D → 204.

The page loads as HTTP 405 because 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 the run_id bodies the script printed. Expect A → 400 Course 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 pop

Prefer fetch from the console? Note this project renames the CSRF cookie to csrf_mitxonline, and the header is X-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.

…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>
@github-actions

Copy link
Copy Markdown

OpenAPI Changes

Show/hide changes
## Changes for v0.yaml:
No changes detected

## Changes for v1.yaml:
No changes detected

## Changes for v2.yaml:
No changes detected

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants