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
1 change: 1 addition & 0 deletions Aptfile
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
libpq-dev
libxmlsec1-dev
11 changes: 11 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
Release Notes
=============

Version 1.162.1
---------------

- anonymous user handling (#3805)
- fix: dirty-check edX sync and ProductPage saves to cut Fastly purges (#3806)
- feat(b2b): add a service-scoped org-manager check endpoint (#3807)
- Identify PostHog persons by Keycloak global_id, not Django pk (#3798)
- Use psycopg's C implementation and drop unused psycopg2 (#3801)
- fix: read Learn's Fastly service ID from MIT_LEARN_FASTLY_SERVICE_ID (#3794)
- feat: retire course run cmd added to retire a b2b contract (#3797)

Version 1.161.0 (Released August 03, 2026)
---------------

Expand Down
20 changes: 12 additions & 8 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,6 @@
"description": "Expose the OIDC login functionality.",
"required": false
},
"FASTLY_AUTH_TOKEN": {
"description": "Optional token for the Fastly purge API.",
"required": false
},
"FASTLY_URL": {
"description": "The URL to the Fastly API.",
"required": false
},
"GA_TRACKING_ID": {
"description": "Google analytics tracking ID",
"required": false
Expand Down Expand Up @@ -482,6 +474,14 @@
"description": "The execution environment that the app is in (e.g. dev, staging, prod)",
"required": true
},
"MITX_ONLINE_FASTLY_AUTH_TOKEN": {
"description": "Optional token for the Fastly purge API.",
"required": false
},
"MITX_ONLINE_FASTLY_URL": {
"description": "The URL to the Fastly API.",
"required": false
},
"MITX_ONLINE_FROM_EMAIL": {
"description": "E-mail to use for the from field",
"required": false
Expand Down Expand Up @@ -546,6 +546,10 @@
"description": "Dashboard URL for UAI enrollment emails",
"required": false
},
"MIT_LEARN_FASTLY_SERVICE_ID": {
"description": "Fastly service ID for the MIT Learn frontend, used for surrogate key (tag) purging.",
"required": false
},
"MIT_LEARN_FROM_EMAIL": {
"description": "From email address for UAI enrollment emails",
"required": false
Expand Down
154 changes: 154 additions & 0 deletions b2b/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
from b2b.constants import (
B2B_RUN_TAG_FORMAT,
CONTRACT_MEMBERSHIP_AUTOS,
CONTRACT_MEMBERSHIP_MANAGED,
ORG_KEY_MAX_LENGTH,
RETIREMENT_CONTRACT_NAME,
RETIREMENT_ORG_KEY,
RETIREMENT_ORG_NAME,
)
from b2b.exceptions import SourceCourseIncompleteError
from b2b.keycloak_admin_api import KCAM_ORGANIZATIONS, get_keycloak_model
Expand Down Expand Up @@ -111,6 +115,156 @@ def ensure_b2b_organization_index() -> OrganizationIndexPage:
return org_index_page


class RetirementContractCollisionError(Exception):
"""Raised when a run can't be moved into the holding contract."""


def get_or_create_retirement_contract() -> ContractPage:
"""
Get (or create) the holding contract that retired course runs live in.

Moving a retired run here rather than nulling its ``b2b_contract`` matters:
``CourseRunQuerySet.exclude_b2b()`` is ``b2b_contract__isnull=True``, so a
run with no contract becomes a candidate for the *public* catalog. Parking
it against an inactive contract keeps it out of the public catalog and out
of every org/contract catalog query, which filter on
``b2b_contract__active=True``.

Both pages are created unpublished so they are never served, and the
contract is inactive with a zero learner cap. The org has no
``sso_organization_id``, which is safe: ``reconcile_keycloak_orgs`` only
creates or updates pages for orgs Keycloak knows about and never prunes
ones it doesn't.

Returns:
ContractPage: the holding contract.
"""

org = OrganizationPage.objects.filter(org_key=RETIREMENT_ORG_KEY).first()

if not org:
# Prefer an existing index page. ensure_b2b_organization_index() also
# re-parents every OrganizationPage when its child count doesn't match,
# which is a side effect we don't want to trigger from here.
org_index = OrganizationIndexPage.objects.first() or (
ensure_b2b_organization_index()
)
org = OrganizationPage(
name=RETIREMENT_ORG_NAME,
org_key=RETIREMENT_ORG_KEY,
live=False,
description=(
"System organization. Holds course runs that have been retired. "
"Not a real customer - do not add members or contracts."
),
)
org_index.add_child(instance=org)
org.refresh_from_db()
log.info("Created retirement holding organization %s", org)

contract = ContractPage.objects.filter(
organization=org, name=RETIREMENT_CONTRACT_NAME
).first()

if not contract:
contract = ContractPage(
name=RETIREMENT_CONTRACT_NAME,
organization=org,
membership_type=CONTRACT_MEMBERSHIP_MANAGED,
active=False,
max_learners=0,
contract_start=None,
contract_end=None,
live=False,
description=(
"System contract. Retired course runs are parked here so they "
"stay hidden but keep their courseware IDs. Never add programs "
"or learners to this contract."
),
)
org.add_child(instance=contract)
contract.refresh_from_db()
log.info("Created retirement holding contract %s", contract)

return contract


def check_retirement_contract_collision(run: CourseRun, contract: ContractPage) -> None:
"""
Check that moving the run into the holding contract won't break a constraint.

``CourseRun`` has two unique constraints that include ``b2b_contract`` with
``nulls_distinct=False`` - ``unique_primary_language_per_group`` and
``unique_language_per_group``. Collisions are unlikely in practice because
the B2B run tag embeds the source contract ID and year, but an
``IntegrityError`` mid-command is a much worse outcome than a clear refusal.

Args:
run (CourseRun): the run being moved.
contract (ContractPage): the holding contract.
Raises:
RetirementContractCollisionError: if a conflicting run is already parked.
"""

siblings = CourseRun.all_objects.filter(
course=run.course,
run_tag=run.run_tag,
is_source_run=run.is_source_run,
b2b_contract=contract,
).exclude(pk=run.pk)

if (
run.language
and siblings.filter(
language=run.language,
variant_length=run.variant_length,
variant_industry=run.variant_industry,
).exists()
):
msg = (
f"A run for {run.course.readable_id} with run tag '{run.run_tag}', "
f"language '{run.language}' and variant "
f"'{run.variant_length}/{run.variant_industry}' is already parked in "
f"{contract}. Rename the run tag or clear the existing one first."
)
raise RetirementContractCollisionError(msg)

if run.is_primary_language and siblings.filter(is_primary_language=True).exists():
msg = (
f"A primary-language run for {run.course.readable_id} with run tag "
f"'{run.run_tag}' is already parked in {contract}. Rename the run tag "
"or clear the existing one first."
)
raise RetirementContractCollisionError(msg)


def move_run_to_retirement_contract(run: CourseRun) -> ContractPage:
"""
Move a course run into the holding contract.

Args:
run (CourseRun): the run to park.
Returns:
ContractPage: the holding contract the run was moved to.
Raises:
RetirementContractCollisionError: if a conflicting run is already parked.
"""

contract = get_or_create_retirement_contract()

if run.b2b_contract_id == contract.id:
return contract

check_retirement_contract_collision(run, contract)

run.b2b_contract = contract
run.save()

log.info("Moved course run %s to %s", run.courseware_id, contract)

return contract


@transaction.atomic
def create_contract_run_key(
source_course: CourseRun, contract: ContractPage, *, org_prefix: str | None = None
Expand Down
20 changes: 16 additions & 4 deletions b2b/commands_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ def _create_run_with_product_and_discount(contract, *, with_enrollment=False):
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."""

mocker.patch("b2b.management.commands.b2b_courseware.update_edx_course")
# b2b_courseware pushes dates through courses.retirement now, shared with
# retire_courserun. Patch the edX boundary rather than the helper, so the
# payload-building logic still runs under test.
mocker.patch("courses.retirement.update_edx_course")

contract = ContractPageFactory.create()
run, product, discount = _create_run_with_product_and_discount(
Expand Down Expand Up @@ -69,7 +72,10 @@ def test_b2b_courseware_remove_run_with_enrollments_keeps_contract_and_deactivat
):
"""Removing a run with enrollments should keep contract link but deactivate run/products/codes."""

mocker.patch("b2b.management.commands.b2b_courseware.update_edx_course")
# b2b_courseware pushes dates through courses.retirement now, shared with
# retire_courserun. Patch the edX boundary rather than the helper, so the
# payload-building logic still runs under test.
mocker.patch("courses.retirement.update_edx_course")

contract = ContractPageFactory.create()
run, product, discount = _create_run_with_product_and_discount(
Expand Down Expand Up @@ -105,7 +111,10 @@ def test_b2b_courseware_remove_run_does_not_delete_used_discount_order_redemptio
):
"""Discounts that have been used for an order should not be deleted."""

mocker.patch("b2b.management.commands.b2b_courseware.update_edx_course")
# b2b_courseware pushes dates through courses.retirement now, shared with
# retire_courserun. Patch the edX boundary rather than the helper, so the
# payload-building logic still runs under test.
mocker.patch("courses.retirement.update_edx_course")

contract = ContractPageFactory.create()
run, product, discount = _create_run_with_product_and_discount(
Expand All @@ -128,7 +137,10 @@ def test_b2b_courseware_remove_run_does_not_delete_used_discount_contract_attach
):
"""Discounts that have been used to attach a user to a contract should not be deleted."""

mocker.patch("b2b.management.commands.b2b_courseware.update_edx_course")
# b2b_courseware pushes dates through courses.retirement now, shared with
# retire_courserun. Patch the edX boundary rather than the helper, so the
# payload-building logic still runs under test.
mocker.patch("courses.retirement.update_edx_course")

contract = ContractPageFactory.create()
run, product, discount = _create_run_with_product_and_discount(
Expand Down
9 changes: 9 additions & 0 deletions b2b/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,12 @@
B2B_RUN_TAG_FORMAT = "{run_idx}T{contract_id}C{year}"

ORG_KEY_MAX_LENGTH = 30

# The holding org/contract that retired course runs get moved into. Runs parked
# here are hidden from every catalog path because the contract is inactive and
# has no members, but the CourseRun row survives - which matters, because
# create_contract_run_key() derives its run index from existing courseware IDs,
# so deleting a retired run risks minting a duplicate courseware ID later.
RETIREMENT_ORG_KEY = "RETIRED"
RETIREMENT_ORG_NAME = "Retired Runs"
RETIREMENT_CONTRACT_NAME = "Retired Runs Holding Contract"
62 changes: 23 additions & 39 deletions b2b/management/commands/b2b_courseware.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import logging
from argparse import RawTextHelpFormatter

from django.contrib.contenttypes.models import ContentType
from django.core.management import BaseCommand, CommandError
from mitol.common.utils.datetime import now_in_utc
from opaque_keys import InvalidKeyError
Expand All @@ -18,8 +17,12 @@
from courses.api import resolve_courseware_object_from_id
from courses.constants import UAI_COURSEWARE_ID_PREFIX
from courses.models import CourseRun, CourseRunEnrollment
from ecommerce.models import Discount, DiscountProduct, Product
from openedx.api import update_edx_course
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__)

Expand Down Expand Up @@ -457,23 +460,13 @@ def handle_remove(self, contract, coursewares, **kwargs): # noqa: C901

courseware.save()

# Deactivate products for this run
# Use all_objects so we can find products regardless of
# their current is_active state, and evaluate to a list so
# subsequent updates don't affect the collection we use
# below when removing discount associations.
content_type = ContentType.objects.get_for_model(CourseRun)
run_products = list(
Product.all_objects.filter(
content_type=content_type,
object_id=courseware.id,
).all()
)

for product in run_products:
if product.is_active:
product.is_active = False
product.save(update_fields=("is_active",))
# 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(
Expand All @@ -497,26 +490,17 @@ def handle_remove(self, contract, coursewares, **kwargs): # noqa: C901

# 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:
pacing_type = (
"self_paced" if courseware.is_self_paced else "instructor_paced"
)

course_params = [
courseware.courseware_id,
courseware.title,
pacing_type,
]

if courseware.start_date and courseware.end_date:
course_params.append(courseware.start_date)
course_params.append(courseware.end_date)

if courseware.enrollment_start and courseware.enrollment_end:
course_params.append(courseware.enrollment_start)
course_params.append(courseware.enrollment_end)

update_edx_course(*course_params)
push_run_dates_to_edx(courseware)
except Exception:
log.exception(
"Failed to update enrollment end date on edX for %s",
Expand Down
Loading
Loading