feat: add webhook endpoint for Open edX course enrollment#3372
feat: add webhook endpoint for Open edX course enrollment#3372Anas12091101 merged 18 commits intomainfrom
Conversation
OpenAPI ChangesShow/hide ## Changes for v0.yaml:Unexpected changes? Ensure your branch is up-to-date with |
|
How/is this limited to course staff?
There are similar use cases for enrolling non-staff as auditors, for example a SPOC or beta-testers
|
e7a2816 to
c1a9944
Compare
| {enrollment.run.start_date | ||
| ? `Starts ${formatPrettyMonthDate( | ||
| parseDateString(enrollment.run.start_date) | ||
| )}` | ||
| : "Coming Soon"} |
There was a problem hiding this comment.
Enrollment in a course run with a null start date was breaking the dashboard page.
Our plugin checks the user’s role first. If it’s admin or staff, it sends the request to the webhook; otherwise, it doesn’t. The logic is implemented here in the plugins PR.
|
|
Should we make this more generic on the mitxonline side, so it can handle more enrollments and not just staff? Maybe we just need to change the URL of the API. Also, how does this handle the possibility that a staff is added, but they don't have an account set up in mitxonline for some reason? I think raising an exception would be best. |
Yes, it's already generic. I'll update the URL in the next commit.
The endpoint returns a 404 response with The 404 is logged on the MITx Online side so we can monitor these cases and investigate further if needed. |
4351e31 to
c280bc6
Compare
| {enrollment.run.start_date ? | ||
| `Starts ${formatPrettyMonthDate( | ||
| parseDateString(enrollment.run.start_date) | ||
| )}` : | ||
| "Coming Soon"} |
There was a problem hiding this comment.
What is this change for? At best, Its is not related to this PR.
There was a problem hiding this comment.
Yes, it’s not directly related to this PR. Enrollment in a course run with a null start date was causing the dashboard page to break. As a result, if someone is added as a staff member to a course in MITxOnline without a start date set through the endpoint added in this PR, their dashboard would break without this change.
Would you prefer that I keep this in the current PR, or should I move it to a separate one?
There was a problem hiding this comment.
A separate PR might be better.
There was a problem hiding this comment.
Again, I don't think these changes are related. Could you open a separate PR for these if needed?
| OPENEDX_WEBHOOK_KEY = get_string( | ||
| name="OPENEDX_WEBHOOK_KEY", | ||
| default=None, | ||
| description="Shared secret token used to authenticate incoming webhook requests from Open edX", | ||
| ) | ||
|
|
There was a problem hiding this comment.
@rhysyngsun what's your opinion on this? Do you think a pre-generated config-based string bearer token is fine here, or should we rather go with a staff OAuth token with expiry, or HMAC maybe (Is it worth it)?
There was a problem hiding this comment.
I would go with a standard OAuth token because there's a few key benefits to that:
- OAuth tokens can quickly be revoked without needing to redeploy
- OAuth tokens can be rotated without downtime
| @extend_schema(exclude=True) | ||
| @api_view(["POST"]) | ||
| @permission_classes([AllowAny]) | ||
| def edx_enrollment_webhook(request): # noqa: PLR0911, C901 |
There was a problem hiding this comment.
Maybe not right now, but we may want to move it to ol-django at some point to make it common between other applications that might want to use this API endpoint. I'll re-think more on this.
| log.error("OPENEDX_WEBHOOK_KEY is not configured") | ||
| return Response( | ||
| {"error": "Webhook is not configured"}, | ||
| status=status.HTTP_500_INTERNAL_SERVER_ERROR, |
There was a problem hiding this comment.
Shouldn't be a 500. It should be a 400 instead.
| {"error": f"User with email {email} not found"}, | ||
| status=status.HTTP_404_NOT_FOUND, | ||
| ) | ||
| except User.MultipleObjectsReturned: |
There was a problem hiding this comment.
Is this ever going to happen? Can there be multiple users in the Users table with same email?
| role, | ||
| ) | ||
| return Response( | ||
| {"error": f"User with email {email} not found"}, |
There was a problem hiding this comment.
PII caring
| {"error": f"User with email {email} not found"}, | |
| {"error": f"User not found"}, |
|
|
||
| # --- Enroll user as auditor --- | ||
| try: | ||
| enrollments, edx_request_success = create_run_enrollments( |
There was a problem hiding this comment.
create_run_enrollments is for creating enrollments for edX as well. In this case, the API request itself is coming from enrollment in edX, so should we call this method? Or create a new one so that we just create a local enrollment upon the Webhook call? Apparently, from the method docstr this is all this method eventually does:
Creates local records of a user's enrollment in course runs, and attempts to enroll them
in edX via API.
Updates the enrollment mode and change_status if the user is already enrolled in the course run
and now is changing the enrollment mode, (e.g. pays or re-enrolls again or getting deferred)
Possible cases are:
1. Downgrade: Verified to Audit via a deferral
2. Upgrade: Audit to Verified via a payment
3. Reactivation: Audit to Audit or Verified to Verified via a re-enrollment
So the point is, do we want to go this route? cc: @pdpinch
| ) | ||
|
|
||
| # --- Enroll user as auditor --- | ||
| try: |
There was a problem hiding this comment.
Should we do an early return at this point or maybe at the start of the API to make it idempotent? The thing we should ideally do is to check if the enrollment exists in the system, before doing anything else, and if the enrollment does exist already, we may return 409 conflict in that case actually otherwise proceed with whatever needs to happen.
|
|
||
| if enrollments: | ||
| enrollment = enrollments[0] | ||
| if not edx_request_success: |
There was a problem hiding this comment.
What will this status be? A false? Because the user would already be enrolled in the course in edX
42febec to
3e558f1
Compare
|
@arslanashraf7, this is ready for another look. A test is failing but it doesn't seem to be related to the changes in this PR |
arslanashraf7
left a comment
There was a problem hiding this comment.
The code looks fine, But I'm unable to test it end-to-end with edX. Did you test it end-to-end?
I was able to test using Postman/curl but not when I'm integrated with edX, I get 403 errors while edX tried to enroll the user.
|
Yes, I tested it end-to-end. It’s most likely a rate-limiting issue, probably caused by the frequent execution of the retry registration task in the MITxOnline Celery workers. The simplest solution would be to temporarily comment out the code here: |
| @extend_schema(exclude=True) | ||
| @api_view(["POST"]) | ||
| @authentication_classes([OAuth2Authentication]) | ||
| @permission_classes([IsAuthenticated]) |
There was a problem hiding this comment.
This should only be a staff token-based API call, not just authenticated. The tests should update accordingly.
| @permission_classes([IsAuthenticated]) | |
| @permission_classes([IsAdminUser]) |
arslanashraf7
left a comment
There was a problem hiding this comment.
👍 with a few nits. There is also an openapi check failing.
| def test_missing_email(self, api_client, oauth_token): | ||
| """Test request missing email returns 400""" | ||
| payload = {"course_id": "course-v1:MITx+1.001x+2025_T1", "role": "staff"} | ||
| response = self._post_webhook(api_client, payload, token=oauth_token.token) | ||
| assert response.status_code == status.HTTP_400_BAD_REQUEST | ||
|
|
||
| def test_missing_course_id(self, api_client, oauth_token): | ||
| """Test request missing course_id returns 400""" | ||
| payload = {"email": "instructor@example.com", "role": "staff"} | ||
| response = self._post_webhook(api_client, payload, token=oauth_token.token) | ||
| assert response.status_code == status.HTTP_400_BAD_REQUEST |
There was a problem hiding this comment.
nit: Can be a single parametrized test.
| def test_invalid_token(self, api_client, webhook_payload): | ||
| """Test request with invalid Bearer token returns 401""" | ||
| response = self._post_webhook( | ||
| api_client, | ||
| webhook_payload, | ||
| token="invalid-token", # noqa: S106 | ||
| ) | ||
| assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||
|
|
||
| def test_expired_token(self, api_client, webhook_payload, expired_oauth_token): |
There was a problem hiding this comment.
Can be a single parametrized test.
| def test_missing_email(self, api_client, oauth_token): | ||
| """Test request missing email returns 400""" | ||
| payload = {"course_id": "course-v1:MITx+1.001x+2025_T1", "role": "staff"} | ||
| response = self._post_webhook(api_client, payload, token=oauth_token.token) | ||
| assert response.status_code == status.HTTP_400_BAD_REQUEST | ||
|
|
||
| def test_missing_course_id(self, api_client, oauth_token): | ||
| """Test request missing course_id returns 400""" | ||
| payload = {"email": "instructor@example.com", "role": "staff"} | ||
| response = self._post_webhook(api_client, payload, token=oauth_token.token) | ||
| assert response.status_code == status.HTTP_400_BAD_REQUEST | ||
|
|
There was a problem hiding this comment.
nit: Can be a single parametrized test.
| def test_user_not_found(self, api_client, oauth_token): | ||
| """Test returns 404 when the user email doesn't exist""" | ||
| course_run = CourseRunFactory.create() | ||
| payload = { | ||
| "email": "nonexistent@example.com", | ||
| "course_id": course_run.courseware_id, | ||
| "role": "instructor", | ||
| } | ||
| response = self._post_webhook(api_client, payload, token=oauth_token.token) | ||
| assert response.status_code == status.HTTP_404_NOT_FOUND | ||
| assert "not found" in response.data["error"] | ||
|
|
||
| def test_course_run_not_found(self, api_client, oauth_token): |
There was a problem hiding this comment.
nit: Can be a single parametrized test.
115e47f to
52195a6
Compare
7753eea to
6847635
Compare

What are the relevant tickets?
https://github.com/mitodl/hq/issues/1209 > https://github.com/mitodl/hq/issues/10518
Description (What does it do?)
This PR adds a webhook endpoint (
api/openedx_webhook/enrollment/) that receives enrollment notifications from Open edX. When a user needs to be enrolled in a course (e.g., staff/instructor role added), the Open edX plugin POSTs the event to MITx Online, which then creates a local enrollment record for the user as an auditor in the corresponding course run (without calling back to Open edX, since the enrollment already exists there).The endpoint authenticates requests using an OAuth2 Bearer access token (Django OAuth Toolkit /
OAuth2Authentication), and handles user/course lookup, idempotent behavior (repeated webhook calls), and appropriate error responses. This enables seamless synchronization of course enrollment state from Open edX into MITx Online.Screenshots (if appropriate):
How can this be tested?
Prerequisites
docker compose up)Automated Tests
docker compose run --rm web uv run pytest openedx/views_test.py -vManual Testing via cURL
Create/Get an OAuth2 token (MITx Online)
<ACCESS_TOKEN>below.Successful enrollment (happy path)
201 Createdwith{"message": "Enrollment successful", "enrollment_id": ..., "active": true, "edx_enrolled": true}CourseRunEnrollmentexists for the user withenrollment_mode=auditIdempotency — already enrolled user
200 OK(should not error; enrollment remains present/active)Missing Authorization header
401 UnauthorizedInvalid/expired token
401 UnauthorizedUser not found
404 Not FoundCourse run not found
404 Not FoundMissing required fields
email:400 Bad RequestGET method rejected
curl http://mitxonline.odl.local:9080/api/openedx_webhook/enrollment/ \ -H "Authorization: Bearer <ACCESS_TOKEN>"405 Method Not AllowedEnd-to-End Testing (with Open edX plugin)
Authorization: Bearer <ACCESS_TOKEN>header.Dashboard Fix (null start_date)
/dashboard/)Additional Context