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 0.140.1
---------------

- Fix unbound query issue with program enrollments (#3366)
- Fix performance for enrollments API w/ 0 results (#3360)
- Add command to re-run an existing course run (#3352)
- Adding prefetch for products in courses api (#3331)

Version 0.139.4 (Released March 05, 2026)
---------------

Expand Down
21 changes: 13 additions & 8 deletions b2b/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
)
from cms.api import get_home_page
from courses.constants import UAI_COURSEWARE_ID_PREFIX
from courses.models import Course, CourseRun, Department
from courses.models import Course, CourseRun, Department, EnrollmentMode
from ecommerce.constants import (
DISCOUNT_TYPE_FIXED_PRICE,
PAYMENT_TYPE_SALES,
Expand All @@ -50,6 +50,7 @@
)
from main import constants as main_constants
from main.utils import date_to_datetime
from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE
from openedx.tasks import clone_courserun

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -274,12 +275,10 @@ def create_contract_run( # noqa: PLR0913
the current year, and the contract ID. This means that the source course will
have runs that have readable IDs that do not match the course ID.

The course key is generated according to a set algorithm. The new course key
will have the organization part set to "UAI_orgkey" and the run tag set to
"year_Cid" where orgkey is the organization key (set in the organization
record), year is the current year, and id is the ID of the contract. For more
information on the key format, see this discussion post:
https://github.com/mitodl/hq/discussions/7525
The course key is generated according to a set algorithm, which is implemented
in "create_course_run_key". Generally these now look like regular semester
run keys, but with extra data in them. For more information on the key format,
see this discussion post: https://github.com/mitodl/hq/discussions/7525

A product will be created for the new contract course run, and its price will
either be zero or the amount specified by the contract. (Free courses still
Expand Down Expand Up @@ -362,8 +361,14 @@ def create_contract_run( # noqa: PLR0913
)
course_run.save()

required_modes = EnrollmentMode.objects.filter(
mode_slug__in=[EDX_ENROLLMENT_VERIFIED_MODE, EDX_ENROLLMENT_AUDIT_MODE]
).all()

[course_run.enrollment_modes.add(mode) for mode in required_modes]

if not skip_edx:
clone_courserun.delay(course_run.id, base_id=clone_course_run.courseware_id)
clone_courserun.delay(course_run.id, clone_course_run.courseware_id)

log.debug(
"Created run %s for course %s in contract %s from course run %s",
Expand Down
96 changes: 95 additions & 1 deletion courses/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import logging
import re
from collections import namedtuple
from datetime import timedelta
from datetime import datetime, timedelta
from decimal import Decimal
from traceback import format_exc
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -74,6 +74,7 @@
get_edx_api_course_list_client,
get_edx_course_modes,
get_edx_grades_with_users,
process_course_run_clone,
unenroll_edx_course_run,
)
from openedx.constants import (
Expand Down Expand Up @@ -1594,3 +1595,96 @@ def create_verifiable_credential(certificate: BaseCertificate, *, raise_on_error
)
if raise_on_error:
raise


def rerun_course_run( # noqa: PLR0913
base_run: CourseRun,
run_tag: str,
*,
courseware_id: str | None = None,
organization: str | None = None,
title: str | None = None,
start_date: datetime | None = None,
end_date: datetime | None = None,
enrollment_start: datetime | None = None,
enrollment_end: datetime | None = None,
is_self_paced: bool | None = None,
live: bool | None = None,
enrollment_modes: list[EnrollmentMode] | None = None,
) -> CourseRun:
"""
Re-run an existing course run.

Takes the specified base run and re-runs it in edX with the specified run tag.
The resulting run must not exist in edX.

By default, the created run will share all the same parameters as the base
run, other than the run tag.

When specifying start and end dates, be aware that course start and end must
be set to set enrollment start and end, and the enrollment start and end dates
must be before or equal to the course start and end dates respectively.

The organization option has nothing to do with B2B organizations. This is the
first part of the courseware ID after the `course-v1:` part.

Specifying a custom courseware ID will override the run tag and organization
settings passed in.

Args:
- base_run (CourseRun): the course to re-run
- run_tag (str): the new run tag to use
Keyword Args:
- courseware_id (str): new courseware/readable ID to use
- organization (str): new organization to use
- title (str): new course title
- start_date (DateTime): course start date
- end_date (DateTime): course end date
- enrollment_start (DateTime): enrollment start date
- enrollment_end (DateTime): enrollment end date
- is_self_paced (bool): course is self-paced
- live (bool): course is live (in MITx Online)
- enrollment_modes (list[EnrollmentMode]): modes to add to the new run
Returns:
- CourseRun, the created run
"""

if courseware_id:
target_run_id = courseware_id
else:
base_key = CourseKey.from_string(base_run.courseware_id)

base_key = base_key.replace(run=run_tag)
if organization:
base_key = base_key.replace(org=organization)

target_run_id = str(base_key)

with transaction.atomic():
new_run = CourseRun.objects.create(
course=base_run.course,
title=title if title else base_run.title,
courseware_id=target_run_id,
run_tag=run_tag,
start_date=start_date if start_date else base_run.start_date,
end_date=end_date if end_date else base_run.end_date,
enrollment_start=enrollment_start
if enrollment_start
else base_run.enrollment_start,
enrollment_end=enrollment_end
if enrollment_end
else base_run.enrollment_end,
is_self_paced=is_self_paced
if is_self_paced is not None
else base_run.is_self_paced,
live=live if live is not None else base_run.live,
)

for mode in (
enrollment_modes if enrollment_modes else base_run.enrollment_modes.all()
):
new_run.enrollment_modes.add(mode)

process_course_run_clone(new_run, base_run.courseware_id)

return new_run
17 changes: 17 additions & 0 deletions courses/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
CourseRunGrade,
CoursesTopic,
Department,
EnrollmentMode,
LearnerProgramRecordShare,
PartnerSchool,
Program,
Expand All @@ -32,6 +33,7 @@
ProgramRequirementNodeType,
ProgramRun,
)
from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE
from users.factories import UserFactory

FAKE = faker.Factory.create()
Expand All @@ -41,6 +43,10 @@
dept["slug"]: dept["name"]
for dept in json.loads(Path("courses/test_data/departments.json").read_text())
}
MODES = [
EDX_ENROLLMENT_AUDIT_MODE,
EDX_ENROLLMENT_VERIFIED_MODE,
]


class DepartmentFactory(DjangoModelFactory):
Expand All @@ -53,6 +59,17 @@ class Meta:
django_get_or_create = ("slug",)


class EnrollmentModeFactory(DjangoModelFactory):
"""Factory for EnrollmentModes."""

mode_slug = factory.Iterator(MODES)
mode_display_name = factory.LazyAttribute(lambda emode: emode.mode_slug)

class Meta:
model = EnrollmentMode
django_get_or_create = ("mode_slug",)


class ProgramFactory(DjangoModelFactory):
"""Factory for Programs"""

Expand Down
162 changes: 162 additions & 0 deletions courses/management/commands/rerun_courserun.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""Re-run a course."""

import logging
from argparse import RawTextHelpFormatter
from decimal import Decimal

import reversion
from django.core.management import BaseCommand, CommandError
from opaque_keys.edx.keys import CourseKey

from courses.api import rerun_course_run, resolve_courseware_object_from_id
from ecommerce.models import Product

log = logging.getLogger(__name__)


class Command(BaseCommand):
"""Re-run a course."""

help = """Re-run an existing course or course run.

Re-running a course will find the source course run for the specified course and then request a re-run of it in edX, with the specified run tag and any optional settings. A record in MITx Online will be created for the new run. The course must have a source run, however; if there's not one, you will get an error message.

Re-running a specific course run will request a re-run of the specified run from edX, regardless of whether it's a source run, with the specified run tag and any optional settings.

In either case, the command will try to create a new run. If it exists in edX already, then this will fail. If you want to pull in a run that exists, you probably want the import_courserun command.
"""

def add_arguments(self, parser):
"""Add command line arguments."""

parser.formatter_class = RawTextHelpFormatter

parser.add_argument(
"course",
help="The course or course run (readable ID or numeric ID) to re-run. If specifying a course, the course must have a source run set up.",
type=str,
)

parser.add_argument(
"--run-tag", type=str, help="The run tag to use for the new run."
)

parser.add_argument(
"--organization",
"--org",
type=str,
help='The organization key to use (the first bit after "course-v1:"). Optional; you probably don\'t need to change this.',
)

parser.add_argument(
"--change-course-org",
action="store_true",
help="If set, change the upstream course's org key to what's set for --organization. Will not change existing runs; requires --organization.",
)

parser.add_argument(
"--keep-product",
action="store_true",
help="If set, create a new product for the new run that mirrors the prior run's product. Overrides --price.",
)

parser.add_argument(
"--price",
type=Decimal,
help="If set, create a new product for the new run with the specified price.",
)

def handle(self, *args, **kwargs): # noqa: ARG002
"""Create the re-run according to the options passed."""

course_opt = kwargs.pop("course", None)

run_to_clone = resolve_courseware_object_from_id(course_opt)

if not run_to_clone:
msg = f"Course/run {course_opt} not found."
raise CommandError(msg)

if not run_to_clone.is_run:
source = run_to_clone.courseruns.filter(is_source_run=True)

if source.count() > 1:
msg = f"Course {course_opt} has more than one source run"
raise CommandError(msg)

if source.count() < 1:
msg = f"Course {course_opt} has no source run"
raise CommandError(msg)

run_to_clone = source.first()

run_tag = kwargs.pop("run_tag", None)
org = kwargs.pop("organization", None)

if not run_tag:
msg = "Run tag is required."
raise CommandError(msg)

# next steps for this:
# - look through create_contract_run and either adapt or something to create the run
# - make new key for the new run
# - grab products and recreate where necessary
# - update upstream course where necessary

self.stdout.write(f"Rerunning {run_to_clone.courseware_id}...")

new_course_run = rerun_course_run(run_to_clone, run_tag, organization=org)

self.stdout.write(self.style.SUCCESS(f"Created new run {new_course_run}"))

if org and kwargs.pop("change_course_org", False):
self.stdout.write(
f"Updating the org key on course {run_to_clone.course} to {org}..."
)

course_key = CourseKey.from_string(
f"{run_to_clone.course.readable_id}+RunTag"
)
course_key = course_key.replace(org=org)

run_to_clone.course.readable_id = (
f"{course_key.CANONICAL_NAMESPACE}:{course_key.org}+{course_key.course}"
)
run_to_clone.course.save()

self.stdout.write(
self.style.SUCCESS(
f"Updated course key to {run_to_clone.course.readable_id}"
)
)

keeping_product = kwargs.pop("keep_product", False)

if keeping_product:
self.stdout.write(
f"Copying products for {run_to_clone} to {new_course_run}"
)

with reversion.create_revision():
for original_product in run_to_clone.products.all():
new_product = Product.objects.create(
purchasable_object=new_course_run,
price=original_product.price,
is_active=original_product.is_active,
description=new_course_run.courseware_id,
)
self.stdout.write(f"Created product {new_product}")

price = kwargs.pop("price", None)

if price and not keeping_product:
self.stdout.write(f"Creating product for {new_course_run}")

with reversion.create_revision():
new_product = Product.objects.create(
purchasable_object=new_course_run,
price=price,
is_active=True,
description=new_course_run.courseware_id,
)
self.stdout.write(f"Created product {new_product}")
Loading