diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3a4fa2cf2..de6ba1dfc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/RELEASE.rst b/RELEASE.rst index 8d9c9995c3..387c6e17ed 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -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) --------------- diff --git a/courses/urls/v3/urls.py b/courses/urls/v3/urls.py index b5b140a0e3..223b990965 100644 --- a/courses/urls/v3/urls.py +++ b/courses/urls/v3/urls.py @@ -1,5 +1,6 @@ """Course API v3 URL configuration.""" +from django.urls import path from rest_framework import routers from courses.views import v3 @@ -19,3 +20,10 @@ ) urlpatterns = router.urls +urlpatterns += [ + path( + "courses//ol_openedx_outline/", + v3.get_course_outline, + name="course_outline", + ), +] diff --git a/courses/views/v3/__init__.py b/courses/views/v3/__init__.py index e51df17f09..1016a9e239 100644 --- a/courses/views/v3/__init__.py +++ b/courses/views/v3/__init__.py @@ -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, @@ -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): @@ -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) diff --git a/courses/views/v3/views_test.py b/courses/views/v3/views_test.py index 83edfcf9ae..b984adbea8 100644 --- a/courses/views/v3/views_test.py +++ b/courses/views/v3/views_test.py @@ -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, @@ -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} diff --git a/hubspot_sync/api.py b/hubspot_sync/api.py index 6db3ada08e..19aa661bea 100644 --- a/hubspot_sync/api.py +++ b/hubspot_sync/api.py @@ -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: { @@ -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() @@ -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( @@ -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) diff --git a/hubspot_sync/api_test.py b/hubspot_sync/api_test.py index 41bc42b0bf..e7a80f58fa 100644 --- a/hubspot_sync/api_test.py +++ b/hubspot_sync/api_test.py @@ -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() diff --git a/main/settings.py b/main/settings.py index 510b74ede3..b296e50bdf 100644 --- a/main/settings.py +++ b/main/settings.py @@ -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() @@ -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", diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 1ce27634ef..abc0060756 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -3264,6 +3264,71 @@ paths: description: No response body '404': description: No response body + /api/v3/courses/{course_id}/ol_openedx_outline/: + get: + operationId: course_outline_retrieve_v3 + description: Fetch course outline data for the given course key from Open edX. + parameters: + - in: path + name: course_id + schema: + type: string + description: Open edX course key (URL-encoded recommended), e.g. course-v1%3AOpenedX%2BDemoX%2BDemoCourse + required: true + tags: + - courses + security: + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseOutlineResponse' + examples: + 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 + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseOutlineBadRequestResponse' + examples: + InvalidCourseId: + value: + detail: Invalid course_id format. Expected an Open edX course + key. + description: '' + '502': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseOutlineUpstreamErrorResponse' + examples: + UpstreamError: + value: + detail: Unable to fetch course outline from Open edX. + description: '' + '500': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseOutlineServerErrorResponse' + description: '' /api/v3/enrollments/: get: operationId: user_enrollments_list_v3 @@ -4019,6 +4084,43 @@ components: - programs - readable_id - title + CourseOutlineBadRequestResponse: + type: object + properties: + detail: + type: string + required: + - detail + CourseOutlineResponse: + type: object + properties: + course_id: + type: string + generated_at: + type: string + modules: + type: array + items: + type: object + additionalProperties: {} + required: + - course_id + - generated_at + - modules + CourseOutlineServerErrorResponse: + type: object + properties: + detail: + type: string + required: + - detail + CourseOutlineUpstreamErrorResponse: + type: object + properties: + detail: + type: string + required: + - detail CoursePage: type: object description: Course page model serializer diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 8e7169e9a7..bbdff327b9 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -3264,6 +3264,71 @@ paths: description: No response body '404': description: No response body + /api/v3/courses/{course_id}/ol_openedx_outline/: + get: + operationId: course_outline_retrieve_v3 + description: Fetch course outline data for the given course key from Open edX. + parameters: + - in: path + name: course_id + schema: + type: string + description: Open edX course key (URL-encoded recommended), e.g. course-v1%3AOpenedX%2BDemoX%2BDemoCourse + required: true + tags: + - courses + security: + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseOutlineResponse' + examples: + 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 + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseOutlineBadRequestResponse' + examples: + InvalidCourseId: + value: + detail: Invalid course_id format. Expected an Open edX course + key. + description: '' + '502': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseOutlineUpstreamErrorResponse' + examples: + UpstreamError: + value: + detail: Unable to fetch course outline from Open edX. + description: '' + '500': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseOutlineServerErrorResponse' + description: '' /api/v3/enrollments/: get: operationId: user_enrollments_list_v3 @@ -4019,6 +4084,43 @@ components: - programs - readable_id - title + CourseOutlineBadRequestResponse: + type: object + properties: + detail: + type: string + required: + - detail + CourseOutlineResponse: + type: object + properties: + course_id: + type: string + generated_at: + type: string + modules: + type: array + items: + type: object + additionalProperties: {} + required: + - course_id + - generated_at + - modules + CourseOutlineServerErrorResponse: + type: object + properties: + detail: + type: string + required: + - detail + CourseOutlineUpstreamErrorResponse: + type: object + properties: + detail: + type: string + required: + - detail CoursePage: type: object description: Course page model serializer diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 2c0bcae9fd..39362f5332 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -3264,6 +3264,71 @@ paths: description: No response body '404': description: No response body + /api/v3/courses/{course_id}/ol_openedx_outline/: + get: + operationId: course_outline_retrieve_v3 + description: Fetch course outline data for the given course key from Open edX. + parameters: + - in: path + name: course_id + schema: + type: string + description: Open edX course key (URL-encoded recommended), e.g. course-v1%3AOpenedX%2BDemoX%2BDemoCourse + required: true + tags: + - courses + security: + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseOutlineResponse' + examples: + 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 + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseOutlineBadRequestResponse' + examples: + InvalidCourseId: + value: + detail: Invalid course_id format. Expected an Open edX course + key. + description: '' + '502': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseOutlineUpstreamErrorResponse' + examples: + UpstreamError: + value: + detail: Unable to fetch course outline from Open edX. + description: '' + '500': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseOutlineServerErrorResponse' + description: '' /api/v3/enrollments/: get: operationId: user_enrollments_list_v3 @@ -4019,6 +4084,43 @@ components: - programs - readable_id - title + CourseOutlineBadRequestResponse: + type: object + properties: + detail: + type: string + required: + - detail + CourseOutlineResponse: + type: object + properties: + course_id: + type: string + generated_at: + type: string + modules: + type: array + items: + type: object + additionalProperties: {} + required: + - course_id + - generated_at + - modules + CourseOutlineServerErrorResponse: + type: object + properties: + detail: + type: string + required: + - detail + CourseOutlineUpstreamErrorResponse: + type: object + properties: + detail: + type: string + required: + - detail CoursePage: type: object description: Course page model serializer diff --git a/openedx/api.py b/openedx/api.py index 8037607920..aa72c60203 100644 --- a/openedx/api.py +++ b/openedx/api.py @@ -4,7 +4,7 @@ import random from datetime import datetime, timedelta from functools import partial -from urllib.parse import parse_qs, urljoin, urlparse +from urllib.parse import parse_qs, quote, urljoin, urlparse import requests from django.conf import settings @@ -39,6 +39,7 @@ PLATFORM_EDX, ) from openedx.exceptions import ( + EdxApiCourseOutlineError, EdxApiEmailSettingsErrorException, EdxApiEnrollErrorException, EdxApiRegistrationValidationException, @@ -887,6 +888,46 @@ def get_edx_api_service_client(): return edx_client # noqa: RET504 +def get_edx_course_outline(course_id: str) -> dict: + """ + Fetch course outline data from the Open edX course outline plugin endpoint. + + Args: + course_id (str): edX course key (e.g., course-v1:MITx+1.00x+1T2026) + Returns: + dict: Parsed JSON response from the outline API + """ + edx_client = get_edx_api_service_client() + encoded_course_id = quote(course_id, safe="") + outline_path = settings.OL_OPENEDX_COURSE_OUTLINE_URL.format( + course_id=encoded_course_id + ) + outline_url = edx_url(outline_path) + requester = edx_client.get_requester() + try: + response = requester.get(outline_url) + response.raise_for_status() + except requests.exceptions.RequestException as exc: + message = "Open edX course outline request failed" + log.exception( + "Failed to fetch Open edX course outline for course_id=%s url=%s", + course_id, + outline_url, + ) + raise EdxApiCourseOutlineError(message) from exc + + try: + return response.json() + except (ValueError, requests.exceptions.JSONDecodeError) as exc: + message = "Open edX course outline response was invalid" + log.exception( + "Open edX course outline response was not valid JSON for course_id=%s url=%s", + course_id, + outline_url, + ) + raise EdxApiCourseOutlineError(message) from exc + + def get_edx_api_jwt_client( client_id: str = settings.OPENEDX_API_CLIENT_ID, client_secret: str = settings.OPENEDX_API_CLIENT_SECRET, diff --git a/openedx/api_test.py b/openedx/api_test.py index 8628bdc7f3..53551f54c5 100644 --- a/openedx/api_test.py +++ b/openedx/api_test.py @@ -10,6 +10,7 @@ import pytest import responses from django.contrib.auth import get_user_model +from django.core.exceptions import ImproperlyConfigured from edx_api.course_runs.exceptions import CourseRunAPIError from freezegun import freeze_time from mitol.common.utils.datetime import now_in_utc @@ -37,6 +38,7 @@ existing_edx_enrollment, generate_unique_username, get_edx_api_client, + get_edx_course_outline, get_edx_retirement_service_client, get_valid_edx_api_auth, process_course_run_clone, @@ -64,6 +66,7 @@ PLATFORM_EDX, ) from openedx.exceptions import ( + EdxApiCourseOutlineError, EdxApiEmailSettingsErrorException, EdxApiEnrollErrorException, EdxApiRegistrationValidationException, @@ -779,6 +782,86 @@ def test_get_edx_api_client(mocker, settings, user): ) +@responses.activate +def test_get_edx_course_outline(settings): + """Tests that get_edx_course_outline fetches and returns outline JSON.""" + settings.OPENEDX_API_BASE_URL = "http://example.com" + settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "outline_token" # noqa: S105 + settings.OL_OPENEDX_COURSE_OUTLINE_URL = "/api/ol-course-outline/v0/{course_id}/" + settings.EDX_API_CLIENT_TIMEOUT = 30 + + course_id = "course-v1:OpenedX+DemoX+DemoCourse" + encoded_course_id = "course-v1%3AOpenedX%2BDemoX%2BDemoCourse" + expected_payload = { + "course_id": course_id, + "blocks": [{"id": "block-v1:demo+type@chapter+block@week1"}], + } + resp = responses.add( + responses.GET, + f"{settings.OPENEDX_API_BASE_URL}/api/ol-course-outline/v0/{encoded_course_id}/", + json=expected_payload, + status=status.HTTP_200_OK, + ) + + result = get_edx_course_outline(course_id) + + assert result == expected_payload + assert resp.call_count == 1 + assert ( + resp.calls[0].request.headers["Authorization"] + == f"Bearer {settings.OPENEDX_SERVICE_WORKER_API_TOKEN}" + ) + + +@responses.activate +def test_get_edx_course_outline_http_error(settings): + """Tests that get_edx_course_outline raises upstream error on failure response.""" + settings.OPENEDX_API_BASE_URL = "http://example.com" + settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "outline_token" # noqa: S105 + settings.OL_OPENEDX_COURSE_OUTLINE_URL = "/api/ol-course-outline/v0/{course_id}/" + + course_id = "course-v1:OpenedX+DemoX+DemoCourse" + encoded_course_id = "course-v1%3AOpenedX%2BDemoX%2BDemoCourse" + responses.add( + responses.GET, + f"{settings.OPENEDX_API_BASE_URL}/api/ol-course-outline/v0/{encoded_course_id}/", + json={"detail": "Not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + with pytest.raises(EdxApiCourseOutlineError): + get_edx_course_outline(course_id) + + +@responses.activate +def test_get_edx_course_outline_invalid_json(settings): + """Tests that get_edx_course_outline raises invalid response for bad JSON.""" + settings.OPENEDX_API_BASE_URL = "http://example.com" + settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "outline_token" # noqa: S105 + settings.OL_OPENEDX_COURSE_OUTLINE_URL = "/api/ol-course-outline/v0/{course_id}/" + + course_id = "course-v1:OpenedX+DemoX+DemoCourse" + encoded_course_id = "course-v1%3AOpenedX%2BDemoX%2BDemoCourse" + responses.add( + responses.GET, + f"{settings.OPENEDX_API_BASE_URL}/api/ol-course-outline/v0/{encoded_course_id}/", + body="not-json", + content_type="text/plain", + status=status.HTTP_200_OK, + ) + + with pytest.raises(EdxApiCourseOutlineError): + get_edx_course_outline(course_id) + + +def test_get_edx_course_outline_missing_service_token(settings): + """Tests that get_edx_course_outline requires OPENEDX_SERVICE_WORKER_API_TOKEN.""" + settings.OPENEDX_SERVICE_WORKER_API_TOKEN = None + + with pytest.raises(ImproperlyConfigured): + get_edx_course_outline("course-v1:OpenedX+DemoX+DemoCourse") + + def test_get_edx_retirement_service_client(mocker, settings): """Tests that get_edx_retirement_service_client returns an EdxApi client""" diff --git a/openedx/exceptions.py b/openedx/exceptions.py index 86e8edfc5d..75bb75d1de 100644 --- a/openedx/exceptions.py +++ b/openedx/exceptions.py @@ -128,3 +128,7 @@ def __init__(self, username, response, msg=None): class OpenEdxUserMissingError(Exception): """We tried to do something that requires an Open edX user, and there isn't one.""" + + +class EdxApiCourseOutlineError(Exception): + """Base exception for Open edX course outline fetch errors.""" diff --git a/uv.lock b/uv.lock index 2f9e61bea8..048100565f 100644 --- a/uv.lock +++ b/uv.lock @@ -79,14 +79,15 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.9" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/82/4d0603f30c1b4629b1f091bb266b0d7986434891d6940a8c87f8098db24e/authlib-1.7.0.tar.gz", hash = "sha256:b3e326c9aa9cc3ea95fe7d89fd880722d3608da4d00e8a27e061e64b48d801d5", size = 175890, upload-time = "2026-04-18T11:00:28.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/ca/48/c954218b2a250e23f178f10167c4173fecb5a75d2c206f0a67ba58006c26/authlib-1.7.0-py2.py3-none-any.whl", hash = "sha256:e36817afb02f6f0b6bf55f150782499ddd6ddf44b402bb055d3263cc65ac9ae0", size = 258779, upload-time = "2026-04-18T11:00:26.64Z" }, ] [[package]] @@ -1531,6 +1532,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "joserfc" +version = "1.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/c6/de8fdbdfa75c8ca04fead38a82d573df8a82906e984c349d58665f459558/joserfc-1.6.4.tar.gz", hash = "sha256:34ce5f499bfcc5e9ad4cc75077f9278ab3227b71da9aaf28f9ab705f8a560d3c", size = 231866, upload-time = "2026-04-13T13:15:40.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/f7/210b27752e972edb36d239315b08d3eb6b14824cc4a590da2337d195260b/joserfc-1.6.4-py3-none-any.whl", hash = "sha256:3e4a22b509b41908989237a045e25c8308d5fd47ab96bdae2dd8057c6451003a", size = 70464, upload-time = "2026-04-13T13:15:39.259Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0"