From d17097fb5b7d1d86cbb4c50132a95a783689e545 Mon Sep 17 00:00:00 2001 From: Dan Subak Date: Wed, 8 Apr 2026 15:06:27 -0400 Subject: [PATCH 1/5] Exit Python Tests step after first failure (#3382) --- scripts/test/python_tests.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/test/python_tests.sh b/scripts/test/python_tests.sh index b87cdb1c44..5dd7a9d3dd 100755 --- a/scripts/test/python_tests.sh +++ b/scripts/test/python_tests.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash +set -e +set -o pipefail status=0 echohighlight() { From a10cbca85037594f26a66378f5925b1fbca9b603 Mon Sep 17 00:00:00 2001 From: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:29:04 +0500 Subject: [PATCH 2/5] fix: filter b2b contracts in users/me API to only show contracts the user is enrolled in (#3470) Co-authored-by: Nathan Levesque --- b2b/api.py | 33 ++++++++++++++++++++++++++++++++- b2b/serializers/v0/__init__.py | 6 +++++- drf_lint_baseline.json | 5 +---- users/serializers.py | 4 ++-- users/views_test.py | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 8 deletions(-) diff --git a/b2b/api.py b/b2b/api.py index 789d1540a8..b82aa82fbc 100644 --- a/b2b/api.py +++ b/b2b/api.py @@ -12,7 +12,7 @@ from django.core.cache import caches from django.core.exceptions import ValidationError from django.db import transaction -from django.db.models import Count, Q +from django.db.models import Count, Prefetch, Q from mitol.common.utils import now_in_utc from opaque_keys.edx.keys import CourseKey from wagtail.models import Page @@ -27,6 +27,7 @@ from b2b.keycloak_admin_dataclasses import OrganizationRepresentation from b2b.models import ( ContractPage, + ContractProgramItem, OrganizationIndexPage, OrganizationPage, UserOrganization, @@ -57,6 +58,36 @@ log = logging.getLogger(__name__) +def get_user_b2b_organizations(user): + """ + Get B2B organizations for a user with their active enrolled contracts prefetched. + + This uses Prefetch to load only contracts that are both active and that + the user is enrolled in, avoiding N+1 queries in serializers. + + Args: + user: The user to get organizations for. + Returns: + QuerySet of OrganizationPage with _user_active_contracts prefetched. + """ + + return OrganizationPage.objects.filter( + organization_users__user=user + ).prefetch_related( + Prefetch( + "contracts", + queryset=ContractPage.objects.prefetch_related( + Prefetch( + "contract_programs", + queryset=ContractProgramItem.objects.order_by("sort_order"), + to_attr="_contract_program_ids", + ) + ).filter(active=True, users=user), + to_attr="_user_active_contracts", + ) + ) + + def ensure_b2b_organization_index() -> OrganizationIndexPage: """ Ensures that an index page has been created for signatories. diff --git a/b2b/serializers/v0/__init__.py b/b2b/serializers/v0/__init__.py index fca6d20a25..a422d03e09 100644 --- a/b2b/serializers/v0/__init__.py +++ b/b2b/serializers/v0/__init__.py @@ -84,7 +84,11 @@ class OrganizationPageSerializer(serializers.ModelSerializer): @extend_schema_field(ContractPageSerializer(many=True)) def get_contracts(self, instance): """Get only active contracts for the organization""" - return ContractPageSerializer(instance.active_contracts, many=True).data + if hasattr(instance, "_user_active_contracts"): + active_contracts = instance._user_active_contracts # noqa: SLF001 + else: + active_contracts = instance.active_contracts + return ContractPageSerializer(active_contracts, many=True).data class Meta: model = OrganizationPage diff --git a/drf_lint_baseline.json b/drf_lint_baseline.json index 00c023b938..81346d147f 100644 --- a/drf_lint_baseline.json +++ b/drf_lint_baseline.json @@ -1,7 +1,4 @@ [ - "b2b/serializers/v0/__init__.py:164:12:ORM002", - "b2b/serializers/v0/__init__.py:27:20:ORM002", - "b2b/serializers/v0/__init__.py:75:27:ORM002", "cms/serializers.py:114:16:ORM001", "cms/serializers.py:153:41:ORM001", "cms/serializers.py:306:12:ORM001", @@ -88,7 +85,7 @@ "hubspot_sync/serializers.py:309:36:ORM001", "hubspot_sync/serializers.py:57:22:ORM002", "hubspot_sync/serializers.py:68:27:ORM002", - "users/serializers.py:208:16:ORM001", + "users/serializers.py:209:16:ORM001", "users/serializers.py:254:20:ORM001", "users/serializers.py:301:19:ORM001", "users/serializers.py:431:13:ORM001", diff --git a/users/serializers.py b/users/serializers.py index 6c0d17408f..2268b5aafd 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -10,6 +10,7 @@ from requests.exceptions import ConnectionError as RequestsConnectionError from rest_framework import serializers +from b2b.api import get_user_b2b_organizations from b2b.serializers.v0 import OrganizationPageSerializer from hubspot_sync.task_helpers import sync_hubspot_user @@ -228,8 +229,7 @@ def get_b2b_organizations(self, instance): return [] return OrganizationPageSerializer( - instance.b2b_organizations, - many=True, + get_user_b2b_organizations(instance), many=True ).data def validate(self, data): diff --git a/users/views_test.py b/users/views_test.py index 20502f62d0..62948ffaac 100644 --- a/users/views_test.py +++ b/users/views_test.py @@ -187,6 +187,40 @@ def test_get_user_by_me_excludes_inactive_contracts(client, user): assert inactive_contract.id not in contract_ids +@pytest.mark.django_db +def test_get_user_by_me_excludes_unenrolled_contracts(client, user): + """Test that /api/v0/users/me only returns contracts the user is enrolled in""" + client.force_login(user) + + # Create two active contracts in the same organization + enrolled_contract = ContractPageFactory.create(active=True) + unenrolled_contract = ContractPageFactory.create( + active=True, organization=enrolled_contract.organization + ) + + # Add user to the organization + user.b2b_organizations.add(enrolled_contract.organization) + # Add user to only one contract + user.b2b_contracts.add(enrolled_contract) + user.save() + + resp = client.get(reverse("users_api-me")) + + assert resp.status_code == status.HTTP_200_OK + + response_data = resp.json() + assert len(response_data["b2b_organizations"]) == 1 + + org_data = response_data["b2b_organizations"][0] + # Should only include the enrolled contract + assert len(org_data["contracts"]) == 1 + assert org_data["contracts"][0]["id"] == enrolled_contract.id + + # Should not include the unenrolled contract + contract_ids = [contract["id"] for contract in org_data["contracts"]] + assert unenrolled_contract.id not in contract_ids + + @pytest.mark.parametrize( ("is_anonymous", "has_openedx_user", "has_edx_username"), [ From 83f73cc1212f43cee157daeabe4d0dfbb6de8e1c Mon Sep 17 00:00:00 2001 From: annagav Date: Wed, 8 Apr 2026 17:56:47 -0400 Subject: [PATCH 3/5] Add management command to upgrade eligible program enrollments (#3455) --- courses/api.py | 47 ++++++++++++ courses/api_test.py | 76 +++++++++++++++++++ .../upgrade_eligible_program_enrollments.py | 39 ++++++++++ courses/tasks.py | 14 ++++ main/settings.py | 20 +++++ 5 files changed, 196 insertions(+) create mode 100644 courses/management/commands/upgrade_eligible_program_enrollments.py diff --git a/courses/api.py b/courses/api.py index f4d5298864..a9659bc09b 100644 --- a/courses/api.py +++ b/courses/api.py @@ -1687,3 +1687,50 @@ def rerun_course_run( # noqa: PLR0913 process_course_run_clone(new_run, base_run.courseware_id) return new_run + + +def upgrade_program_enrollment_if_eligible(program_enrollment): + """ + For a given program enrollment checks if learner is qualified for an upgrade + + Returns: + (ProgramEnrollment, bool): A tuple containing a + ProgramEnrollment paired + with a boolean indicating whether the enrollment was upgraded. + """ + program = program_enrollment.program + user = program_enrollment.user + + if ProgramCertificate.objects.filter( + user=program_enrollment.user, program=program + ).exists(): + return program_enrollment, False + + program_course_ids = [course[0].id for course in program.courses] + + verified_courses = Course.objects.filter( + id__in=program_course_ids, + courseruns__enrollments__user=user, + courseruns__enrollments__enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, + courseruns__enrollments__active=True, + ).distinct() + + verified_course_ids = set(verified_courses.values_list("id", flat=True)) + + # make sure all core courses are verified + required_courses = program.required_courses + if not all(course.id in verified_course_ids for course in required_courses): + return program_enrollment, False + + nim_elective_num = program.minimum_elective_courses_requirement + elective_course_ids = [course.id for course in program.elective_courses] + verified_elective_count = verified_courses.filter( + id__in=elective_course_ids + ).count() + + if nim_elective_num is not None and verified_elective_count < nim_elective_num: + return program_enrollment, False + + program_enrollment.enrollment_mode = EDX_ENROLLMENT_VERIFIED_MODE + program_enrollment.save_and_log(None) + return program_enrollment, True diff --git a/courses/api_test.py b/courses/api_test.py index 4eb13f9878..dd4298724b 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -51,6 +51,7 @@ pull_course_modes, sync_course_mode, sync_course_runs, + upgrade_program_enrollment_if_eligible, ) from courses.constants import ( ALL_ENROLL_CHANGE_STATUSES, @@ -3079,3 +3080,78 @@ def test_pull_course_modes(mocker, no_initial_modes): if returned_mode.mode_slug in ["audit", "verified", "bonus"] ] assert len(expected_modes) == 3 + + +def test_upgrade_program_enrollment_if_eligible_upgrades_when_requirements_met( + user, + program_with_requirements, # noqa: F811 +): + """Upgrade succeeds when all required courses and enough electives are verified.""" + program = program_with_requirements.program + program_enrollment = ProgramEnrollmentFactory.create( + user=user, + program=program, + enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE, + ) + + for course in program.required_courses: + run = CourseRunFactory.create(course=course) + CourseRunEnrollmentFactory.create( + user=user, + run=run, + enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, + active=True, + ) + + min_electives = program.minimum_elective_courses_requirement or 0 + for course in program.elective_courses[:min_electives]: + run = CourseRunFactory.create(course=course) + CourseRunEnrollmentFactory.create( + user=user, + run=run, + enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, + active=True, + ) + + _, upgraded = upgrade_program_enrollment_if_eligible(program_enrollment) + + program_enrollment.refresh_from_db() + assert upgraded is True + assert program_enrollment.enrollment_mode == EDX_ENROLLMENT_VERIFIED_MODE + + +def test_upgrade_program_enrollment_if_eligible_returns_false_when_electives_missing( + user, + program_with_requirements, # noqa: F811 +): + """Upgrade should return False (without crashing) if elective minimum is not met.""" + program = program_with_requirements.program + program_enrollment = ProgramEnrollmentFactory.create( + user=user, + program=program, + enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE, + ) + + for course in program.required_courses: + run = CourseRunFactory.create(course=course) + CourseRunEnrollmentFactory.create( + user=user, + run=run, + enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, + active=True, + ) + + if program.elective_courses: + run = CourseRunFactory.create(course=program.elective_courses[0]) + CourseRunEnrollmentFactory.create( + user=user, + run=run, + enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, + active=True, + ) + + _, upgraded = upgrade_program_enrollment_if_eligible(program_enrollment) + + program_enrollment.refresh_from_db() + assert upgraded is False + assert program_enrollment.enrollment_mode == EDX_ENROLLMENT_AUDIT_MODE diff --git a/courses/management/commands/upgrade_eligible_program_enrollments.py b/courses/management/commands/upgrade_eligible_program_enrollments.py new file mode 100644 index 0000000000..7a68c4d05c --- /dev/null +++ b/courses/management/commands/upgrade_eligible_program_enrollments.py @@ -0,0 +1,39 @@ +"""Upgrade program enrollments that are eligible for verified mode.""" + +from django.core.management.base import BaseCommand + +from courses.api import upgrade_program_enrollment_if_eligible +from courses.models import ProgramEnrollment +from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE + + +class Command(BaseCommand): + """Iterate through all program enrollments and upgrade eligible ones.""" + + help = "Upgrade all eligible program enrollments to verified mode" + + def handle(self, *args, **options): # pylint: disable=unused-argument # noqa: ARG002 + processed_count = 0 + upgraded_count = 0 + + for program_enrollment in ProgramEnrollment.objects.filter( + enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE + ): + _, upgraded = upgrade_program_enrollment_if_eligible(program_enrollment) + + if upgraded: + upgraded_count += 1 + self.stdout.write( + self.style.SUCCESS( + f"Upgraded enrollment for user {program_enrollment.user.username}." + ) + ) + processed_count += 1 + + self.stdout.write( + self.style.SUCCESS( + "Processed " + f"{processed_count} program enrollments; " + f"upgraded {upgraded_count}." + ) + ) diff --git a/courses/tasks.py b/courses/tasks.py index 45d8932e9e..7120b6f925 100644 --- a/courses/tasks.py +++ b/courses/tasks.py @@ -12,8 +12,10 @@ CourseRun, CourseRunEnrollment, LearnerProgramRecordShare, + ProgramEnrollment, ) from main.celery import app +from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE log = logging.getLogger(__name__) @@ -72,3 +74,15 @@ def send_partner_school_email(record_uuid): record = LearnerProgramRecordShare.objects.get(share_uuid=record_uuid) send_partner_school_sharing_message(record) + + +@app.task +def upgrade_eligible_program_enrollments(): + """Upgrade eligible learners for all audit-mode program enrollments.""" + from courses.api import upgrade_program_enrollment_if_eligible + + enrollments = ProgramEnrollment.objects.filter( + enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE + ) + for enrollment in enrollments.iterator(): + upgrade_program_enrollment_if_eligible(enrollment) diff --git a/main/settings.py b/main/settings.py index 6eaab58d50..b3fd89ea0b 100644 --- a/main/settings.py +++ b/main/settings.py @@ -902,6 +902,16 @@ default="*", description="'day_of_week' value for 'generate-course-certificate' scheduled task (default will run once a day).", ) +CRON_UPGRADE_PROGRAM_ENROLLMENTS_HOURS = get_string( + name="CRON_UPGRADE_PROGRAM_ENROLLMENTS_HOURS", + default="1", + description="'hours' value for the 'upgrade-eligible-program-enrollments' scheduled task (default runs at 1 AM).", +) +CRON_UPGRADE_PROGRAM_ENROLLMENTS_DAYS = get_string( + name="CRON_UPGRADE_PROGRAM_ENROLLMENTS_DAYS", + default="*", + description="'day_of_week' value for 'upgrade-eligible-program-enrollments' scheduled task (default runs daily).", +) CRON_ORPHAN_CHECK_HOURS = get_string( name="CRON_ORPHAN_CHECK_HOURS", default="3", @@ -1021,6 +1031,16 @@ "task": "main.tasks.run_clear_tokens", "schedule": crontab(minute=0, hour=9, day_of_week=1), # every week }, + "upgrade-eligible-program-enrollments": { + "task": "courses.tasks.upgrade_eligible_program_enrollments", + "schedule": crontab( + minute=0, + hour=CRON_UPGRADE_PROGRAM_ENROLLMENTS_HOURS, + day_of_week=CRON_UPGRADE_PROGRAM_ENROLLMENTS_DAYS, + day_of_month="*", + month_of_year="*", + ), + }, "update-b2b-enrollment-code-sheets": { "task": "b2b.tasks.queue_update_all_contract_enrollment_sheets", "schedule": OffsettingSchedule( From 9f95a85bbf6300589e5e68228dc5b42f0cfac654 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 8 Apr 2026 19:02:30 -0400 Subject: [PATCH 4/5] Include program titles in v0 TransactionLineSerializer (#3469) Co-authored-by: Claude Opus 4.6 (1M context) --- drf_lint_baseline.json | 34 +++--- ecommerce/serializers/v0/__init__.py | 15 ++- ecommerce/serializers/v0/serializers_test.py | 115 +++++++++++++++++++ openapi/specs/v0.yaml | 3 + openapi/specs/v1.yaml | 3 + openapi/specs/v2.yaml | 3 + 6 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 ecommerce/serializers/v0/serializers_test.py diff --git a/drf_lint_baseline.json b/drf_lint_baseline.json index 81346d147f..45d4a85d58 100644 --- a/drf_lint_baseline.json +++ b/drf_lint_baseline.json @@ -51,23 +51,23 @@ "ecommerce/serializers/__init__.py:704:22:ORM002", "ecommerce/serializers/__init__.py:821:28:ORM002", "ecommerce/serializers/__init__.py:891:28:ORM002", - "ecommerce/serializers/v0/__init__.py:270:17:ORM001", - "ecommerce/serializers/v0/__init__.py:272:18:ORM001", - "ecommerce/serializers/v0/__init__.py:273:18:ORM001", - "ecommerce/serializers/v0/__init__.py:295:26:ORM002", - "ecommerce/serializers/v0/__init__.py:383:26:ORM002", - "ecommerce/serializers/v0/__init__.py:392:35:ORM002", - "ecommerce/serializers/v0/__init__.py:399:20:ORM002", - "ecommerce/serializers/v0/__init__.py:405:35:ORM002", - "ecommerce/serializers/v0/__init__.py:421:31:ORM002", - "ecommerce/serializers/v0/__init__.py:493:24:ORM002", - "ecommerce/serializers/v0/__init__.py:512:12:ORM001", - "ecommerce/serializers/v0/__init__.py:536:22:ORM002", - "ecommerce/serializers/v0/__init__.py:583:22:ORM002", - "ecommerce/serializers/v0/__init__.py:648:20:ORM002", - "ecommerce/serializers/v0/__init__.py:798:22:ORM002", - "ecommerce/serializers/v0/__init__.py:82:28:ORM002", - "ecommerce/serializers/v0/__init__.py:942:28:ORM002", + "ecommerce/serializers/v0/__init__.py:279:17:ORM001", + "ecommerce/serializers/v0/__init__.py:281:18:ORM001", + "ecommerce/serializers/v0/__init__.py:282:18:ORM001", + "ecommerce/serializers/v0/__init__.py:304:26:ORM002", + "ecommerce/serializers/v0/__init__.py:392:26:ORM002", + "ecommerce/serializers/v0/__init__.py:401:35:ORM002", + "ecommerce/serializers/v0/__init__.py:408:20:ORM002", + "ecommerce/serializers/v0/__init__.py:414:35:ORM002", + "ecommerce/serializers/v0/__init__.py:430:31:ORM002", + "ecommerce/serializers/v0/__init__.py:502:24:ORM002", + "ecommerce/serializers/v0/__init__.py:521:12:ORM001", + "ecommerce/serializers/v0/__init__.py:545:22:ORM002", + "ecommerce/serializers/v0/__init__.py:592:22:ORM002", + "ecommerce/serializers/v0/__init__.py:657:20:ORM002", + "ecommerce/serializers/v0/__init__.py:807:22:ORM002", + "ecommerce/serializers/v0/__init__.py:83:28:ORM002", + "ecommerce/serializers/v0/__init__.py:951:28:ORM002", "flexiblepricing/serializers.py:129:38:ORM001", "flexiblepricing/serializers.py:132:34:ORM001", "flexiblepricing/serializers.py:147:34:ORM001", diff --git a/ecommerce/serializers/v0/__init__.py b/ecommerce/serializers/v0/__init__.py index be18ffd375..dc10628858 100644 --- a/ecommerce/serializers/v0/__init__.py +++ b/ecommerce/serializers/v0/__init__.py @@ -68,6 +68,7 @@ class TransactionLineSerializer(serializers.Serializer): quantity = serializers.IntegerField() CEUs = serializers.CharField() content_title = serializers.CharField() + content_type = serializers.CharField() readable_id = serializers.CharField() start_date = serializers.DateTimeField() end_date = serializers.DateTimeField() @@ -90,19 +91,27 @@ def to_representation(self, instance): content_object = instance.product.purchasable_object (content_title, readable_id) = (None, None) - if isinstance(content_object, ProgramRun): - content_title = content_object.program.title - readable_id = content_object.program.readable_id + if isinstance(content_object, Program): + content_title = content_object.title + readable_id = content_object.readable_id elif isinstance(content_object, CourseRun): readable_id = content_object.course.readable_id content_title = f"{content_object.course_number} {content_object.title}" + # Add content_type from product's content_type model + content_type = ( + instance.product.content_type.model + if hasattr(instance.product, "content_type") + else None + ) + line = dict( # noqa: C408 quantity=instance.quantity, total_paid=str(total_paid), discount=str(discount), CEUs=None, content_title=content_title, + content_type=content_type, readable_id=readable_id, price=str(instance.product.price), start_date=content_object.start_date, diff --git a/ecommerce/serializers/v0/serializers_test.py b/ecommerce/serializers/v0/serializers_test.py new file mode 100644 index 0000000000..fc227afeb1 --- /dev/null +++ b/ecommerce/serializers/v0/serializers_test.py @@ -0,0 +1,115 @@ +"""Tests for v0 ecommerce serializers.""" + +from decimal import Decimal + +import pytest +import reversion +from django.test import Client, RequestFactory +from django.urls import reverse + +from courses.models import CourseRun, Program +from ecommerce.api import generate_checkout_payload +from ecommerce.factories import ProductFactory, ProgramProductFactory +from ecommerce.models import Order, OrderStatus +from ecommerce.serializers.v0 import TransactionLineSerializer +from ecommerce.views.legacy.views_test import create_basket + +pytestmark = [pytest.mark.django_db] + + +def create_order(mocker, user, products): + """Create a fulfilled order for the given user and products.""" + mocker.patch( + "mitol.payment_gateway.api.PaymentGateway.validate_processor_response", + return_value=True, + ) + create_basket(user, products) + + rf = RequestFactory() + request = rf.get("/") + request.user = user + request.session = {} + checkout_payload = generate_checkout_payload(request) + + payload = checkout_payload["payload"] + payload = { + **{f"req_{key}": value for key, value in payload.items()}, + "decision": "ACCEPT", + "message": "payment processor message", + "transaction_id": "12345", + } + + order = Order.objects.get(state=OrderStatus.PENDING, purchaser=user) + + client = Client() + client.force_login(user) + client.post(reverse("checkout-result-callback"), payload) + + order.refresh_from_db() + return order + + +def build_expected_line(instance): + """Build the expected serialized line dict for a given order line.""" + coupon_redemption = instance.order.discounts.first() + discount = 0.0 + + if coupon_redemption: + discount = instance.product.price - instance.discounted_price + + total_paid = (instance.product.price - Decimal(discount)) * instance.quantity + + content_object = instance.product.purchasable_object + (content_title, readable_id) = (None, None) + + if isinstance(content_object, Program): + content_title = content_object.title + readable_id = content_object.readable_id + elif isinstance(content_object, CourseRun): + readable_id = content_object.course.readable_id + content_title = f"{content_object.course_number} {content_object.title}" + + return dict( # noqa: C408 + quantity=instance.quantity, + total_paid=str(total_paid), + discount=str(discount), + CEUs=None, + content_title=content_title, + content_type=instance.product.content_type.model, + readable_id=readable_id, + price=str(instance.product.price), + start_date=content_object.start_date, + end_date=content_object.end_date, + ) + + +@pytest.mark.skip_nplusone_check +def test_courserun_order_lines_serializer(settings, mocker, user): + """Test TransactionLineSerializer for course run products.""" + settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105 + + with reversion.create_revision(): + products = ProductFactory.create_batch(5) + + order = create_order(mocker, user, products) + + expected_lines = [build_expected_line(line) for line in order.lines.all()] + serialized_data = TransactionLineSerializer(instance=order.lines, many=True).data + + assert serialized_data == expected_lines + + +@pytest.mark.skip_nplusone_check +def test_program_order_lines_serializer(settings, mocker, user): + """Test TransactionLineSerializer for program products.""" + settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105 + + with reversion.create_revision(): + products = ProgramProductFactory.create_batch(5) + + order = create_order(mocker, user, products) + + expected_lines = [build_expected_line(line) for line in order.lines.all()] + serialized_data = TransactionLineSerializer(instance=order.lines, many=True).data + + assert serialized_data == expected_lines diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 677c9ed163..a4d6fde8ca 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -7208,6 +7208,8 @@ components: type: string content_title: type: string + content_type: + type: string readable_id: type: string start_date: @@ -7225,6 +7227,7 @@ components: required: - CEUs - content_title + - content_type - discount - end_date - price diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index c96ce624a2..59cf4312df 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -7208,6 +7208,8 @@ components: type: string content_title: type: string + content_type: + type: string readable_id: type: string start_date: @@ -7225,6 +7227,7 @@ components: required: - CEUs - content_title + - content_type - discount - end_date - price diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 15b6263c22..cc8258b3f4 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -7208,6 +7208,8 @@ components: type: string content_title: type: string + content_type: + type: string readable_id: type: string start_date: @@ -7225,6 +7227,7 @@ components: required: - CEUs - content_title + - content_type - discount - end_date - price From f8b64418630f1660b3a516faf02cb6d704246b67 Mon Sep 17 00:00:00 2001 From: Doof Date: Thu, 9 Apr 2026 06:45:23 +0000 Subject: [PATCH 5/5] Release 1.146.0 --- RELEASE.rst | 8 ++++++++ main/settings.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 179b1dca36..b7479678c3 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,14 @@ Release Notes ============= +Version 1.146.0 +--------------- + +- Include program titles in v0 TransactionLineSerializer (#3469) +- Add management command to upgrade eligible program enrollments (#3455) +- fix: filter b2b contracts in users/me API to only show contracts the user is enrolled in (#3470) +- Exit Python Tests step after first failure (#3382) + Version 1.145.2 (Released April 08, 2026) --------------- diff --git a/main/settings.py b/main/settings.py index b3fd89ea0b..d6293854e5 100644 --- a/main/settings.py +++ b/main/settings.py @@ -37,7 +37,7 @@ from main.sentry import init_sentry from openapi.settings_spectacular import open_spectacular_settings -VERSION = "1.145.2" +VERSION = "1.146.0" log = logging.getLogger()