Skip to content

Commit dfb2d5c

Browse files
authored
Add import-to-contract-courserun workflow (#3057)
1 parent c643e07 commit dfb2d5c

File tree

6 files changed

+435
-164
lines changed

6 files changed

+435
-164
lines changed

b2b/api.py

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
)
3434
from cms.api import get_home_page
3535
from courses.constants import UAI_COURSEWARE_ID_PREFIX
36-
from courses.models import Course, CourseRun
36+
from courses.models import Course, CourseRun, Department
3737
from ecommerce.constants import (
3838
DISCOUNT_TYPE_FIXED_PRICE,
3939
PAYMENT_TYPE_SALES,
@@ -76,27 +76,111 @@ def ensure_b2b_organization_index() -> OrganizationIndexPage:
7676
return org_index_page
7777

7878

79-
def import_and_create_contract_run(contract: ContractPage, course_run_id: str):
79+
def import_and_create_contract_run( # noqa: PLR0913
80+
contract: ContractPage,
81+
course_run_id: str,
82+
departments: list[Department | str],
83+
*,
84+
live: bool = True,
85+
use_specific_course: str | None = None,
86+
create_depts: bool = False,
87+
block_countries: list[str] | None = None,
88+
create_cms_page: bool = True,
89+
publish_cms_page: bool = False,
90+
include_in_learn_catalog: bool = False,
91+
ingest_content_files_for_ai: bool = True,
92+
skip_edx: bool = False,
93+
require_designated_source_run: bool = False,
94+
):
8095
"""
8196
Create a contract run for the given course, importing it from edX if necessary.
8297
83-
Check for the specified course run. If it exists, create the contract run in
84-
the usual fashion. If it doesn't, check for it in edX and import it into
85-
MITx Online first, then create the contract run.
86-
87-
If the specified run is imported, it will have the "is_source_run" flag set.
98+
Wraps the create_contract_run function in some logic to check for the specified
99+
course, and then import it. If the course is imported from edX, this will set
100+
a few flags to reasonable values and call the import_courserun_from_edx
101+
function, then call create_contract_run using the imported run as the source
102+
course. If the course is already in the system, this just calls
103+
create_contract_run with the source run and the use_specific_course flag set
104+
to force it to use the specified run.
105+
106+
The kwargs combine the set from import_courserun_from_edx and
107+
create_contract_run. Some of the defaults are different, owning to the use case
108+
for this function. Those differences are:
109+
- You must pass in a list of departments.
110+
- By default, the live flag is set to True.
111+
- Learn AI ingestion will be set to True.
112+
- require_designated_source_run is set to False. (We assume you want the
113+
specified course, regardless of the source flag.)
114+
115+
In addition, if the course is imported, the corresponding run that is created
116+
for it will have the is_source_run flag set to True. This cannot be
117+
overridden. By using this function, we are assuming you really want the
118+
specified run as the source.
119+
120+
This won't pass a price to the import call, so the imported run won't have a
121+
product. This _will_ set a price on the contract run, because the contract
122+
run must have a price, even if that price is zero. The price will be whatever
123+
is specified in the contract (or zero).
124+
125+
Calling this function will result in a contract course run being created. If
126+
the course is imported, then you will end up with a course with two runs:
127+
the one that was imported and the new contract run.
88128
89129
Args:
90130
contract (ContractPage): The contract to create the run for.
91131
course_run_id (str): The readable ID for the source course run.
92-
Keyword Args:
132+
departments (list[Department | str]): Departments to add to the new course.
133+
Keyword Args (passed to import_courserun_from_edx and create_contract_run):
134+
live (bool): Make the new course run live, and the course if one is created.
135+
use_specific_course (str|None): Readable ID of a specific course to use as the base course.
136+
create_depts (bool): Create departments.
137+
block_countries (list[str] | None): Country codes to add to the block list for the course.
138+
create_cms_page (bool): Create a CMS page for the course. Only applies if a course is being created.
139+
publish_cms_page (bool): Publish the new CMS page. Only takes effect if creating a CMS page.
140+
include_in_learn_catalog (bool): Set the "include_in_learn_catalog" flag on the new page.
141+
ingest_content_files_for_ai (bool): Set the "ingest_content_files_for_ai" flag on the new page.
93142
skip_edx (bool): Don't try to create a course run in edX.
94143
require_designated_source_run (bool): Require a flagged source run.
95144
Returns:
96145
CourseRun: The created CourseRun object.
97146
Product: The created Product object.
98147
"""
99148

149+
run_qs = CourseRun.objects.filter(courseware_id=course_run_id)
150+
151+
if run_qs.exists():
152+
run = run_qs.get()
153+
else:
154+
from courses.api import import_courserun_from_edx
155+
156+
rundata = import_courserun_from_edx(
157+
course_key=course_run_id,
158+
live=live,
159+
use_specific_course=use_specific_course,
160+
departments=departments,
161+
create_depts=create_depts,
162+
block_countries=block_countries,
163+
price=None,
164+
create_cms_page=create_cms_page,
165+
publish_cms_page=publish_cms_page,
166+
include_in_learn_catalog=include_in_learn_catalog,
167+
ingest_content_files_for_ai=ingest_content_files_for_ai,
168+
is_source_run=True,
169+
)
170+
171+
if not rundata:
172+
msg = f"Import and create contract run for {course_run_id} failed - could not import from edX."
173+
raise ValueError(msg)
174+
175+
run = rundata[0]
176+
177+
return create_contract_run(
178+
contract,
179+
run.course,
180+
skip_edx=skip_edx,
181+
require_designated_source_run=require_designated_source_run,
182+
)
183+
100184

101185
def create_contract_run(
102186
contract: ContractPage,

b2b/management/commands/b2b_courseware.py

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from django.core.management import BaseCommand, CommandError
1111

12-
from b2b.api import create_contract_run
12+
from b2b.api import create_contract_run, import_and_create_contract_run
1313
from b2b.models import ContractPage, ContractProgramItem
1414
from courses.api import resolve_courseware_object_from_id
1515
from courses.models import CourseRun
@@ -27,13 +27,17 @@ class Command(BaseCommand):
2727
Specifying courseware: You must specify one courseware item (of any type). You can specify more than one by adding "--also <courseware id>" to the end of the command. You can repeat this as many times as necessary.
2828
2929
To add courseware:
30-
b2b_courseware add [--no-create-runs] [--force] contract courseware [--also courseware] [--also courseware...]
30+
b2b_courseware add [--import <departments>] [--no-create-runs] [--force] <contract> <courseware> [--also <courseware>] [--also <courseware>...]
3131
3232
Example: b2b_courseware add contract-100-101 program-v1:UAI+Fundamentals --also course-v1:UAI_C100+14.314x+2025_C101
3333
34+
Specifying "--import" will attempt to import the course run from edX if it can't be found in MITx Online. A corresponding course and course run will be created in MITx Online, and the run will be flagged as a source run. A new contract run will be created for the contract specified. The "--import" flag is ignored if a course is specified, as edX only has course runs. The "--import" flag is also ignored if a program is specified, as the command won't be able to determine what to import (again, because programs have courses, and edX doesn't have courses).
35+
36+
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.
37+
3438
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.
3539
36-
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.
40+
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.
3741
3842
Specifying a program will iterate through the program's courses and create runs for each. It will also link the program to the contract.
3943
@@ -114,6 +118,12 @@ def add_arguments(self, parser):
114118
dest="force",
115119
action="store_true",
116120
)
121+
add_subparser.add_argument(
122+
"--import",
123+
help="Attempt to import course runs specified into the department(s), if they don't exist in MITx Online.",
124+
dest="can_import",
125+
type=str,
126+
)
117127

118128
remove_subparser = subparsers.add_parser(
119129
"remove",
@@ -128,15 +138,57 @@ def add_arguments(self, parser):
128138

129139
return super().add_arguments(parser)
130140

131-
def handle_add(self, contract, coursewares, **kwargs):
141+
def handle_add(self, contract, coursewares, **kwargs): # noqa: PLR0915, C901
132142
"""Handle the add subcommand."""
133143

134144
create_runs = kwargs.pop("create_runs")
135145
force_associate = kwargs.pop("force")
146+
can_import = kwargs.pop("import")
136147

137148
managed = skipped = 0
138149

150+
if can_import:
151+
# Get the courseware IDs we got passed in that weren't matched to
152+
# system records. We will try to pull these in from edX.
153+
154+
importable_ids = [kwargs.get("courseware", "")]
155+
importable_extras = kwargs.get("additional_courseware")
156+
if importable_extras:
157+
importable_ids.extend(importable_extras)
158+
159+
non_importable_ids = [
160+
courseware.readable_id for courseware in coursewares if courseware
161+
]
162+
163+
for importable_id in importable_ids:
164+
if importable_id in non_importable_ids:
165+
continue
166+
167+
self.stdout.write(f"Attempting to import {importable_id} from edX...")
168+
169+
imported_run, _ = import_and_create_contract_run(
170+
contract=contract,
171+
course_run_id=importable_id,
172+
departments=can_import.split(sep=","),
173+
create_cms_page=True,
174+
)
175+
176+
if not imported_run:
177+
self.stdout.write(
178+
self.style.ERROR(f"Importing {importable_id} failed. Skipping")
179+
)
180+
continue
181+
182+
self.stdout.write(
183+
self.style.SUCCESS(
184+
f"Importing {importable_id} succeeded: {imported_run} created."
185+
)
186+
)
187+
139188
for courseware in coursewares:
189+
if not courseware:
190+
continue
191+
140192
if courseware.is_program:
141193
# If you're specifying a program, we will always make new runs
142194
# since we won't be able to tell which existing ones to use.
@@ -286,8 +338,8 @@ def handle(self, *args, **kwargs): # noqa: ARG002
286338
"""Dispatch the requested task."""
287339

288340
contract_id = kwargs.pop("contract")
289-
courseware_id = kwargs.pop("courseware")
290-
additional_courseware_ids = kwargs.pop("additional_courseware")
341+
courseware_id = kwargs.get("courseware", "")
342+
additional_courseware_ids = kwargs.get("additional_courseware")
291343
subcommand = kwargs.pop("subcommand")
292344

293345
if contract_id.isdecimal():

courses/api.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,6 +1141,7 @@ def import_courserun_from_edx( # noqa: C901, PLR0913
11411141
publish_cms_page: bool = False,
11421142
include_in_learn_catalog: bool = False,
11431143
ingest_content_files_for_ai: bool = False,
1144+
is_source_run: bool = False,
11441145
):
11451146
"""
11461147
Import a course run from edX.
@@ -1182,14 +1183,15 @@ def import_courserun_from_edx( # noqa: C901, PLR0913
11821183
- publish_cms_page (bool): Publish the new CMS page. Only takes effect if creating a CMS page.
11831184
- include_in_learn_catalog (bool): Set the "include_in_learn_catalog" flag on the new page.
11841185
- ingest_content_files_for_ai (bool): Set the "ingest_content_files_for_ai" flag on the new page.
1186+
- is_source_run (bool): Set the "is_source_run" flag on the course run to designate it as a B2B source course.
11851187
Returns:
11861188
tuple of (CourseRun, CoursePage|None, Product|None) - relevant objects for the imported run
11871189
"""
11881190

11891191
if CourseRun.objects.filter(courseware_id=course_key).exists():
11901192
return False
11911193

1192-
processed_course_key = CourseKey(course_key)
1194+
processed_course_key = CourseKey.from_string(course_key)
11931195

11941196
edx_course_detail = get_edx_api_course_detail_client()
11951197

@@ -1198,7 +1200,7 @@ def import_courserun_from_edx( # noqa: C901, PLR0913
11981200
username=settings.OPENEDX_SERVICE_WORKER_USERNAME,
11991201
)
12001202

1201-
processed_run_key = CourseKey(edx_course_run.course_id)
1203+
processed_run_key = CourseKey.from_string(edx_course_run.course_id)
12021204

12031205
if use_specific_course:
12041206
root_course = Course.objects.get(readable_id=use_specific_course)
@@ -1223,11 +1225,12 @@ def import_courserun_from_edx( # noqa: C901, PLR0913
12231225

12241226
for department in departments:
12251227
if isinstance(department, str) and create_depts:
1226-
dept = Department.objects.get_or_create(name=department)
1227-
else:
1228+
dept, _ = Department.objects.get_or_create(name=department)
1229+
dept.save()
1230+
elif isinstance(department, Department):
12281231
dept = department
12291232

1230-
root_course.departments.add(dept)
1233+
root_course.departments.add(dept.id)
12311234

12321235
new_run = CourseRun.objects.create(
12331236
course=root_course,
@@ -1240,14 +1243,15 @@ def import_courserun_from_edx( # noqa: C901, PLR0913
12401243
title=edx_course_run.name,
12411244
live=live,
12421245
is_self_paced=edx_course_run.is_self_paced(),
1246+
is_source_run=is_source_run,
12431247
courseware_url_path=urljoin(
12441248
settings.OPENEDX_COURSE_BASE_URL,
12451249
f"/{edx_course_run.course_id}/course",
12461250
),
12471251
)
12481252

12491253
course_page = None
1250-
if create_cms_page:
1254+
if create_cms_page and not use_specific_course:
12511255
course_page = create_default_courseware_page(
12521256
courseware=new_run.course,
12531257
live=publish_cms_page,

0 commit comments

Comments
 (0)