feat: add edx certificate webhook#3459
Conversation
OpenAPI ChangesShow/hide ## Changes for v0.yaml:Unexpected changes? Ensure your branch is up-to-date with |
| 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 |
There was a problem hiding this comment.
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.
43b0d69 to
fabb512
Compare
| """ | ||
|
|
||
| authentication_classes = [OAuth2Authentication] | ||
| permission_classes = [IsAuthenticated] |
There was a problem hiding this comment.
Should be admin level permissions. IsAdminUser
| 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) | ||
| ) |
There was a problem hiding this comment.
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.
| 0, | ||
| ) | ||
| for edx_grade, user in edx_grade_user_iter: | ||
| for edx_grade, run_user in edx_grade_user_iter: |
There was a problem hiding this comment.
nit: this rename isn't needed really.
| if created: | ||
| certificate_status = "created" | ||
| elif certificate: | ||
| certificate_status = "exists" |
There was a problem hiding this comment.
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.
| 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 | ||
| ) |
There was a problem hiding this comment.
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.
| if course_runs is None or course_runs.count() == 0: | ||
| log.info("No course runs matched the certificates generation criteria") | ||
| return | ||
| certificate_status = None |
There was a problem hiding this comment.
No need for this certificate_status = None and the related complexity, The view should just call this method and assume the certificate processing started.
| name="openedx-private-oauth-complete-no-apisix", | ||
| ), | ||
| path( | ||
| "api/certificate_webhook/", |
There was a problem hiding this comment.
| "api/certificate_webhook/", | |
| "api/process_certificate_webhook/", |
| return HttpResponse(status=status.HTTP_200_OK) | ||
|
|
||
|
|
||
| class CertificateWebhookView(APIView): |
There was a problem hiding this comment.
| class CertificateWebhookView(APIView): | |
| class ProcessCertificateWebhookView(APIView): |
| course_run_id, | ||
| ) | ||
|
|
||
| certificate_status = generate_course_run_certificates( |
There was a problem hiding this comment.
At this point, the view can check for an existing certificate before calling generate_course_run_certificates for idempotency.
af3f215 to
1d62ba6
Compare
f065d19 to
2ae2863
Compare
| if is_webhook and user and course_run: | ||
| course_runs = [course_run] |
There was a problem hiding this comment.
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:
- If a course run is passed, we process only that course run
- If a user is passed, we process only that user
- If
bypass_validationorforceis passed, we bypass the dates, eligibility, etc. checks
This way the method would be more flexible.
| permission_classes = [IsAdminUser] | ||
|
|
||
| def post(self, request): | ||
| user_email = request.data.get("user_id") |
There was a problem hiding this comment.
Also let's call it email:
| user_email = request.data.get("user_id") | |
| user_email = request.data.get("email") |
ca0bcfd to
22ff4d4
Compare
| 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). |
There was a problem hiding this comment.
| - 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). |
| if len(course_runs) == 1: | ||
| return |
There was a problem hiding this comment.
No need for this check. If there is just one course run in the loop, it will automatically stop at this point after continue.
| name="openedx-enrollment-webhook", | ||
| ), | ||
| path( | ||
| "api/process_certificate_webhook/", |
There was a problem hiding this comment.
Let's make the URL pattern similar to "api/openedx_webhook/enrollment/" above, to keep it standard with other webhook(s).
| authentication_classes = [OAuth2Authentication] | ||
| permission_classes = [IsAdminUser] | ||
|
|
||
| def post(self, request): |
There was a problem hiding this comment.
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.
| log.info( | ||
| "Certificate webhook received for user %s, course run %s", | ||
| user_email, | ||
| course_run_id, | ||
| ) |
There was a problem hiding this comment.
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"
| @extend_schema(exclude=True) | ||
| class ProcessCertificateWebhookView(APIView): |
There was a problem hiding this comment.
nit: let's use decorator based functional view here as well just like we did in enrollment webhook.
67b2ede to
d3c56a0
Compare
for more information, see https://pre-commit.ci
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):
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)
Expected response:
{"certificate_status": "created"} HTTP_STATUS: 2004. Test idempotency — duplicate call (200)
Run the same curl command again. Expected response:
{"certificate_status": "exists"} HTTP_STATUS: 2005. Test unauthenticated request (401)
Expected:
HTTP_STATUS: 4016. Test missing fields (400)
Expected:
HTTP_STATUS: 400with error about missingcourse_id.Additional Context