Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/ol_openedx_events_handler/CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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)
---------------------------

Expand Down
4 changes: 4 additions & 0 deletions src/ol_openedx_events_handler/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,6 +45,8 @@ edx-platform configuration

ENROLLMENT_WEBHOOK_URL: "https://example.com/api/openedx_webhook/enrollment/"
ENROLLMENT_WEBHOOK_ACCESS_TOKEN: "<your-oauth-access-token>"
CERTIFICATE_WEBHOOK_URL: "https://example.com/api/openedx_webhook/certificate/"
CERTIFICATE_WEBHOOK_ACCESS_TOKEN: "<your-oauth-access-token>"

- Optionally, override the roles that trigger the webhook (defaults to ``["instructor", "staff"]``):

Expand Down
45 changes: 27 additions & 18 deletions src/ol_openedx_events_handler/ol_openedx_events_handler/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -25,30 +45,19 @@ 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",
PluginSignals.RECEIVERS: [_COURSE_ACCESS_ROLE_ADDED_RECEIVER],
},
},
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,
},
}
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Signal receivers for ol_openedx_events_handler."""
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
29 changes: 29 additions & 0 deletions src/ol_openedx_events_handler/ol_openedx_events_handler/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion src/ol_openedx_events_handler/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading