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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
run: cat Aptfile | sudo xargs apt-get install

- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
enable-cache: true

Expand Down
8 changes: 8 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Release Notes
=============

Version 1.147.3
---------------

- Add skip_certificates flag to HubSpot property setup (#3511)
- feat: mitxonline API to surface course modules metadata (#3490)
- Update dependency authlib to v1.6.11 [SECURITY] (#3506)
- Update astral-sh/setup-uv action to v8 (#3512)

Version 1.147.2 (Released April 21, 2026)
---------------

Expand Down
8 changes: 8 additions & 0 deletions courses/urls/v3/urls.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Course API v3 URL configuration."""

from django.urls import path
from rest_framework import routers

from courses.views import v3
Expand All @@ -19,3 +20,10 @@
)

urlpatterns = router.urls
urlpatterns += [
path(
"courses/<str:course_id>/ol_openedx_outline/",
v3.get_course_outline,
name="course_outline",
),
]
126 changes: 123 additions & 3 deletions courses/views/v3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,33 @@
Course API Views version 3
"""

import logging
import re

import django_filters
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Prefetch, Q
from django.shortcuts import get_object_or_404
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
from rest_framework import mixins, status, viewsets
from drf_spectacular.utils import (
OpenApiExample,
OpenApiParameter,
extend_schema,
extend_schema_view,
inline_serializer,
)
from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import (
AllowAny,
IsAuthenticated,
)
from rest_framework.response import Response

from courses.api import create_program_enrollments, deactivate_run_enrollment
from courses.constants import ENROLL_CHANGE_STATUS_UNENROLLED
from courses.constants import COURSE_KEY_PATTERN, ENROLL_CHANGE_STATUS_UNENROLLED
from courses.models import (
CourseRunEnrollment,
Program,
Expand All @@ -29,6 +41,10 @@
)
from ecommerce.models import Product
from main import features
from openedx.api import get_edx_course_outline
from openedx.exceptions import EdxApiCourseOutlineError

log = logging.getLogger(__name__)


class UserEnrollmentFilterSet(django_filters.FilterSet):
Expand Down Expand Up @@ -243,3 +259,107 @@ def destroy(self, request, *args, **kwargs): # noqa: ARG002
enrollment.deactivate_and_save(ENROLL_CHANGE_STATUS_UNENROLLED)

return Response(status=status.HTTP_204_NO_CONTENT)


@extend_schema(
operation_id="course_outline_retrieve_v3",
description="Fetch course outline data for the given course key from Open edX.",
parameters=[
OpenApiParameter(
name="course_id",
type=OpenApiTypes.STR,
location=OpenApiParameter.PATH,
required=True,
description="Open edX course key (URL-encoded recommended), e.g. course-v1%3AOpenedX%2BDemoX%2BDemoCourse",
)
],
responses={
200: inline_serializer(
name="CourseOutlineResponse",
fields={
"course_id": serializers.CharField(),
"generated_at": serializers.CharField(),
"modules": serializers.ListField(child=serializers.DictField()),
},
),
400: inline_serializer(
name="CourseOutlineBadRequestResponse",
fields={"detail": serializers.CharField()},
),
502: inline_serializer(
name="CourseOutlineUpstreamErrorResponse",
fields={"detail": serializers.CharField()},
),
500: inline_serializer(
name="CourseOutlineServerErrorResponse",
fields={"detail": serializers.CharField()},
),
},
examples=[
OpenApiExample(
"CourseOutlineSuccess",
value={
"course_id": "course-v1:OpenedX+DemoX+DemoCourse",
"generated_at": "2026-04-10T07:17:20Z",
"modules": [
{
"id": "block-v1:OpenedX+DemoX+DemoCourse+type@chapter+block@abc123",
"title": "Module 1",
"effort_time": 0,
"effort_activities": 0,
"counts": {
"videos": 2,
"readings": 1,
"problems": 1,
"assignments": 0,
"app_items": 0,
},
}
],
},
response_only=True,
status_codes=["200"],
),
OpenApiExample(
"InvalidCourseId",
value={
"detail": "Invalid course_id format. Expected an Open edX course key."
},
response_only=True,
status_codes=["400"],
),
OpenApiExample(
"UpstreamError",
value={"detail": "Unable to fetch course outline from Open edX."},
response_only=True,
status_codes=["502"],
),
],
)
@api_view(["GET"])
@permission_classes([AllowAny])
def get_course_outline(request, course_id): # noqa: ARG001
"""
Return course outline data from Open edX for the specified course key.
"""
if not re.fullmatch(COURSE_KEY_PATTERN, course_id):
return Response(
{"detail": "Invalid course_id format. Expected an Open edX course key."},
status=status.HTTP_400_BAD_REQUEST,
)

try:
outline_data = get_edx_course_outline(course_id)
except EdxApiCourseOutlineError:
return Response(
{"detail": "Unable to fetch course outline from Open edX."},
status=status.HTTP_502_BAD_GATEWAY,
)
except ImproperlyConfigured:
log.exception("Course outline service is not configured")
return Response(
{"detail": "Course outline service is not configured."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

return Response(outline_data, status=status.HTTP_200_OK)
87 changes: 87 additions & 0 deletions courses/views/v3/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from ecommerce.factories import OrderFactory
from ecommerce.models import OrderStatus
from main.test_utils import drf_datetime
from openedx.exceptions import EdxApiCourseOutlineError

pytestmark = [
pytest.mark.django_db,
Expand Down Expand Up @@ -636,3 +637,89 @@ def test_destroy_program_enrollment_paid_fails(user_drf_client, user):

enrollment.refresh_from_db()
assert enrollment.active is True


def test_course_outline_v3_public_success(mocker):
"""GET course outline endpoint should succeed without authentication."""
client = APIClient()
expected_outline = {
"course_id": "course-v1:OpenedX+DemoX+DemoCourse",
"blocks": [],
}
mocked_get_outline = mocker.patch(
"courses.views.v3.get_edx_course_outline", return_value=expected_outline
)
resp = client.get(
reverse(
"v3:course_outline",
kwargs={"course_id": "course-v1:OpenedX+DemoX+DemoCourse"},
)
)
assert resp.status_code == status.HTTP_200_OK
assert resp.json() == expected_outline
mocked_get_outline.assert_called_once_with("course-v1:OpenedX+DemoX+DemoCourse")


def test_course_outline_v3_authenticated_success(user_drf_client, mocker):
"""Authenticated request should return proxied Open edX outline data."""
expected_outline = {
"course_id": "course-v1:OpenedX+DemoX+DemoCourse",
"blocks": [],
}
mocked_get_outline = mocker.patch(
"courses.views.v3.get_edx_course_outline", return_value=expected_outline
)

resp = user_drf_client.get(
reverse(
"v3:course_outline",
kwargs={"course_id": "course-v1:OpenedX+DemoX+DemoCourse"},
)
)

assert resp.status_code == status.HTTP_200_OK
assert resp.json() == expected_outline
mocked_get_outline.assert_called_once_with("course-v1:OpenedX+DemoX+DemoCourse")


def test_course_outline_v3_invalid_course_id():
"""Invalid course IDs should return 400 before upstream call."""
client = APIClient()
resp = client.get(
reverse("v3:course_outline", kwargs={"course_id": "not-a-course-key"})
)
assert resp.status_code == status.HTTP_400_BAD_REQUEST
assert resp.json() == {
"detail": "Invalid course_id format. Expected an Open edX course key."
}


@pytest.mark.parametrize(
("exception_instance", "expected_detail"),
[
(
EdxApiCourseOutlineError("boom"),
"Unable to fetch course outline from Open edX.",
),
(
EdxApiCourseOutlineError("bad json"),
"Unable to fetch course outline from Open edX.",
),
],
)
def test_course_outline_v3_upstream_failures(
user_drf_client, mocker, exception_instance, expected_detail
):
"""Upstream errors should be mapped to 502 responses with safe messages."""
mocker.patch(
"courses.views.v3.get_edx_course_outline",
side_effect=exception_instance,
)
resp = user_drf_client.get(
reverse(
"v3:course_outline",
kwargs={"course_id": "course-v1:OpenedX+DemoX+DemoCourse"},
)
)
assert resp.status_code == status.HTTP_502_BAD_GATEWAY
assert resp.json() == {"detail": expected_detail}
30 changes: 20 additions & 10 deletions hubspot_sync/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1699,7 +1699,9 @@ def _normalize_line_item_properties_for_target_account(
line_item_properties["status"] = resolved_status


def _ensure_target_hubspot_custom_properties(hubspot_client: HubspotApi) -> None:
def _ensure_target_hubspot_custom_properties(
hubspot_client: HubspotApi, *, skip_certificates: bool = False
) -> None:
"""Ensure custom MITx e-commerce properties and groups exist in the target account."""
object_configs = {
object_type: {
Expand All @@ -1708,12 +1710,14 @@ def _ensure_target_hubspot_custom_properties(hubspot_client: HubspotApi) -> None
}
for object_type, config in CUSTOM_ECOMMERCE_PROPERTIES.items()
}
object_configs[HubspotObjectType.CONTACTS.value]["properties"].extend(
[
_get_course_run_certificate_hubspot_property(),
_get_program_certificate_hubspot_property(),
]
)
# Skip certificate properties for UAI courses since they don't need them
if not skip_certificates:
object_configs[HubspotObjectType.CONTACTS.value]["properties"].extend(
[
_get_course_run_certificate_hubspot_property(),
_get_program_certificate_hubspot_property(),
]
)

for object_type, config in object_configs.items():
wait_for_hubspot_rate_limit()
Expand Down Expand Up @@ -1978,9 +1982,13 @@ def _ensure_target_hubspot_product_for_line(
return created_product.id


def _ensure_target_hubspot_contact_properties(hubspot_client: HubspotApi) -> None:
def _ensure_target_hubspot_contact_properties(
hubspot_client: HubspotApi, *, skip_certificates: bool = False
) -> None:
"""Backward-compatible wrapper retained for tests/callers."""
_ensure_target_hubspot_custom_properties(hubspot_client)
_ensure_target_hubspot_custom_properties(
hubspot_client, skip_certificates=skip_certificates
)


def _ensure_hubspot_contact_for_user(
Expand Down Expand Up @@ -2085,7 +2093,9 @@ def track_cart_add_with_hubspot(

try:
hubspot_client = HubspotApi(access_token=token)
_ensure_target_hubspot_contact_properties(hubspot_client)
_ensure_target_hubspot_contact_properties(
hubspot_client, skip_certificates=is_uai_course
)

# UAI deals must have a contact in the same HubSpot account.
contact_id = _ensure_hubspot_contact_for_user(user, hubspot_client)
Expand Down
4 changes: 3 additions & 1 deletion hubspot_sync/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,9 @@ def test_track_cart_add_with_hubspot_syncs_missing_contact(settings, mocker, use
mock_sync_deal = mocker.patch("hubspot_sync.api._sync_cart_add_deal_with_hubspot")

assert api.track_cart_add_with_hubspot(user, product, is_uai_course=True) is True
mock_ensure_props.assert_called_once_with(mock_client.return_value)
mock_ensure_props.assert_called_once_with(
mock_client.return_value, skip_certificates=True
)
mock_ensure_contact.assert_called_once_with(user, mock_client.return_value)
mock_sync_deal.assert_called_once()

Expand Down
7 changes: 6 additions & 1 deletion main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from main.sentry import init_sentry
from openapi.settings_spectacular import open_spectacular_settings

VERSION = "1.147.2"
VERSION = "1.147.3"

log = logging.getLogger()

Expand Down Expand Up @@ -1203,6 +1203,11 @@
default="/home",
description="The suffix (with leading slash) to append to a course URL.",
)
OL_OPENEDX_COURSE_OUTLINE_URL = get_string(
name="OL_OPENEDX_COURSE_OUTLINE_URL",
default="/api/ol-course-outline/v0/{course_id}/",
description="Path template for Open edX course outline plugin endpoint.",
)

OPENEDX_BASE_REDIRECT_URL = get_string(
name="OPENEDX_BASE_REDIRECT_URL",
Expand Down
Loading
Loading