diff --git a/src/ol_openedx_events_handler/CHANGELOG.rst b/src/ol_openedx_events_handler/CHANGELOG.rst index 9a8b72b7..ba65d8d3 100644 --- a/src/ol_openedx_events_handler/CHANGELOG.rst +++ b/src/ol_openedx_events_handler/CHANGELOG.rst @@ -1,6 +1,12 @@ Change Log ========== +Version 0.2.0 (2026-04-17) +--------------------------- + +* Added LMS receiver for ``COURSE_GRADE_NOW_PASSED`` to trigger certificate + creation callbacks in MIT systems. + Version 0.1.0 (2026-03-17) --------------------------- diff --git a/src/ol_openedx_events_handler/README.rst b/src/ol_openedx_events_handler/README.rst index 5e7d6bb7..1b024782 100644 --- a/src/ol_openedx_events_handler/README.rst +++ b/src/ol_openedx_events_handler/README.rst @@ -18,6 +18,8 @@ Currently handled events: access role (e.g. instructor, staff) is added, notifies an external system via webhook so the user can be enrolled as an auditor in the corresponding course. +* ``openedx.core.djangoapps.signals.signals.COURSE_GRADE_NOW_PASSED`` — When a learner earns a passing grade, + notifies an external system to create a certificate. Installation @@ -43,6 +45,8 @@ edx-platform configuration ENROLLMENT_WEBHOOK_URL: "https://example.com/api/openedx_webhook/enrollment/" ENROLLMENT_WEBHOOK_ACCESS_TOKEN: "" + CERTIFICATE_WEBHOOK_URL: "https://example.com/api/openedx_webhook/certificate/" + CERTIFICATE_WEBHOOK_ACCESS_TOKEN: "" - Optionally, override the roles that trigger the webhook (defaults to ``["instructor", "staff"]``): diff --git a/src/ol_openedx_events_handler/ol_openedx_events_handler/apps.py b/src/ol_openedx_events_handler/ol_openedx_events_handler/apps.py index e8d8bc86..ac608752 100644 --- a/src/ol_openedx_events_handler/ol_openedx_events_handler/apps.py +++ b/src/ol_openedx_events_handler/ol_openedx_events_handler/apps.py @@ -15,6 +15,26 @@ ), } +_COURSE_GRADE_NOW_PASSED_RECEIVER = { + PluginSignals.RECEIVER_FUNC_NAME: "listen_for_passing_grade", + PluginSignals.SIGNAL_PATH: ( + "openedx.core.djangoapps.signals.signals.COURSE_GRADE_NOW_PASSED" + ), + PluginSignals.DISPATCH_UID: ( + "ol_openedx_events_handler.receivers.certificate_passing_receiver" + ".listen_for_passing_grade" + ), +} + +_SETTINGS_CONFIG = { + SettingsType.COMMON: { + PluginSettings.RELATIVE_PATH: "settings.common", + }, + SettingsType.PRODUCTION: { + PluginSettings.RELATIVE_PATH: "settings.production", + }, +} + class OlOpenedxEventsHandlerConfig(AppConfig): """App configuration for the OL Open edX events handler plugin.""" @@ -25,8 +45,11 @@ class OlOpenedxEventsHandlerConfig(AppConfig): plugin_app = { PluginSignals.CONFIG: { ProjectType.LMS: { - PluginSignals.RELATIVE_PATH: "handlers.course_access_role", - PluginSignals.RECEIVERS: [_COURSE_ACCESS_ROLE_ADDED_RECEIVER], + PluginSignals.RELATIVE_PATH: "handlers.lms", + PluginSignals.RECEIVERS: [ + _COURSE_ACCESS_ROLE_ADDED_RECEIVER, + _COURSE_GRADE_NOW_PASSED_RECEIVER, + ], }, ProjectType.CMS: { PluginSignals.RELATIVE_PATH: "handlers.course_access_role", @@ -34,21 +57,7 @@ class OlOpenedxEventsHandlerConfig(AppConfig): }, }, PluginSettings.CONFIG: { - ProjectType.LMS: { - SettingsType.COMMON: { - PluginSettings.RELATIVE_PATH: "settings.common", - }, - SettingsType.PRODUCTION: { - PluginSettings.RELATIVE_PATH: "settings.production", - }, - }, - ProjectType.CMS: { - SettingsType.COMMON: { - PluginSettings.RELATIVE_PATH: "settings.common", - }, - SettingsType.PRODUCTION: { - PluginSettings.RELATIVE_PATH: "settings.production", - }, - }, + ProjectType.LMS: _SETTINGS_CONFIG, + ProjectType.CMS: _SETTINGS_CONFIG, }, } diff --git a/src/ol_openedx_events_handler/ol_openedx_events_handler/handlers/lms.py b/src/ol_openedx_events_handler/ol_openedx_events_handler/handlers/lms.py new file mode 100644 index 00000000..5977ebdf --- /dev/null +++ b/src/ol_openedx_events_handler/ol_openedx_events_handler/handlers/lms.py @@ -0,0 +1,10 @@ +"""LMS-only signal handlers exported for plugin signal registration.""" + +from ol_openedx_events_handler.handlers.course_access_role import ( + handle_course_access_role_added, +) +from ol_openedx_events_handler.receivers.certificate_passing_receiver import ( + listen_for_passing_grade, +) + +__all__ = ["handle_course_access_role_added", "listen_for_passing_grade"] diff --git a/src/ol_openedx_events_handler/ol_openedx_events_handler/receivers/__init__.py b/src/ol_openedx_events_handler/ol_openedx_events_handler/receivers/__init__.py new file mode 100644 index 00000000..eb0e2ed9 --- /dev/null +++ b/src/ol_openedx_events_handler/ol_openedx_events_handler/receivers/__init__.py @@ -0,0 +1 @@ +"""Signal receivers for ol_openedx_events_handler.""" diff --git a/src/ol_openedx_events_handler/ol_openedx_events_handler/receivers/certificate_passing_receiver.py b/src/ol_openedx_events_handler/ol_openedx_events_handler/receivers/certificate_passing_receiver.py new file mode 100644 index 00000000..cacf5327 --- /dev/null +++ b/src/ol_openedx_events_handler/ol_openedx_events_handler/receivers/certificate_passing_receiver.py @@ -0,0 +1,70 @@ +"""Django Signal handlers.""" + +import logging + +from common.djangoapps.course_modes import api as modes_api +from common.djangoapps.student.models import CourseEnrollment +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from xmodule.data import CertificatesDisplayBehaviors + +from ol_openedx_events_handler.tasks.certificate_passing import ( + create_certificate_for_passing_grade, +) +from ol_openedx_events_handler.utils import validate_certificate_webhook + +log = logging.getLogger(__name__) + + +def _is_eligible_for_certificate(user, course_id): + """ + Determine whether certificate generation should be triggered. + """ + enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user( + user, course_id + ) + + if not is_active: + return False + + is_mode_eligible_for_cert = modes_api.is_eligible_for_certificate(enrollment_mode) + course_overview = CourseOverview.get_from_id(course_id) + certificate_display_behavior = course_overview.certificates_display_behavior + + return is_mode_eligible_for_cert and ( + course_overview.self_paced + or certificate_display_behavior == CertificatesDisplayBehaviors.EARLY_NO_INFO + ) + + +def listen_for_passing_grade(sender, user, course_id, **kwargs): # noqa: ARG001 + """ + Automatically create a certificate in the relevant MIT application when a user + completes a course and gets a passing grade. + """ + + if not _is_eligible_for_certificate(user, course_id): + return + + if not validate_certificate_webhook(): + return + + course_key = str(course_id) + user_email = getattr(user, "email", None) + if not user_email and getattr(user, "pii", None): + user_email = getattr(user.pii, "email", None) + if not user_email: + log.error( + "Cannot dispatch certificate webhook without user email for course '%s'.", + course_key, + ) + return + + log.info( + "User '%s' passed course '%s'. Dispatching certificate webhook.", + user_email, + course_key, + ) + create_certificate_for_passing_grade.delay( + user_email=user_email, + course_key=course_key, + ) diff --git a/src/ol_openedx_events_handler/ol_openedx_events_handler/settings/common.py b/src/ol_openedx_events_handler/ol_openedx_events_handler/settings/common.py index e54fa777..187afa0b 100644 --- a/src/ol_openedx_events_handler/ol_openedx_events_handler/settings/common.py +++ b/src/ol_openedx_events_handler/ol_openedx_events_handler/settings/common.py @@ -14,3 +14,9 @@ def plugin_settings(settings): # Course access roles that should trigger the enrollment webhook. settings.ENROLLMENT_COURSE_ACCESS_ROLES = ["instructor", "staff"] + + # Settings for the Certificate Webhook + # Webhook URL used to request certificate creation after course completion. + settings.CERTIFICATE_WEBHOOK_URL = None + # OAuth access token for the certificate webhook. + settings.CERTIFICATE_WEBHOOK_ACCESS_TOKEN = None diff --git a/src/ol_openedx_events_handler/ol_openedx_events_handler/settings/production.py b/src/ol_openedx_events_handler/ol_openedx_events_handler/settings/production.py index dfffb059..1dad48b1 100644 --- a/src/ol_openedx_events_handler/ol_openedx_events_handler/settings/production.py +++ b/src/ol_openedx_events_handler/ol_openedx_events_handler/settings/production.py @@ -16,3 +16,10 @@ def plugin_settings(settings): settings.ENROLLMENT_COURSE_ACCESS_ROLES = env_tokens.get( "ENROLLMENT_COURSE_ACCESS_ROLES", settings.ENROLLMENT_COURSE_ACCESS_ROLES ) + + settings.CERTIFICATE_WEBHOOK_URL = env_tokens.get( + "CERTIFICATE_WEBHOOK_URL", settings.CERTIFICATE_WEBHOOK_URL + ) + settings.CERTIFICATE_WEBHOOK_ACCESS_TOKEN = env_tokens.get( + "CERTIFICATE_WEBHOOK_ACCESS_TOKEN", settings.CERTIFICATE_WEBHOOK_ACCESS_TOKEN + ) diff --git a/src/ol_openedx_events_handler/ol_openedx_events_handler/tasks/certificate_passing.py b/src/ol_openedx_events_handler/ol_openedx_events_handler/tasks/certificate_passing.py new file mode 100644 index 00000000..5ccde459 --- /dev/null +++ b/src/ol_openedx_events_handler/ol_openedx_events_handler/tasks/certificate_passing.py @@ -0,0 +1,79 @@ +"""Celery tasks for certificate passing webhook notifications.""" + +import logging + +import requests +from celery import shared_task +from django.conf import settings + +log = logging.getLogger(__name__) + +REQUEST_TIMEOUT = 30 + + +def _get_certificate_webhook_url(): + """Return the configured certificate webhook URL.""" + return getattr(settings, "CERTIFICATE_WEBHOOK_URL", None) + + +def _get_certificate_webhook_access_token(): + """Return the configured certificate webhook access token.""" + return getattr(settings, "CERTIFICATE_WEBHOOK_ACCESS_TOKEN", None) + + +@shared_task( + autoretry_for=(requests.exceptions.RequestException,), + retry_kwargs={"max_retries": 2}, + retry_backoff=True, + retry_backoff_max=120, +) +def create_certificate_for_passing_grade(user_email, course_key): + """ + Notify an external system that a learner passed a course. + + Sends a POST request to the configured certificate webhook endpoint so the + external system can create a certificate for the learner. + """ + webhook_url = _get_certificate_webhook_url() + access_token = _get_certificate_webhook_access_token() + + if not webhook_url or not access_token: + log.error( + "Certificate webhook is not fully configured. " + "Skipping dispatch for user '%s' in course '%s'.", + user_email, + course_key, + ) + return + + payload = { + "email": user_email, + "course_id": course_key, + } + + headers = { + "Content-Type": "application/json", + } + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + + log.info( + "Sending certificate webhook for user '%s' in course '%s'.", + user_email, + course_key, + ) + response = requests.post( + webhook_url, + json=payload, + headers=headers, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + + log.info( + "Successfully sent certificate webhook for user '%s' in course '%s'. " + "Response status: %s", + user_email, + course_key, + response.status_code, + ) diff --git a/src/ol_openedx_events_handler/ol_openedx_events_handler/tasks/course_access_role.py b/src/ol_openedx_events_handler/ol_openedx_events_handler/tasks/course_access_role.py index e4a51909..4c8c7bba 100644 --- a/src/ol_openedx_events_handler/ol_openedx_events_handler/tasks/course_access_role.py +++ b/src/ol_openedx_events_handler/ol_openedx_events_handler/tasks/course_access_role.py @@ -13,7 +13,7 @@ @shared_task( autoretry_for=(requests.exceptions.RequestException,), - retry_kwargs={"max_retries": 3}, + retry_kwargs={"max_retries": 2}, retry_backoff=True, retry_backoff_max=120, ) diff --git a/src/ol_openedx_events_handler/ol_openedx_events_handler/utils.py b/src/ol_openedx_events_handler/ol_openedx_events_handler/utils.py index 27e403af..d7a079be 100644 --- a/src/ol_openedx_events_handler/ol_openedx_events_handler/utils.py +++ b/src/ol_openedx_events_handler/ol_openedx_events_handler/utils.py @@ -34,3 +34,32 @@ def validate_enrollment_webhook(): return False return True + + +def validate_certificate_webhook(): + """ + Validate that the certificate webhook is properly configured. + + Checks that both the certificate webhook URL and access token are set in + Django settings. + + Returns: + bool: True if the webhook is fully configured, False otherwise. + """ + webhook_url = getattr(settings, "CERTIFICATE_WEBHOOK_URL", None) + if not webhook_url: + log.warning( + "Certificate webhook URL is not configured. " + "Skipping certificate webhook dispatch." + ) + return False + + webhook_key = getattr(settings, "CERTIFICATE_WEBHOOK_ACCESS_TOKEN", None) + if not webhook_key: + log.warning( + "Certificate webhook access token is not configured. " + "Skipping certificate webhook dispatch." + ) + return False + + return True diff --git a/src/ol_openedx_events_handler/pyproject.toml b/src/ol_openedx_events_handler/pyproject.toml index 08396161..966f0035 100644 --- a/src/ol_openedx_events_handler/pyproject.toml +++ b/src/ol_openedx_events_handler/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ol-openedx-events-handler" -version = "0.1.0" +version = "0.2.0" description = "Open edX plugin to handle Open edX signals and events for MIT OL" readme = "README.rst" requires-python = ">=3.11" diff --git a/src/ol_openedx_events_handler/tests/test_certificate_passing_receiver.py b/src/ol_openedx_events_handler/tests/test_certificate_passing_receiver.py new file mode 100644 index 00000000..b7a1dd45 --- /dev/null +++ b/src/ol_openedx_events_handler/tests/test_certificate_passing_receiver.py @@ -0,0 +1,80 @@ +"""Tests for the COURSE_GRADE_NOW_PASSED signal handler.""" + +from unittest import mock + +from ol_openedx_events_handler.receivers.certificate_passing_receiver import ( + listen_for_passing_grade, +) + +COURSE_KEY = "course-v1:MITx+6.001x+2026_T1" +VALID_WEBHOOK_PATCH = mock.patch( + "ol_openedx_events_handler.receivers.certificate_passing_receiver" + ".validate_certificate_webhook", + return_value=True, +) +TASK_PATCH = mock.patch( + "ol_openedx_events_handler.receivers.certificate_passing_receiver" + ".create_certificate_for_passing_grade" +) + + +@mock.patch( + "ol_openedx_events_handler.receivers.certificate_passing_receiver" + "._is_eligible_for_certificate", + return_value=False, +) +@TASK_PATCH +def test_skips_when_not_eligible(mock_task, _mock_is_eligible): # noqa: PT019 + """Do nothing when the enrollment is not certificate-eligible.""" + user = mock.MagicMock(email="user@example.com", username="user") + + listen_for_passing_grade(sender=None, user=user, course_id=COURSE_KEY) + + mock_task.delay.assert_not_called() + + +@VALID_WEBHOOK_PATCH +@mock.patch( + "ol_openedx_events_handler.receivers.certificate_passing_receiver" + "._is_eligible_for_certificate", + return_value=True, +) +@TASK_PATCH +def test_dispatches_certificate_task( + mock_task, + _mock_is_eligible, # noqa: PT019 + _mock_validate, # noqa: PT019 +): + """Dispatch certificate task when learner is eligible and configured.""" + user = mock.MagicMock(email="user@example.com", username="user") + + listen_for_passing_grade(sender=None, user=user, course_id=COURSE_KEY) + + mock_task.delay.assert_called_once_with( + user_email="user@example.com", + course_key=COURSE_KEY, + ) + + +@mock.patch( + "ol_openedx_events_handler.receivers.certificate_passing_receiver" + ".validate_certificate_webhook", + return_value=False, +) +@mock.patch( + "ol_openedx_events_handler.receivers.certificate_passing_receiver" + "._is_eligible_for_certificate", + return_value=True, +) +@TASK_PATCH +def test_skips_when_certificate_webhook_not_configured( + mock_task, + _mock_is_eligible, # noqa: PT019 + _mock_validate, # noqa: PT019 +): + """Skip dispatch when no certificate webhook URL is configured.""" + user = mock.MagicMock(email="user@example.com", username="user") + + listen_for_passing_grade(sender=None, user=user, course_id=COURSE_KEY) + + mock_task.delay.assert_not_called() diff --git a/src/ol_openedx_events_handler/tests/test_certificate_passing_tasks.py b/src/ol_openedx_events_handler/tests/test_certificate_passing_tasks.py new file mode 100644 index 00000000..34f1be8d --- /dev/null +++ b/src/ol_openedx_events_handler/tests/test_certificate_passing_tasks.py @@ -0,0 +1,95 @@ +"""Tests for the certificate passing webhook Celery task.""" + +from unittest import mock + +import pytest +import requests +from django.test import override_settings +from ol_openedx_events_handler.tasks.certificate_passing import ( + create_certificate_for_passing_grade, +) + +WEBHOOK_URL = "https://example.com/api/openedx_webhook/certificate/" +ACCESS_TOKEN = "certificate-access-token" # noqa: S105 +USER_EMAIL = "learner@example.com" +COURSE_KEY = "course-v1:MITx+6.001x+2026_T1" + + +@mock.patch("ol_openedx_events_handler.tasks.certificate_passing.requests.post") +def test_sends_certificate_webhook(mock_post): + """POST the certificate payload with the configured auth header.""" + mock_response = mock.MagicMock() + mock_response.status_code = 200 + mock_post.return_value = mock_response + + with override_settings( + CERTIFICATE_WEBHOOK_URL=WEBHOOK_URL, + CERTIFICATE_WEBHOOK_ACCESS_TOKEN=ACCESS_TOKEN, + ): + create_certificate_for_passing_grade( + user_email=USER_EMAIL, + course_key=COURSE_KEY, + ) + + mock_post.assert_called_once_with( + WEBHOOK_URL, + json={ + "email": USER_EMAIL, + "course_id": COURSE_KEY, + }, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {ACCESS_TOKEN}", + }, + timeout=30, + ) + mock_response.raise_for_status.assert_called_once() + + +@override_settings( + CERTIFICATE_WEBHOOK_URL=WEBHOOK_URL, + CERTIFICATE_WEBHOOK_ACCESS_TOKEN=ACCESS_TOKEN, +) +@mock.patch("ol_openedx_events_handler.tasks.certificate_passing.requests.post") +def test_raises_on_http_error(mock_post): + """HTTP errors should propagate for Celery retry.""" + mock_response = mock.MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError( + "500 Server Error" + ) + mock_post.return_value = mock_response + + with pytest.raises(requests.exceptions.HTTPError): + create_certificate_for_passing_grade( + user_email=USER_EMAIL, + course_key=COURSE_KEY, + ) + + +@pytest.mark.parametrize( + ("certificate_webhook_url", "certificate_webhook_access_token"), + [ + pytest.param(None, ACCESS_TOKEN, id="missing-webhook-url"), + pytest.param(WEBHOOK_URL, None, id="missing-access-token"), + ], +) +@mock.patch("ol_openedx_events_handler.tasks.certificate_passing.log.error") +@mock.patch("ol_openedx_events_handler.tasks.certificate_passing.requests.post") +def test_skips_dispatch_when_webhook_not_fully_configured( + mock_post, + mock_log_error, + certificate_webhook_url, + certificate_webhook_access_token, +): + """Do not call certificate webhook when required settings are missing.""" + with override_settings( + CERTIFICATE_WEBHOOK_URL=certificate_webhook_url, + CERTIFICATE_WEBHOOK_ACCESS_TOKEN=certificate_webhook_access_token, + ): + create_certificate_for_passing_grade( + user_email=USER_EMAIL, + course_key=COURSE_KEY, + ) + + mock_post.assert_not_called() + mock_log_error.assert_called_once()