Skip to content
Open
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
9 changes: 5 additions & 4 deletions frontend/public/src/components/EnrolledItemCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Comment on lines +523 to +527
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this change for? At best, Its is not related to this PR.

</a>
)}
</div>
Expand Down
24 changes: 12 additions & 12 deletions frontend/public/src/lib/util.js
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, I don't think these changes are related. Could you open a separate PR for these if needed?

Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
6 changes: 6 additions & 0 deletions main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)

Comment on lines +1258 to +1263
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rhysyngsun what's your opinion on this? Do you think a pre-generated config-based string bearer token is fine here, or should we rather go with a staff OAuth token with expiry, or HMAC maybe (Is it worth it)?

# django debug toolbar only in debug mode
if DEBUG:
INSTALLED_APPS += ("debug_toolbar",)
Expand Down
5 changes: 5 additions & 0 deletions openedx/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
)
161 changes: 161 additions & 0 deletions openedx/views.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe not right now, but we may want to move it to ol-django at some point to make it common between other applications that might want to use this API endpoint. I'll re-think more on this.

"""
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:
Comment on lines +45 to +46
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic may change as per my comment above for the token mechanism.

log.error("OPENEDX_WEBHOOK_KEY is not configured")
return Response(
{"error": "Webhook is not configured"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't be a 500. It should be a 400 instead.

)

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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't allow case-sensitive emails.

Suggested change
user = User.objects.get(email=email)
user = User.objects.get(email__iexact=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"},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PII caring

Suggested change
{"error": f"User with email {email} not found"},
{"error": f"User not found"},

status=status.HTTP_404_NOT_FOUND,
)
except User.MultipleObjectsReturned:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this ever going to happen? Can there be multiple users in the Users table with same email?

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,
Comment on lines +99 to +101
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be a 400 as well.

)

# --- 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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do an early return at this point or maybe at the start of the API to make it idempotent? The thing we should ideally do is to check if the enrollment exists in the system, before doing anything else, and if the enrollment does exist already, we may return 409 conflict in that case actually otherwise proceed with whatever needs to happen.

enrollments, edx_request_success = create_run_enrollments(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_run_enrollments is for creating enrollments for edX as well. In this case, the API request itself is coming from enrollment in edX, so should we call this method? Or create a new one so that we just create a local enrollment upon the Webhook call? Apparently, from the method docstr this is all this method eventually does:

    Creates local records of a user's enrollment in course runs, and attempts to enroll them
    in edX via API.
    Updates the enrollment mode and change_status if the user is already enrolled in the course run
    and now is changing the enrollment mode, (e.g. pays or re-enrolls again or getting deferred)
    Possible cases are:
    1. Downgrade: Verified to Audit via a deferral
    2. Upgrade: Audit to Verified via a payment
    3. Reactivation: Audit to Audit or Verified to Verified via a re-enrollment

So the point is, do we want to go this route? cc: @pdpinch

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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

400

)

if enrollments:
enrollment = enrollments[0]
if not edx_request_success:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What will this status be? A false? Because the user would already be enrolled in the course in edX

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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be 201 since this is a creation response.

)
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,
)
Loading
Loading