Skip to content

Commit 50f59f0

Browse files
feat: add event handler webhook for certificate generation (#684)
* feat: add event handler webhook for certificate generation * retry to 2
1 parent f750fdb commit 50f59f0

14 files changed

Lines changed: 416 additions & 20 deletions

File tree

src/ol_openedx_events_handler/CHANGELOG.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
Change Log
22
==========
33

4+
Version 0.2.0 (2026-04-17)
5+
---------------------------
6+
7+
* Added LMS receiver for ``COURSE_GRADE_NOW_PASSED`` to trigger certificate
8+
creation callbacks in MIT systems.
9+
410
Version 0.1.0 (2026-03-17)
511
---------------------------
612

src/ol_openedx_events_handler/README.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Currently handled events:
1818
access role (e.g. instructor, staff) is added, notifies an external system
1919
via webhook so the user can be enrolled as an auditor in the corresponding
2020
course.
21+
* ``openedx.core.djangoapps.signals.signals.COURSE_GRADE_NOW_PASSED`` — When a learner earns a passing grade,
22+
notifies an external system to create a certificate.
2123

2224

2325
Installation
@@ -43,6 +45,8 @@ edx-platform configuration
4345
4446
ENROLLMENT_WEBHOOK_URL: "https://example.com/api/openedx_webhook/enrollment/"
4547
ENROLLMENT_WEBHOOK_ACCESS_TOKEN: "<your-oauth-access-token>"
48+
CERTIFICATE_WEBHOOK_URL: "https://example.com/api/openedx_webhook/certificate/"
49+
CERTIFICATE_WEBHOOK_ACCESS_TOKEN: "<your-oauth-access-token>"
4650
4751
- Optionally, override the roles that trigger the webhook (defaults to ``["instructor", "staff"]``):
4852

src/ol_openedx_events_handler/ol_openedx_events_handler/apps.py

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,26 @@
1515
),
1616
}
1717

18+
_COURSE_GRADE_NOW_PASSED_RECEIVER = {
19+
PluginSignals.RECEIVER_FUNC_NAME: "listen_for_passing_grade",
20+
PluginSignals.SIGNAL_PATH: (
21+
"openedx.core.djangoapps.signals.signals.COURSE_GRADE_NOW_PASSED"
22+
),
23+
PluginSignals.DISPATCH_UID: (
24+
"ol_openedx_events_handler.receivers.certificate_passing_receiver"
25+
".listen_for_passing_grade"
26+
),
27+
}
28+
29+
_SETTINGS_CONFIG = {
30+
SettingsType.COMMON: {
31+
PluginSettings.RELATIVE_PATH: "settings.common",
32+
},
33+
SettingsType.PRODUCTION: {
34+
PluginSettings.RELATIVE_PATH: "settings.production",
35+
},
36+
}
37+
1838

1939
class OlOpenedxEventsHandlerConfig(AppConfig):
2040
"""App configuration for the OL Open edX events handler plugin."""
@@ -25,30 +45,19 @@ class OlOpenedxEventsHandlerConfig(AppConfig):
2545
plugin_app = {
2646
PluginSignals.CONFIG: {
2747
ProjectType.LMS: {
28-
PluginSignals.RELATIVE_PATH: "handlers.course_access_role",
29-
PluginSignals.RECEIVERS: [_COURSE_ACCESS_ROLE_ADDED_RECEIVER],
48+
PluginSignals.RELATIVE_PATH: "handlers.lms",
49+
PluginSignals.RECEIVERS: [
50+
_COURSE_ACCESS_ROLE_ADDED_RECEIVER,
51+
_COURSE_GRADE_NOW_PASSED_RECEIVER,
52+
],
3053
},
3154
ProjectType.CMS: {
3255
PluginSignals.RELATIVE_PATH: "handlers.course_access_role",
3356
PluginSignals.RECEIVERS: [_COURSE_ACCESS_ROLE_ADDED_RECEIVER],
3457
},
3558
},
3659
PluginSettings.CONFIG: {
37-
ProjectType.LMS: {
38-
SettingsType.COMMON: {
39-
PluginSettings.RELATIVE_PATH: "settings.common",
40-
},
41-
SettingsType.PRODUCTION: {
42-
PluginSettings.RELATIVE_PATH: "settings.production",
43-
},
44-
},
45-
ProjectType.CMS: {
46-
SettingsType.COMMON: {
47-
PluginSettings.RELATIVE_PATH: "settings.common",
48-
},
49-
SettingsType.PRODUCTION: {
50-
PluginSettings.RELATIVE_PATH: "settings.production",
51-
},
52-
},
60+
ProjectType.LMS: _SETTINGS_CONFIG,
61+
ProjectType.CMS: _SETTINGS_CONFIG,
5362
},
5463
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""LMS-only signal handlers exported for plugin signal registration."""
2+
3+
from ol_openedx_events_handler.handlers.course_access_role import (
4+
handle_course_access_role_added,
5+
)
6+
from ol_openedx_events_handler.receivers.certificate_passing_receiver import (
7+
listen_for_passing_grade,
8+
)
9+
10+
__all__ = ["handle_course_access_role_added", "listen_for_passing_grade"]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Signal receivers for ol_openedx_events_handler."""
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Django Signal handlers."""
2+
3+
import logging
4+
5+
from common.djangoapps.course_modes import api as modes_api
6+
from common.djangoapps.student.models import CourseEnrollment
7+
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
8+
from xmodule.data import CertificatesDisplayBehaviors
9+
10+
from ol_openedx_events_handler.tasks.certificate_passing import (
11+
create_certificate_for_passing_grade,
12+
)
13+
from ol_openedx_events_handler.utils import validate_certificate_webhook
14+
15+
log = logging.getLogger(__name__)
16+
17+
18+
def _is_eligible_for_certificate(user, course_id):
19+
"""
20+
Determine whether certificate generation should be triggered.
21+
"""
22+
enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(
23+
user, course_id
24+
)
25+
26+
if not is_active:
27+
return False
28+
29+
is_mode_eligible_for_cert = modes_api.is_eligible_for_certificate(enrollment_mode)
30+
course_overview = CourseOverview.get_from_id(course_id)
31+
certificate_display_behavior = course_overview.certificates_display_behavior
32+
33+
return is_mode_eligible_for_cert and (
34+
course_overview.self_paced
35+
or certificate_display_behavior == CertificatesDisplayBehaviors.EARLY_NO_INFO
36+
)
37+
38+
39+
def listen_for_passing_grade(sender, user, course_id, **kwargs): # noqa: ARG001
40+
"""
41+
Automatically create a certificate in the relevant MIT application when a user
42+
completes a course and gets a passing grade.
43+
"""
44+
45+
if not _is_eligible_for_certificate(user, course_id):
46+
return
47+
48+
if not validate_certificate_webhook():
49+
return
50+
51+
course_key = str(course_id)
52+
user_email = getattr(user, "email", None)
53+
if not user_email and getattr(user, "pii", None):
54+
user_email = getattr(user.pii, "email", None)
55+
if not user_email:
56+
log.error(
57+
"Cannot dispatch certificate webhook without user email for course '%s'.",
58+
course_key,
59+
)
60+
return
61+
62+
log.info(
63+
"User '%s' passed course '%s'. Dispatching certificate webhook.",
64+
user_email,
65+
course_key,
66+
)
67+
create_certificate_for_passing_grade.delay(
68+
user_email=user_email,
69+
course_key=course_key,
70+
)

src/ol_openedx_events_handler/ol_openedx_events_handler/settings/common.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,9 @@ def plugin_settings(settings):
1414

1515
# Course access roles that should trigger the enrollment webhook.
1616
settings.ENROLLMENT_COURSE_ACCESS_ROLES = ["instructor", "staff"]
17+
18+
# Settings for the Certificate Webhook
19+
# Webhook URL used to request certificate creation after course completion.
20+
settings.CERTIFICATE_WEBHOOK_URL = None
21+
# OAuth access token for the certificate webhook.
22+
settings.CERTIFICATE_WEBHOOK_ACCESS_TOKEN = None

src/ol_openedx_events_handler/ol_openedx_events_handler/settings/production.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,10 @@ def plugin_settings(settings):
1616
settings.ENROLLMENT_COURSE_ACCESS_ROLES = env_tokens.get(
1717
"ENROLLMENT_COURSE_ACCESS_ROLES", settings.ENROLLMENT_COURSE_ACCESS_ROLES
1818
)
19+
20+
settings.CERTIFICATE_WEBHOOK_URL = env_tokens.get(
21+
"CERTIFICATE_WEBHOOK_URL", settings.CERTIFICATE_WEBHOOK_URL
22+
)
23+
settings.CERTIFICATE_WEBHOOK_ACCESS_TOKEN = env_tokens.get(
24+
"CERTIFICATE_WEBHOOK_ACCESS_TOKEN", settings.CERTIFICATE_WEBHOOK_ACCESS_TOKEN
25+
)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Celery tasks for certificate passing webhook notifications."""
2+
3+
import logging
4+
5+
import requests
6+
from celery import shared_task
7+
from django.conf import settings
8+
9+
log = logging.getLogger(__name__)
10+
11+
REQUEST_TIMEOUT = 30
12+
13+
14+
def _get_certificate_webhook_url():
15+
"""Return the configured certificate webhook URL."""
16+
return getattr(settings, "CERTIFICATE_WEBHOOK_URL", None)
17+
18+
19+
def _get_certificate_webhook_access_token():
20+
"""Return the configured certificate webhook access token."""
21+
return getattr(settings, "CERTIFICATE_WEBHOOK_ACCESS_TOKEN", None)
22+
23+
24+
@shared_task(
25+
autoretry_for=(requests.exceptions.RequestException,),
26+
retry_kwargs={"max_retries": 2},
27+
retry_backoff=True,
28+
retry_backoff_max=120,
29+
)
30+
def create_certificate_for_passing_grade(user_email, course_key):
31+
"""
32+
Notify an external system that a learner passed a course.
33+
34+
Sends a POST request to the configured certificate webhook endpoint so the
35+
external system can create a certificate for the learner.
36+
"""
37+
webhook_url = _get_certificate_webhook_url()
38+
access_token = _get_certificate_webhook_access_token()
39+
40+
if not webhook_url or not access_token:
41+
log.error(
42+
"Certificate webhook is not fully configured. "
43+
"Skipping dispatch for user '%s' in course '%s'.",
44+
user_email,
45+
course_key,
46+
)
47+
return
48+
49+
payload = {
50+
"email": user_email,
51+
"course_id": course_key,
52+
}
53+
54+
headers = {
55+
"Content-Type": "application/json",
56+
}
57+
if access_token:
58+
headers["Authorization"] = f"Bearer {access_token}"
59+
60+
log.info(
61+
"Sending certificate webhook for user '%s' in course '%s'.",
62+
user_email,
63+
course_key,
64+
)
65+
response = requests.post(
66+
webhook_url,
67+
json=payload,
68+
headers=headers,
69+
timeout=REQUEST_TIMEOUT,
70+
)
71+
response.raise_for_status()
72+
73+
log.info(
74+
"Successfully sent certificate webhook for user '%s' in course '%s'. "
75+
"Response status: %s",
76+
user_email,
77+
course_key,
78+
response.status_code,
79+
)

src/ol_openedx_events_handler/ol_openedx_events_handler/tasks/course_access_role.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
@shared_task(
1515
autoretry_for=(requests.exceptions.RequestException,),
16-
retry_kwargs={"max_retries": 3},
16+
retry_kwargs={"max_retries": 2},
1717
retry_backoff=True,
1818
retry_backoff_max=120,
1919
)

0 commit comments

Comments
 (0)