Skip to content

feat: add edx certificate webhook#3459

Open
Anas12091101 wants to merge 8 commits intomainfrom
anas/add-edx-certificate-webhook
Open

feat: add edx certificate webhook#3459
Anas12091101 wants to merge 8 commits intomainfrom
anas/add-edx-certificate-webhook

Conversation

@Anas12091101
Copy link
Copy Markdown
Contributor

@Anas12091101 Anas12091101 commented Apr 6, 2026

What are the relevant tickets?

https://github.com/mitodl/hq/issues/10777

Description (What does it do?)

This PR adds a webhook API endpoint (POST /api/certificate_webhook/) that receives certificate creation events from an Open edX plugin. When edX creates a certificate, the plugin (WIP, see: mitodl/open-edx-plugins#684) sends the user's email and course run ID; this endpoint fetches the grade from edX, syncs it locally, and creates the corresponding certificate in MITx Online.

Screenshots (if appropriate):

  • Desktop screenshots
  • Mobile width screenshots

How can this be tested?

1. Set up OAuth2 credentials

Create an OAuth2 access token in Django admin (/admin/oauth2_provider/accesstoken/) or use an existing access token.

2. Prerequisites

Ensure the user has a verified enrollment in the course run, and the course run exists in edX with a passing grade for the user.

3. Test certificate creation (201)

curl -s -w "\nHTTP_STATUS: %{http_code}\n" -X POST \
  http://localhost:9080/api/process_certificate_webhook/ \
  -H "Authorization: Bearer <your_access_token>" \
  -H "Content-Type: application/json" \
  -d '{"user_id": "<user_email>", "course_id": "<course_run_courseware_id>"}'

Expected response:

{"certificate_status": "created"}
HTTP_STATUS: 200

4. Test idempotency — duplicate call (200)

Run the same curl command again. Expected response:

{"certificate_status": "exists"}
HTTP_STATUS: 200

5. Test unauthenticated request (401)

curl -s -w "\nHTTP_STATUS: %{http_code}\n" -X POST \
  http://localhost:9080/api/process_certificate_webhook/ \
  -H "Content-Type: application/json" \
  -d '{"user_id": "test@example.com", "course_id": "course-v1:MITx+1+2024"}'

Expected: HTTP_STATUS: 401

6. Test missing fields (400)

curl -s -w "\nHTTP_STATUS: %{http_code}\n" -X POST \
  http://localhost:9080/api/process_certificate_webhook/ \
  -H "Authorization: Bearer <your_access_token>" \
  -H "Content-Type: application/json" \
  -d '{"user_id": "test@example.com"}'

Expected: HTTP_STATUS: 400 with error about missing course_id.

Additional Context

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 6, 2026

OpenAPI Changes

Show/hide ## Changes for v0.yaml:
## 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).

Comment thread openedx/views_test.py
Comment on lines +57 to +66
def test_missing_fields_returns_400(
self, user_drf_client, payload, expected_error_field
):
"""Test that missing required fields return 400"""
response = user_drf_client.post(
self.WEBHOOK_URL,
payload,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The call to get_edx_grades_with_users in the webhook path is not wrapped in an exception handler, which can lead to unhandled exceptions from the external grades API.
Severity: HIGH

Suggested Fix

Wrap the get_edx_grades_with_users(run, user=user) call in the webhook path with the exception_logging_generator, similar to how it is handled in the periodic task path. This will ensure exceptions from the external API are caught and logged, preventing 500 errors.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: openedx/views_test.py#L57-L66

Potential issue: In the webhook path for certificate generation, the call to
`get_edx_grades_with_users` is not wrapped in an exception handler. This function makes
an external API call to the edX grades service. If this external call fails due to
network issues, an invalid `user.edx_username`, or other API errors, the exception will
propagate unhandled. This will cause the webhook endpoint to return a 500 server error
instead of gracefully handling the failure.

Did we get this right? 👍 / 👎 to inform future reviews.

@Anas12091101 Anas12091101 force-pushed the anas/add-edx-certificate-webhook branch from 43b0d69 to fabb512 Compare April 6, 2026 15:13
@arslanashraf7 arslanashraf7 self-assigned this Apr 16, 2026
Comment thread openedx/views.py Outdated
"""

authentication_classes = [OAuth2Authentication]
permission_classes = [IsAuthenticated]
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.

Should be admin level permissions. IsAdminUser

Comment thread courses/api.py Outdated
Comment on lines +937 to +943
if is_webhook:
# Webhook: fetch grade for the specific user only
edx_grade_user_iter = get_edx_grades_with_users(run, user=user)
else:
edx_grade_user_iter = exception_logging_generator(
get_edx_grades_with_users(run)
)
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.

Don't need this condition if the passed user is None. So, in both cases, we should be able to do get_edx_grades_with_users(run, user=user) where when user=None, it would automatically run for every user.

Comment thread courses/api.py
0,
)
for edx_grade, user in edx_grade_user_iter:
for edx_grade, run_user in edx_grade_user_iter:
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.

nit: this rename isn't needed really.

Comment thread courses/api.py Outdated
Comment on lines +1002 to +1005
if created:
certificate_status = "created"
elif certificate:
certificate_status = "exists"
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.

Aren't they always going to be available together? I checked process_course_run_grade_certificate and it would only return a certificate object when it creates one.

Comment thread openedx/views.py Outdated
Comment on lines +70 to +80
certificate_status = generate_course_run_certificates(
user=user,
course_run=course_run,
is_webhook=True,
)

response_status = (
status.HTTP_201_CREATED
if certificate_status == "created"
else status.HTTP_200_OK
)
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.

The idea of the webhook is to trigger the processing of the certificate. I don't think we specifically need to return the created/deleted status from the actual certificate creation. So, if a valid request is received, it would return 200 that the request if being processed.
Additionally, Open edX is not going to look for the actual status and do something about it unless it is 40x or 50x.

Comment thread courses/api.py Outdated
if course_runs is None or course_runs.count() == 0:
log.info("No course runs matched the certificates generation criteria")
return
certificate_status = None
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.

No need for this certificate_status = None and the related complexity, The view should just call this method and assume the certificate processing started.

Comment thread openedx/urls.py Outdated
name="openedx-private-oauth-complete-no-apisix",
),
path(
"api/certificate_webhook/",
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.

Suggested change
"api/certificate_webhook/",
"api/process_certificate_webhook/",

Comment thread openedx/views.py Outdated
return HttpResponse(status=status.HTTP_200_OK)


class CertificateWebhookView(APIView):
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.

Suggested change
class CertificateWebhookView(APIView):
class ProcessCertificateWebhookView(APIView):

Comment thread openedx/views.py Outdated
course_run_id,
)

certificate_status = generate_course_run_certificates(
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.

At this point, the view can check for an existing certificate before calling generate_course_run_certificates for idempotency.

@Anas12091101 Anas12091101 force-pushed the anas/add-edx-certificate-webhook branch 2 times, most recently from af3f215 to 1d62ba6 Compare April 20, 2026 12:30
Comment thread openedx/views.py
Comment thread courses/api.py Outdated
Comment thread courses/api.py
Comment thread courses/api.py Outdated
Comment on lines +959 to +960
if is_webhook and user and course_run:
course_runs = [course_run]
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.

Now that I think more about this, it would be kind of an anti-pattern. e.g., if someone is passing a course_run explicitly, we should still let them do the desired thing. So, if a run or a user is passed, the function should work on that independently. The webhook check is just to bypass the conditions below. In fact, I think the name of this boolean should be bypass_validation or force, which will allow running the cert generation without the dates and eligibility check.

In the end, I think we should handle every parameter separately:

  1. If a course run is passed, we process only that course run
  2. If a user is passed, we process only that user
  3. If bypass_validation or force is passed, we bypass the dates, eligibility, etc. checks
    This way the method would be more flexible.

Comment thread openedx/views.py Outdated
permission_classes = [IsAdminUser]

def post(self, request):
user_email = request.data.get("user_id")
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.

Also let's call it email:

Suggested change
user_email = request.data.get("user_id")
user_email = request.data.get("email")

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.

@Anas12091101 This still needs to be applied.

Comment thread courses/api.py Outdated
Hits the edX grades API and generates the certificates and grades for users for course runs.

Each parameter works independently:
- If course_run is provided, only that course run is processed (skipping eligibility filtering).
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.

Suggested change
- If course_run is provided, only that course run is processed (skipping eligibility filtering).
- If course_run is provided, only that course run is processed (skipping course runs eligibility filtering).

Comment thread courses/api.py Outdated
Comment on lines +988 to +989
if len(course_runs) == 1:
return
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.

No need for this check. If there is just one course run in the loop, it will automatically stop at this point after continue.

Comment thread openedx/urls.py Outdated
name="openedx-enrollment-webhook",
),
path(
"api/process_certificate_webhook/",
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.

Let's make the URL pattern similar to "api/openedx_webhook/enrollment/" above, to keep it standard with other webhook(s).

Comment thread openedx/views.py Outdated
authentication_classes = [OAuth2Authentication]
permission_classes = [IsAdminUser]

def post(self, request):
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.

Let's add more log statements in different code paths. Currently there is no log in the application that tells what happened to the request.

Comment thread openedx/views.py Outdated
Comment on lines +165 to +169
log.info(
"Certificate webhook received for user %s, course run %s",
user_email,
course_run_id,
)
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.

This log should be at the start of the view, indicating that the webhook request has been received. In this place, we can log something like "Validation completed, the webook will be processed. etc"

Comment thread openedx/views.py Outdated
Comment on lines +125 to +126
@extend_schema(exclude=True)
class ProcessCertificateWebhookView(APIView):
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.

nit: let's use decorator based functional view here as well just like we did in enrollment webhook.

@Anas12091101 Anas12091101 force-pushed the anas/add-edx-certificate-webhook branch from 67b2ede to d3c56a0 Compare April 30, 2026 12:33
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