From c3aedaed46d03c23c5993e4e5317b10a202bc7ce Mon Sep 17 00:00:00 2001 From: cp-at-mit Date: Wed, 21 Jan 2026 10:04:09 -0500 Subject: [PATCH 01/15] Courses v2 org id tests (#3230) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- courses/views/v2/views_test.py | 300 +++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) diff --git a/courses/views/v2/views_test.py b/courses/views/v2/views_test.py index 798170a07d..06e304706c 100644 --- a/courses/views/v2/views_test.py +++ b/courses/views/v2/views_test.py @@ -335,6 +335,7 @@ def test_filter_with_org_id_anonymous(): @pytest.mark.django_db +@pytest.mark.skip_nplusone_check def test_filter_with_org_id_returns_contracted_course( mocker, contract_ready_course, mock_course_run_clone ): @@ -363,6 +364,7 @@ def test_filter_with_org_id_returns_contracted_course( @pytest.mark.django_db +@pytest.mark.skip_nplusone_check def test_filter_with_org_id_user_not_associated_with_org_returns_no_courses( contract_ready_course, mock_course_run_clone ): @@ -386,6 +388,304 @@ def test_filter_with_org_id_user_not_associated_with_org_returns_no_courses( assert unrelated_course.title not in titles +@pytest.mark.django_db +@pytest.mark.skip_nplusone_check +def test_filter_with_org_id_multiple_courses_same_org( + contract_ready_course, mock_course_run_clone +): + """Test that filtering by org_id returns all contracted courses for that org""" + org = OrganizationPageFactory(name="Test Org") + contract = ContractPageFactory(organization=org, active=True) + user = UserFactory() + user.b2b_organizations.add(org) + user.b2b_contracts.add(contract) + user.refresh_from_db() + + # Create multiple courses for the same org + (course1, _) = contract_ready_course + create_contract_run(contract, course1) + + course2 = CourseFactory() + CourseRunFactory(course=course2, is_source_run=True) + create_contract_run(contract, course2) + + course3 = CourseFactory() + CourseRunFactory(course=course3, is_source_run=True) + create_contract_run(contract, course3) + + # Create unrelated course (no contract_run - should not appear in results) + unrelated_course = CourseFactory() + CourseRunFactory(course=unrelated_course) + + client = APIClient() + client.force_authenticate(user=user) + + url = reverse("v2:courses_api-list") + response = client.get(url, {"org_id": org.id}) + + course_ids = [result["id"] for result in response.data["results"]] + assert course1.id in course_ids + assert course2.id in course_ids + assert course3.id in course_ids + assert unrelated_course.id not in course_ids + assert len(course_ids) == 3 + + +@pytest.mark.django_db +@pytest.mark.skip_nplusone_check +def test_filter_with_org_id_inactive_contract_excluded( + contract_ready_course, mock_course_run_clone +): + """Test that courses from inactive contracts are not returned""" + org = OrganizationPageFactory(name="Test Org") + contract = ContractPageFactory(organization=org, active=False) + user = UserFactory() + user.b2b_organizations.add(org) + user.b2b_contracts.add(contract) + user.refresh_from_db() + + (course, _) = contract_ready_course + create_contract_run(contract, course) + + client = APIClient() + client.force_authenticate(user=user) + + url = reverse("v2:courses_api-list") + response = client.get(url, {"org_id": org.id}) + + assert response.data["results"] == [] + + +@pytest.mark.django_db +@pytest.mark.skip_nplusone_check +def test_filter_with_org_id_multiple_orgs(contract_ready_course, mock_course_run_clone): + """Test that filtering by org_id returns courses only for that specific org""" + org1 = OrganizationPageFactory(name="Test Org 1") + org2 = OrganizationPageFactory(name="Test Org 2") + contract1 = ContractPageFactory(organization=org1, active=True) + contract2 = ContractPageFactory(organization=org2, active=True) + + user = UserFactory() + user.b2b_organizations.add(org1, org2) + user.b2b_contracts.add(contract1, contract2) + user.refresh_from_db() + + (course1, _) = contract_ready_course + create_contract_run(contract1, course1) + + course2 = CourseFactory() + CourseRunFactory(course=course2, is_source_run=True) + create_contract_run(contract2, course2) + + client = APIClient() + client.force_authenticate(user=user) + + url = reverse("v2:courses_api-list") + + # Filter for org1 + response = client.get(url, {"org_id": org1.id}) + course_ids = [result["id"] for result in response.data["results"]] + assert course1.id in course_ids + assert course2.id not in course_ids + + # Filter for org2 + response = client.get(url, {"org_id": org2.id}) + course_ids = [result["id"] for result in response.data["results"]] + assert course2.id in course_ids + assert course1.id not in course_ids + + +@pytest.mark.django_db +@pytest.mark.skip_nplusone_check +def test_filter_with_org_id_user_in_org_but_no_contract( + contract_ready_course, mock_course_run_clone +): + """Test that user in org can see org's contracted courses""" + org = OrganizationPageFactory(name="Test Org") + contract = ContractPageFactory(organization=org, active=True) + user = UserFactory() + user.b2b_organizations.add(org) + # User is in org but NOT added to contract + user.refresh_from_db() + + (course, _) = contract_ready_course + create_contract_run(contract, course) + + client = APIClient() + client.force_authenticate(user=user) + + url = reverse("v2:courses_api-list") + response = client.get(url, {"org_id": org.id}) + + titles = [result["title"] for result in response.data["results"]] + assert course.title in titles + + +@pytest.mark.django_db +def test_filter_with_org_id_nonexistent_org_id(user_drf_client): + """Test that filtering with a nonexistent org_id returns no results""" + course = CourseFactory(title="Test Course") + CourseRunFactory(course=course) + + url = reverse("v2:courses_api-list") + response = user_drf_client.get(url, {"org_id": 99999}) + + assert response.data["results"] == [] + + +@pytest.mark.django_db +@pytest.mark.skip_nplusone_check +def test_filter_with_org_id_returns_detail_view( + contract_ready_course, mock_course_run_clone +): + """Test that org_id filter works on detail view endpoint""" + org = OrganizationPageFactory(name="Test Org") + contract = ContractPageFactory(organization=org, active=True) + user = UserFactory() + user.b2b_organizations.add(org) + user.b2b_contracts.add(contract) + user.refresh_from_db() + + (course, _) = contract_ready_course + create_contract_run(contract, course) + + client = APIClient() + client.force_authenticate(user=user) + + url = reverse("v2:courses_api-detail", kwargs={"pk": course.id}) + response = client.get(url, {"org_id": org.id}) + + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == course.id + assert response.data["title"] == course.title + + +@pytest.mark.django_db +@pytest.mark.skip_nplusone_check +def test_filter_with_org_id_detail_view_unauthorized_user( + contract_ready_course, mock_course_run_clone +): + """Test that org_id filter prevents unauthorized users from viewing contracted courses""" + org = OrganizationPageFactory(name="Test Org") + contract = ContractPageFactory(organization=org, active=True) + user = UserFactory() + # User NOT added to org + user.refresh_from_db() + + (course, _) = contract_ready_course + create_contract_run(contract, course) + + client = APIClient() + client.force_authenticate(user=user) + + url = reverse("v2:courses_api-detail", kwargs={"pk": course.id}) + response = client.get(url, {"org_id": org.id}) + + # User doesn't have access to this org, so should get 404 + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +@pytest.mark.skip_nplusone_check +def test_filter_with_org_id_respects_course_live_status( + contract_ready_course, mock_course_run_clone +): + """Test that org_id filter returns contracted courses regardless of live status""" + org = OrganizationPageFactory(name="Test Org") + contract = ContractPageFactory(organization=org, active=True) + user = UserFactory() + user.b2b_organizations.add(org) + user.b2b_contracts.add(contract) + user.refresh_from_db() + + (course, _) = contract_ready_course + create_contract_run(contract, course) + + # Make course page not live - org_id filter doesn't filter by live status + course.page.live = False + course.page.save() + + client = APIClient() + client.force_authenticate(user=user) + + url = reverse("v2:courses_api-list") + response = client.get(url, {"org_id": org.id}) + + # Course should appear even though it's not live + course_ids = [result["id"] for result in response.data["results"]] + assert course.id in course_ids + + +@pytest.mark.django_db +@pytest.mark.skip_nplusone_check +def test_filter_with_org_id_pagination(contract_ready_course, mock_course_run_clone): + """Test that org_id filter works correctly with pagination""" + org = OrganizationPageFactory(name="Test Org") + contract = ContractPageFactory(organization=org, active=True) + user = UserFactory() + user.b2b_organizations.add(org) + user.b2b_contracts.add(contract) + user.refresh_from_db() + + (course1, _) = contract_ready_course + create_contract_run(contract, course1) + + # Create more courses to test pagination + for i in range(15): + course = CourseFactory(title=f"Org Course {i}") + CourseRunFactory(course=course, is_source_run=True) + create_contract_run(contract, course) + + client = APIClient() + client.force_authenticate(user=user) + + url = reverse("v2:courses_api-list") + response = client.get(url, {"org_id": org.id, "page_size": 5}) + + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] >= 15 + assert len(response.data["results"]) == 5 + assert "next" in response.data + assert response.data["next"] is not None + + +@pytest.mark.django_db +@pytest.mark.skip_nplusone_check +def test_filter_with_org_id_combined_with_other_filters( + contract_ready_course, mock_course_run_clone +): + """Test that org_id filter can be combined with other filters""" + org = OrganizationPageFactory(name="Test Org") + contract = ContractPageFactory(organization=org, active=True) + user = UserFactory() + user.b2b_organizations.add(org) + user.b2b_contracts.add(contract) + user.refresh_from_db() + + (course1, _) = contract_ready_course + create_contract_run(contract, course1) + + course2 = CourseFactory() + CourseRunFactory(course=course2, is_source_run=True) + create_contract_run(contract, course2) + + unrelated_course = CourseFactory() + CourseRunFactory(course=unrelated_course) + + client = APIClient() + client.force_authenticate(user=user) + + url = reverse("v2:courses_api-list") + # Filter by org_id and specific course readable_id + response = client.get(url, {"org_id": org.id, "readable_id": course1.readable_id}) + + assert response.status_code == status.HTTP_200_OK + course_ids = [result["id"] for result in response.data["results"]] + assert course1.id in course_ids + assert course2.id not in course_ids + assert unrelated_course.id not in course_ids + + @pytest.mark.django_db @pytest.mark.skip_nplusone_check def test_filter_without_org_id_authenticated_user(user_drf_client): From 5a4747cb633ca3de807479388e3a23fe75724815 Mon Sep 17 00:00:00 2001 From: annagav Date: Wed, 21 Jan 2026 14:21:28 -0500 Subject: [PATCH 02/15] Add celery task to run cleartokens every week (#3232) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- main/settings.py | 4 ++++ main/tasks.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 main/tasks.py diff --git a/main/settings.py b/main/settings.py index 56153db1d3..c6a610c85d 100644 --- a/main/settings.py +++ b/main/settings.py @@ -1026,6 +1026,10 @@ offset=timedelta(seconds=KEYCLOAK_ORG_SYNC_OFFSET), ), }, + "clear-expired-tokens": { + "task": "main.tasks.clear_expired_tokens", + "schedule": crontab(minute=0, hour=9, day_of_week=1), # every week + }, } # Hijack diff --git a/main/tasks.py b/main/tasks.py new file mode 100644 index 0000000000..f461dd988c --- /dev/null +++ b/main/tasks.py @@ -0,0 +1,15 @@ +import logging + +from celery import shared_task +from django.core.management import call_command + +log = logging.getLogger(__name__) + + +@shared_task +def run_cleartokens(): + try: + call_command("cleartokens") + log.info("Successfully ran cleartokens management command.") + except Exception: + log.exception("Error running cleartokens") From d71feea44c8d9659334a47b51f022dadb3680bd7 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 21 Jan 2026 14:51:16 -0500 Subject: [PATCH 03/15] add test for v2/courses api programs property (#3229) --- courses/serializers/v1/courses.py | 2 +- courses/serializers/v1/courses_test.py | 14 ++++--- courses/serializers/v2/courses.py | 2 +- courses/serializers/v2/courses_test.py | 10 ++--- courses/views/v1/__init__.py | 2 +- courses/views/v1/views_test.py | 2 +- courses/views/v2/__init__.py | 2 +- courses/views/v2/views_test.py | 53 ++++++++++++++++++++++++++ 8 files changed, 72 insertions(+), 15 deletions(-) diff --git a/courses/serializers/v1/courses.py b/courses/serializers/v1/courses.py index 9a67dc7ee6..f7862bdd8c 100644 --- a/courses/serializers/v1/courses.py +++ b/courses/serializers/v1/courses.py @@ -49,7 +49,7 @@ def get_next_run_id(self, instance) -> int | None: ) ) def get_programs(self, instance): - if self.context.get("all_runs", False): + if self.context.get("include_programs", False): from courses.serializers.v1.base import ( # noqa: PLC0415 BaseProgramSerializer, ) diff --git a/courses/serializers/v1/courses_test.py b/courses/serializers/v1/courses_test.py index fd89e7f964..5a9accde10 100644 --- a/courses/serializers/v1/courses_test.py +++ b/courses/serializers/v1/courses_test.py @@ -33,13 +33,15 @@ @pytest.mark.parametrize("is_anonymous", [True, False]) -@pytest.mark.parametrize("all_runs", [True, False]) -def test_serialize_course(mocker, mock_context, is_anonymous, all_runs, settings): +@pytest.mark.parametrize("include_programs", [True, False]) +def test_serialize_course( + mocker, mock_context, is_anonymous, include_programs, settings +): """Test Course serialization""" if is_anonymous: mock_context["request"].user = AnonymousUser() - if all_runs: - mock_context["all_runs"] = True + if include_programs: + mock_context["include_programs"] = True user = mock_context["request"].user course = CourseFactory.create() course_runs = CourseRunFactory.create_batch(10, course=course) @@ -70,7 +72,9 @@ def test_serialize_course(mocker, mock_context, is_anonymous, all_runs, settings "departments": [{"name": department}], "page": CoursePageSerializer(course.page).data, "programs": ( - ProgramSerializer(course.programs, many=True).data if all_runs else None + ProgramSerializer(course.programs, many=True).data + if include_programs + else None ), }, ) diff --git a/courses/serializers/v2/courses.py b/courses/serializers/v2/courses.py index 1c92986615..9a80a95bae 100644 --- a/courses/serializers/v2/courses.py +++ b/courses/serializers/v2/courses.py @@ -111,7 +111,7 @@ def get_next_run_id(self, instance) -> int | None: return run.id if run is not None else None def get_programs(self, instance) -> list[dict] | None: - if self.context.get("all_runs", False): + if self.context.get("include_programs", False): from courses.serializers.v1.base import ( # noqa: PLC0415 BaseProgramSerializer, ) diff --git a/courses/serializers/v2/courses_test.py b/courses/serializers/v2/courses_test.py index 3dc8cb86b7..1179bc8ded 100644 --- a/courses/serializers/v2/courses_test.py +++ b/courses/serializers/v2/courses_test.py @@ -28,18 +28,18 @@ @pytest.mark.parametrize("is_anonymous", [True, False]) -@pytest.mark.parametrize("all_runs", [True, False]) +@pytest.mark.parametrize("include_programs", [True, False]) @pytest.mark.parametrize( "certificate_type", ["MicroMasters Credential", "Certificate of Completion"] ) def test_serialize_course( - mocker, mock_context, is_anonymous, all_runs, certificate_type + mocker, mock_context, is_anonymous, include_programs, certificate_type ): """Test Course serialization""" if is_anonymous: mock_context["request"].user = AnonymousUser() - if all_runs: - mock_context["all_runs"] = True + if include_programs: + mock_context["include_programs"] = True user = mock_context["request"].user courseRun1 = CourseRunFactory.create() courseRun2 = CourseRunFactory.create(course=courseRun1.course) @@ -87,7 +87,7 @@ def test_serialize_course( "time_commitment": course.page.effort, "programs": ( BaseProgramSerializer(course.programs, many=True).data - if all_runs + if include_programs else None ), "include_in_learn_catalog": course.page.include_in_learn_catalog, diff --git a/courses/views/v1/__init__.py b/courses/views/v1/__init__.py index a007c22a2e..c7a382866d 100644 --- a/courses/views/v1/__init__.py +++ b/courses/views/v1/__init__.py @@ -201,7 +201,7 @@ def get_serializer_context(self): and self.request.query_params and self.request.query_params.get("readable_id", None) ): - added_context["all_runs"] = True + added_context["include_programs"] = True return {**super().get_serializer_context(), **added_context} diff --git a/courses/views/v1/views_test.py b/courses/views/v1/views_test.py index 95b6f2b995..393acfedae 100644 --- a/courses/views/v1/views_test.py +++ b/courses/views/v1/views_test.py @@ -254,7 +254,7 @@ def test_get_course_by_readable_id( # noqa: PLR0913 instance=course, context={ **mock_context, - "all_runs": True, + "include_programs": True, }, ).data ) diff --git a/courses/views/v2/__init__.py b/courses/views/v2/__init__.py index dd0eeb90b4..5e1af49310 100644 --- a/courses/views/v2/__init__.py +++ b/courses/views/v2/__init__.py @@ -325,7 +325,7 @@ def get_serializer_context(self): added_context = {} qp = self.request.query_params if qp.get("readable_id"): - added_context["all_runs"] = True + added_context["include_programs"] = True if qp.get("include_approved_financial_aid"): added_context["include_approved_financial_aid"] = True if qp.get("org_id") or qp.get("contract_id"): diff --git a/courses/views/v2/views_test.py b/courses/views/v2/views_test.py index 06e304706c..c296af8b35 100644 --- a/courses/views/v2/views_test.py +++ b/courses/views/v2/views_test.py @@ -319,6 +319,59 @@ def test_get_course( assert_drf_json_equal(course_data, course_from_fixture, ignore_order=True) +@pytest.mark.django_db +def test_get_course_with_readable_id_includes_programs(user_drf_client): + """Test that requesting a course with readable_id query param includes programs in response""" + # Create a course and a program that includes it + course = CourseFactory.create() + CourseRunFactory.create(course=course) + + program = ProgramFactory.create() + program.add_requirement(course) + program.refresh_from_db() + + # Request the course with readable_id query param + resp = user_drf_client.get( + reverse("v2:courses_api-detail", kwargs={"pk": course.id}), + {"readable_id": course.readable_id}, + ) + + assert resp.status_code == status.HTTP_200_OK + course_data = resp.json() + + # Verify that programs are included in the response + assert "programs" in course_data + assert course_data["programs"] is not None + assert len(course_data["programs"]) == 1 + assert course_data["programs"][0]["id"] == program.id + assert course_data["programs"][0]["readable_id"] == program.readable_id + assert course_data["programs"][0]["title"] == program.title + + +@pytest.mark.django_db +def test_get_course_without_readable_id_excludes_programs(user_drf_client): + """Test that requesting a course without readable_id query param excludes programs from response""" + # Create a course and a program that includes it + course = CourseFactory.create() + CourseRunFactory.create(course=course) + + program = ProgramFactory.create() + program.add_requirement(course) + program.refresh_from_db() + + # Request the course without readable_id query param + resp = user_drf_client.get( + reverse("v2:courses_api-detail", kwargs={"pk": course.id}) + ) + + assert resp.status_code == status.HTTP_200_OK + course_data = resp.json() + + # Verify that programs field is None when readable_id is not provided + assert "programs" in course_data + assert course_data["programs"] is None + + @pytest.mark.django_db def test_filter_with_org_id_anonymous(): org = OrganizationPageFactory(name="Test Org") From 7d6657c20c5db62a50707b087a3994bc036b50a2 Mon Sep 17 00:00:00 2001 From: James Kachel Date: Wed, 21 Jan 2026 15:45:22 -0600 Subject: [PATCH 04/15] Add order history/receipt APIs to OpenAPI spec. (#3234) --- ecommerce/factories.py | 2 + ecommerce/serializers/v0/__init__.py | 10 +- ecommerce/views/v0/__init__.py | 45 ++++++++ ecommerce/views/v0/urls.py | 8 ++ ecommerce/views/v0/views_test.py | 87 +++++++++++++++ openapi/hooks.py | 1 - openapi/specs/v0.yaml | 159 +++++++++++++++++++++++++++ openapi/specs/v1.yaml | 159 +++++++++++++++++++++++++++ openapi/specs/v2.yaml | 159 +++++++++++++++++++++++++++ 9 files changed, 626 insertions(+), 4 deletions(-) diff --git a/ecommerce/factories.py b/ecommerce/factories.py index cc0bcb3a80..e6d9211749 100644 --- a/ecommerce/factories.py +++ b/ecommerce/factories.py @@ -12,6 +12,7 @@ REDEMPTION_TYPE_ONE_TIME, REDEMPTION_TYPE_ONE_TIME_PER_USER, REDEMPTION_TYPE_UNLIMITED, + ZERO_PAYMENT_DATA, ) from main.utils import now_datetime_with_tz from users.factories import UserFactory @@ -118,6 +119,7 @@ class Meta: class TransactionFactory(DjangoModelFactory): order = SubFactory(OrderFactory) amount = fuzzy.FuzzyDecimal(10.00, 10.00) + data = ZERO_PAYMENT_DATA class Meta: model = models.Transaction diff --git a/ecommerce/serializers/v0/__init__.py b/ecommerce/serializers/v0/__init__.py index 13d139845d..a546eca883 100644 --- a/ecommerce/serializers/v0/__init__.py +++ b/ecommerce/serializers/v0/__init__.py @@ -32,7 +32,11 @@ USER_MSG_TYPE_ENROLL_DUPLICATED, ) from main.settings import TIME_ZONE -from users.serializers import ExtendedLegalAddressSerializer, UserSerializer +from users.serializers import ( + ExtendedLegalAddressSerializer, + PublicUserSerializer, + UserSerializer, +) User = get_user_model() @@ -438,7 +442,6 @@ class Meta: fields = [ "quantity", "item_description", - "content_type", "unit_price", "total_price", "id", @@ -612,6 +615,7 @@ class Meta: class OrderHistorySerializer(serializers.ModelSerializer): titles = serializers.SerializerMethodField() lines = LineSerializer(many=True) + purchaser = PublicUserSerializer() @extend_schema_field(serializers.ListField) def get_titles(self, instance): @@ -621,7 +625,7 @@ def get_titles(self, instance): product = models.Product.all_objects.get( pk=line.product_version.field_dict["id"] ) - if product.content_type.model == "courserun": + if product.content_type.model == "courserun" and product.purchasable_object: titles.append(product.purchasable_object.course.title) elif product.content_type.model == "programrun": titles.append(product.description) diff --git a/ecommerce/views/v0/__init__.py b/ecommerce/views/v0/__init__.py index 78877fcfd3..b8e134a5da 100644 --- a/ecommerce/views/v0/__init__.py +++ b/ecommerce/views/v0/__init__.py @@ -22,6 +22,7 @@ from rest_framework.authentication import SessionAuthentication from rest_framework.decorators import action, api_view, permission_classes from rest_framework.exceptions import ParseError +from rest_framework.generics import RetrieveAPIView from rest_framework.pagination import LimitOffsetPagination from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response @@ -43,6 +44,8 @@ Discount, DiscountProduct, DiscountRedemption, + Order, + OrderStatus, Product, UserDiscount, ) @@ -53,6 +56,8 @@ CheckoutPayloadSerializer, DiscountProductSerializer, DiscountRedemptionSerializer, + OrderHistorySerializer, + OrderSerializer, ProductFlexiblePriceSerializer, ProductSerializer, UserDiscountMetaSerializer, @@ -843,3 +848,43 @@ class UserDiscountViewSet(ModelViewSet): authentication_classes = (SessionAuthentication,) permission_classes = (IsAdminUser,) pagination_class = LimitOffsetPagination + + +@extend_schema_view( + list=extend_schema( + description=("Retrives the current user's order history."), + parameters=[], + ), + retrieve=extend_schema( + description="Retrieve a historical order for the current user.", + parameters=[ + OpenApiParameter("id", OpenApiTypes.INT, OpenApiParameter.PATH), + ], + ), +) +class OrderHistoryViewSet(ReadOnlyModelViewSet): + """Viewset to retrieve a user's order history.""" + + serializer_class = OrderHistorySerializer + pagination_class = LimitOffsetPagination + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """Return only the user's fulfilled or refunded orders.""" + return ( + Order.objects.filter(purchaser=self.request.user) + .filter(state__in=[OrderStatus.FULFILLED, OrderStatus.REFUNDED]) + .order_by("-created_on") + .all() + ) + + +class OrderReceiptView(RetrieveAPIView): + """Viewset to retrieve an order so it can be viewed as a receipt.""" + + serializer_class = OrderSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """Return only the user's orders""" + return Order.objects.filter(purchaser=self.request.user).all() diff --git a/ecommerce/views/v0/urls.py b/ecommerce/views/v0/urls.py index 3df510b759..3e6eb069b8 100644 --- a/ecommerce/views/v0/urls.py +++ b/ecommerce/views/v0/urls.py @@ -11,6 +11,8 @@ NestedDiscountRedemptionViewSet, NestedDiscountTierViewSet, NestedUserDiscountViewSet, + OrderHistoryViewSet, + OrderReceiptView, ProductViewSet, add_discount_to_basket, checkout_basket, @@ -25,6 +27,7 @@ router.register(r"products/all", AllProductViewSet, basename="all_products_api") router.register(r"products", ProductViewSet, basename="products_api") +router.register(r"orders/history", OrderHistoryViewSet, basename="orderhistory_api") basket_router = router.register(r"baskets", BasketViewSet, basename="baskets_api") basket_router.register( @@ -93,6 +96,11 @@ checkout_basket, name="baskets_api-checkout", ), + path( + "orders/receipt//", + OrderReceiptView.as_view(), + name="order_receipt_api", + ), re_path( r"^", include( diff --git a/ecommerce/views/v0/views_test.py b/ecommerce/views/v0/views_test.py index c84014b925..671a3c4576 100644 --- a/ecommerce/views/v0/views_test.py +++ b/ecommerce/views/v0/views_test.py @@ -3,6 +3,7 @@ import operator as op import random from datetime import datetime, timedelta +from decimal import Decimal import freezegun import pytest @@ -11,6 +12,7 @@ from django.forms.models import model_to_dict from django.urls import reverse from mitol.common.utils.datetime import now_in_utc +from reversion.models import Version from b2b.constants import CONTRACT_MEMBERSHIP_CODE, CONTRACT_MEMBERSHIP_MANAGED from b2b.factories import ContractPageFactory @@ -32,8 +34,10 @@ BasketFactory, BasketItemFactory, DiscountFactory, + LineFactory, OrderFactory, ProductFactory, + TransactionFactory, UnlimitedUseDiscountFactory, ) from ecommerce.models import ( @@ -1103,3 +1107,86 @@ def test_start_checkout_with_bad_discount(user, user_drf_client): assert "error" in resp_body assert resp_body["error"] == USER_MSG_TYPE_DISCOUNT_INVALID assert resp.status_code == 400 + + +@pytest.mark.skip_nplusone_check +def test_order_history_list(user, user_drf_client): + """Test that we can get a user's order history.""" + with reversion.create_revision(): + prod_1 = ProductFactory.create() + prod_2 = ProductFactory.create() + + prod_1_version = Version.objects.get_for_object(prod_1).last() + prod_2_version = Version.objects.get_for_object(prod_2).last() + + order_1 = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + order_1_line = LineFactory.create(order=order_1, product_version=prod_1_version) + + order_2 = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + order_2_line = LineFactory.create(order=order_2, product_version=prod_2_version) + + resp = user_drf_client.get(reverse("v0:orderhistory_api-list")) + + assert resp.status_code == 200 + + returned_orders = resp.json() + assert len(returned_orders) == 2 + for returned_order in returned_orders: + assert returned_order["id"] in [ + order_1.id, + order_2.id, + ] + for order_line in returned_order["lines"]: + assert order_line["id"] in [ + order_1_line.id, + order_2_line.id, + ] + + +@pytest.mark.skip_nplusone_check +def test_order_history_retrieve(user, user_drf_client): + """Test that we can get a user's order history.""" + with reversion.create_revision(): + prod_1 = ProductFactory.create() + + prod_1_version = Version.objects.get_for_object(prod_1).last() + + order_1 = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + order_1_line = LineFactory.create(order=order_1, product_version=prod_1_version) + + resp = user_drf_client.get(reverse("v0:orderhistory_api-list"), {"id": order_1.id}) + + assert resp.status_code == 200 + + returned_orders = resp.json() + assert len(returned_orders) == 1 + returned_order = returned_orders[0] + assert returned_order["id"] == order_1.id + assert returned_order["lines"][0]["id"] == order_1_line.id + + +@pytest.mark.skip_nplusone_check +def test_order_receipt_retrieve(user, user_drf_client): + """Test that we can get a receipt for a user's order.""" + with reversion.create_revision(): + prod_1 = ProductFactory.create() + + prod_1_version = Version.objects.get_for_object(prod_1).last() + + order_1 = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + order_1_line = LineFactory.create(order=order_1, product_version=prod_1_version) + TransactionFactory.create(order=order_1) + + resp = user_drf_client.get( + reverse("v0:order_receipt_api", kwargs={"pk": order_1.id}) + ) + + assert resp.status_code == 200 + + receipt = resp.json() + + assert receipt["reference_number"] == order_1.reference_number + assert Decimal(receipt["lines"][0]["price"]) == order_1_line.total_price + # The TransactionFactory creates a transaction with no payment data so this + # should be None. + assert receipt["transactions"]["card_number"] is None diff --git a/openapi/hooks.py b/openapi/hooks.py index 5d7cfb20b2..e948b157e9 100644 --- a/openapi/hooks.py +++ b/openapi/hooks.py @@ -102,7 +102,6 @@ def exclude_paths_hook(endpoints, **kwargs): # noqa: ARG001 "/api/checkout/", "/api/instructor/", "/api/v0/checkout/", - "/api/v0/orders/", "/api/v2/pages/", "/api/v2/images/", "/api/v2/documents/", diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 6970b08a73..c5737646b3 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -1631,6 +1631,70 @@ paths: schema: $ref: '#/components/schemas/V0Discount' description: '' + /api/v0/orders/history/: + get: + operationId: orders_history_list + description: Retrives the current user's order history. + parameters: + - name: limit + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: offset + required: false + in: query + description: The initial index from which to return the results. + schema: + type: integer + tags: + - orders + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedOrderHistoryList' + description: '' + /api/v0/orders/history/{id}/: + get: + operationId: orders_history_retrieve + description: Retrieve a historical order for the current user. + parameters: + - in: path + name: id + schema: + type: integer + required: true + tags: + - orders + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/OrderHistory' + description: '' + /api/v0/orders/receipt/{id}/: + get: + operationId: orders_receipt_retrieve + description: Viewset to retrieve an order so it can be viewed as a receipt. + parameters: + - in: path + name: id + schema: + type: integer + required: true + tags: + - orders + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + description: '' /api/v0/products/: get: operationId: products_list @@ -4697,6 +4761,35 @@ components: maxLength: 10 required: - country + Line: + type: object + description: Serializes order lines. + properties: + quantity: + type: integer + item_description: + type: string + unit_price: + type: string + format: decimal + pattern: ^-?\d{0,7}(?:\.\d{0,2})?$ + total_price: + type: string + format: decimal + pattern: ^-?\d{0,7}(?:\.\d{0,2})?$ + id: + type: integer + product: + allOf: + - $ref: '#/components/schemas/Product' + readOnly: true + required: + - id + - item_description + - product + - quantity + - total_price + - unit_price Nested: type: object properties: @@ -4865,6 +4958,49 @@ components: - street_address - total_price_paid - transactions + OrderHistory: + type: object + properties: + id: + type: integer + readOnly: true + state: + $ref: '#/components/schemas/StateEnum' + reference_number: + type: string + nullable: true + maxLength: 255 + purchaser: + $ref: '#/components/schemas/PublicUser' + total_price_paid: + type: string + format: decimal + pattern: ^-?\d{0,15}(?:\.\d{0,5})?$ + lines: + type: array + items: + $ref: '#/components/schemas/Line' + created_on: + type: string + format: date-time + readOnly: true + titles: + type: array + items: {} + readOnly: true + updated_on: + type: string + format: date-time + readOnly: true + required: + - created_on + - id + - lines + - purchaser + - state + - titles + - total_price_paid + - updated_on OrderRequest: type: object properties: @@ -5185,6 +5321,29 @@ components: type: array items: $ref: '#/components/schemas/FlexiblePriceTier' + PaginatedOrderHistoryList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?offset=400&limit=100 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?offset=200&limit=100 + results: + type: array + items: + $ref: '#/components/schemas/OrderHistory' PaginatedProductList: type: object required: diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 22ea3573d9..19d87347cd 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -1631,6 +1631,70 @@ paths: schema: $ref: '#/components/schemas/V0Discount' description: '' + /api/v0/orders/history/: + get: + operationId: orders_history_list + description: Retrives the current user's order history. + parameters: + - name: limit + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: offset + required: false + in: query + description: The initial index from which to return the results. + schema: + type: integer + tags: + - orders + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedOrderHistoryList' + description: '' + /api/v0/orders/history/{id}/: + get: + operationId: orders_history_retrieve + description: Retrieve a historical order for the current user. + parameters: + - in: path + name: id + schema: + type: integer + required: true + tags: + - orders + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/OrderHistory' + description: '' + /api/v0/orders/receipt/{id}/: + get: + operationId: orders_receipt_retrieve + description: Viewset to retrieve an order so it can be viewed as a receipt. + parameters: + - in: path + name: id + schema: + type: integer + required: true + tags: + - orders + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + description: '' /api/v0/products/: get: operationId: products_list @@ -4697,6 +4761,35 @@ components: maxLength: 10 required: - country + Line: + type: object + description: Serializes order lines. + properties: + quantity: + type: integer + item_description: + type: string + unit_price: + type: string + format: decimal + pattern: ^-?\d{0,7}(?:\.\d{0,2})?$ + total_price: + type: string + format: decimal + pattern: ^-?\d{0,7}(?:\.\d{0,2})?$ + id: + type: integer + product: + allOf: + - $ref: '#/components/schemas/Product' + readOnly: true + required: + - id + - item_description + - product + - quantity + - total_price + - unit_price Nested: type: object properties: @@ -4865,6 +4958,49 @@ components: - street_address - total_price_paid - transactions + OrderHistory: + type: object + properties: + id: + type: integer + readOnly: true + state: + $ref: '#/components/schemas/StateEnum' + reference_number: + type: string + nullable: true + maxLength: 255 + purchaser: + $ref: '#/components/schemas/PublicUser' + total_price_paid: + type: string + format: decimal + pattern: ^-?\d{0,15}(?:\.\d{0,5})?$ + lines: + type: array + items: + $ref: '#/components/schemas/Line' + created_on: + type: string + format: date-time + readOnly: true + titles: + type: array + items: {} + readOnly: true + updated_on: + type: string + format: date-time + readOnly: true + required: + - created_on + - id + - lines + - purchaser + - state + - titles + - total_price_paid + - updated_on OrderRequest: type: object properties: @@ -5185,6 +5321,29 @@ components: type: array items: $ref: '#/components/schemas/FlexiblePriceTier' + PaginatedOrderHistoryList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?offset=400&limit=100 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?offset=200&limit=100 + results: + type: array + items: + $ref: '#/components/schemas/OrderHistory' PaginatedProductList: type: object required: diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index c568f8e3d0..30df111cc4 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -1631,6 +1631,70 @@ paths: schema: $ref: '#/components/schemas/V0Discount' description: '' + /api/v0/orders/history/: + get: + operationId: orders_history_list + description: Retrives the current user's order history. + parameters: + - name: limit + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: offset + required: false + in: query + description: The initial index from which to return the results. + schema: + type: integer + tags: + - orders + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedOrderHistoryList' + description: '' + /api/v0/orders/history/{id}/: + get: + operationId: orders_history_retrieve + description: Retrieve a historical order for the current user. + parameters: + - in: path + name: id + schema: + type: integer + required: true + tags: + - orders + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/OrderHistory' + description: '' + /api/v0/orders/receipt/{id}/: + get: + operationId: orders_receipt_retrieve + description: Viewset to retrieve an order so it can be viewed as a receipt. + parameters: + - in: path + name: id + schema: + type: integer + required: true + tags: + - orders + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + description: '' /api/v0/products/: get: operationId: products_list @@ -4697,6 +4761,35 @@ components: maxLength: 10 required: - country + Line: + type: object + description: Serializes order lines. + properties: + quantity: + type: integer + item_description: + type: string + unit_price: + type: string + format: decimal + pattern: ^-?\d{0,7}(?:\.\d{0,2})?$ + total_price: + type: string + format: decimal + pattern: ^-?\d{0,7}(?:\.\d{0,2})?$ + id: + type: integer + product: + allOf: + - $ref: '#/components/schemas/Product' + readOnly: true + required: + - id + - item_description + - product + - quantity + - total_price + - unit_price Nested: type: object properties: @@ -4865,6 +4958,49 @@ components: - street_address - total_price_paid - transactions + OrderHistory: + type: object + properties: + id: + type: integer + readOnly: true + state: + $ref: '#/components/schemas/StateEnum' + reference_number: + type: string + nullable: true + maxLength: 255 + purchaser: + $ref: '#/components/schemas/PublicUser' + total_price_paid: + type: string + format: decimal + pattern: ^-?\d{0,15}(?:\.\d{0,5})?$ + lines: + type: array + items: + $ref: '#/components/schemas/Line' + created_on: + type: string + format: date-time + readOnly: true + titles: + type: array + items: {} + readOnly: true + updated_on: + type: string + format: date-time + readOnly: true + required: + - created_on + - id + - lines + - purchaser + - state + - titles + - total_price_paid + - updated_on OrderRequest: type: object properties: @@ -5185,6 +5321,29 @@ components: type: array items: $ref: '#/components/schemas/FlexiblePriceTier' + PaginatedOrderHistoryList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?offset=400&limit=100 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?offset=200&limit=100 + results: + type: array + items: + $ref: '#/components/schemas/OrderHistory' PaginatedProductList: type: object required: From 1fc1ccdffb810e6c39433ddf4fa5e5d935ac7c1f Mon Sep 17 00:00:00 2001 From: Dan Subak Date: Thu, 22 Jan 2026 12:01:56 -0500 Subject: [PATCH 05/15] Tweaks to `create_courseware_page` to populate a bit more data (#3237) --- cms/api.py | 15 ++- .../commands/create_courseware_page.py | 98 +++++++++++++++++-- courses/api.py | 6 +- 3 files changed, 103 insertions(+), 16 deletions(-) diff --git a/cms/api.py b/cms/api.py index 00ca8d05c8..1f990bfa11 100644 --- a/cms/api.py +++ b/cms/api.py @@ -287,6 +287,7 @@ def create_default_courseware_page( live: bool = False, include_in_learn_catalog: bool = False, ingest_content_files_for_ai: bool = False, + optional_kwargs: dict | None = None, ): """ Creates a default about page for the given courseware object. Created pages @@ -330,6 +331,9 @@ def create_default_courseware_page( } program_only_kwargs = {} + if optional_kwargs is None: + optional_kwargs = {} + try: if isinstance(courseware, Course): parent_page = CourseIndexPage.objects.filter(live=True).get() @@ -339,9 +343,16 @@ def create_default_courseware_page( raise ValidationError(f"No valid index page found for {courseware}.") # noqa: B904, EM102 if isinstance(courseware, Course): - page = CoursePage(course=courseware, **page_framework, **course_only_kwargs) + page = CoursePage( + course=courseware, **page_framework, **course_only_kwargs, **optional_kwargs + ) else: - page = ProgramPage(program=courseware, **page_framework, **program_only_kwargs) + page = ProgramPage( + program=courseware, + **page_framework, + **program_only_kwargs, + **optional_kwargs, + ) parent_page.add_child(instance=page) diff --git a/cms/management/commands/create_courseware_page.py b/cms/management/commands/create_courseware_page.py index 83802d02cf..70ebe3b78f 100644 --- a/cms/management/commands/create_courseware_page.py +++ b/cms/management/commands/create_courseware_page.py @@ -2,11 +2,13 @@ Creates a basic courseware about page. This can be for programs or courses. """ +import sys + from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.management import BaseCommand from cms.api import create_default_courseware_page -from cms.models import Course, Program +from cms.models import Course, InstructorPage, InstructorPageLink, Program from cms.utils import get_page_editing_url @@ -17,6 +19,52 @@ class Command(BaseCommand): help = "Creates a basic draft about page for the given courseware object." + def get_optional_values_for_courseware_type( + self, courseware_type: Course | Program + ) -> dict: + """ + Returns a dictionary of optional values to include when creating the page, + based on the type of courseware (Course or Program). + """ + + # Just some hardcoded example values for demonstration purposes. + # Might make sense to use faker for some of this or allow selection of values from different presets + # For now though, this sets up a page which is reasonably complete and can be immediately published + values = { + "price": [ + ( + "price_details", + { + "text": "PLACEHOLDER - Three easy payments of 99.99", + "link": "https://example.com/pricing", + }, + ) + ], + "min_weeks": 1, + "max_weeks": 1, + "effort": "PLACEHOLDER - 1-2 hours per week", + "min_price": 37, + "max_price": 149, + "prerequisites": "PLACEHOLDER - No prerequisites, other than a willingness to learn", + "faq_url": "https://example.com", + } + if isinstance(courseware_type, Course): + values["about"] = ( + "PLACEHOLDER - In this engineering course, we will explore the processing and structure of cellular solids as they are created from polymers, metals, ceramics, glasses and composites." + ) + values["what_you_learn"] = ( + "PLACEHOLDER - In this engineering course, we will explore the processing and structure of cellular solids as they are created from polymers, metals, ceramics, glasses and composites." + ) + elif isinstance(courseware_type, Program): + values["about"] = ( + "PLACEHOLDER - In this engineering program, we will explore the processing and structure of cellular solids as they are created from polymers, metals, ceramics, glasses and composites." + ) + values["what_you_learn"] = ( + "PLACEHOLDER - In this engineering program, we will explore the processing and structure of cellular solids as they are created from polymers, metals, ceramics, glasses and composites." + ) + + return values + def add_arguments(self, parser) -> None: parser.add_argument( "courseware_id", @@ -37,8 +85,26 @@ def add_arguments(self, parser) -> None: action="store_true", help="Ingest content files for AI processing; courses-only. (Defaults to not.)", ) + parser.add_argument( + "--include_optional_values", + action="store_true", + help="Include more than bare minimum required fields while creating the page. By default these will not be populated", + ) + parser.add_argument( + "--link_to_instructor", + action="store", + type=str, + default=None, + help="Comma separated list of instructor IDs to link to the courseware page.", + ) + + def error(self, message): + self.stdout.write(self.style.ERROR(message)) + sys.exit(1) def handle(self, *args, **kwargs): # pylint: disable=unused-argument # noqa: ARG002 + include_optional_values = kwargs["include_optional_values"] + link_to_instructor = kwargs["link_to_instructor"] try: courseware = Course.objects.filter( readable_id=kwargs["courseware_id"] @@ -49,19 +115,22 @@ def handle(self, *args, **kwargs): # pylint: disable=unused-argument # noqa: A readable_id=kwargs["courseware_id"] ).get() except ObjectDoesNotExist: - self.stdout.write( - self.style.ERROR( - f"Can't find courseware object for {kwargs['courseware_id']}, stopping." - ) + self.error( + f"Can't find courseware object for {kwargs['courseware_id']}, stopping." ) - return try: + optional_kwargs = ( + {} + if not include_optional_values + else self.get_optional_values_for_courseware_type(courseware) + ) page = create_default_courseware_page( courseware, live=kwargs["live"], include_in_learn_catalog=kwargs["include_in_learn_catalog"], ingest_content_files_for_ai=kwargs["ingest_content_files_for_ai"], + optional_kwargs=optional_kwargs, ) self.stdout.write( self.style.SUCCESS( @@ -70,8 +139,17 @@ def handle(self, *args, **kwargs): # pylint: disable=unused-argument # noqa: A ) ) except ValidationError as e: - self.stderr.write( - self.style.ERROR( - f"An error occurred creating the about page for {courseware.readable_id}: {e}" - ) + self.error( + f"An error occurred creating the about page for {courseware.readable_id}: {e}" ) + + if link_to_instructor: + instructor_ids = [ + int(instructor_id) + for instructor_id in kwargs["link_to_instructor"].split(",") + ] + instructor_pages = InstructorPage.objects.filter(id__in=instructor_ids) + for instructor_page in instructor_pages: + InstructorPageLink( + linked_instructor_page=instructor_page, page=page + ).save() diff --git a/courses/api.py b/courses/api.py index f673bba40c..e3b00572f6 100644 --- a/courses/api.py +++ b/courses/api.py @@ -1294,12 +1294,10 @@ def import_courserun_from_edx( # noqa: C901, PLR0913 course_page = create_default_courseware_page( courseware=new_run.course, live=publish_cms_page, + ingest_content_files_for_ai=ingest_content_files_for_ai, + include_in_learn_catalog=include_in_learn_catalog, ) - course_page.ingest_content_files_for_ai = ingest_content_files_for_ai - course_page.include_in_learn_catalog = include_in_learn_catalog - course_page.save() - course_product = None if price: content_type = ContentType.objects.get_for_model(CourseRun) From 87f83a578c21d736b2fe2fcdce3bf3f5b0f293ff Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Thu, 22 Jan 2026 12:26:59 -0500 Subject: [PATCH 06/15] Improve v2/courses program field spec (#3238) --- courses/serializers/v1/base.py | 2 +- courses/serializers/v2/courses.py | 8 +++----- openapi/specs/v0.yaml | 28 ++++++++++++++++++++++++---- openapi/specs/v1.yaml | 28 ++++++++++++++++++++++++---- openapi/specs/v2.yaml | 28 ++++++++++++++++++++++++---- 5 files changed, 76 insertions(+), 18 deletions(-) diff --git a/courses/serializers/v1/base.py b/courses/serializers/v1/base.py index 285082dfca..afb3639695 100644 --- a/courses/serializers/v1/base.py +++ b/courses/serializers/v1/base.py @@ -96,7 +96,7 @@ class BaseProgramSerializer(serializers.ModelSerializer): type = serializers.SerializerMethodField(read_only=True) @staticmethod - def get_type(obj): # noqa: ARG004 + def get_type(obj) -> str: # noqa: ARG004 return CONTENT_TYPE_MODEL_PROGRAM class Meta: diff --git a/courses/serializers/v2/courses.py b/courses/serializers/v2/courses.py index 9a80a95bae..c3a16eb07a 100644 --- a/courses/serializers/v2/courses.py +++ b/courses/serializers/v2/courses.py @@ -17,6 +17,7 @@ BaseCourseRunEnrollmentSerializer, BaseCourseRunSerializer, BaseCourseSerializer, + BaseProgramSerializer, ProductRelatedField, ) from courses.serializers.v1.departments import DepartmentSerializer @@ -110,12 +111,9 @@ def get_next_run_id(self, instance) -> int | None: run = instance.first_unexpired_run return run.id if run is not None else None - def get_programs(self, instance) -> list[dict] | None: + @extend_schema_field(BaseProgramSerializer(many=True, allow_null=True)) + def get_programs(self, instance): if self.context.get("include_programs", False): - from courses.serializers.v1.base import ( # noqa: PLC0415 - BaseProgramSerializer, - ) - return BaseProgramSerializer(instance.programs, many=True).data return None diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index c5737646b3..11b98c89ec 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -3120,6 +3120,28 @@ components: - readable_id - title - type + BaseProgram: + type: object + description: Basic program model serializer + properties: + title: + type: string + maxLength: 255 + readable_id: + type: string + pattern: ^[\w\-+:\.]+$ + maxLength: 255 + id: + type: integer + readOnly: true + type: + type: string + readOnly: true + required: + - id + - readable_id + - title + - type Basket: type: object description: Basket model serializer @@ -4082,8 +4104,7 @@ components: programs: type: array items: - type: object - additionalProperties: {} + $ref: '#/components/schemas/BaseProgram' nullable: true readOnly: true topics: @@ -7221,8 +7242,7 @@ components: programs: type: array items: - type: object - additionalProperties: {} + $ref: '#/components/schemas/BaseProgram' nullable: true readOnly: true topics: diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 19d87347cd..1ee5f8f690 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -3120,6 +3120,28 @@ components: - readable_id - title - type + BaseProgram: + type: object + description: Basic program model serializer + properties: + title: + type: string + maxLength: 255 + readable_id: + type: string + pattern: ^[\w\-+:\.]+$ + maxLength: 255 + id: + type: integer + readOnly: true + type: + type: string + readOnly: true + required: + - id + - readable_id + - title + - type Basket: type: object description: Basket model serializer @@ -4082,8 +4104,7 @@ components: programs: type: array items: - type: object - additionalProperties: {} + $ref: '#/components/schemas/BaseProgram' nullable: true readOnly: true topics: @@ -7221,8 +7242,7 @@ components: programs: type: array items: - type: object - additionalProperties: {} + $ref: '#/components/schemas/BaseProgram' nullable: true readOnly: true topics: diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 30df111cc4..abd621881c 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -3120,6 +3120,28 @@ components: - readable_id - title - type + BaseProgram: + type: object + description: Basic program model serializer + properties: + title: + type: string + maxLength: 255 + readable_id: + type: string + pattern: ^[\w\-+:\.]+$ + maxLength: 255 + id: + type: integer + readOnly: true + type: + type: string + readOnly: true + required: + - id + - readable_id + - title + - type Basket: type: object description: Basket model serializer @@ -4082,8 +4104,7 @@ components: programs: type: array items: - type: object - additionalProperties: {} + $ref: '#/components/schemas/BaseProgram' nullable: true readOnly: true topics: @@ -7221,8 +7242,7 @@ components: programs: type: array items: - type: object - additionalProperties: {} + $ref: '#/components/schemas/BaseProgram' nullable: true readOnly: true topics: From bba652f3d4bc627de31efacf30c778fb89ddacb8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:44:28 -0500 Subject: [PATCH 07/15] Update dependency pytest-cov to v7 (#3204) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- poetry.lock | 180 ++++++++++++++++++++++++++++--------------------- pyproject.toml | 2 +- 2 files changed, 104 insertions(+), 78 deletions(-) diff --git a/poetry.lock b/poetry.lock index dd836eab8f..aee4fa3b35 100644 --- a/poetry.lock +++ b/poetry.lock @@ -676,79 +676,104 @@ markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", [[package]] name = "coverage" -version = "7.8.2" +version = "7.13.1" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "coverage-7.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bd8ec21e1443fd7a447881332f7ce9d35b8fbd2849e761bb290b584535636b0a"}, - {file = "coverage-7.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26c2396674816deaeae7ded0e2b42c26537280f8fe313335858ffff35019be"}, - {file = "coverage-7.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1aec326ed237e5880bfe69ad41616d333712c7937bcefc1343145e972938f9b3"}, - {file = "coverage-7.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e818796f71702d7a13e50c70de2a1924f729228580bcba1607cccf32eea46e6"}, - {file = "coverage-7.8.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:546e537d9e24efc765c9c891328f30f826e3e4808e31f5d0f87c4ba12bbd1622"}, - {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab9b09a2349f58e73f8ebc06fac546dd623e23b063e5398343c5270072e3201c"}, - {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd51355ab8a372d89fb0e6a31719e825cf8df8b6724bee942fb5b92c3f016ba3"}, - {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0774df1e093acb6c9e4d58bce7f86656aeed6c132a16e2337692c12786b32404"}, - {file = "coverage-7.8.2-cp310-cp310-win32.whl", hash = "sha256:00f2e2f2e37f47e5f54423aeefd6c32a7dbcedc033fcd3928a4f4948e8b96af7"}, - {file = "coverage-7.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:145b07bea229821d51811bf15eeab346c236d523838eda395ea969d120d13347"}, - {file = "coverage-7.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b99058eef42e6a8dcd135afb068b3d53aff3921ce699e127602efff9956457a9"}, - {file = "coverage-7.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5feb7f2c3e6ea94d3b877def0270dff0947b8d8c04cfa34a17be0a4dc1836879"}, - {file = "coverage-7.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:670a13249b957bb9050fab12d86acef7bf8f6a879b9d1a883799276e0d4c674a"}, - {file = "coverage-7.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bdc8bf760459a4a4187b452213e04d039990211f98644c7292adf1e471162b5"}, - {file = "coverage-7.8.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07a989c867986c2a75f158f03fdb413128aad29aca9d4dbce5fc755672d96f11"}, - {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2db10dedeb619a771ef0e2949ccba7b75e33905de959c2643a4607bef2f3fb3a"}, - {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e6ea7dba4e92926b7b5f0990634b78ea02f208d04af520c73a7c876d5a8d36cb"}, - {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ef2f22795a7aca99fc3c84393a55a53dd18ab8c93fb431004e4d8f0774150f54"}, - {file = "coverage-7.8.2-cp311-cp311-win32.whl", hash = "sha256:641988828bc18a6368fe72355df5f1703e44411adbe49bba5644b941ce6f2e3a"}, - {file = "coverage-7.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:8ab4a51cb39dc1933ba627e0875046d150e88478dbe22ce145a68393e9652975"}, - {file = "coverage-7.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:8966a821e2083c74d88cca5b7dcccc0a3a888a596a04c0b9668a891de3a0cc53"}, - {file = "coverage-7.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2f6fe3654468d061942591aef56686131335b7a8325684eda85dacdf311356c"}, - {file = "coverage-7.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76090fab50610798cc05241bf83b603477c40ee87acd358b66196ab0ca44ffa1"}, - {file = "coverage-7.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd0a0a5054be160777a7920b731a0570284db5142abaaf81bcbb282b8d99279"}, - {file = "coverage-7.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da23ce9a3d356d0affe9c7036030b5c8f14556bd970c9b224f9c8205505e3b99"}, - {file = "coverage-7.8.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9392773cffeb8d7e042a7b15b82a414011e9d2b5fdbbd3f7e6a6b17d5e21b20"}, - {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:876cbfd0b09ce09d81585d266c07a32657beb3eaec896f39484b631555be0fe2"}, - {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3da9b771c98977a13fbc3830f6caa85cae6c9c83911d24cb2d218e9394259c57"}, - {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a990f6510b3292686713bfef26d0049cd63b9c7bb17e0864f133cbfd2e6167f"}, - {file = "coverage-7.8.2-cp312-cp312-win32.whl", hash = "sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8"}, - {file = "coverage-7.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223"}, - {file = "coverage-7.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f"}, - {file = "coverage-7.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ea561010914ec1c26ab4188aef8b1567272ef6de096312716f90e5baa79ef8ca"}, - {file = "coverage-7.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cb86337a4fcdd0e598ff2caeb513ac604d2f3da6d53df2c8e368e07ee38e277d"}, - {file = "coverage-7.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a4636ddb666971345541b59899e969f3b301143dd86b0ddbb570bd591f1e85"}, - {file = "coverage-7.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5040536cf9b13fb033f76bcb5e1e5cb3b57c4807fef37db9e0ed129c6a094257"}, - {file = "coverage-7.8.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc67994df9bcd7e0150a47ef41278b9e0a0ea187caba72414b71dc590b99a108"}, - {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e6c86888fd076d9e0fe848af0a2142bf606044dc5ceee0aa9eddb56e26895a0"}, - {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:684ca9f58119b8e26bef860db33524ae0365601492e86ba0b71d513f525e7050"}, - {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8165584ddedb49204c4e18da083913bdf6a982bfb558632a79bdaadcdafd0d48"}, - {file = "coverage-7.8.2-cp313-cp313-win32.whl", hash = "sha256:34759ee2c65362163699cc917bdb2a54114dd06d19bab860725f94ef45a3d9b7"}, - {file = "coverage-7.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f9bc608fbafaee40eb60a9a53dbfb90f53cc66d3d32c2849dc27cf5638a21e3"}, - {file = "coverage-7.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9fe449ee461a3b0c7105690419d0b0aba1232f4ff6d120a9e241e58a556733f7"}, - {file = "coverage-7.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8369a7c8ef66bded2b6484053749ff220dbf83cba84f3398c84c51a6f748a008"}, - {file = "coverage-7.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:159b81df53a5fcbc7d45dae3adad554fdbde9829a994e15227b3f9d816d00b36"}, - {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6fcbbd35a96192d042c691c9e0c49ef54bd7ed865846a3c9d624c30bb67ce46"}, - {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05364b9cc82f138cc86128dc4e2e1251c2981a2218bfcd556fe6b0fbaa3501be"}, - {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46d532db4e5ff3979ce47d18e2fe8ecad283eeb7367726da0e5ef88e4fe64740"}, - {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4000a31c34932e7e4fa0381a3d6deb43dc0c8f458e3e7ea6502e6238e10be625"}, - {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:43ff5033d657cd51f83015c3b7a443287250dc14e69910577c3e03bd2e06f27b"}, - {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94316e13f0981cbbba132c1f9f365cac1d26716aaac130866ca812006f662199"}, - {file = "coverage-7.8.2-cp313-cp313t-win32.whl", hash = "sha256:3f5673888d3676d0a745c3d0e16da338c5eea300cb1f4ada9c872981265e76d8"}, - {file = "coverage-7.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2c08b05ee8d7861e45dc5a2cc4195c8c66dca5ac613144eb6ebeaff2d502e73d"}, - {file = "coverage-7.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:1e1448bb72b387755e1ff3ef1268a06617afd94188164960dba8d0245a46004b"}, - {file = "coverage-7.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:496948261eaac5ac9cf43f5d0a9f6eb7a6d4cb3bedb2c5d294138142f5c18f2a"}, - {file = "coverage-7.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eacd2de0d30871eff893bab0b67840a96445edcb3c8fd915e6b11ac4b2f3fa6d"}, - {file = "coverage-7.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b039ffddc99ad65d5078ef300e0c7eed08c270dc26570440e3ef18beb816c1ca"}, - {file = "coverage-7.8.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e49824808d4375ede9dd84e9961a59c47f9113039f1a525e6be170aa4f5c34d"}, - {file = "coverage-7.8.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b069938961dfad881dc2f8d02b47645cd2f455d3809ba92a8a687bf513839787"}, - {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:de77c3ba8bb686d1c411e78ee1b97e6e0b963fb98b1637658dd9ad2c875cf9d7"}, - {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1676628065a498943bd3f64f099bb573e08cf1bc6088bbe33cf4424e0876f4b3"}, - {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8e1a26e7e50076e35f7afafde570ca2b4d7900a491174ca357d29dece5aacee7"}, - {file = "coverage-7.8.2-cp39-cp39-win32.whl", hash = "sha256:6782a12bf76fa61ad9350d5a6ef5f3f020b57f5e6305cbc663803f2ebd0f270a"}, - {file = "coverage-7.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:1efa4166ba75ccefd647f2d78b64f53f14fb82622bc94c5a5cb0a622f50f1c9e"}, - {file = "coverage-7.8.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:ec455eedf3ba0bbdf8f5a570012617eb305c63cb9f03428d39bf544cb2b94837"}, - {file = "coverage-7.8.2-py3-none-any.whl", hash = "sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32"}, - {file = "coverage-7.8.2.tar.gz", hash = "sha256:a886d531373a1f6ff9fad2a2ba4a045b68467b779ae729ee0b3b10ac20033b27"}, + {file = "coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147"}, + {file = "coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29"}, + {file = "coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f"}, + {file = "coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1"}, + {file = "coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88"}, + {file = "coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba"}, + {file = "coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19"}, + {file = "coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a"}, + {file = "coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c"}, + {file = "coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3"}, + {file = "coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c"}, + {file = "coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7"}, + {file = "coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6"}, + {file = "coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c"}, + {file = "coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78"}, + {file = "coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784"}, + {file = "coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461"}, + {file = "coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500"}, + {file = "coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9"}, + {file = "coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc"}, + {file = "coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53"}, + {file = "coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842"}, + {file = "coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2"}, + {file = "coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09"}, + {file = "coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894"}, + {file = "coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9"}, + {file = "coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5"}, + {file = "coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a"}, + {file = "coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0"}, + {file = "coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a"}, + {file = "coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416"}, + {file = "coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f"}, + {file = "coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79"}, + {file = "coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4"}, + {file = "coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573"}, + {file = "coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd"}, ] [package.dependencies] @@ -4458,22 +4483,23 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests [[package]] name = "pytest-cov" -version = "4.1.0" +version = "7.0.0" description = "Pytest plugin for measuring coverage." optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, - {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, + {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, + {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, ] [package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} -pytest = ">=4.6" +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" [package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-django" @@ -6051,4 +6077,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "9bab4984576436829a7460261fc5f33fbe9f4c72a3a34b6ce8c14f6829c1fb73" +content-hash = "debef34b427abc4c8c312d7db0f1e07c0b5fe7fab748a987e593dcf36c9f2e2c" diff --git a/pyproject.toml b/pyproject.toml index 4eed6859d1..dd2534d674 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,7 +106,7 @@ freezegun = "^1.2" ipdb = "^0.13.13" pdbpp = "^0.11.6" pre-commit = "^4.0.0" -pytest-cov = "^4.1.0" +pytest-cov = "^7.0.0" pytest-django = "^4.5.2" pytest-env = "^1.0.0" pytest-mock = "^3.11.1" From dd59eb1b4308a3550f222a3d0e02f47c05099cc1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:08:10 -0500 Subject: [PATCH 08/15] Update actions/setup-python digest to a309ff8 (#3243) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/docs.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb3545e86c..b6888a5245 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: virtualenvs-in-project: true - name: Set up Python - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.10" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 494da0259b..03b9a7929d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - name: Set up Python - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.10" - name: Install ghp-import From e97b0dd8b31639379c200ddb62af9f2917a85d09 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:08:19 -0500 Subject: [PATCH 09/15] Update actions/cache digest to 8b402f5 (#3242) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6888a5245..9208067972 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,7 @@ jobs: id: yarn-cache-dir-path run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5 + - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5 id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} From 62ab3414378cb9801221d2d11c48a0e477342774 Mon Sep 17 00:00:00 2001 From: Dan Subak Date: Fri, 23 Jan 2026 10:18:22 -0500 Subject: [PATCH 10/15] Add ability to specify a certificate uuid directly to generate vc (#3240) --- .../backfill_verifiable_credentials.py | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/courses/management/commands/backfill_verifiable_credentials.py b/courses/management/commands/backfill_verifiable_credentials.py index d45cb45f7c..cb4c2f4e6c 100644 --- a/courses/management/commands/backfill_verifiable_credentials.py +++ b/courses/management/commands/backfill_verifiable_credentials.py @@ -19,14 +19,13 @@ def add_arguments(self, parser): "--type", choices=["program", "course"], help="Whether to backfill program certificates or course run certificates", - required=True, ) parser.add_argument( "-i", "--ids", type=str, + default="", help="Comma-separated list of readable ids for the programs or course runs to backfill", - required=True, ) parser.add_argument( "-f", @@ -49,6 +48,13 @@ def add_arguments(self, parser): help="If specified, actually performs the backfill; otherwise, just simulates the process", default=False, ) + parser.add_argument( + "-u", + "--uuid", + type=str, + default=None, + help="UUID for a certificate to generate a verifiable credential for", + ) def generate_credential_for_certificate(self, certificate, *, force=False): """ @@ -77,13 +83,39 @@ def handle(self, *args, **options): # noqa: ARG002 force = options["force"] sleep = options["sleep"] execute = options["execute"] + certificate_uuid = options["uuid"] + courseware_type = options["type"] + if not ids and not certificate_uuid: + self.stderr.write( + self.style.ERROR( + "You must provide either --ids or --uuid to backfill certificates." + ) + ) + return + certificates = [] - if options["type"] == "program": + if certificate_uuid: + certificate = ProgramCertificate.objects.filter( + uuid=certificate_uuid + ).first() + if not certificate: + certificate = CourseRunCertificate.objects.filter( + uuid=certificate_uuid + ).first() + if not certificate: + self.stderr.write( + self.style.ERROR( + f"No certificate found with UUID {certificate_uuid}." + ) + ) + return + certificates = [certificate] + elif courseware_type == "program": program_ids = Program.objects.filter(readable_id__in=ids).values_list( "id", flat=True ) certificates = ProgramCertificate.objects.filter(id__in=program_ids) - elif options["type"] == "course": + elif courseware_type == "course": course_run_ids = CourseRun.objects.filter( courseware_id__in=ids ).values_list("id", flat=True) From 65f88f1a11f178d3524c922f033059d317583abe Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:39:11 -0500 Subject: [PATCH 11/15] [pre-commit.ci] pre-commit autoupdate (#3228) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 77e2a76253..158ad8a8ae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: hooks: - id: shfmt - repo: https://github.com/adrienverge/yamllint.git - rev: v1.37.1 + rev: v1.38.0 hooks: - id: yamllint args: [--format, parsable, -d, relaxed] @@ -47,7 +47,7 @@ repos: - "config/keycloak/*" additional_dependencies: ["gibberish-detector"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.14.11" + rev: "v0.14.13" hooks: - id: ruff-format - id: ruff From 9c8ff4bf0edba873a1c9a7ebdf9e97b778487708 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:20:41 -0500 Subject: [PATCH 12/15] Update dependency ubuntu to v24 (#3246) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/docs.yml | 2 +- .github/workflows/openapi-diff.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9208067972..4eaa8d1bc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: [push] jobs: python-tests: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 services: db: @@ -109,7 +109,7 @@ jobs: SECRET_KEY: local_unsafe_key # pragma: allowlist secret javascript-tests: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 03b9a7929d..7aaf8f9d53 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,7 +7,7 @@ on: jobs: publish: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 name: Publish Documentation steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 diff --git a/.github/workflows/openapi-diff.yml b/.github/workflows/openapi-diff.yml index 6a572819ec..b5f4c2ac76 100644 --- a/.github/workflows/openapi-diff.yml +++ b/.github/workflows/openapi-diff.yml @@ -5,7 +5,7 @@ permissions: pull-requests: write jobs: openapi-diff: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout HEAD uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 From 195ec4ccdeee765e8176e8d62ee1b71ce2068bd5 Mon Sep 17 00:00:00 2001 From: annagav Date: Mon, 26 Jan 2026 09:08:15 -0500 Subject: [PATCH 13/15] Update python 3.11 (#3105) --- .github/workflows/ci.yml | 10 ++--- .github/workflows/docs.yml | 2 +- Dockerfile | 2 +- courses/api_test.py | 2 +- poetry.lock | 80 +------------------------------------- pyproject.toml | 2 +- 6 files changed, 11 insertions(+), 87 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4eaa8d1bc2..a3daaf1d9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,11 @@ jobs: - name: Apt install run: cat Aptfile | sudo xargs apt-get install + - name: Set up Python + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6 + with: + python-version: "3.11" + - name: Install poetry uses: snok/install-poetry@v1 with: @@ -41,11 +46,6 @@ jobs: virtualenvs-create: true virtualenvs-in-project: true - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - with: - python-version: "3.10" - - name: Install dependencies run: poetry install --no-interaction diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7aaf8f9d53..2d0d0fe9a7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: - python-version: "3.10" + python-version: "3.11" - name: Install ghp-import run: pip install ghp-import - name: Build documentation diff --git a/Dockerfile b/Dockerfile index f18d05fc24..77446f5ccc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-slim AS base +FROM python:3.11-slim AS base LABEL maintainer="ODL DevOps " diff --git a/courses/api_test.py b/courses/api_test.py index 8cfc85b2c9..86f1a9d755 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -1212,7 +1212,7 @@ def test_override_user_grade(grade, letter_grade, should_force_pass, is_passed): test_grade.refresh_from_db() assert test_grade.grade == grade assert test_grade.passed is is_passed - assert test_grade.letter_grade is letter_grade + assert test_grade.letter_grade == letter_grade assert test_grade.set_by_admin is True diff --git a/poetry.lock b/poetry.lock index aee4fa3b35..5b46d40dba 100644 --- a/poetry.lock +++ b/poetry.lock @@ -64,9 +64,6 @@ files = [ {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, ] -[package.dependencies] -typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} - [package.extras] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] @@ -273,7 +270,6 @@ greenlet = "*" pygments = "*" pyxdg = "*" requests = "*" -typing_extensions = {version = "*", markers = "python_version < \"3.11\""} [package.extras] clipboard = ["pyperclip"] @@ -776,9 +772,6 @@ files = [ {file = "coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd"}, ] -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] @@ -1399,7 +1392,6 @@ files = [ asgiref = "*" django = "*" django-stubs-ext = ">=5.2.0" -tomli = {version = "*", markers = "python_version < \"3.11\""} types-PyYAML = "*" typing-extensions = ">=4.11.0" @@ -1733,25 +1725,6 @@ files = [ {file = "et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54"}, ] -[[package]] -name = "exceptiongroup" -version = "1.3.0" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "execnet" version = "2.1.1" @@ -2425,7 +2398,6 @@ files = [ [package.dependencies] decorator = {version = "*", markers = "python_version >= \"3.11\""} ipython = {version = ">=7.31.1", markers = "python_version >= \"3.11\""} -tomli = {version = "*", markers = "python_version > \"3.6\" and python_version < \"3.11\""} [[package]] name = "ipython" @@ -2442,7 +2414,6 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} decorator = "*" -exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} jedi = ">=0.16" matplotlib-inline = "*" pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} @@ -4471,12 +4442,10 @@ files = [ [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} iniconfig = ">=1.0.1" packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] @@ -4534,7 +4503,6 @@ files = [ [package.dependencies] pytest = ">=8.4.2" -tomli = {version = ">=2.2.1", markers = "python_version < \"3.11\""} [package.extras] testing = ["covdefaults (>=2.3)", "coverage (>=7.10.7)", "pytest-mock (>=3.15.1)"] @@ -4861,7 +4829,6 @@ files = [ [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] @@ -5367,49 +5334,6 @@ files = [ doc = ["reno", "sphinx"] test = ["pytest", "tornado (>=4.5)", "typeguard"] -[[package]] -name = "tomli" -version = "2.2.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, -] -markers = {main = "python_version == \"3.10\"", dev = "python_full_version <= \"3.11.0a6\""} - [[package]] name = "toolz" version = "1.0.0" @@ -6076,5 +6000,5 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" -python-versions = "^3.10" -content-hash = "debef34b427abc4c8c312d7db0f1e07c0b5fe7fab748a987e593dcf36c9f2e2c" +python-versions = "^3.11" +content-hash = "9ece6691009b45425eee807581bfad05397e18aad9a09ab857595ecffc30a127" diff --git a/pyproject.toml b/pyproject.toml index dd2534d674..29dc7133bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ requires-poetry = ">2.1,<3" [tool.poetry.dependencies] -python = "^3.10" +python = "^3.11" beautifulsoup4 = "^4.8.2" bleach = "^6.0.0" From b6758aa02909a1690a30dc149b6c2de0664580d3 Mon Sep 17 00:00:00 2001 From: annagav Date: Tue, 27 Jan 2026 07:41:13 -0500 Subject: [PATCH 14/15] Call clear_expired to clear tokens (#3248) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- main/settings.py | 2 +- main/tasks.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/main/settings.py b/main/settings.py index c6a610c85d..889f854cc2 100644 --- a/main/settings.py +++ b/main/settings.py @@ -1027,7 +1027,7 @@ ), }, "clear-expired-tokens": { - "task": "main.tasks.clear_expired_tokens", + "task": "main.tasks.run_clear_tokens", "schedule": crontab(minute=0, hour=9, day_of_week=1), # every week }, } diff --git a/main/tasks.py b/main/tasks.py index f461dd988c..c56b8acae5 100644 --- a/main/tasks.py +++ b/main/tasks.py @@ -1,15 +1,15 @@ import logging from celery import shared_task -from django.core.management import call_command +from oauth2_provider.models import clear_expired log = logging.getLogger(__name__) @shared_task -def run_cleartokens(): +def run_clear_tokens(): try: - call_command("cleartokens") + clear_expired() log.info("Successfully ran cleartokens management command.") except Exception: log.exception("Error running cleartokens") From 04ecf89bbed312f006ede4a5d46a98f3b0a0b9c4 Mon Sep 17 00:00:00 2001 From: Doof Date: Tue, 27 Jan 2026 12:42:22 +0000 Subject: [PATCH 15/15] Release 0.137.8 --- RELEASE.rst | 18 ++++++++++++++++++ main/settings.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index dbba92df3c..1086651882 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,24 @@ Release Notes ============= +Version 0.137.8 +--------------- + +- Call clear_expired to clear tokens (#3248) +- Update python 3.11 (#3105) +- Update dependency ubuntu to v24 (#3246) +- [pre-commit.ci] pre-commit autoupdate (#3228) +- Add ability to specify a certificate uuid directly to generate vc (#3240) +- Update actions/cache digest to 8b402f5 (#3242) +- Update actions/setup-python digest to a309ff8 (#3243) +- Update dependency pytest-cov to v7 (#3204) +- Improve v2/courses program field spec (#3238) +- Tweaks to `create_courseware_page` to populate a bit more data (#3237) +- Add order history/receipt APIs to OpenAPI spec. (#3234) +- add test for v2/courses api programs property (#3229) +- Add celery task to run cleartokens every week (#3232) +- Courses v2 org id tests (#3230) + Version 0.137.7 (Released January 21, 2026) --------------- diff --git a/main/settings.py b/main/settings.py index 889f854cc2..4cd921e30a 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 = "0.137.7" +VERSION = "0.137.8" log = logging.getLogger()