Skip to content

Commit 76f7b4d

Browse files
authored
Fix ordering for mitxonline programs (#3360)
1 parent a493f4e commit 76f7b4d

5 files changed

Lines changed: 499 additions & 58 deletions

File tree

learning_resources/etl/loaders.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from django.contrib.auth import get_user_model
99
from django.core.cache import caches
1010
from django.db import transaction
11-
from django.db.models import Q
11+
from django.db.models import Max, Q
1212

1313
from learning_resources.constants import (
1414
CONTENT_TYPE_PAGE,
@@ -657,6 +657,15 @@ class ProgramLoadResult(NamedTuple):
657657
child_programs_data: list[dict]
658658

659659

660+
class LoadedProgramCourse(NamedTuple):
661+
"""A loaded course paired with the explicit child position to assign,
662+
or None to fall back to the sequential index in load_program.
663+
"""
664+
665+
position: int | None
666+
resource: LearningResource
667+
668+
660669
def load_program(
661670
program_data: dict,
662671
blocklist: list[str],
@@ -695,7 +704,6 @@ def load_program(
695704
program_data.setdefault("delivery", [LearningResourceDelivery.online.name])
696705
runs_data = program_data.get("runs", [])
697706

698-
course_resources = []
699707
with transaction.atomic():
700708
learning_resource, created = upsert_course_or_program(
701709
program_data, [], [], LearningResourceType.program.name
@@ -729,16 +737,22 @@ def load_program(
729737

730738
load_run_dependent_values(learning_resource)
731739

740+
loaded_courses: list[LoadedProgramCourse] = []
732741
for course_data in courses_data:
733742
# skip courses that don't define a readable_id
734743
if not course_data.get("readable_id", None):
735744
continue
736745

746+
explicit_position = course_data.pop("position", None)
737747
course_resource = load_course(
738748
course_data, blocklist, duplicates, config=config.courses
739749
)
740750
if course_resource:
741-
course_resources.append(course_resource)
751+
loaded_courses.append(
752+
LoadedProgramCourse(
753+
position=explicit_position, resource=course_resource
754+
)
755+
)
742756
# Replace all children with position-ordered course relationships.
743757
# Pass 2 in load_programs() will re-create child-program
744758
# relationships after all programs exist.
@@ -747,11 +761,13 @@ def load_program(
747761
[
748762
LearningResourceRelationship(
749763
parent=learning_resource,
750-
child=course_resource,
764+
child=loaded.resource,
751765
relation_type=LearningResourceRelationTypes.PROGRAM_COURSES,
752-
position=position,
766+
position=(
767+
loaded.position if loaded.position is not None else fallback_idx
768+
),
753769
)
754-
for position, course_resource in enumerate(course_resources)
770+
for fallback_idx, loaded in enumerate(loaded_courses)
755771
]
756772
)
757773

@@ -786,7 +802,20 @@ def _create_child_program_relationships(
786802
)
787803
}
788804

789-
next_position = parent_resource.children.count()
805+
existing_max = parent_resource.children.aggregate(Max("position")).get(
806+
"position__max"
807+
)
808+
explicit_max = max(
809+
(
810+
cpd["position"]
811+
for cpd in child_programs_data
812+
if cpd.get("position") is not None
813+
),
814+
default=-1,
815+
)
816+
next_position = (
817+
max(existing_max if existing_max is not None else -1, explicit_max) + 1
818+
)
790819
kept_child_ids = set()
791820
for child_program_data in child_programs_data:
792821
readable_id = child_program_data.get("readable_id")
@@ -811,16 +840,21 @@ def _create_child_program_relationships(
811840
if child_program_data.get("display_mode") == "course"
812841
else LearningResourceRelationTypes.PROGRAM_PROGRAMS
813842
)
843+
explicit_position = child_program_data.get("position")
844+
if explicit_position is not None:
845+
position = explicit_position
846+
else:
847+
position = next_position
848+
next_position += 1
814849
_, _created = LearningResourceRelationship.objects.update_or_create(
815850
parent=parent_resource,
816851
child=child_resource,
817852
defaults={
818853
"relation_type": relation_type,
819-
"position": next_position,
854+
"position": position,
820855
},
821856
)
822857
kept_child_ids.add(child_resource.id)
823-
next_position += 1
824858

825859
# Remove stale child-program relationships no longer in req_tree.
826860
# Only delete relationships whose child is a program resource;

learning_resources/etl/loaders_test.py

Lines changed: 174 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,28 +1406,37 @@ def test_load_programs(mocker, mock_blocklist, mock_duplicates):
14061406
mock_duplicates.assert_called_once_with("mitx")
14071407

14081408

1409-
def test_load_programs_with_child_program_relationships(mocker, settings):
1410-
"""End-to-end loader test for mixed child course/program relationships."""
1409+
@pytest.fixture
1410+
def mitxonline_program_children_fixture(mocker, settings):
1411+
"""
1412+
Set up topics/platform, patch _fetch_courses_by_ids, and return the parsed
1413+
mitxonline_program_children_loader JSON fixture.
1414+
"""
14111415
set_up_topics(is_mitx=True)
14121416
LearningResourcePlatformFactory.create(code=PlatformType.mitxonline.name)
14131417

14141418
with open("./test_json/mitxonline_program_children_loader.json") as f: # noqa: PTH123
14151419
fixture_data = json.load(f)
14161420

1417-
def _mock_fetch_courses_by_ids(course_ids):
1418-
return [
1421+
mocker.patch(
1422+
"learning_resources.etl.mitxonline._fetch_courses_by_ids",
1423+
side_effect=lambda course_ids: [
14191424
course
14201425
for course in fixture_data["courses"]
14211426
if course["id"] in set(course_ids)
1422-
]
1423-
1424-
mocker.patch(
1425-
"learning_resources.etl.mitxonline._fetch_courses_by_ids",
1426-
side_effect=_mock_fetch_courses_by_ids,
1427+
],
14271428
)
14281429
settings.MITX_ONLINE_BASE_URL = "https://mitxonline.mit.edu"
1430+
return fixture_data
1431+
14291432

1430-
transformed_programs = list(transform_programs(fixture_data["programs"]))
1433+
def test_load_programs_with_child_program_relationships(
1434+
mitxonline_program_children_fixture,
1435+
):
1436+
"""End-to-end loader test for mixed child course/program relationships."""
1437+
transformed_programs = list(
1438+
transform_programs(mitxonline_program_children_fixture["programs"])
1439+
)
14311440
load_programs(
14321441
ETLSource.mitxonline.name,
14331442
transformed_programs,
@@ -1470,43 +1479,177 @@ def _mock_fetch_courses_by_ids(course_ids):
14701479
assert child_program_positions == sorted(set(child_program_positions))
14711480

14721481

1473-
def test_load_programs_idempotent_child_relationships(mocker, settings):
1474-
"""Running load_programs twice should not duplicate child program relationships."""
1475-
set_up_topics(is_mitx=True)
1476-
LearningResourcePlatformFactory.create(code=PlatformType.mitxonline.name)
1477-
1478-
with open("./test_json/mitxonline_program_children_loader.json") as f: # noqa: PTH123
1479-
fixture_data = json.load(f)
1482+
def test_load_program_honors_explicit_course_position(mock_upsert_tasks):
1483+
"""An explicit `position` on each course_data entry should be honored, preserving gaps for pass 2 to fill."""
1484+
platform = LearningResourcePlatformFactory.create()
1485+
program = ProgramFactory.build(courses=[], platform=platform.code)
1486+
courses = CourseFactory.create_batch(3, platform=platform.code)
14801487

1481-
def _mock_fetch_courses_by_ids(course_ids):
1482-
return [
1483-
course
1484-
for course in fixture_data["courses"]
1485-
if course["id"] in set(course_ids)
1486-
]
1488+
# Simulate gaps where pass 2 would later insert display_mode="course"
1489+
# child programs at positions 0 and 2.
1490+
program_courses = [
1491+
{
1492+
"readable_id": courses[0].learning_resource.readable_id,
1493+
"platform": platform.code,
1494+
"availability": courses[0].learning_resource.availability,
1495+
"position": 1,
1496+
},
1497+
{
1498+
"readable_id": courses[1].learning_resource.readable_id,
1499+
"platform": platform.code,
1500+
"availability": courses[1].learning_resource.availability,
1501+
"position": 3,
1502+
},
1503+
{
1504+
"readable_id": courses[2].learning_resource.readable_id,
1505+
"platform": platform.code,
1506+
"availability": courses[2].learning_resource.availability,
1507+
"position": 4,
1508+
},
1509+
]
14871510

1488-
mocker.patch(
1489-
"learning_resources.etl.mitxonline._fetch_courses_by_ids",
1490-
side_effect=_mock_fetch_courses_by_ids,
1511+
result, _, _ = load_program(
1512+
{
1513+
"platform": platform.code,
1514+
"readable_id": program.learning_resource.readable_id,
1515+
"professional": False,
1516+
"title": program.learning_resource.title,
1517+
"url": program.learning_resource.url,
1518+
"image": {"url": program.learning_resource.image.url},
1519+
"published": True,
1520+
"runs": [
1521+
{
1522+
"run_id": program.learning_resource.readable_id,
1523+
"start_date": "2024-01-01T00:00:00Z",
1524+
"enrollment_start": "2023-12-01T00:00:00Z",
1525+
"end_date": "2024-06-01T00:00:00Z",
1526+
}
1527+
],
1528+
"availability": program.learning_resource.availability,
1529+
"courses": program_courses,
1530+
},
1531+
[],
1532+
[],
14911533
)
1492-
settings.MITX_ONLINE_BASE_URL = "https://mitxonline.mit.edu"
14931534

1494-
transformed = list(transform_programs(fixture_data["programs"]))
1535+
positions_by_readable = {
1536+
rel.child.readable_id: rel.position for rel in result.children.all()
1537+
}
1538+
assert positions_by_readable == {
1539+
courses[0].learning_resource.readable_id: 1,
1540+
courses[1].learning_resource.readable_id: 3,
1541+
courses[2].learning_resource.readable_id: 4,
1542+
}
1543+
14951544

1496-
# Run twice
1545+
def test_load_programs_orders_courses_by_req_tree_with_display_mode_course_children(
1546+
mitxonline_program_children_fixture,
1547+
):
1548+
"""
1549+
PROGRAM_COURSES children should land in req_tree order, interleaving
1550+
display_mode="course" sub-programs among regular courses.
1551+
"""
1552+
transformed = list(
1553+
transform_programs(mitxonline_program_children_fixture["programs"])
1554+
)
14971555
load_programs(
14981556
ETLSource.mitxonline.name,
14991557
transformed,
15001558
config=ProgramLoaderConfig(prune=False),
15011559
)
1502-
# Re-transform since transform_programs pops keys
1503-
transformed = list(transform_programs(fixture_data["programs"]))
1560+
1561+
parent_resource = LearningResource.objects.get(readable_id="mitx-parent-program")
1562+
program_courses = (
1563+
parent_resource.children.filter(
1564+
relation_type=LearningResourceRelationTypes.PROGRAM_COURSES.value
1565+
)
1566+
.order_by("position")
1567+
.values_list("child__readable_id", flat=True)
1568+
)
1569+
assert list(program_courses) == [
1570+
"course-10",
1571+
"course-70",
1572+
"mitx-child-program-displayed-as-course",
1573+
]
1574+
1575+
1576+
def test_load_programs_appends_program_program_children_after_courses(
1577+
mitxonline_program_children_fixture,
1578+
):
1579+
"""PROGRAM_PROGRAMS children must sit at positions strictly greater than every PROGRAM_COURSES position so pass 2's display_mode="course" children don't collide with them."""
1580+
transformed = list(
1581+
transform_programs(mitxonline_program_children_fixture["programs"])
1582+
)
15041583
load_programs(
15051584
ETLSource.mitxonline.name,
15061585
transformed,
15071586
config=ProgramLoaderConfig(prune=False),
15081587
)
15091588

1589+
parent_resource = LearningResource.objects.get(readable_id="mitx-parent-program")
1590+
program_courses_max = max(
1591+
parent_resource.children.filter(
1592+
relation_type=LearningResourceRelationTypes.PROGRAM_COURSES.value
1593+
).values_list("position", flat=True)
1594+
)
1595+
program_programs_positions = list(
1596+
parent_resource.children.filter(
1597+
relation_type=LearningResourceRelationTypes.PROGRAM_PROGRAMS.value
1598+
).values_list("position", flat=True)
1599+
)
1600+
assert program_programs_positions, "expected at least one PROGRAM_PROGRAMS child"
1601+
assert min(program_programs_positions) > program_courses_max
1602+
1603+
1604+
def test_create_child_program_relationships_uses_existing_max_position():
1605+
"""New child without explicit position should land at existing_max + 1."""
1606+
platform = LearningResourcePlatformFactory.create(code=PlatformType.mitxonline.name)
1607+
parent = ProgramFactory.create(
1608+
platform=platform.code,
1609+
learning_resource__readable_id="parent-with-existing-children",
1610+
).learning_resource
1611+
existing_child = CourseFactory.create(platform=platform.code).learning_resource
1612+
LearningResourceRelationship.objects.create(
1613+
parent=parent,
1614+
child=existing_child,
1615+
relation_type=LearningResourceRelationTypes.PROGRAM_COURSES.value,
1616+
position=5,
1617+
)
1618+
new_child_program = ProgramFactory.create(
1619+
platform=platform.code,
1620+
learning_resource__readable_id="new-child-program",
1621+
).learning_resource
1622+
1623+
loaders._create_child_program_relationships( # noqa: SLF001
1624+
[
1625+
(
1626+
parent,
1627+
[{"readable_id": new_child_program.readable_id}],
1628+
)
1629+
]
1630+
)
1631+
1632+
new_rel = LearningResourceRelationship.objects.get(
1633+
parent=parent, child=new_child_program
1634+
)
1635+
assert new_rel.position == 6
1636+
1637+
1638+
def test_load_programs_idempotent_child_relationships(
1639+
mitxonline_program_children_fixture,
1640+
):
1641+
"""Running load_programs twice should not duplicate child program relationships."""
1642+
for _ in range(2):
1643+
# Re-transform each iteration since transform_programs pops keys.
1644+
transformed = list(
1645+
transform_programs(mitxonline_program_children_fixture["programs"])
1646+
)
1647+
load_programs(
1648+
ETLSource.mitxonline.name,
1649+
transformed,
1650+
config=ProgramLoaderConfig(prune=False),
1651+
)
1652+
15101653
parent_resource = LearningResource.objects.get(readable_id="mitx-parent-program")
15111654
# Should have exactly 2 child-program relationships, not 4
15121655
program_child_count = parent_resource.children.filter(

0 commit comments

Comments
 (0)