Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Release Notes
=============

Version 1.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)
---------------

Expand Down
33 changes: 32 additions & 1 deletion b2b/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +27,7 @@
from b2b.keycloak_admin_dataclasses import OrganizationRepresentation
from b2b.models import (
ContractPage,
ContractProgramItem,
OrganizationIndexPage,
OrganizationPage,
UserOrganization,
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion b2b/serializers/v0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions courses/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
76 changes: 76 additions & 0 deletions courses/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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}."
)
)
14 changes: 14 additions & 0 deletions courses/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
39 changes: 18 additions & 21 deletions drf_lint_baseline.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -54,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",
Expand All @@ -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",
Expand Down
15 changes: 12 additions & 3 deletions ecommerce/serializers/v0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down
Loading
Loading