diff --git a/frontend/public/src/components/EnrolledItemCard.js b/frontend/public/src/components/EnrolledItemCard.js index c96a10d3c6..f1a8463d8f 100644 --- a/frontend/public/src/components/EnrolledItemCard.js +++ b/frontend/public/src/components/EnrolledItemCard.js @@ -520,10 +520,11 @@ export class EnrolledItemCard extends React.Component< className="btn btn-primary btn-gradient-red-to-blue disabled" rel="noopener noreferrer" > - Starts{" "} - {formatPrettyMonthDate( - parseDateString(enrollment.run.start_date) - )} + {enrollment.run.start_date ? + `Starts ${formatPrettyMonthDate( + parseDateString(enrollment.run.start_date) + )}` : + "Coming Soon"} )} diff --git a/frontend/public/src/lib/util.js b/frontend/public/src/lib/util.js index d1973d6bb7..cf8f02c4a5 100644 --- a/frontend/public/src/lib/util.js +++ b/frontend/public/src/lib/util.js @@ -145,24 +145,24 @@ export const objectToFormData = (object: Object) => { } // Example return values: "January 1, 2019", "December 31, 2019" -export const formatPrettyDate = (momentDate: Moment) => - momentDate.format("MMMM D, YYYY") +export const formatPrettyDate = (momentDate: ?Moment) => + momentDate ? momentDate.format("MMMM D, YYYY") : "" // Example return values: "Jan 1, 2019", "Dec 31, 2019" -export const formatPrettyShortDate = (momentDate: Moment) => - momentDate.format("MMM D, YYYY") +export const formatPrettyShortDate = (momentDate: ?Moment) => + momentDate ? momentDate.format("MMM D, YYYY") : "" -export const formatPrettyMonthDate = (momentDate: Moment) => - momentDate.format("MMMM D") +export const formatPrettyMonthDate = (momentDate: ?Moment) => + momentDate ? momentDate.format("MMMM D") : "" -export const formatPrettyDateUtc = (momentDate: Moment) => - momentDate.tz("UTC").format("MMMM D, YYYY") +export const formatPrettyDateUtc = (momentDate: ?Moment) => + momentDate ? momentDate.tz("UTC").format("MMMM D, YYYY") : "" -export const formatPrettyDateTimeAmPm = (momentDate: Moment) => - momentDate.format("LLL") +export const formatPrettyDateTimeAmPm = (momentDate: ?Moment) => + momentDate ? momentDate.format("LLL") : "" -export const formatPrettyDateTimeAmPmTz = (monthDate: Moment) => - monthDate.tz(moment.tz.guess()).format("LLL z") +export const formatPrettyDateTimeAmPmTz = (monthDate: ?Moment) => + monthDate ? monthDate.tz(moment.tz.guess()).format("LLL z") : "" export const firstItem = R.view(R.lensIndex(0)) diff --git a/main/settings.py b/main/settings.py index 8a7677f766..dcb2448b58 100644 --- a/main/settings.py +++ b/main/settings.py @@ -1255,6 +1255,12 @@ description="Timeout (in seconds) for requests made via the edX API client", ) +OPENEDX_WEBHOOK_KEY = get_string( + name="OPENEDX_WEBHOOK_KEY", + default=None, + description="Shared secret token used to authenticate incoming webhook requests from Open edX", +) + # django debug toolbar only in debug mode if DEBUG: INSTALLED_APPS += ("debug_toolbar",) diff --git a/openedx/urls.py b/openedx/urls.py index 782b2c5bf0..ec5a30784d 100644 --- a/openedx/urls.py +++ b/openedx/urls.py @@ -15,4 +15,9 @@ views.openedx_private_auth_complete, name="openedx-private-oauth-complete-no-apisix", ), + path( + "api/openedx_webhook/enrollment/", + views.edx_enrollment_webhook, + name="openedx-enrollment-webhook", + ), ) diff --git a/openedx/views.py b/openedx/views.py index e41b86ec6b..d0f62db0f9 100644 --- a/openedx/views.py +++ b/openedx/views.py @@ -1,10 +1,171 @@ """Views for openedx""" +import logging + +from django.conf import settings from django.http import HttpResponse +from drf_spectacular.utils import extend_schema from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from courses.api import create_run_enrollments +from courses.models import CourseRun +from users.models import User + +log = logging.getLogger(__name__) def openedx_private_auth_complete(request): # noqa: ARG001 """Responds with a simple HTTP_200_OK""" # NOTE: this is only meant as a landing endpoint for api.create_edx_auth_token() flow return HttpResponse(status=status.HTTP_200_OK) + + +@extend_schema(exclude=True) +@api_view(["POST"]) +@permission_classes([AllowAny]) +def edx_enrollment_webhook(request): # noqa: PLR0911, C901 + """ + Webhook endpoint that receives enrollment notifications from Open edX. + + When a user needs to be enrolled in a course (e.g., staff/instructor role added), + the Open edX plugin POSTs to this endpoint so MITx Online can enroll them as an + auditor in the corresponding course run. + + Expected payload: + { + "email": "instructor@example.com", + "course_id": "course-v1:MITx+1.001x+2025_T1", + "role": "instructor" + } + """ + # --- Authenticate via Bearer token --- + webhook_key = getattr(settings, "OPENEDX_WEBHOOK_KEY", None) + if not webhook_key: + log.error("OPENEDX_WEBHOOK_KEY is not configured") + return Response( + {"error": "Webhook is not configured"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + if not auth_header.startswith("Bearer "): + return Response( + {"error": "Missing or invalid Authorization header"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + token = auth_header[len("Bearer ") :] + if token != webhook_key: + return Response( + {"error": "Invalid webhook token"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # --- Validate payload --- + email = request.data.get("email") + course_id = request.data.get("course_id") + role = request.data.get("role", "") + + if not email or not course_id: + return Response( + {"error": "Missing required fields: email and course_id"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # --- Look up user --- + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + log.warning( + "Webhook: No user found with email %s for course %s (role: %s)", + email, + course_id, + role, + ) + return Response( + {"error": f"User with email {email} not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + except User.MultipleObjectsReturned: + log.warning( + "Webhook: Multiple users found with email %s for course %s (role: %s)", + email, + course_id, + role, + ) + return Response( + {"error": f"Multiple users found with email {email}"}, + status=status.HTTP_409_CONFLICT, + ) + + # --- Look up course run --- + try: + course_run = CourseRun.objects.get(courseware_id=course_id) + except CourseRun.DoesNotExist: + log.warning( + "Webhook: No course run found with courseware_id %s (user: %s, role: %s)", + course_id, + email, + role, + ) + return Response( + {"error": f"Course run with id {course_id} not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # --- Enroll user as auditor --- + try: + enrollments, edx_request_success = create_run_enrollments( + user, + [course_run], + keep_failed_enrollments=True, + ) + except Exception: + log.exception( + "Webhook: Error creating enrollment for user %s in course run %s", + email, + course_id, + ) + return Response( + {"error": "Failed to create enrollment"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + if enrollments: + enrollment = enrollments[0] + if not edx_request_success: + log.warning( + "Webhook: Local enrollment created but edX API call failed for user %s in course run %s", + email, + course_id, + ) + log.info( + "Webhook: Successfully enrolled user %s in course run %s as auditor (role: %s, active: %s, edx_synced: %s)", + email, + course_id, + role, + enrollment.active, + edx_request_success, + ) + return Response( + { + "message": "Enrollment successful", + "enrollment_id": enrollment.id, + "active": enrollment.active, + "edx_enrolled": enrollment.edx_enrolled, + }, + status=status.HTTP_200_OK, + ) + else: + log.error( + "Webhook: Enrollment creation returned empty for user %s in course run %s", + email, + course_id, + ) + return Response( + {"error": "Enrollment creation failed"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/openedx/views_test.py b/openedx/views_test.py index 7b8d69ac86..8a728aef5b 100644 --- a/openedx/views_test.py +++ b/openedx/views_test.py @@ -1,11 +1,20 @@ """Test openedx views""" +from unittest.mock import patch + import pytest from django.shortcuts import reverse from rest_framework import status +from rest_framework.test import APIClient + +from courses.factories import CourseRunEnrollmentFactory, CourseRunFactory +from users.factories import UserFactory pytestmark = [pytest.mark.django_db] +WEBHOOK_URL = "openedx-enrollment-webhook" +TEST_WEBHOOK_KEY = "test-webhook-secret-key" + @pytest.mark.parametrize( "route", @@ -18,3 +27,197 @@ def test_openedx_private_auth_complete_view(client, route): """Verify the openedx_private_auth_complete view returns a 200""" response = client.get(reverse(route)) assert response.status_code == status.HTTP_200_OK + + +class TestEdxEnrollmentWebhook: + """Tests for the edx_enrollment_webhook view""" + + @pytest.fixture + def api_client(self): + """Unauthenticated API client""" + return APIClient() + + @pytest.fixture + def webhook_payload(self): + """Standard webhook payload""" + return { + "email": "instructor@example.com", + "course_id": "course-v1:MITx+1.001x+2025_T1", + "role": "instructor", + } + + def _post_webhook(self, api_client, payload, token=TEST_WEBHOOK_KEY): + """Helper to POST to the webhook with Bearer auth""" + headers = {} + if token is not None: + headers["HTTP_AUTHORIZATION"] = f"Bearer {token}" + return api_client.post( + reverse(WEBHOOK_URL), + data=payload, + format="json", + **headers, + ) + + @pytest.mark.parametrize("role", ["instructor", "staff"]) + @patch("openedx.views.create_run_enrollments") + def test_successful_enrollment( + self, mock_create_enrollments, api_client, role, settings + ): + """Test successful enrollment of a user as auditor via webhook""" + settings.OPENEDX_WEBHOOK_KEY = TEST_WEBHOOK_KEY + user = UserFactory.create() + course_run = CourseRunFactory.create() + enrollment = CourseRunEnrollmentFactory.create(user=user, run=course_run) + mock_create_enrollments.return_value = ([enrollment], True) + + payload = { + "email": user.email, + "course_id": course_run.courseware_id, + "role": role, + } + response = self._post_webhook(api_client, payload) + + assert response.status_code == status.HTTP_200_OK + assert response.data["message"] == "Enrollment successful" + assert response.data["enrollment_id"] == enrollment.id + mock_create_enrollments.assert_called_once_with( + user, + [course_run], + keep_failed_enrollments=True, + ) + + def test_missing_authorization_header(self, api_client, webhook_payload, settings): + """Test request without Authorization header returns 401""" + settings.OPENEDX_WEBHOOK_KEY = TEST_WEBHOOK_KEY + response = api_client.post( + reverse(WEBHOOK_URL), + data=webhook_payload, + format="json", + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_invalid_auth_scheme(self, api_client, webhook_payload, settings): + """Test request with non-Bearer auth scheme returns 401""" + settings.OPENEDX_WEBHOOK_KEY = TEST_WEBHOOK_KEY + response = api_client.post( + reverse(WEBHOOK_URL), + data=webhook_payload, + format="json", + HTTP_AUTHORIZATION="Basic dXNlcjpwYXNz", + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_wrong_token(self, api_client, webhook_payload, settings): + """Test request with wrong Bearer token returns 403""" + settings.OPENEDX_WEBHOOK_KEY = TEST_WEBHOOK_KEY + response = self._post_webhook(api_client, webhook_payload, token="wrong-token") # noqa: S106 + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_webhook_key_not_configured(self, api_client, webhook_payload, settings): + """Test returns 500 when OPENEDX_WEBHOOK_KEY is not set""" + settings.OPENEDX_WEBHOOK_KEY = None + response = self._post_webhook(api_client, webhook_payload) + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert "not configured" in response.data["error"] + + def test_missing_email(self, api_client, settings): + """Test request missing email returns 400""" + settings.OPENEDX_WEBHOOK_KEY = TEST_WEBHOOK_KEY + payload = {"course_id": "course-v1:MITx+1.001x+2025_T1", "role": "staff"} + response = self._post_webhook(api_client, payload) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_missing_course_id(self, api_client, settings): + """Test request missing course_id returns 400""" + settings.OPENEDX_WEBHOOK_KEY = TEST_WEBHOOK_KEY + payload = {"email": "instructor@example.com", "role": "staff"} + response = self._post_webhook(api_client, payload) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_user_not_found(self, api_client, settings): + """Test returns 404 when the user email doesn't exist""" + settings.OPENEDX_WEBHOOK_KEY = TEST_WEBHOOK_KEY + course_run = CourseRunFactory.create() + payload = { + "email": "nonexistent@example.com", + "course_id": course_run.courseware_id, + "role": "instructor", + } + response = self._post_webhook(api_client, payload) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "not found" in response.data["error"] + + def test_course_run_not_found(self, api_client, settings): + """Test returns 404 when the course run doesn't exist""" + settings.OPENEDX_WEBHOOK_KEY = TEST_WEBHOOK_KEY + user = UserFactory.create() + payload = { + "email": user.email, + "course_id": "course-v1:MITx+NONEXISTENT+2025_T1", + "role": "instructor", + } + response = self._post_webhook(api_client, payload) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "not found" in response.data["error"] + + @patch("openedx.views.create_run_enrollments") + def test_enrollment_creation_exception( + self, mock_create_enrollments, api_client, settings + ): + """Test returns 500 when enrollment creation raises an exception""" + settings.OPENEDX_WEBHOOK_KEY = TEST_WEBHOOK_KEY + user = UserFactory.create() + course_run = CourseRunFactory.create() + mock_create_enrollments.side_effect = Exception("Unexpected error") + + payload = { + "email": user.email, + "course_id": course_run.courseware_id, + "role": "instructor", + } + response = self._post_webhook(api_client, payload) + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert "Failed to create enrollment" in response.data["error"] + + @patch("openedx.views.create_run_enrollments") + def test_enrollment_returns_empty( + self, mock_create_enrollments, api_client, settings + ): + """Test returns 500 when enrollment creation returns empty list""" + settings.OPENEDX_WEBHOOK_KEY = TEST_WEBHOOK_KEY + user = UserFactory.create() + course_run = CourseRunFactory.create() + mock_create_enrollments.return_value = ([], True) + + payload = { + "email": user.email, + "course_id": course_run.courseware_id, + "role": "instructor", + } + response = self._post_webhook(api_client, payload) + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + + @patch("openedx.views.create_run_enrollments") + def test_already_enrolled_user(self, mock_create_enrollments, api_client, settings): + """Test that webhook succeeds for an already-enrolled user (idempotent)""" + settings.OPENEDX_WEBHOOK_KEY = TEST_WEBHOOK_KEY + user = UserFactory.create() + course_run = CourseRunFactory.create() + enrollment = CourseRunEnrollmentFactory.create(user=user, run=course_run) + mock_create_enrollments.return_value = ([enrollment], True) + + payload = { + "email": user.email, + "course_id": course_run.courseware_id, + "role": "instructor", + } + response = self._post_webhook(api_client, payload) + + assert response.status_code == status.HTTP_200_OK + assert response.data["message"] == "Enrollment successful" + + def test_get_method_not_allowed(self, api_client, settings): + """Test that GET requests are rejected""" + settings.OPENEDX_WEBHOOK_KEY = TEST_WEBHOOK_KEY + response = api_client.get(reverse(WEBHOOK_URL)) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED