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
9 changes: 9 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
Release Notes
=============

Version 0.138.2
---------------

- add enrollment_mode to the v3 program enrollments response (#3305)
- Fix very occasionally flaky test_discount_redemptions_api test (#3302)
- Updates for B2B course run key generation (#3292)
- Adds a test contract and org to test_datagen (#3295)
- Fix v0/baskets/create_from_product so that baskets get discounts as we expect (#3290)

Version 0.138.1 (Released February 18, 2026)
---------------

Expand Down
85 changes: 78 additions & 7 deletions b2b/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from django.contrib.contenttypes.models import ContentType
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 mitol.common.utils import now_in_utc
from opaque_keys.edx.keys import CourseKey
Expand Down Expand Up @@ -75,6 +76,66 @@ def ensure_b2b_organization_index() -> OrganizationIndexPage:
return org_index_page


@transaction.atomic
def create_contract_run_key(
source_course: CourseRun, contract: ContractPage, *, org_prefix: str | None = None
) -> str:
"""
Create a course key for a contract course run for the specified source course
and contract.

The format for the run tag is in the B2B_RUN_TAG_FORMAT constant; if this
changes, you will likely need to change it here too.

Args:
- source_course (CourseRun): the source course to get the original key from
- contract (ContractPage): the contract the target run is for
Kwargs:
- org_prefix (str|None): the prefix to use for the org part of the key
Returns:
- str, the new key
"""

if not org_prefix:
org_prefix = (
contract.organization.org_key_prefix
if contract.organization.org_key_prefix
else UAI_COURSEWARE_ID_PREFIX
)

source_id = CourseKey.from_string(source_course.readable_id)
new_course_key = (
f"course-v1:{org_prefix}{contract.organization.org_key}+{source_id.course}"
)
mostly_run_tag = B2B_RUN_TAG_FORMAT.format(
run_idx="",
contract_id=contract.id,
year=now_in_utc().year,
)
run_idx = 1

last_run_tag = (
CourseRun.objects.filter(
courseware_id__startswith=new_course_key,
courseware_id__endswith=mostly_run_tag,
)
.select_for_update()
.order_by("-courseware_id")
.first()
)
if last_run_tag:
run_idx, _ = CourseKey.from_string(last_run_tag.courseware_id).run.split("T")
run_idx = int(run_idx) + 1

new_run_tag = B2B_RUN_TAG_FORMAT.format(
year=now_in_utc().year,
contract_id=contract.id,
run_idx=run_idx,
)
# Validate and return
return str(CourseKey.from_string(f"{new_course_key}+{new_run_tag}"))


def import_and_create_contract_run( # noqa: PLR0913
contract: ContractPage,
course_run_id: str,
Expand All @@ -90,6 +151,7 @@ def import_and_create_contract_run( # noqa: PLR0913
ingest_content_files_for_ai: bool = True,
skip_edx: bool = False,
require_designated_source_run: bool = False,
org_prefix=UAI_COURSEWARE_ID_PREFIX,
):
"""
Create a contract run for the given course, importing it from edX if necessary.
Expand Down Expand Up @@ -178,15 +240,18 @@ def import_and_create_contract_run( # noqa: PLR0913
run.course,
skip_edx=skip_edx,
require_designated_source_run=require_designated_source_run,
org_prefix=org_prefix,
)


def create_contract_run(
def create_contract_run( # noqa: PLR0913
contract: ContractPage,
course: Course,
*,
skip_edx=False,
require_designated_source_run=True,
org_prefix: str | None = UAI_COURSEWARE_ID_PREFIX,
no_reruns: bool = False,
) -> tuple[CourseRun, Product]:
"""
Create a run for the specified contract.
Expand Down Expand Up @@ -228,6 +293,8 @@ def create_contract_run(
Keyword Args:
skip_edx (bool): Don't try to create a course run in edX.
require_designated_source_run (bool): Require a flagged source run.
org_prefix (str): Organization prefix. For UAI courses, this should be "UAI_".
no_reruns (bool): Don't rerun the course - raise an exception instead.
Returns:
CourseRun: The created CourseRun object.
Product: The created Product object.
Expand All @@ -253,15 +320,19 @@ def create_contract_run(
msg = f"No course runs available for {course}."
raise SourceCourseIncompleteError(msg)

new_run_tag = B2B_RUN_TAG_FORMAT.format(
year=now_in_utc().year,
contract_id=contract.id,
new_course_key = CourseKey.from_string(
create_contract_run_key(
source_course=clone_course_run, contract=contract, org_prefix=org_prefix
)
)
source_id = CourseKey.from_string(clone_course_run.readable_id)
new_readable_id = f"{UAI_COURSEWARE_ID_PREFIX}{contract.organization.org_key}+{source_id.course}+{new_run_tag}"
new_readable_id = str(new_course_key)
new_run_tag = new_course_key.run

# Check first for an existing run with the same readable ID.
if CourseRun.objects.filter(course=course, courseware_id=new_readable_id).exists():
if (
CourseRun.objects.filter(course=course, b2b_contract=contract).exists()
and no_reruns
):
msg = f"Can't create a run for {course} and contract {contract}: courseware ID {new_readable_id} already exists."
raise TargetCourseRunExistsError(msg)

Expand Down
141 changes: 120 additions & 21 deletions b2b/api_test.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Tests for B2B API functions."""

from datetime import timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo

import faker
import freezegun
import pytest
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
Expand All @@ -17,6 +19,7 @@
_handle_extra_enrollment_codes,
create_b2b_enrollment,
create_contract_run,
create_contract_run_key,
ensure_contract_run_pricing,
ensure_contract_run_products,
ensure_enrollment_codes_exist,
Expand All @@ -36,7 +39,7 @@
CONTRACT_MEMBERSHIP_NONSSO,
CONTRACT_MEMBERSHIP_SSO,
)
from b2b.exceptions import SourceCourseIncompleteError, TargetCourseRunExistsError
from b2b.exceptions import SourceCourseIncompleteError
from b2b.factories import ContractPageFactory, OrganizationPageFactory
from b2b.models import OrganizationIndexPage, OrganizationPage, UserOrganization
from courses.constants import UAI_COURSEWARE_ID_PREFIX
Expand Down Expand Up @@ -113,7 +116,7 @@ def test_create_single_course_run(mocker, contract_ready_course, has_start, has_

assert run.course == source_course
assert run.run_tag == B2B_RUN_TAG_FORMAT.format(
year=now_time.year, contract_id=contract.id
run_idx=1, year=now_time.year, contract_id=contract.id
)
assert run.b2b_contract == contract

Expand Down Expand Up @@ -533,21 +536,7 @@ def test_create_contract_run(mocker, source_run_exists, run_exists):

contract = factories.ContractPageFactory.create()
course = CourseFactory.create()
source_course_key = CourseKey.from_string(f"{course.readable_id}+SOURCE")
mocked_clone_run = mocker.patch("openedx.tasks.clone_courserun.delay")
this_year = now_in_utc().year

if source_run_exists:
# This should be the default, but need to test the case in which the
# source run isn't configured.
source_course_run_key = (
f"course-v1:{source_course_key.org}+{source_course_key.course}+SOURCE"
)
CourseRunFactory.create(
course=course, courseware_id=source_course_run_key, run_tag="SOURCE"
)

target_course_id = f"{UAI_COURSEWARE_ID_PREFIX}{contract.organization.org_key}+{source_course_key.course}+{this_year}_C{contract.id}"

if not source_run_exists:
with pytest.raises(SourceCourseIncompleteError) as exc:
Expand All @@ -556,16 +545,31 @@ def test_create_contract_run(mocker, source_run_exists, run_exists):
assert "No course runs available" in str(exc)
return

source_course_run_key = CourseKey.from_string(f"{course.readable_id}+SOURCE")
source_run = CourseRunFactory.create(
course=course, courseware_id=str(source_course_run_key), run_tag="SOURCE"
)

target_course_id = create_contract_run_key(source_run, contract)

if run_exists:
# This behavior is different than it was
# We used to raise an exception if this was the case - now we index the
# runs, so we _can_ rerun things.
collision_run = CourseRunFactory.create(
course=course, courseware_id=target_course_id
course=course,
courseware_id=target_course_id,
b2b_contract=contract,
)

with pytest.raises(TargetCourseRunExistsError) as exc:
create_contract_run(contract, course)
new_run, _ = create_contract_run(contract, course)

collision_key = CourseKey.from_string(collision_run.courseware_id)
new_run_key = CourseKey.from_string(new_run.courseware_id)

target_course_key = CourseKey.from_string(collision_run.courseware_id)
assert f"courseware ID {target_course_key} already exists" in str(exc)
# If the run tag format changes then this will fail.
collision_idx = collision_key.run[0]
assert new_run_key.run[0] == str(int(collision_idx) + 1)
return

assert not course.courseruns.filter(courseware_id=target_course_id).exists()
Expand Down Expand Up @@ -1056,6 +1060,7 @@ def test_import_and_create_contract_run(mocker, run_exists, import_succeeds):
existing_run.course,
skip_edx=False,
require_designated_source_run=False,
org_prefix=UAI_COURSEWARE_ID_PREFIX,
)
assert result == (mock_run, mock_product)
else:
Expand Down Expand Up @@ -1099,6 +1104,7 @@ def test_import_and_create_contract_run(mocker, run_exists, import_succeeds):
imported_course,
skip_edx=False,
require_designated_source_run=False,
org_prefix=UAI_COURSEWARE_ID_PREFIX,
)
assert result == (mock_run, mock_product)
else:
Expand Down Expand Up @@ -1176,6 +1182,7 @@ def test_import_and_create_contract_run_with_all_kwargs(mocker):
imported_course,
skip_edx=True,
require_designated_source_run=True,
org_prefix=UAI_COURSEWARE_ID_PREFIX,
)

assert result == (mock_run, mock_product)
Expand Down Expand Up @@ -1213,6 +1220,7 @@ def test_import_and_create_contract_run_with_string_departments(mocker):
existing_run.course,
skip_edx=False,
require_designated_source_run=False,
org_prefix=UAI_COURSEWARE_ID_PREFIX,
)
assert result == (mock_run, mock_product)

Expand Down Expand Up @@ -1307,3 +1315,94 @@ def test_remove_extra_codes():

for code in codes_we_have:
assert code in codes_we_should_keep


def test_create_contract_run_key():
"""Test that contract course run keys are generated properly."""

contract = ContractPageFactory.create()
course = CourseFactory.create()
source_run_key = CourseKey.from_string(f"{course.readable_id}+SOURCE")
source_run = CourseRunFactory.create(
course=course, courseware_id=str(source_run_key), run_tag="SOURCE"
)

year = now_in_utc().year
contract_id = contract.id
run_idx = "1"

new_course_key = CourseKey.from_string(
create_contract_run_key(source_run, contract)
)

assert (
new_course_key.org
== f"{contract.organization.org_key_prefix}{contract.organization.org_key}"
)
assert new_course_key.course == source_run_key.course
assert new_course_key.run == B2B_RUN_TAG_FORMAT.format(
run_idx=run_idx, contract_id=contract_id, year=year
)

courserun = CourseRunFactory.create(
course=course, courseware_id=str(new_course_key)
)

# Test index incrementing - first, naturally

run_idx = "2"

new_course_key = CourseKey.from_string(
create_contract_run_key(source_run, contract)
)

assert (
new_course_key.org
== f"{contract.organization.org_key_prefix}{contract.organization.org_key}"
)
assert new_course_key.course == source_run_key.course
assert new_course_key.run == B2B_RUN_TAG_FORMAT.format(
run_idx=run_idx, contract_id=contract_id, year=year
)

# Now, test when we've manually adjusted the index

out_of_sequence_run_tag = CourseKey.from_string(courserun.courseware_id)
_, run_tag_remainder = out_of_sequence_run_tag.run.split("T")
courserun.courseware_id = f"course-v1:{out_of_sequence_run_tag.org}+{out_of_sequence_run_tag.course}+99T{run_tag_remainder}"

courserun.save()

run_idx = "100"

new_course_key = CourseKey.from_string(
create_contract_run_key(source_run, contract)
)

assert (
new_course_key.org
== f"{contract.organization.org_key_prefix}{contract.organization.org_key}"
)
assert new_course_key.course == source_run_key.course
assert new_course_key.run == B2B_RUN_TAG_FORMAT.format(
run_idx=run_idx, contract_id=contract_id, year=year
)

next_year = now_in_utc() + timedelta(days=366)

with freezegun.freeze_time(next_year):
# If it's next year, the index should become 1 again.
run_idx = "1"

new_course_key = CourseKey.from_string(
create_contract_run_key(source_run, contract)
)

assert (
new_course_key.org
== f"{contract.organization.org_key_prefix}{contract.organization.org_key}"
)
assert new_course_key.course == source_run_key.course
assert new_course_key.run == B2B_RUN_TAG_FORMAT.format(
run_idx=run_idx, contract_id=contract_id, year=next_year.year
)
2 changes: 1 addition & 1 deletion b2b/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,6 @@
)
)

B2B_RUN_TAG_FORMAT = "{year}_C{contract_id}"
B2B_RUN_TAG_FORMAT = "{run_idx}T{contract_id}C{year}"

ORG_KEY_MAX_LENGTH = 30
Loading