From 9ec0ee6d1ac588bd0ad8638e185faaf2a19a087d Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Mon, 14 Sep 2026 20:37:09 -0400 Subject: [PATCH 1/4] Move contract setup out of the management commands into b2b.contracts (C3 2/3) The staff contract API needs the same create, courseware add/remove and code-count logic the commands carry inline, so it lives in one module both call. Behaviour that changes on the way: - create_contract adds the default variant set. Command-created contracts had none, and get_all_variant_runs returns nothing without one, so check_contract_variant --fix-default was a required follow-up step. b2b_contract create also prints the new contract's ID. - Programs added through b2b_courseware follow the organization's courseware prefix, as courses already did. add_program_courses never passed a prefix, so program runs always got UAI_. Other callers of add_program_courses keep that default. - b2b_courseware remove only touches runs that are in the contract. Naming a run outside it used to close the run and deactivate its products. - b2b_codes validate counts expected codes per active product rather than per linked run, matching what ensure_enrollment_codes_exist creates. A removed run kept linked by its enrollments has an inactive product, so the per-run count never matched and validate re-ran every time. Also corrects the docs and the create_contract_run comment that said saving a contract generates enrollment codes. ContractPage.save only sets the title. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NUA1EjC95LZV4J5swhZgxy --- b2b/api.py | 4 +- b2b/contracts.py | 290 ++++++++++++++++++ b2b/contracts_test.py | 178 +++++++++++ b2b/management/commands/b2b_codes.py | 15 +- b2b/management/commands/b2b_contract.py | 12 +- b2b/management/commands/b2b_courseware.py | 271 +++------------- .../commands/check_contract_variant.py | 9 +- b2b/models.py | 5 + docs/source/b2b/commands.md | 6 +- docs/source/b2b/orgs_contracts.md | 8 +- 10 files changed, 541 insertions(+), 257 deletions(-) create mode 100644 b2b/contracts.py create mode 100644 b2b/contracts_test.py diff --git a/b2b/api.py b/b2b/api.py index c8f8ae88dd..19776b607c 100644 --- a/b2b/api.py +++ b/b2b/api.py @@ -779,8 +779,8 @@ def create_contract_run( # noqa: PLR0913 if queue_codes: queue_enrollment_code_check.delay(contract.id) - # Saving the contract here triggers any shoring up of related data, - # like generating enrollment codes. + # Saving the contract does not generate enrollment codes; ContractPage.save + # only sets the title. Pass queue_codes, or queue the check separately. contract.save() return results diff --git a/b2b/contracts.py b/b2b/contracts.py new file mode 100644 index 0000000000..5669239556 --- /dev/null +++ b/b2b/contracts.py @@ -0,0 +1,290 @@ +""" +Contract provisioning for B2B (capability C3). + +The steps of setting up a contract, pulled out of the b2b_contract, +b2b_courseware, b2b_codes and check_contract_variant commands so the commands +and the staff contract API do them the same way. +""" + +import logging +from dataclasses import dataclass + +from django.db import transaction +from mitol.common.utils import now_in_utc + +from b2b.api import create_contract_run +from b2b.models import ContractPage, ContractProgramItem, OrganizationPage +from courses.models import CourseRun, CourseRunEnrollment +from courses.retirement import ( + deactivate_run_products, + get_run_products, + push_run_dates_to_edx, +) +from ecommerce.models import Discount, DiscountProduct +from variants.models import SupportedVariant + +log = logging.getLogger(__name__) + +DEFAULT_CONTRACT_VARIANT_LANGUAGE = "en" + + +@dataclass +class CoursewareAddition: + """What adding one courseware object to a contract did.""" + + runs_added: int = 0 + courses_without_source_run: int = 0 + skipped_reason: str = "" + + +def ensure_default_variant(contract: ContractPage) -> SupportedVariant: + """ + Return the contract's default variant set, creating one if it has none. + + get_all_variant_runs returns nothing for a contract with no default variant, + so its learners would see an empty contract. + """ + + return contract.variant_options.filter( + default_variant=True + ).first() or SupportedVariant.objects.create( + variant_object=contract, + language=DEFAULT_CONTRACT_VARIANT_LANGUAGE, + b2b_only=False, + default_variant=True, + ) + + +@transaction.atomic +def create_contract( # noqa: PLR0913 + organization: OrganizationPage, + *, + name: str, + membership_type: str, + description: str = "", + welcome_message: str = "", + contract_start=None, + contract_end=None, + max_learners: int | None = None, + enrollment_fixed_price=None, +) -> ContractPage: + """ + Create a contract under an organization, with a default variant set. + + Creates no courseware and no enrollment codes. + """ + + contract = ContractPage( + name=name, + membership_type=membership_type, + organization=organization, + description=description, + welcome_message=welcome_message, + contract_start=contract_start, + contract_end=contract_end, + max_learners=max_learners, + enrollment_fixed_price=enrollment_fixed_price, + ) + organization.add_child(instance=contract) + contract.save() + ensure_default_variant(contract) + + return contract + + +def add_courseware_to_contract( # noqa: PLR0913 + contract: ContractPage, + courseware, + *, + skip_edx: bool = False, + no_reruns: bool = True, + force: bool = False, + org_prefix: str | None = None, + ignore_langs: bool = False, + only_lang: str | None = None, + filter_variants: list | None = None, +) -> CoursewareAddition: + """ + Add a program, course or course run to a contract. + + - A program gets contract runs for each of its courses that has a source + run, and is linked to the contract. + - A course gets contract runs from its source runs. + - An existing run is attached as it is. A run already in another contract + is left there unless force is set. + + Runs are created for the variant sets in filter_variants, which defaults to + every variant set on the contract. no_reruns defaults to True, unlike + create_contract_run, so repeating a call does not mint another run. + org_prefix defaults to the organization's own prefix. + """ + + if filter_variants is None: + filter_variants = list(contract.variant_options.all()) + + if courseware.is_program: + runs_added, no_source = contract.add_program_courses( + courseware, + skip_edx=skip_edx, + no_reruns=no_reruns, + org_prefix=org_prefix, + ignore_langs=ignore_langs, + only_lang=only_lang, + filter_variants=filter_variants, + ) + return CoursewareAddition( + runs_added=runs_added, courses_without_source_run=no_source + ) + + if courseware.is_run: + if courseware.b2b_contracts.filter(id=contract.id).exists(): + return CoursewareAddition( + skipped_reason=( + f"Run '{courseware.courseware_id}' is already in this contract." + ) + ) + + other_contract = courseware.b2b_contracts.exclude(id=contract.id).first() + if other_contract and not force: + return CoursewareAddition( + skipped_reason=( + f"Run '{courseware.courseware_id}' is already in {other_contract}." + ) + ) + + courseware.b2b_contract = contract + courseware.save() + courseware.b2b_contracts.add(contract) + return CoursewareAddition(runs_added=1) + + created = create_contract_run( + contract, + courseware, + skip_edx=skip_edx, + org_prefix=org_prefix, + no_reruns=no_reruns, + ignore_langs=ignore_langs, + only_lang=only_lang, + filter_variants=filter_variants, + ) + return CoursewareAddition(runs_added=len(created)) + + +def remove_run_from_contract(contract: ContractPage, run: CourseRun) -> bool: + """ + Close a contract run to new enrollments and take it out of the contract. + + The run is closed and its products deactivated either way. It is unlinked + only when nobody has enrolled in it, so enrolled learners keep their course. + Enrollment codes left applying to no product, and never redeemed, are + deleted. + + Returns True if the run was unlinked. + """ + + unlinked = not CourseRunEnrollment.objects.filter(run=run).exists() + + now = now_in_utc() + if run.live or run.enrollment_end is None or run.enrollment_end > now: + run.live = False + run.enrollment_end = now + + if unlinked: + if run.b2b_contract == contract: + run.b2b_contract = None + run.b2b_contracts.remove(contract) + + run.save() + + # get_run_products uses all_objects so it finds products regardless of + # their current is_active state, and returns a list so the deactivation + # below doesn't mutate the collection reused when removing discount + # associations. Shared with the retire_courserun command. + run_products = get_run_products(run) + deactivate_run_products(run) + + for discount in Discount.objects.filter( + products__product__in=run_products + ).distinct(): + DiscountProduct.objects.filter( + discount=discount, product__in=run_products + ).delete() + + if discount.products.count() == 0 and not ( + discount.order_redemptions.exists() + or discount.contract_redemptions.exists() + ): + discount.delete() + + # Push the new enrollment_end to edX so the next sync from edX doesn't + # overwrite it. edX will not accept an enrollment window for a run with no + # start and end date, so for a run with a null end_date the new + # enrollment_end never reaches edX and the next sync reverts it. + # push_run_dates_to_edx returns False and logs a warning in that case. + # Fixing that means also moving end_date into the past, which is what the + # retire_courserun command does. + try: + push_run_dates_to_edx(run) + except Exception: + log.exception( + "Failed to update enrollment end date on edX for %s", run.courseware_id + ) + + return unlinked + + +def remove_courseware_from_contract( + contract: ContractPage, courseware, *, remove_program_runs: bool = False +) -> list[tuple[CourseRun, bool]]: + """ + Remove a program, course or course run from a contract. + + A program is unlinked from the contract, and its contract runs are removed + too only if remove_program_runs is set. A course has no link of its own, so + removing it removes its contract runs. Only runs in this contract are + touched. + + Returns (run, unlinked) for each run removed; see remove_run_from_contract. + """ + + contract_runs = CourseRun.objects.filter(b2b_contracts=contract) + + if courseware.is_program: + runs = ( + list( + contract_runs.filter( + course__in=[course for course, _ in courseware.courses] + ) + ) + if remove_program_runs + else [] + ) + ContractProgramItem.objects.filter( + contract=contract, program=courseware + ).delete() + elif courseware.is_run: + runs = list(contract_runs.filter(id=courseware.id)) + else: + runs = list(contract_runs.filter(course=courseware)) + + return [(run, remove_run_from_contract(contract, run)) for run in runs] + + +def expected_enrollment_code_count(contract: ContractPage) -> int: + """ + Return how many enrollment codes the contract should have. + + Zero when learners join without codes (managed or auto membership with no + price). Otherwise one unlimited-use code per product, or max_learners + one-time codes per product when seats are capped. That is the per-product + basis ensure_enrollment_codes_exist creates codes on, so a removed run that + stays linked because of its enrollments, whose product is inactive, is not + counted. + """ + + if not contract.requires_enrollment_codes: + return 0 + + product_count = contract.get_products().count() + + return product_count * (contract.max_learners or 1) diff --git a/b2b/contracts_test.py b/b2b/contracts_test.py new file mode 100644 index 0000000000..863818eafe --- /dev/null +++ b/b2b/contracts_test.py @@ -0,0 +1,178 @@ +"""Tests for B2B contract provisioning.""" + +from decimal import Decimal + +import pytest + +from b2b.constants import ( + CONTRACT_MEMBERSHIP_AUTO, + CONTRACT_MEMBERSHIP_CODE, + CONTRACT_MEMBERSHIP_MANAGED, +) +from b2b.contracts import ( + add_courseware_to_contract, + create_contract, + ensure_default_variant, + expected_enrollment_code_count, + remove_courseware_from_contract, +) +from b2b.factories import ContractPageFactory, OrganizationPageFactory +from courses.factories import ( + CourseRunEnrollmentFactory, + CourseRunFactory, + ProgramFactory, +) + +pytestmark = [pytest.mark.django_db] + + +@pytest.fixture(autouse=True) +def mocked_edx(mocker): + """Keep contract run creation and removal away from edX.""" + + mocker.patch("b2b.contracts.push_run_dates_to_edx") + return mocker.patch("openedx.tasks.clone_courserun.delay") + + +def _source_run(): + """Create a course with a source run contract runs can be cloned from.""" + + return CourseRunFactory.create( + is_source_run=True, language="en", is_primary_language=True + ) + + +def test_create_contract(): + """A new contract sits under its organization with a default variant set.""" + + organization = OrganizationPageFactory.create() + + contract = create_contract( + organization, + name="Spring cohort", + membership_type=CONTRACT_MEMBERSHIP_CODE, + max_learners=25, + enrollment_fixed_price=Decimal("10.00"), + ) + + assert contract.get_parent().specific == organization + assert contract.organization == organization + assert contract.title == "Spring cohort" + assert contract.max_learners == 25 + default_variant = contract.variant_options.get() + assert default_variant.default_variant is True + assert default_variant.language == "en" + + +def test_ensure_default_variant_keeps_existing(): + """A contract that has a default variant set keeps it.""" + + contract = ContractPageFactory.create() + existing = contract.variant_options.get(default_variant=True) + + assert ensure_default_variant(contract) == existing + assert contract.variant_options.count() == 1 + + +def test_add_course_does_not_rerun(mocked_edx): + """Adding a course twice leaves one contract run, and queues one clone.""" + + contract = ContractPageFactory.create() + course = _source_run().course + + first = add_courseware_to_contract(contract, course) + second = add_courseware_to_contract(contract, course) + + assert first.runs_added == 1 + assert second.runs_added == 0 + assert contract.get_course_runs().count() == 1 + mocked_edx.assert_called_once() + + +def test_add_program(): + """Adding a program creates its courses' runs and links the program.""" + + contract = ContractPageFactory.create() + program = ProgramFactory.create() + for _ in range(2): + program.add_requirement(_source_run().course) + + added = add_courseware_to_contract(contract, program, skip_edx=True) + + assert added.runs_added == 2 + assert added.courses_without_source_run == 0 + assert list(contract.programs) == [program] + + +@pytest.mark.parametrize("force", [True, False]) +def test_add_run_in_another_contract(force): + """A run in another contract moves only when forced.""" + + run = CourseRunFactory.create() + other_contract = ContractPageFactory.create() + run.b2b_contracts.add(other_contract) + contract = ContractPageFactory.create() + + added = add_courseware_to_contract(contract, run, force=force) + + assert added.runs_added == (1 if force else 0) + assert bool(added.skipped_reason) is not force + assert run.b2b_contracts.filter(id=contract.id).exists() is force + + +@pytest.mark.parametrize("has_enrollments", [True, False]) +def test_remove_course(has_enrollments): + """ + Removing a course closes its contract runs, and unlinks those nobody has + enrolled in. + """ + + contract = ContractPageFactory.create() + course = _source_run().course + add_courseware_to_contract(contract, course, skip_edx=True) + [run] = contract.get_course_runs() + if has_enrollments: + CourseRunEnrollmentFactory.create(run=run) + + [(removed_run, unlinked)] = remove_courseware_from_contract(contract, course) + + removed_run.refresh_from_db() + assert unlinked is not has_enrollments + assert removed_run.live is False + assert contract.get_course_runs().filter(id=run.id).exists() is has_enrollments + assert not contract.get_products().exists() + + +def test_remove_run_outside_contract(): + """A run that is not in the contract is not touched.""" + + contract = ContractPageFactory.create() + run = CourseRunFactory.create(live=True) + + assert remove_courseware_from_contract(contract, run) == [] + run.refresh_from_db() + assert run.live is True + + +@pytest.mark.parametrize( + ("membership_type", "price", "max_learners", "expected"), + [ + (CONTRACT_MEMBERSHIP_MANAGED, None, 5, 0), + (CONTRACT_MEMBERSHIP_AUTO, None, None, 0), + (CONTRACT_MEMBERSHIP_AUTO, Decimal("5.00"), None, 2), + (CONTRACT_MEMBERSHIP_CODE, None, None, 2), + (CONTRACT_MEMBERSHIP_CODE, None, 3, 6), + ], +) +def test_expected_enrollment_code_count(membership_type, price, max_learners, expected): + """Codes are expected per product, times the seat cap when there is one.""" + + contract = ContractPageFactory.create( + membership_type=membership_type, + enrollment_fixed_price=price, + max_learners=max_learners, + ) + for _ in range(2): + add_courseware_to_contract(contract, _source_run().course, skip_edx=True) + + assert expected_enrollment_code_count(contract) == expected diff --git a/b2b/management/commands/b2b_codes.py b/b2b/management/commands/b2b_codes.py index aa473c56c3..00b04027f8 100644 --- a/b2b/management/commands/b2b_codes.py +++ b/b2b/management/commands/b2b_codes.py @@ -23,6 +23,7 @@ get_contract_runs_without_products, ) from b2b.constants import CONTRACT_MEMBERSHIP_AUTOS +from b2b.contracts import expected_enrollment_code_count from b2b.models import ContractPage, DiscountContractAttachmentRedemption from courses.models import CourseRun from ecommerce.constants import REDEMPTION_TYPE_ONE_TIME, REDEMPTION_TYPE_UNLIMITED @@ -504,14 +505,12 @@ def handle_validate(self, **kwargs): else contract.enrollment_fixed_price ) - if contract.max_learners: - expected_codes_count = ( - contract.max_learners * contract.get_course_runs().count() - ) - code_redemption_type = REDEMPTION_TYPE_ONE_TIME - else: - expected_codes_count = contract.get_course_runs().count() - code_redemption_type = REDEMPTION_TYPE_UNLIMITED + expected_codes_count = expected_enrollment_code_count(contract) + code_redemption_type = ( + REDEMPTION_TYPE_ONE_TIME + if contract.max_learners + else REDEMPTION_TYPE_UNLIMITED + ) total_code_count = contract.get_discounts().count() diff --git a/b2b/management/commands/b2b_contract.py b/b2b/management/commands/b2b_contract.py index 8690369d7d..80252f8b5d 100644 --- a/b2b/management/commands/b2b_contract.py +++ b/b2b/management/commands/b2b_contract.py @@ -15,6 +15,7 @@ from b2b.constants import ( CONTRACT_MEMBERSHIP_TYPE_CHOICES, ) +from b2b.contracts import create_contract from b2b.exceptions import SourceCourseIncompleteError from b2b.models import ( ContractPage, @@ -288,20 +289,19 @@ def handle_create(self, *args, **kwargs): # noqa: ARG002 ) raise CommandError(msg) - contract = ContractPage( + contract = create_contract( + org, name=contract_name, - description=description or "", membership_type=membership_type, - organization=org, + description=description or "", contract_start=start_date, contract_end=end_date, max_learners=max_learners, enrollment_fixed_price=price, ) - org.add_child(instance=contract) - contract.save() self.stdout.write( - f"Created contract '{contract_name}' for organization '{organization_name}'" + f"Created contract '{contract_name}' (ID {contract.id}) for " + f"organization '{organization_name}'" ) def handle_modify(self, *args, **kwargs): # noqa: ARG002, C901 diff --git a/b2b/management/commands/b2b_courseware.py b/b2b/management/commands/b2b_courseware.py index 51998232a7..3643ad7a3e 100644 --- a/b2b/management/commands/b2b_courseware.py +++ b/b2b/management/commands/b2b_courseware.py @@ -8,21 +8,14 @@ from argparse import RawTextHelpFormatter from django.core.management import BaseCommand, CommandError -from mitol.common.utils.datetime import now_in_utc from opaque_keys import InvalidKeyError -from b2b.api import create_contract_run, import_and_create_contract_run -from b2b.models import ContractPage, ContractProgramItem +from b2b.api import import_and_create_contract_run +from b2b.contracts import add_courseware_to_contract, remove_courseware_from_contract +from b2b.models import ContractPage from b2b.tasks import queue_enrollment_code_check from courses.api import resolve_courseware_object_from_id from courses.constants import UAI_COURSEWARE_ID_PREFIX -from courses.models import CourseRun, CourseRunEnrollment -from courses.retirement import ( - deactivate_run_products, - get_run_products, - push_run_dates_to_edx, -) -from ecommerce.models import Discount, DiscountProduct log = logging.getLogger(__name__) @@ -72,55 +65,6 @@ class Command(BaseCommand): Specifying a program will only unlink the program from the contract, unless "--remove-program-runs" is set. If it is, then all the runs that belong to both the contract and the program's courses will be removed from the contract. Note that doing this and then re-adding the program will *not* re-attach the existing runs to the contract - you will need to do that manually. """ - def create_run( # noqa: PLR0913 - self, - contract, - courseware, - *, - skip_edx=False, - org_prefix=None, - no_reruns=False, - ignore_langs=False, - only_lang=None, - filter_variants=None, - ): - """Create a run for the specified contract.""" - try: - run_tuples = create_contract_run( - contract=contract, - course=courseware, - skip_edx=skip_edx, - org_prefix=org_prefix, - no_reruns=no_reruns, - ignore_langs=ignore_langs, - only_lang=only_lang, - filter_variants=filter_variants, - ) - except InvalidKeyError: - self.stderr.write( - self.style.ERROR( - f"Invalid key error for course {courseware}. Is the course's readable ID configured correctly?" - ) - ) - return False - - if not run_tuples: - self.stdout.write( - self.style.ERROR( - f"Failed to create run for course {courseware} for contract {contract}." - ) - ) - return False - - for run, product in run_tuples: - self.stdout.write( - self.style.SUCCESS( - f"Created run {run} and product {product} for course {courseware} for contract {contract}." - ) - ) - - return True - def add_arguments(self, parser): """Add command line arguments.""" @@ -302,77 +246,57 @@ def handle_add(self, contract, coursewares, **kwargs): # noqa: C901, PLR0915 continue if courseware.is_program: - # If you're specifying a program, we will always make new runs - # since we won't be able to tell which existing ones to use. - self.stdout.write( self.style.WARNING( f"'{courseware.readable_id}' is a program, so creating runs for all of its courses." ) ) - prog_add, prog_no_source = contract.add_program_courses( + try: + added = add_courseware_to_contract( + contract, courseware, skip_edx=skip_edx, no_reruns=no_reruns, + force=force_associate, + org_prefix=org_prefix, ignore_langs=ignore_langs, only_lang=only_lang, filter_variants=filter_variants, ) - if prog_no_source > 0: - self.stdout.write( - self.style.WARNING( - f"Program '{courseware.readable_id}' has {prog_no_source} courses with no source runs; cannot create contract runs for these courses." - ) + except InvalidKeyError: + self.stderr.write( + self.style.ERROR( + f"Invalid key error for course {courseware}. Is the course's readable ID configured correctly?" ) - contract.save() - managed += prog_add - self.stdout.write( - self.style.SUCCESS(f"Added {courseware.readable_id} to {contract}.") ) - elif courseware.is_run: - # This run already exists, so: - # - If it's in a contract already and we're not forcing it, skip it. - # - If it's in a contract already and we *are* forcing it, set it to be in this contract. - # - If it's not in a contract, add it to this contract. + continue - other_contracts = courseware.b2b_contracts.exclude(id=contract.id) + if added.skipped_reason: + self.stdout.write(self.style.WARNING(added.skipped_reason)) + continue - if not force_associate and other_contracts.exists(): - # Already owned by another contract, so skip - self.stdout.write( - self.style.WARNING( - f"Run '{courseware.courseware_id}' is already owned by {other_contracts.first()}." - ) + if added.courses_without_source_run: + self.stdout.write( + self.style.WARNING( + f"Program '{courseware.readable_id}' has {added.courses_without_source_run} courses with no source runs; cannot create contract runs for these courses." ) - continue - elif courseware.b2b_contracts.filter(id=contract.id).exists(): - # Already owned by this contract, so skip - self.stdout.write( - self.style.WARNING( - f"Run '{courseware.courseware_id}' is already owned by this contract." - ) + ) + + if not added.runs_added and not courseware.is_program: + self.stdout.write( + self.style.ERROR( + f"Failed to create run for course {courseware} for contract {contract}." ) - continue + ) + continue - # Add the run to the contract - courseware.b2b_contract = contract - courseware.save() - courseware.b2b_contracts.add(contract) - managed += 1 - elif self.create_run( - contract, - courseware, - skip_edx=skip_edx, - org_prefix=org_prefix, - no_reruns=no_reruns, - ignore_langs=ignore_langs, - only_lang=only_lang, - filter_variants=filter_variants, - ): - # This is a course, so create a run (unless we've been told not to). - - managed += 1 + managed += added.runs_added + self.stdout.write( + self.style.SUCCESS( + f"Added {courseware.readable_id} to {contract} ({added.runs_added} runs)." + ) + ) if make_codes: self.stdout.write(f"Queueing enrollment code check for {contract}") @@ -386,141 +310,34 @@ def handle_add(self, contract, coursewares, **kwargs): # noqa: C901, PLR0915 return True - def handle_remove(self, contract, coursewares, **kwargs): # noqa: C901 + def handle_remove(self, contract, coursewares, **kwargs): """Handle removing courseware from a contract.""" remove_runs = kwargs.pop("remove_program_runs") for courseware in coursewares: - if courseware.is_program: - # If we have a program, unlink the program from the contract. - # Then, if we're told to, unlink any contract runs that are - # part of the program too. - - if remove_runs: - program_courses = courseware.courses - program_runs = CourseRun.objects.filter( - b2b_contracts=contract, - course__in=[course for (course, _) in program_courses], - ).all() - - coursewares.extend(program_runs) - - self.stdout.write( - self.style.NOTICE( - f"{courseware.readable_id} is a program and --remove-program-runs set, so adding {len(program_runs)} course runs" - ) - ) - - ContractProgramItem.objects.filter( - contract=contract, program=courseware - ).delete() - self.stdout.write( - self.style.SUCCESS( - f"Removed program {courseware.readable_id} from contract {contract}." - ) - ) - elif not courseware.is_run: - # If we have a course, find and add the contract runs for the - # course to the list. We don't link courses to contracts, so - # there's nothing else to do here. - - course_contract_runs = courseware.courseruns.filter( - b2b_contracts=contract - ).all() - - coursewares.extend(course_contract_runs) + removed = remove_courseware_from_contract( + contract, courseware, remove_program_runs=remove_runs + ) + if courseware.is_program: self.stdout.write( self.style.SUCCESS( - f"Added {len(course_contract_runs)} course runs from course {courseware.readable_id} to remove from contract {contract}." + f"Removed program {courseware.readable_id} from contract {contract}, with {len(removed)} of its runs." ) ) - else: - # We're actually at a course run now. - - has_enrollments = CourseRunEnrollment.objects.filter( - run=courseware - ).exists() - - # Deactivate the run for future enrollments - now = now_in_utc() - if ( - courseware.live - or courseware.enrollment_end is None - or courseware.enrollment_end > now - ): - courseware.live = False - courseware.enrollment_end = now - - # If there are no enrollments, detach the run from the contract - if ( - not has_enrollments - and courseware.b2b_contracts.filter(id=contract.id).exists() - ): - if courseware.b2b_contract == contract: - courseware.b2b_contract = None - courseware.b2b_contracts.remove(contract) - - courseware.save() - - # Deactivate products for this run. get_run_products uses - # all_objects so it finds products regardless of their current - # is_active state, and returns a list so the deactivation below - # doesn't mutate the collection we reuse when removing discount - # associations. Shared with the retire_courserun command. - run_products = get_run_products(courseware) - deactivate_run_products(courseware) - - # Invalidate/delete any enrollment codes (Discounts) associated with this run's products - discounts = Discount.objects.filter( - products__product__in=run_products - ).distinct() - - for discount in discounts: - # Remove only associations for these products - DiscountProduct.objects.filter( - discount=discount, - product__in=run_products, - ).delete() - discount.refresh_from_db() - - # If the discount no longer applies to any products, remove it - if discount.products.count() == 0 and not ( - discount.order_redemptions.exists() - or discount.contract_redemptions.exists() - ): - discount.delete() - - # Attempt to push the new enrollment_end to edX so it isn't - # overwritten by the next sync from edX. - # - # NOTE: edX will not accept an enrollment window for a run that - # has no start and end date, so for a run with a null end_date - # the new enrollment_end never reaches edX and the next sync - # reverts it. push_run_dates_to_edx returns False and logs a - # warning in that case. Fixing it properly means also moving - # end_date into the past, which is what the retire_courserun - # command does; this command's contract is narrower, so the - # behaviour is left as-is here. - try: - push_run_dates_to_edx(courseware) - except Exception: - log.exception( - "Failed to update enrollment end date on edX for %s", - courseware.courseware_id, - ) - if not has_enrollments: + for run, unlinked in removed: + if unlinked: self.stdout.write( self.style.SUCCESS( - f"Deactivated and unlinked {courseware.courseware_id} from {contract} (no enrollments)." + f"Deactivated and unlinked {run.courseware_id} from {contract} (no enrollments)." ) ) else: self.stdout.write( self.style.SUCCESS( - f"Deactivated {courseware.courseware_id} but kept it linked to {contract} (has enrollments)." + f"Deactivated {run.courseware_id} but kept it linked to {contract} (has enrollments)." ) ) diff --git a/b2b/management/commands/check_contract_variant.py b/b2b/management/commands/check_contract_variant.py index 51a481dcb3..f9cd5284b7 100644 --- a/b2b/management/commands/check_contract_variant.py +++ b/b2b/management/commands/check_contract_variant.py @@ -5,8 +5,8 @@ from django.core.management import BaseCommand from django.core.management.base import CommandParser +from b2b.contracts import ensure_default_variant from b2b.models import ContractPage -from variants.models import SupportedVariant class Command(BaseCommand): @@ -60,12 +60,7 @@ def handle(self, *_args, **kwargs): self.stdout.write( "'fix-default' flag set, creating a default variant set for the contract." ) - default_variant = SupportedVariant.objects.create( - variant_object=contract_obj, - language="en", - b2b_only=False, - default_variant=True, - ) + default_variant = ensure_default_variant(contract_obj) else: return diff --git a/b2b/models.py b/b2b/models.py index 4263d50fd1..d68ab7af46 100644 --- a/b2b/models.py +++ b/b2b/models.py @@ -678,6 +678,7 @@ def add_program_courses( # noqa: PLR0913 *, skip_edx=False, no_reruns=True, + org_prefix=UAI_COURSEWARE_ID_PREFIX, ignore_langs=False, only_lang=None, filter_variants=None, @@ -689,6 +690,9 @@ def add_program_courses( # noqa: PLR0913 Args: - program (courses.Program): the program to add + Kwargs: + - org_prefix (str|None): passed to create_contract_run; None uses the + organization's own prefix Returns: - tuple: Tuple with three integers: @@ -717,6 +721,7 @@ def add_program_courses( # noqa: PLR0913 course, no_reruns=no_reruns, skip_edx=skip_edx, + org_prefix=org_prefix, ignore_langs=ignore_langs, only_lang=only_lang, filter_variants=filter_variants, diff --git a/docs/source/b2b/commands.md b/docs/source/b2b/commands.md index bad272385b..2b67b27519 100644 --- a/docs/source/b2b/commands.md +++ b/docs/source/b2b/commands.md @@ -16,13 +16,13 @@ This document will go over the basic use of the management commands. To get the :Name: b2b_contract :Description: Manages organizations and contracts within the B2B system. :Subcommands: - `create` - creates a new contract (and optionally organization) + `create` - creates a new contract, with a default variant set `modify` - changes the parameters for an existing contract :Description: - The `b2b_contract` command can be used to set up a new contract. It can also create an organization for you, if necessary. + The `b2b_contract` command can be used to set up a new contract for an existing organization. Organizations are created through the provisioning API (`POST /api/v0/b2b/provisioning/organizations/`), which creates the Keycloak organization alongside it. - It can also be used to modify an existing contract. Changing certain parameters - pricing, seat count, and activation dates especially - will also caused _unredeemed_ enrollment codes to be updated, and new ones to be created, where appropriate. + It can also be used to modify an existing contract. Modifying a contract does not touch its enrollment codes. After changing the price, seat count or membership type, run `b2b_codes validate` to bring the codes in line. ### b2b_courseware diff --git a/docs/source/b2b/orgs_contracts.md b/docs/source/b2b/orgs_contracts.md index 6741d3e2ed..028a64d99c 100644 --- a/docs/source/b2b/orgs_contracts.md +++ b/docs/source/b2b/orgs_contracts.md @@ -88,7 +88,7 @@ Note that a learner using a standard, one-time use code via either of these meth Enrollment codes are redeemed **automatically** sometimes. If the learner is in the contract, they don't have to continue to apply enrollment codes to enroll in courses. (We've already verified a code, so we're aware they're allowed to take courses.) Instead, learners clicking on the "Start Course" button in their dashboard in Learn trigger a process that creates an order and fulfills it for the course in MITx Online. This results in the learner being enrolled in the course, and the system consumes an enrollment code for this purpose. -If a contract that requires enrollment codes is changed, enrollment codes may be refreshed accordingly: -- If new courses are added, new codes will be created for those courses. -- If the seat limit changes, codes will be adjusted accordingly. (If the limit increases, new codes may be added; if the limit decreases other than to zero, codes may be removed.) -- Codes that have been used won't be modified. +Enrollment codes are not refreshed when a contract changes. Saving the contract, adding courseware, or changing its price or seat limit leaves the codes as they were. Codes are brought in line with the contract only when something asks for it: `b2b_courseware add --make-codes` or `b2b_codes validate`. When the check runs: +- Courses added since the last check get codes. +- A changed seat limit adds codes, or removes unused ones if the limit dropped to a non-zero value. +- Codes that have been used are not modified. From 672b1098b31cc005bad450f979de6e568728bec9 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 15 Sep 2026 11:38:30 -0400 Subject: [PATCH 2/4] Keep the per-run removal helper private and report courses with nothing to remove remove_run_from_contract closes a run, deactivates its products and deletes codes without checking that the run is in the contract. Its only caller, remove_courseware_from_contract, filters to the contract's runs first, so the helper is now private and that check stays in one place. b2b_courseware remove printed nothing for a course or run with no runs in the contract, where the old command reported how many runs it found. It now says nothing was removed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NUA1EjC95LZV4J5swhZgxy --- b2b/commands_test.py | 16 ++++++++++++++++ b2b/contracts.py | 6 +++--- b2b/management/commands/b2b_courseware.py | 6 ++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/b2b/commands_test.py b/b2b/commands_test.py index e7ef0f105c..0801e5e2b0 100644 --- a/b2b/commands_test.py +++ b/b2b/commands_test.py @@ -1,5 +1,7 @@ """Tests for B2B management commands.""" +from io import StringIO + import pytest from django.core.management import call_command @@ -30,6 +32,20 @@ def _create_run_with_product_and_discount(contract, *, with_enrollment=False): return run, product, discount +def test_b2b_courseware_remove_course_with_no_contract_runs_says_so(): + """Removing a course with no runs in the contract reports that nothing was removed.""" + + contract = ContractPageFactory.create() + course = CourseRunFactory.create().course + out = StringIO() + + call_command( + "b2b_courseware", "remove", str(contract.id), course.readable_id, stdout=out + ) + + assert f"{course.readable_id} has no runs in {contract}" in out.getvalue() + + def test_b2b_courseware_remove_run_without_enrollments_unlinks_and_deactivates(mocker): """Removing a run with no enrollments should unlink it and deactivate related objects.""" diff --git a/b2b/contracts.py b/b2b/contracts.py index 5669239556..706d076163 100644 --- a/b2b/contracts.py +++ b/b2b/contracts.py @@ -170,7 +170,7 @@ def add_courseware_to_contract( # noqa: PLR0913 return CoursewareAddition(runs_added=len(created)) -def remove_run_from_contract(contract: ContractPage, run: CourseRun) -> bool: +def _remove_run_from_contract(contract: ContractPage, run: CourseRun) -> bool: """ Close a contract run to new enrollments and take it out of the contract. @@ -244,7 +244,7 @@ def remove_courseware_from_contract( removing it removes its contract runs. Only runs in this contract are touched. - Returns (run, unlinked) for each run removed; see remove_run_from_contract. + Returns (run, unlinked) for each run removed; see _remove_run_from_contract. """ contract_runs = CourseRun.objects.filter(b2b_contracts=contract) @@ -267,7 +267,7 @@ def remove_courseware_from_contract( else: runs = list(contract_runs.filter(course=courseware)) - return [(run, remove_run_from_contract(contract, run)) for run in runs] + return [(run, _remove_run_from_contract(contract, run)) for run in runs] def expected_enrollment_code_count(contract: ContractPage) -> int: diff --git a/b2b/management/commands/b2b_courseware.py b/b2b/management/commands/b2b_courseware.py index 3643ad7a3e..3c21571269 100644 --- a/b2b/management/commands/b2b_courseware.py +++ b/b2b/management/commands/b2b_courseware.py @@ -326,6 +326,12 @@ def handle_remove(self, contract, coursewares, **kwargs): f"Removed program {courseware.readable_id} from contract {contract}, with {len(removed)} of its runs." ) ) + elif not removed: + self.stdout.write( + self.style.WARNING( + f"{courseware.readable_id} has no runs in {contract}; nothing removed." + ) + ) for run, unlinked in removed: if unlinked: From 9e476471653f81f96b78a4d923a03f47dc63544f Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Wed, 16 Sep 2026 17:25:43 -0400 Subject: [PATCH 3/4] Drop --force from b2b_courseware add and the shared courseware service Copilot flagged that force changed the legacy FK but only added to the M2M, so the run stayed in its previous contract as well. jkachel, who added the flag, says it was meant to re-point a run when something went wrong, that run membership can be adjusted elsewhere, and that the many-to-many needs a different mechanism anyway, so it should go. A run already in another contract is now always left where it is and reported as skipped. That also matches where contracts are heading: pdpinch notes that letting public runs into contracts requires one run to appear in several of them, which is the opposite of a move. The remaining questions about run-to-contract membership are tracked in witan as tk-decide-how-a-course-run-belongs-to-contracts-sha-a00239. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NUA1EjC95LZV4J5swhZgxy --- b2b/contracts.py | 7 +++--- b2b/contracts_test.py | 13 +++++------ b2b/management/commands/b2b_courseware.py | 12 ++-------- b2b/management/tests/b2b_courseware_test.py | 26 ++++----------------- docs/source/b2b/commands.md | 2 +- 5 files changed, 18 insertions(+), 42 deletions(-) diff --git a/b2b/contracts.py b/b2b/contracts.py index 706d076163..86060a7b47 100644 --- a/b2b/contracts.py +++ b/b2b/contracts.py @@ -98,7 +98,6 @@ def add_courseware_to_contract( # noqa: PLR0913 *, skip_edx: bool = False, no_reruns: bool = True, - force: bool = False, org_prefix: str | None = None, ignore_langs: bool = False, only_lang: str | None = None, @@ -111,7 +110,9 @@ def add_courseware_to_contract( # noqa: PLR0913 run, and is linked to the contract. - A course gets contract runs from its source runs. - An existing run is attached as it is. A run already in another contract - is left there unless force is set. + is left there and reported as skipped: a run can legitimately belong to + more than one contract, and moving it out of one silently takes its + learners' courseware with it. Runs are created for the variant sets in filter_variants, which defaults to every variant set on the contract. no_reruns defaults to True, unlike @@ -145,7 +146,7 @@ def add_courseware_to_contract( # noqa: PLR0913 ) other_contract = courseware.b2b_contracts.exclude(id=contract.id).first() - if other_contract and not force: + if other_contract: return CoursewareAddition( skipped_reason=( f"Run '{courseware.courseware_id}' is already in {other_contract}." diff --git a/b2b/contracts_test.py b/b2b/contracts_test.py index 863818eafe..f113afab95 100644 --- a/b2b/contracts_test.py +++ b/b2b/contracts_test.py @@ -104,20 +104,19 @@ def test_add_program(): assert list(contract.programs) == [program] -@pytest.mark.parametrize("force", [True, False]) -def test_add_run_in_another_contract(force): - """A run in another contract moves only when forced.""" +def test_add_run_in_another_contract_is_skipped(): + """A run already in another contract stays there and is reported.""" run = CourseRunFactory.create() other_contract = ContractPageFactory.create() run.b2b_contracts.add(other_contract) contract = ContractPageFactory.create() - added = add_courseware_to_contract(contract, run, force=force) + added = add_courseware_to_contract(contract, run) - assert added.runs_added == (1 if force else 0) - assert bool(added.skipped_reason) is not force - assert run.b2b_contracts.filter(id=contract.id).exists() is force + assert added.runs_added == 0 + assert str(other_contract) in added.skipped_reason + assert not run.b2b_contracts.filter(id=contract.id).exists() @pytest.mark.parametrize("has_enrollments", [True, False]) diff --git a/b2b/management/commands/b2b_courseware.py b/b2b/management/commands/b2b_courseware.py index 3c21571269..bbce140e35 100644 --- a/b2b/management/commands/b2b_courseware.py +++ b/b2b/management/commands/b2b_courseware.py @@ -30,7 +30,7 @@ class Command(BaseCommand): Specifying courseware: You must specify one courseware item (of any type). You can specify more than one by adding "--also " to the end of the command. You can repeat this as many times as necessary. To add courseware: - b2b_courseware add [--import ] [--no-create-runs] [--force] [--prefix ] [--make-codes] [--also ] [--also ...] + b2b_courseware add [--import ] [--no-create-runs] [--prefix ] [--make-codes] [--also ] [--also ...] Example: b2b_courseware add contract-100-101 program-v1:UAI+Fundamentals --also course-v1:UAI_C100+14.314x+2025_C101 @@ -38,7 +38,7 @@ class Command(BaseCommand): If "--import" is specified, it expects a list of departments for the new courses to be added to. This should be a list of names, separated by commas. You must specify at least one department as courses must belong to at least one department. The departments must exist; it won't create them for you. -Specifying a course run will attach it to the contract unless the contract is already attached to a contract. Specify "--force" to override any existing contract attachment. +Specifying a course run will attach it to the contract unless the run is already in another contract, in which case it is left alone and reported as skipped. Specifying a course will attempt to create a course run for the contract for the specified course. It will try to create a course run in edX as well unless "--no-create-runs" is specified. This flag is ignored if "--import" is specified. @@ -108,12 +108,6 @@ def add_arguments(self, parser): dest="allow_reruns", action="store_true", ) - add_subparser.add_argument( - "--force", - help="Force adding any specified runs to the contract (overwrite existing contract associations).", - dest="force", - action="store_true", - ) add_subparser.add_argument( "--import", help="Attempt to import course runs specified into the department(s), if they don't exist in MITx Online.", @@ -165,7 +159,6 @@ def handle_add(self, contract, coursewares, **kwargs): # noqa: C901, PLR0915 """Handle the add subcommand.""" skip_edx = kwargs.pop("no_create_runs", False) - force_associate = kwargs.pop("force") can_import = kwargs.pop("can_import") org_prefix = kwargs.pop("prefix") make_codes = kwargs.pop("make_codes", False) @@ -258,7 +251,6 @@ def handle_add(self, contract, coursewares, **kwargs): # noqa: C901, PLR0915 courseware, skip_edx=skip_edx, no_reruns=no_reruns, - force=force_associate, org_prefix=org_prefix, ignore_langs=ignore_langs, only_lang=only_lang, diff --git a/b2b/management/tests/b2b_courseware_test.py b/b2b/management/tests/b2b_courseware_test.py index 036b0a4675..2451ef3336 100644 --- a/b2b/management/tests/b2b_courseware_test.py +++ b/b2b/management/tests/b2b_courseware_test.py @@ -111,7 +111,6 @@ def test_add_program(mock_clone_courserun, with_languages, no_create_runs, try_r courseware=str(program.readable_id), no_create_runs=no_create_runs, allow_reruns=True, - force=False, can_import="", prefix="", make_code=False, @@ -134,7 +133,6 @@ def test_add_program(mock_clone_courserun, with_languages, no_create_runs, try_r courseware=str(program.readable_id), no_create_runs=no_create_runs, allow_reruns=try_reruns, - force=False, can_import="", prefix="", make_code=False, @@ -188,7 +186,6 @@ def test_add_course(mock_clone_courserun, with_languages, no_create_runs, try_re courseware=str(run.course.readable_id), no_create_runs=no_create_runs, allow_reruns=True, - force=False, can_import="", prefix="", make_code=False, @@ -211,7 +208,6 @@ def test_add_course(mock_clone_courserun, with_languages, no_create_runs, try_re courseware=str(run.course.readable_id), no_create_runs=no_create_runs, allow_reruns=try_reruns, - force=False, can_import="", prefix="", make_code=False, @@ -235,7 +231,6 @@ def test_add_courserun(): courseware=str(run.courseware_id), no_create_runs=False, allow_reruns=True, - force=False, can_import="", prefix="", make_code=False, @@ -245,19 +240,13 @@ def test_add_courserun(): assert run.b2b_contract == contract -@pytest.mark.parametrize( - "force", - [ - True, - False, - ], -) -def test_add_courserun_existing_contract(force): - """Test adding an extant courserun to a contract.""" +def test_add_courserun_existing_contract(): + """A run already in another contract is left where it is.""" contract = ContractPageFactory.create() existing_contract = ContractPageFactory.create() run = CourseRunFactory.create(b2b_contract=existing_contract) + run.b2b_contracts.add(existing_contract) command = b2b_courseware.Command() command.handle( @@ -266,14 +255,14 @@ def test_add_courserun_existing_contract(force): courseware=str(run.courseware_id), no_create_runs=False, allow_reruns=True, - force=force, can_import="", prefix="", make_code=False, ) run.refresh_from_db() - assert run.b2b_contract == (contract if force else existing_contract) + assert run.b2b_contract == existing_contract + assert not run.b2b_contracts.filter(id=contract.id).exists() @pytest.mark.parametrize( @@ -305,7 +294,6 @@ def test_add_course_ignore_langs(mock_clone_courserun, explicit_primary): contract=str(contract.id), courseware=str(run.course.readable_id), allow_reruns=True, - force=False, can_import="", prefix="", make_code=False, @@ -354,7 +342,6 @@ def test_add_program_ignore_langs(mock_clone_courserun, explicit_primary): contract=str(contract.id), courseware=str(program.readable_id), allow_reruns=True, - force=False, can_import="", prefix="", make_code=False, @@ -398,7 +385,6 @@ def test_add_course_specific_lang(mock_clone_courserun, explicit_primary): contract=str(contract.id), courseware=str(run.course.readable_id), allow_reruns=True, - force=False, can_import="", prefix="", make_code=False, @@ -452,7 +438,6 @@ def test_add_program_specific_lang(mock_clone_courserun, explicit_primary): contract=str(contract.id), courseware=str(program.readable_id), allow_reruns=True, - force=False, can_import="", prefix="", make_code=False, @@ -584,7 +569,6 @@ def test_add_course_variants(mock_clone_courserun, add_filtering): contract=str(contract.id), courseware=str(course.readable_id), allow_reruns=False, - force=False, can_import="", prefix="", make_code=False, diff --git a/docs/source/b2b/commands.md b/docs/source/b2b/commands.md index 2b67b27519..5f076bcba8 100644 --- a/docs/source/b2b/commands.md +++ b/docs/source/b2b/commands.md @@ -40,7 +40,7 @@ This document will go over the basic use of the management commands. To get the ::: In `add` mode, the command adds courseware to the contract. - - If the object specified is a course run, it will be added to the contract (unless it's already associated with a contract, and `--force` isn't set) + - If the object specified is a course run, it will be added to the contract, unless it is already in another contract, in which case it is left alone. - If the object specified is a course, a contract course run will be created. - If the object specified is a program, the program will be added to the contract, and it will create contract runs for each course in the program. From b9c83c4db2d595dcfd4f2e93086402814e1c83b5 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Thu, 17 Sep 2026 13:55:12 -0400 Subject: [PATCH 4/4] Fix add_program_courses docstring and restore the remove-course summary line add_program_courses returns a 2-tuple; the docstring said three. The remove subcommand's refactor dropped the course-level summary message that the pre-refactor command printed, leaving only per-run lines. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01W2XzZbf9sYVFaw6PVaw7uX --- b2b/management/commands/b2b_courseware.py | 6 ++++++ b2b/management/tests/b2b_courseware_test.py | 22 +++++++++++++++++++++ b2b/models.py | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/b2b/management/commands/b2b_courseware.py b/b2b/management/commands/b2b_courseware.py index bbce140e35..a4a6d44e76 100644 --- a/b2b/management/commands/b2b_courseware.py +++ b/b2b/management/commands/b2b_courseware.py @@ -324,6 +324,12 @@ def handle_remove(self, contract, coursewares, **kwargs): f"{courseware.readable_id} has no runs in {contract}; nothing removed." ) ) + else: + self.stdout.write( + self.style.SUCCESS( + f"Removed {courseware.readable_id} from contract {contract}, with {len(removed)} of its runs." + ) + ) for run, unlinked in removed: if unlinked: diff --git a/b2b/management/tests/b2b_courseware_test.py b/b2b/management/tests/b2b_courseware_test.py index 2451ef3336..e6c3972968 100644 --- a/b2b/management/tests/b2b_courseware_test.py +++ b/b2b/management/tests/b2b_courseware_test.py @@ -581,3 +581,25 @@ def test_add_course_variants(mock_clone_courserun, add_filtering): assert contract_runs.filter(language__in=language_check).count() == len( language_check ) + + +def test_remove_course_prints_summary(capsys): + """Removing a course should print a removal summary, like removing a program does.""" + + contract = ContractPageFactory.create() + run = CourseRunFactory.create(b2b_contract=contract) + command = b2b_courseware.Command() + + command.handle( + subcommand="remove", + contract=str(contract.id), + courseware=str(run.course.readable_id), + additional_courseware=None, + remove_program_runs=False, + ) + + output = capsys.readouterr().out + assert ( + f"Removed {run.course.readable_id} from contract {contract}, with 1 of its runs." + in output + ) diff --git a/b2b/models.py b/b2b/models.py index d68ab7af46..59ac1fbe7e 100644 --- a/b2b/models.py +++ b/b2b/models.py @@ -695,7 +695,7 @@ def add_program_courses( # noqa: PLR0913 organization's own prefix Returns: - - tuple: Tuple with three integers: + - tuple: Tuple with two integers: - number of course runs created - number of courses with no source run """