feat: enhance Course Optimizer to update previous course links via API (#37206)

* feat: API to update previous-run course links

* feat: handle edge cases and update tests for prev-run links API
This commit is contained in:
Devasia Joseph
2025-09-01 19:58:40 +05:30
committed by GitHub
parent 9c90fa0dd1
commit a9bd29ea6e
9 changed files with 1428 additions and 131 deletions

View File

@@ -7,8 +7,13 @@ from opaque_keys.edx.keys import CourseKey
from user_tasks.conf import settings as user_tasks_settings
from user_tasks.models import UserTaskArtifact, UserTaskStatus
from cms.djangoapps.contentstore.tasks import CourseLinkCheckTask, LinkState, extract_content_URLs_from_course
from cms.djangoapps.contentstore.utils import create_course_info_usage_key
from cms.djangoapps.contentstore.tasks import (
CourseLinkCheckTask,
CourseLinkUpdateTask,
LinkState,
extract_content_URLs_from_course
)
from cms.djangoapps.contentstore.utils import create_course_info_usage_key, get_previous_run_course_key
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import get_xblock
from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import usage_key_with_run
from openedx.core.lib.xblock_utils import get_course_update_items
@@ -118,7 +123,13 @@ def generate_broken_links_descriptor(json_content, request_user, course_key):
'url': 'url/to/block',
'brokenLinks: [],
'lockedLinks: [],
'previousRunLinks: []
'previousRunLinks: [
{
'originalLink': 'http://...',
'isUpdated': true,
'updatedLink': 'http://...'
}
]
},
...,
]
@@ -138,7 +149,13 @@ def generate_broken_links_descriptor(json_content, request_user, course_key):
'brokenLinks': [],
'lockedLinks': [],
'externalForbiddenLinks': [],
'previousRunLinks': []
'previousRunLinks': [
{
'originalLink': 'http://...',
'isUpdated': true,
'updatedLink': 'http://...'
}
]
},
...
{
@@ -147,7 +164,13 @@ def generate_broken_links_descriptor(json_content, request_user, course_key):
'brokenLinks': [],
'lockedLinks': [],
'externalForbiddenLinks': [],
'previousRunLinks': []
'previousRunLinks': [
{
'originalLink': 'http://...',
'isUpdated': true,
'updatedLink': 'http://...'
}
]
}
],
'custom_pages': [
@@ -157,7 +180,13 @@ def generate_broken_links_descriptor(json_content, request_user, course_key):
'brokenLinks': [],
'lockedLinks': [],
'externalForbiddenLinks': [],
'previousRunLinks': []
'previousRunLinks': [
{
'originalLink': 'http://...',
'isUpdated': true,
'updatedLink': 'http://...'
}
]
},
...
]
@@ -166,7 +195,7 @@ def generate_broken_links_descriptor(json_content, request_user, course_key):
return _generate_enhanced_links_descriptor(json_content, request_user, course_key)
def _update_node_tree_and_dictionary(block, link, link_state, node_tree, dictionary):
def _update_node_tree_and_dictionary(block, link, link_state, node_tree, dictionary, course_key=None):
"""
Inserts a block into the node tree and add its attributes to the dictionary.
@@ -215,7 +244,7 @@ def _update_node_tree_and_dictionary(block, link, link_state, node_tree, diction
# Traverse the path and build the tree structure
for xblock in path:
xblock_id = xblock.location.block_id
xblock_id = xblock.location
updated_dictionary.setdefault(
xblock_id,
{
@@ -240,7 +269,7 @@ def _update_node_tree_and_dictionary(block, link, link_state, node_tree, diction
elif link_state == LinkState.EXTERNAL_FORBIDDEN:
updated_dictionary[xblock_id].setdefault('external_forbidden_links', []).append(link)
elif link_state == LinkState.PREVIOUS_RUN:
updated_dictionary[xblock_id].setdefault('previous_run_links', []).append(link)
_add_previous_run_link(updated_dictionary, xblock_id, link, course_key)
else:
updated_dictionary[xblock_id].setdefault('broken_links', []).append(link)
@@ -325,11 +354,11 @@ def sort_course_sections(course_key, data):
revision=ModuleStoreEnum.RevisionOption.published_only
)
# Return unchanged data if course_blocks or required keys are missing
if not course_blocks or 'LinkCheckOutput' not in data or 'sections' not in data['LinkCheckOutput']:
return data # Return unchanged data if course_blocks or required keys are missing
sorted_section_ids = [section.location.block_id for section in course_blocks[0].get_children()]
return data
sorted_section_ids = [section.location for section in course_blocks[0].get_children()]
sections_map = {section['id']: section for section in data['LinkCheckOutput']['sections']}
data['LinkCheckOutput']['sections'] = [
sections_map[section_id]
@@ -340,7 +369,7 @@ def sort_course_sections(course_key, data):
return data
def _generate_links_descriptor_for_content(json_content, request_user):
def _generate_links_descriptor_for_content(json_content, request_user, course_key=None):
"""
Creates a content tree of all links in a course and their states
Returns a structure containing all broken links and locked links for a course.
@@ -363,6 +392,7 @@ def _generate_links_descriptor_for_content(json_content, request_user):
link_state=link_state,
node_tree=xblock_node_tree,
dictionary=xblock_dictionary,
course_key=course_key,
)
result = _create_dto_recursive(xblock_node_tree, xblock_dictionary)
@@ -386,7 +416,7 @@ def _generate_enhanced_links_descriptor(json_content, request_user, course_key):
for item in json_content:
block_id, link, *rest = item
if "course_info" in block_id and "updates" in block_id:
if isinstance(block_id, int):
course_updates_links.append(item)
elif "course_info" in block_id and "handouts" in block_id:
handouts_links.append(item)
@@ -396,22 +426,22 @@ def _generate_enhanced_links_descriptor(json_content, request_user, course_key):
content_links.append(item)
try:
main_content = _generate_links_descriptor_for_content(content_links, request_user)
main_content = _generate_links_descriptor_for_content(content_links, request_user, course_key)
except Exception: # pylint: disable=broad-exception-caught
main_content = {"sections": []}
course_updates_data = (
_generate_course_updates_structure(course, course_updates_links)
_generate_enhanced_content_structure(course, course_updates_links, "updates", course_key)
if course_updates_links and course else []
)
handouts_data = (
_generate_handouts_structure(course, handouts_links)
_generate_enhanced_content_structure(course, handouts_links, "handouts", course_key)
if handouts_links and course else []
)
custom_pages_data = (
_generate_custom_pages_structure(course, custom_pages_links)
_generate_enhanced_content_structure(course, custom_pages_links, "custom_pages", course_key)
if custom_pages_links and course else []
)
@@ -421,7 +451,7 @@ def _generate_enhanced_links_descriptor(json_content, request_user, course_key):
return result
def _generate_enhanced_content_structure(course, content_links, content_type):
def _generate_enhanced_content_structure(course, content_links, content_type, course_key=None):
"""
Unified function to generate structure for enhanced content (updates, handouts, custom pages).
@@ -429,24 +459,25 @@ def _generate_enhanced_content_structure(course, content_links, content_type):
course: Course object
content_links: List of link items for this content type
content_type: 'updates', 'handouts', or 'custom_pages'
course_key: Course key to check for link updates (optional)
Returns:
List of content items with categorized links
"""
result = []
try:
if content_type == "custom_pages":
result = _generate_custom_pages_content(course, content_links)
elif content_type == "updates":
result = _generate_course_updates_content(course, content_links)
elif content_type == "handouts":
result = _generate_handouts_content(course, content_links)
return result
except Exception as e: # pylint: disable=broad-exception-caught
return result
generators = {
"custom_pages": _generate_custom_pages_content,
"updates": _generate_course_updates_content,
"handouts": _generate_handouts_content,
}
generator = generators.get(content_type)
if generator:
return generator(course, content_links, course_key)
return []
def _generate_course_updates_content(course, updates_links):
def _generate_course_updates_content(course, updates_links, course_key=None):
"""Generate course updates content with categorized links."""
store = modulestore()
usage_key = create_course_info_usage_key(course, "updates")
@@ -460,23 +491,10 @@ def _generate_course_updates_content(course, updates_links):
if not update_items:
return course_updates
# Create link state mapping
link_state_map = {
item[1]: item[2] if len(item) >= 3 else LinkState.BROKEN
for item in updates_links if len(item) >= 2
}
for update in update_items:
if update.get("status") != "deleted":
update_content = update.get("content", "")
update_links = extract_content_URLs_from_course(update_content) if update_content else []
# Match links with their states
update_link_data = _create_empty_links_data()
for link in update_links:
link_state = link_state_map.get(link)
if link_state is not None:
_categorize_link_by_state(link, link_state, update_link_data)
update_link_data = _process_content_links(update_content, updates_links, course_key)
course_updates.append(
{
@@ -490,7 +508,7 @@ def _generate_course_updates_content(course, updates_links):
return course_updates
def _generate_handouts_content(course, handouts_links):
def _generate_handouts_content(course, handouts_links, course_key=None):
"""Generate handouts content with categorized links."""
store = modulestore()
usage_key = create_course_info_usage_key(course, "handouts")
@@ -504,15 +522,7 @@ def _generate_handouts_content(course, handouts_links):
):
return course_handouts
# Create link state mapping for handouts
link_state_map = {
item[1]: item[2] if len(item) >= 3 else LinkState.BROKEN
for item in handouts_links if len(item) >= 2
}
links_data = _create_empty_links_data()
for link, link_state in link_state_map.items():
_categorize_link_by_state(link, link_state, links_data)
links_data = _process_content_links(handouts_block.data, handouts_links, course_key)
course_handouts = [
{
@@ -525,7 +535,7 @@ def _generate_handouts_content(course, handouts_links):
return course_handouts
def _generate_custom_pages_content(course, custom_pages_links):
def _generate_custom_pages_content(course, custom_pages_links, course_key=None):
"""Generate custom pages content with categorized links."""
custom_pages = []
@@ -539,7 +549,7 @@ def _generate_custom_pages_content(course, custom_pages_links):
block_id, link = item[0], item[1]
link_state = item[2] if len(item) >= 3 else LinkState.BROKEN
links_by_page.setdefault(block_id, _create_empty_links_data())
_categorize_link_by_state(link, link_state, links_by_page[block_id])
_categorize_link_by_state(link, link_state, links_by_page[block_id], course_key)
# Process static tabs and add their pages
for tab in course.tabs:
@@ -555,24 +565,7 @@ def _generate_custom_pages_content(course, custom_pages_links):
return custom_pages
def _generate_course_updates_structure(course, updates_links):
"""Generate structure for course updates."""
return _generate_enhanced_content_structure(course, updates_links, "updates")
def _generate_handouts_structure(course, handouts_links):
"""Generate structure for course handouts."""
return _generate_enhanced_content_structure(course, handouts_links, "handouts")
def _generate_custom_pages_structure(course, custom_pages_links):
"""Generate structure for custom pages (static tabs)."""
return _generate_enhanced_content_structure(
course, custom_pages_links, "custom_pages"
)
def _categorize_link_by_state(link, link_state, links_data):
def _categorize_link_by_state(link, link_state, links_data, course_key=None):
"""
Helper function to categorize a link into the appropriate list based on its state.
@@ -580,6 +573,7 @@ def _categorize_link_by_state(link, link_state, links_data):
link (str): The URL link to categorize
link_state (str): The state of the link (broken, locked, external-forbidden, previous-run)
links_data (dict): Dictionary containing the categorized link lists
course_key: Course key to check for link updates (optional)
"""
state_to_key = {
LinkState.BROKEN: "brokenLinks",
@@ -590,7 +584,11 @@ def _categorize_link_by_state(link, link_state, links_data):
key = state_to_key.get(link_state)
if key:
links_data[key].append(link)
if key == "previousRunLinks":
data = _generate_link_update_info(link, course_key)
links_data[key].append(data)
else:
links_data[key].append(link)
def _create_empty_links_data():
@@ -606,3 +604,267 @@ def _create_empty_links_data():
"externalForbiddenLinks": [],
"previousRunLinks": [],
}
def get_course_link_update_data(request, course_id):
"""
Retrieves data and formats it for the course link update status request.
"""
status = None
results = []
task_status = _latest_course_link_update_task_status(request, course_id)
if task_status is None:
status = "uninitiated"
else:
status = task_status.state
if task_status.state == UserTaskStatus.SUCCEEDED:
try:
artifact = UserTaskArtifact.objects.get(
status=task_status, name="LinkUpdateResults"
)
with artifact.file as file:
content = file.read()
results = json.loads(content)
except (UserTaskArtifact.DoesNotExist, ValueError):
# If no artifact found or invalid JSON, just return empty results
results = []
data = {
"status": status,
**({"results": results}),
}
return data
def _latest_course_link_update_task_status(request, course_id, view_func=None):
"""
Get the most recent course link update status for the specified course key.
"""
args = {"course_id": course_id}
name = CourseLinkUpdateTask.generate_name(args)
task_status = UserTaskStatus.objects.filter(name=name)
for status_filter in STATUS_FILTERS:
task_status = status_filter().filter_queryset(request, task_status, view_func)
return task_status.order_by("-created").first()
def _get_link_update_status(original_url, course_key):
"""
Check whether a given link has been updated based on the latest link update results.
Args:
original_url (str): The original URL to check
course_key: The course key
Returns:
dict: Dictionary with 'originalLink', 'isUpdated', and 'updatedLink' keys
"""
def _create_response(original_link, is_updated, updated_link=None):
"""Helper to create consistent response format."""
return {
"originalLink": original_link,
"isUpdated": is_updated,
"updatedLink": updated_link,
}
try:
# Check if URL contains current course key (indicates it's been updated)
current_course_str = str(course_key)
if current_course_str in original_url:
prev_run_key = get_previous_run_course_key(course_key)
if prev_run_key:
reconstructed_original = original_url.replace(current_course_str, str(prev_run_key))
return _create_response(reconstructed_original, True, original_url)
return _create_response(original_url, True, original_url)
update_results = _get_update_results(course_key)
if not update_results:
return _create_response(original_url, False, None)
for result in update_results:
if not result.get("success", False):
continue
result_original = result.get("original_url", "")
result_new = result.get("new_url", "")
# Direct match with original URL
if result_original == original_url:
return _create_response(original_url, True, result_new)
# Check if current URL is an updated URL
if result_new == original_url:
return _create_response(result_original, True, original_url)
# Check if URLs match through reconstruction
if _urls_match_through_reconstruction(original_url, result_new, course_key):
return _create_response(original_url, True, result_new)
return _create_response(original_url, False, None)
except Exception: # pylint: disable=broad-except
return _create_response(original_url, False, None)
def _get_update_results(course_key):
"""
Helper function to get update results from the latest link update task.
Returns:
list: Update results or empty list if not found
"""
try:
task_status = _latest_course_link_update_task_status(None, str(course_key))
if not task_status or task_status.state != UserTaskStatus.SUCCEEDED:
return []
artifact = UserTaskArtifact.objects.get(
status=task_status, name="LinkUpdateResults"
)
with artifact.file as file:
content = file.read()
return json.loads(content)
except (UserTaskArtifact.DoesNotExist, ValueError, json.JSONDecodeError):
return []
def _is_previous_run_link(link, course_key):
"""
Check if a link is a previous run link by checking if it contains a previous course key
or if it has update results indicating it was updated.
Args:
link: The URL to check
course_key: The current course key
Returns:
bool: True if the link appears to be a previous run link
"""
try:
if str(course_key) in link:
return True
prev_run_key = get_previous_run_course_key(course_key)
if prev_run_key and str(prev_run_key) in link:
return True
update_results = _get_update_results(course_key)
for result in update_results:
if not result.get("success", False):
continue
if link in [result.get("original_url", ""), result.get("new_url", "")]:
return True
return False
except Exception: # pylint: disable=broad-except
return False
def _urls_match_through_reconstruction(original_url, new_url, course_key):
"""
Check if an original URL matches a new URL through course key reconstruction.
Args:
original_url (str): The original URL from broken links
new_url (str): The new URL from update results
course_key: The current course key
Returns:
bool: True if they match through reconstruction
"""
try:
prev_run_key = get_previous_run_course_key(course_key)
if not prev_run_key:
return False
# Reconstruct what the original URL would have been
reconstructed_original = new_url.replace(str(course_key), str(prev_run_key))
return reconstructed_original == original_url
except Exception: # pylint: disable=broad-except
return False
def _process_content_links(content_text, all_links, course_key=None):
"""
Helper function to process links in content and categorize them by state.
Args:
content_text: The text content to extract links from
all_links: List of tuples containing (url, state) or (url, state, extra_info)
course_key: Course key to check for link updates (optional)
Returns:
dict: Categorized link data
"""
if not content_text:
return _create_empty_links_data()
content_links = extract_content_URLs_from_course(content_text)
if not content_links:
return _create_empty_links_data()
# Create link state mapping
link_state_map = {
item[1]: item[2] if len(item) >= 3 else LinkState.BROKEN
for item in all_links if len(item) >= 2
}
# Categorize links by state
link_data = _create_empty_links_data()
for link in content_links:
link_state = link_state_map.get(link)
if link_state is not None:
_categorize_link_by_state(link, link_state, link_data, course_key)
else:
# Check if this link is a previous run link that might have been updated
if course_key and _is_previous_run_link(link, course_key):
_categorize_link_by_state(link, LinkState.PREVIOUS_RUN, link_data, course_key)
return link_data
def _generate_link_update_info(link, course_key=None):
"""
Create a previous run link data with appropriate update status.
Args:
link: The link URL
course_key: Course key to check for updates (optional)
Returns:
dict: Previous run link data with originalLink, isUpdated, and updatedLink
"""
if course_key:
updated_info = _get_link_update_status(link, course_key)
if updated_info:
return {
'originalLink': updated_info['originalLink'],
'isUpdated': updated_info['isUpdated'],
'updatedLink': updated_info['updatedLink']
}
return {
'originalLink': link,
'isUpdated': False,
'updatedLink': None
}
def _add_previous_run_link(dictionary, xblock_id, link, course_key):
"""
Helper function to add a previous run link with appropriate update status.
Args:
dictionary: The xblock dictionary to update
xblock_id: The ID of the xblock
link: The link URL
course_key: Course key to check for updates (optional)
"""
data = _generate_link_update_info(link, course_key)
dictionary[xblock_id].setdefault('previous_run_links', []).append(data)

View File

@@ -61,10 +61,10 @@ class TestLinkCheckProvider(CourseTestCase):
when passed a block level xblock.
"""
expected_tree = {
'chapter_1': {
'sequential_1': {
'vertical_1': {
'block_1': {}
self.mock_section.location: {
self.mock_subsection.location: {
self.mock_unit.location: {
self.mock_block.location: {}
}
}
}
@@ -81,19 +81,19 @@ class TestLinkCheckProvider(CourseTestCase):
when passed a block level xblock.
"""
expected_dictionary = {
'chapter_1': {
self.mock_section.location: {
'display_name': 'Section Name',
'category': 'chapter'
},
'sequential_1': {
self.mock_subsection.location: {
'display_name': 'Subsection Name',
'category': 'sequential'
},
'vertical_1': {
self.mock_unit.location: {
'display_name': 'Unit Name',
'category': 'vertical'
},
'block_1': {
self.mock_block.location: {
'display_name': 'Block Name',
'category': 'html',
'url': f'/course/{self.course.id}/editor/html/{self.mock_block.location}',
@@ -274,11 +274,16 @@ class TestLinkCheckProvider(CourseTestCase):
def test_sorts_sections_correctly(self, mock_modulestore):
"""Test that the function correctly sorts sections based on published course structure."""
# Create mock location objects that will match the section IDs in data
mock_location2 = "section2"
mock_location3 = "section3"
mock_location1 = "section1"
mock_course_block = Mock()
mock_course_block.get_children.return_value = [
Mock(location=Mock(block_id="section2")),
Mock(location=Mock(block_id="section3")),
Mock(location=Mock(block_id="section1")),
Mock(location=mock_location2),
Mock(location=mock_location3),
Mock(location=mock_location1),
]
mock_modulestore_instance = Mock()
@@ -301,8 +306,7 @@ class TestLinkCheckProvider(CourseTestCase):
{"id": "section3", "name": "Bonus"},
{"id": "section1", "name": "Intro"},
]
assert result["LinkCheckOutput"]["sections"] == expected_sections
self.assertEqual(result["LinkCheckOutput"]["sections"], expected_sections)
def test_prev_run_link_detection(self):
"""Test the core logic of separating previous run links from regular links."""
@@ -366,46 +370,47 @@ class TestLinkCheckProvider(CourseTestCase):
def test_course_updates_and_custom_pages_structure(self):
"""Test that course_updates and custom_pages are properly structured in the response."""
course_key = self.course.id
# Test data that represents the broken links JSON structure
json_content = [
# Regular course content
[
"course-v1:Test+Course+2024+type@html+block@content1",
str(self.mock_block.location),
"http://content-link.com",
"broken",
LinkState.BROKEN,
],
[
"course-v1:Test+Course+2024+type@vertical+block@unit1",
str(self.mock_unit.location),
"http://unit-link.com",
"locked",
LinkState.LOCKED,
],
# Course updates
[
"course-v1:Test+Course+2024+type@course_info+block@updates",
f"{course_key}+type@course_info+block@updates",
"http://update1.com",
"broken",
LinkState.BROKEN,
],
[
"course-v1:Test+Course+2024+type@course_info+block@updates",
f"{course_key}+type@course_info+block@updates",
"http://update2.com",
"locked",
LinkState.LOCKED,
],
# Handouts (should be merged into course_updates)
[
"course-v1:Test+Course+2024+type@course_info+block@handouts",
f"{course_key}+type@course_info+block@handouts",
"http://handout.com",
"broken",
LinkState.BROKEN,
],
# Custom pages (static tabs)
[
"course-v1:Test+Course+2024+type@static_tab+block@page1",
f"{course_key}+type@static_tab+block@page1",
"http://page1.com",
"broken",
LinkState.BROKEN,
],
[
"course-v1:Test+Course+2024+type@static_tab+block@page2",
f"{course_key}+type@static_tab+block@page2",
"http://page2.com",
"external-forbidden",
LinkState.EXTERNAL_FORBIDDEN,
],
]
@@ -413,17 +418,42 @@ class TestLinkCheckProvider(CourseTestCase):
"cms.djangoapps.contentstore.core.course_optimizer_provider._generate_links_descriptor_for_content"
) as mock_content, mock.patch(
"cms.djangoapps.contentstore.core.course_optimizer_provider.modulestore"
) as mock_modulestore:
) as mock_modulestore, mock.patch(
"cms.djangoapps.contentstore.core.course_optimizer_provider.create_course_info_usage_key"
) as mock_create_usage_key, mock.patch(
"cms.djangoapps.contentstore.core.course_optimizer_provider.get_course_update_items"
) as mock_get_update_items, mock.patch(
"cms.djangoapps.contentstore.core.course_optimizer_provider.extract_content_URLs_from_course"
) as mock_extract_urls:
mock_content.return_value = {"sections": []}
mock_course = self.mock_course
mock_tab1 = StaticTab(name="Page1", url_slug="page1")
mock_tab2 = StaticTab(name="Page2", url_slug="page2")
mock_tab1 = StaticTab(name="Test Page 1", url_slug="page1")
mock_tab2 = StaticTab(name="Test Page 2", url_slug="page2")
mock_course.tabs = [mock_tab1, mock_tab2]
mock_course.id = CourseKey.from_string("course-v1:Test+Course+2024")
mock_course.id = course_key
mock_modulestore.return_value.get_course.return_value = mock_course
course_key = CourseKey.from_string("course-v1:Test+Course+2024")
mock_updates_usage_key = Mock()
mock_handouts_usage_key = Mock()
mock_create_usage_key.side_effect = lambda course, info_type: (
mock_updates_usage_key if info_type == "updates" else mock_handouts_usage_key
)
mock_updates_block = Mock()
mock_updates_block.data = "Check out <a href='http://update1.com'>this update</a>"
mock_handouts_block = Mock()
mock_handouts_block.data = "Download <a href='http://handout.com'>handout</a>"
mock_get_item_mapping = {
mock_updates_usage_key: mock_updates_block,
mock_handouts_usage_key: mock_handouts_block,
}
mock_modulestore.return_value.get_item.side_effect = (
lambda usage_key: mock_get_item_mapping.get(usage_key, Mock())
)
mock_get_update_items.return_value = [
{"id": "update1", "date": "2024-01-01", "content": "Update content 1", "status": "visible"},
{"id": "update2", "date": "2024-01-02", "content": "Update content 2", "status": "visible"}
]
mock_extract_urls.return_value = ["http://update1.com", "http://update2.com"]
result = generate_broken_links_descriptor(
json_content, self.user, course_key
)

View File

@@ -50,3 +50,62 @@ class LinkCheckSerializer(serializers.Serializer):
LinkCheckCreatedAt = serializers.DateTimeField(required=False)
LinkCheckOutput = LinkCheckOutputSerializer(required=False)
LinkCheckError = serializers.CharField(required=False)
class CourseRerunLinkDataSerializer(serializers.Serializer):
""" Serializer for individual course rerun link data """
url = serializers.CharField(required=True, allow_null=False, allow_blank=False)
type = serializers.CharField(required=True, allow_null=False, allow_blank=False)
id = serializers.CharField(required=True, allow_null=False, allow_blank=False)
class CourseRerunLinkUpdateRequestSerializer(serializers.Serializer):
"""Serializer for course rerun link update request."""
ACTION_CHOICES = ("all", "single")
action = serializers.ChoiceField(choices=ACTION_CHOICES, required=True)
data = CourseRerunLinkDataSerializer(many=True, required=False)
def validate(self, attrs):
"""
Validate that 'data' is provided when action is 'single'.
"""
action = attrs.get("action")
data = attrs.get("data")
if action == "single" and not data:
raise serializers.ValidationError(
{"data": "This field is required when action is 'single'."}
)
return attrs
class CourseRerunLinkUpdateResultSerializer(serializers.Serializer):
""" Serializer for individual course rerun link update result """
new_url = serializers.CharField(required=True, allow_null=False, allow_blank=False)
original_url = serializers.CharField(required=False, allow_null=True, allow_blank=True)
type = serializers.CharField(required=True, allow_null=False, allow_blank=True)
id = serializers.CharField(required=True, allow_null=False, allow_blank=False)
success = serializers.BooleanField(required=True)
error_message = serializers.CharField(required=False, allow_null=True, allow_blank=True)
def to_representation(self, instance):
"""
Override to exclude error_message field when success is True or error_message is null/empty
"""
data = super().to_representation(instance)
if data.get('success') is True or not data.get('error_message'):
data.pop('error_message', None)
return data
class CourseRerunLinkUpdateStatusSerializer(serializers.Serializer):
""" Serializer for course rerun link update status """
status = serializers.ChoiceField(
choices=['pending', 'in_progress', 'completed', 'failed', 'uninitiated'],
required=True
)
results = CourseRerunLinkUpdateResultSerializer(many=True, required=False)

View File

@@ -0,0 +1,160 @@
"""
Unit tests for Course Rerun Link Update API
"""
import json
from unittest.mock import Mock, patch
from django.urls import reverse
from user_tasks.models import UserTaskStatus
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
class TestCourseLinkUpdateAPI(CourseTestCase):
"""
Tests for the Course Rerun Link Update API endpoints
"""
def setUp(self):
super().setUp()
self.sample_links_data = [
{
"url": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course_2023/course",
"type": "course_content",
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@intro",
},
{
"url": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course_2023/progress",
"type": "course_updates",
"id": "1",
},
{
"url": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course_2023/handouts",
"type": "handouts",
"id": "block-v1:edX+DemoX+Demo_Course+type@course_info+block@handouts",
},
]
self.enable_optimizer_patch = (
"cms.djangoapps.contentstore.rest_api.v0.views.course_optimizer."
"enable_course_optimizer_check_prev_run_links"
)
self.update_links_patch = (
"cms.djangoapps.contentstore.rest_api.v0.views.course_optimizer."
"update_course_rerun_links"
)
self.task_status_patch = (
"cms.djangoapps.contentstore.core.course_optimizer_provider."
"_latest_course_link_update_task_status"
)
self.user_task_artifact_patch = (
"cms.djangoapps.contentstore.core.course_optimizer_provider."
"UserTaskArtifact"
)
def make_post_request(self, course_id=None, data=None, **kwargs):
"""Helper method to make POST requests to the link update endpoint"""
url = self.get_update_url(course_id or self.course.id)
response = self.client.post(
url,
data=json.dumps(data) if data else None,
content_type="application/json",
)
return response
def get_update_url(self, course_key):
"""Get the update endpoint URL"""
return reverse(
"cms.djangoapps.contentstore:v0:rerun_link_update",
kwargs={"course_id": str(course_key)},
)
def get_status_url(self, course_key):
"""Get the status endpoint URL"""
return reverse(
"cms.djangoapps.contentstore:v0:rerun_link_update_status",
kwargs={"course_id": str(course_key)},
)
def test_post_update_all_links_success(self):
"""Test successful request to update all links"""
with patch(self.enable_optimizer_patch, return_value=True):
with patch(self.update_links_patch) as mock_task:
mock_task.delay.return_value = Mock()
data = {"action": "all"}
response = self.make_post_request(data=data)
self.assertEqual(response.status_code, 200)
self.assertIn("status", response.json())
mock_task.delay.assert_called_once()
def test_post_update_single_links_success(self):
"""Test successful request to update single links"""
with patch(self.enable_optimizer_patch, return_value=True):
with patch(self.update_links_patch) as mock_task:
mock_task.delay.return_value = Mock()
data = {
"action": "single",
"data": [
{
"url": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course/course",
"type": "course_content",
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@abc123",
},
{
"url": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course/progress",
"type": "course_updates",
"id": "1",
},
],
}
response = self.make_post_request(data=data)
self.assertEqual(response.status_code, 200)
self.assertIn("status", response.json())
mock_task.delay.assert_called_once()
def test_post_update_missing_action_returns_400(self):
"""Test that missing action parameter returns 400"""
with patch(
self.enable_optimizer_patch,
return_value=True,
):
data = {}
response = self.make_post_request(data=data)
self.assertEqual(response.status_code, 400)
self.assertIn("error", response.json())
self.assertIn("action", response.json()["error"])
def test_error_handling_workflow(self):
"""Test error handling in the complete workflow"""
with patch(
self.enable_optimizer_patch,
return_value=True,
):
with patch(self.update_links_patch) as mock_task:
# Step 1: Start task
mock_task.delay.return_value = Mock()
data = {"action": "all"}
response = self.make_post_request(data=data)
self.assertEqual(response.status_code, 200)
# Step 2: Check failed status
with patch(self.task_status_patch) as mock_status:
with patch(self.user_task_artifact_patch) as mock_artifact:
mock_task_status = Mock()
mock_task_status.state = UserTaskStatus.FAILED
mock_status.return_value = mock_task_status
status_url = self.get_status_url(self.course.id)
status_response = self.client.get(status_url)
self.assertEqual(status_response.status_code, 200)
status_data = status_response.json()
self.assertEqual(status_data["status"], "Failed")
self.assertEqual(status_data["results"], [])

View File

@@ -9,11 +9,13 @@ from .views import (
AdvancedCourseSettingsView,
APIHeartBeatView,
AuthoringGradingView,
CourseTabSettingsView,
CourseTabListView,
CourseTabReorderView,
LinkCheckView,
CourseTabSettingsView,
LinkCheckStatusView,
LinkCheckView,
RerunLinkUpdateStatusView,
RerunLinkUpdateView,
TranscriptView,
YoutubeTranscriptCheckView,
YoutubeTranscriptUploadView,
@@ -114,4 +116,13 @@ urlpatterns = [
fr'^link_check_status/{settings.COURSE_ID_PATTERN}$',
LinkCheckStatusView.as_view(), name='link_check_status'
),
re_path(
fr'^rerun_link_update/{settings.COURSE_ID_PATTERN}$',
RerunLinkUpdateView.as_view(), name='rerun_link_update'
),
re_path(
fr'^rerun_link_update_status/{settings.COURSE_ID_PATTERN}$',
RerunLinkUpdateStatusView.as_view(), name='rerun_link_update_status'
),
]

View File

@@ -4,6 +4,6 @@ Views for v0 contentstore API.
from .advanced_settings import AdvancedCourseSettingsView
from .api_heartbeat import APIHeartBeatView
from .authoring_grading import AuthoringGradingView
from .course_optimizer import LinkCheckView, LinkCheckStatusView
from .tabs import CourseTabSettingsView, CourseTabListView, CourseTabReorderView
from .course_optimizer import LinkCheckStatusView, LinkCheckView, RerunLinkUpdateStatusView, RerunLinkUpdateView
from .tabs import CourseTabListView, CourseTabReorderView, CourseTabSettingsView
from .transcripts import TranscriptView, YoutubeTranscriptCheckView, YoutubeTranscriptUploadView

View File

@@ -1,17 +1,33 @@
""" API Views for Course Optimizer. """
"""API Views for Course Optimizer."""
import edx_api_doc_tools as apidocs
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework.views import APIView
from rest_framework import status
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from user_tasks.models import UserTaskStatus
from cms.djangoapps.contentstore.core.course_optimizer_provider import get_link_check_data, sort_course_sections
from cms.djangoapps.contentstore.rest_api.v0.serializers.course_optimizer import LinkCheckSerializer
from cms.djangoapps.contentstore.tasks import check_broken_links
from cms.djangoapps.contentstore.core.course_optimizer_provider import (
get_course_link_update_data,
get_link_check_data,
sort_course_sections,
)
from cms.djangoapps.contentstore.rest_api.v0.serializers.course_optimizer import (
CourseRerunLinkUpdateStatusSerializer,
LinkCheckSerializer,
CourseRerunLinkUpdateRequestSerializer,
)
from cms.djangoapps.contentstore.tasks import check_broken_links, update_course_rerun_links
from cms.djangoapps.contentstore.toggles import enable_course_optimizer_check_prev_run_links
from common.djangoapps.student.auth import has_course_author_access, has_studio_read_access
from common.djangoapps.util.json_request import JsonResponse
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
from openedx.core.lib.api.view_utils import (
DeveloperErrorViewMixin,
verify_course_exists,
view_auth_classes,
)
@view_auth_classes(is_authenticated=True)
@@ -113,7 +129,14 @@ class LinkCheckStatusView(DeveloperErrorViewMixin, APIView):
"brokenLinks": [<string>, ...],
"lockedLinks": [<string>, ...],
"externalForbiddenLinks": [<string>, ...],
"previousRunLinks": [<string>, ...]
"previousRunLinks": [
{
"originalLink": <string>,
"isUpdated": <boolean>,
"updatedLink": <string>
},
...
]
},
{ <another block> },
],
@@ -134,7 +157,14 @@ class LinkCheckStatusView(DeveloperErrorViewMixin, APIView):
"brokenLinks": [<string>, ...],
"lockedLinks": [<string>, ...],
"externalForbiddenLinks": [<string>, ...],
"previousRunLinks": [<string>, ...]
"previousRunLinks": [
{
"originalLink": <string>,
"isUpdated": <boolean>,
"updatedLink": <string>
},
...
]
},
...,
{ <another course-updates> },
@@ -146,7 +176,14 @@ class LinkCheckStatusView(DeveloperErrorViewMixin, APIView):
"brokenLinks": [<string>, ...],
"lockedLinks": [<string>, ...],
"externalForbiddenLinks": [<string>, ...],
"previousRunLinks": [<string>, ...]
"previousRunLinks": [
{
"originalLink": <string>,
"isUpdated": <boolean>,
"updatedLink": <string>
},
...
]
}
],
"custom_pages": [
@@ -157,7 +194,14 @@ class LinkCheckStatusView(DeveloperErrorViewMixin, APIView):
"brokenLinks": [<string>, ...],
"lockedLinks": [<string>, ...],
"externalForbiddenLinks": [<string>, ...],
"previousRunLinks": [<string>, ...]
"previousRunLinks": [
{
"originalLink": <string>,
"isUpdated": <boolean>,
"updatedLink": <string>
},
...
]
},
...,
{ <another page> },
@@ -167,11 +211,212 @@ class LinkCheckStatusView(DeveloperErrorViewMixin, APIView):
"""
course_key = CourseKey.from_string(course_id)
if not has_course_author_access(request.user, course_key):
print('missing course author access')
self.permission_denied(request)
data = get_link_check_data(request, course_id)
data = sort_course_sections(course_key, data)
link_check_data = get_link_check_data(request, course_id)
sorted_sections = sort_course_sections(course_key, link_check_data)
serializer = LinkCheckSerializer(data)
serializer = LinkCheckSerializer(sorted_sections)
return Response(serializer.data)
@view_auth_classes(is_authenticated=True)
class RerunLinkUpdateView(DeveloperErrorViewMixin, APIView):
"""
View for queueing a celery task to update course links to the latest re-run.
"""
@apidocs.schema(
parameters=[
apidocs.string_parameter(
"course_id", apidocs.ParameterLocation.PATH, description="Course ID"
)
],
body=CourseRerunLinkUpdateRequestSerializer,
responses={
200: "Celery task queued.",
400: "Bad request - invalid action or missing data.",
401: "The requester is not authenticated.",
403: "The requester cannot access the specified course.",
404: "The requested course does not exist.",
},
)
@verify_course_exists()
def post(self, request: Request, course_id: str):
"""
Queue celery task to update course links to the latest re-run.
**Example Request - Update All Links**
POST /api/contentstore/v0/rerun_link_update/{course_id}
```json
{
"action": "all"
}
```
**Example Request - Update Single Links**
POST /api/contentstore/v0/rerun_link_update/{course_id}
```json
{
"action": "single",
"data": [
{
"url": "http://localhost:18000/course/course-v1:edX+DemoX+Demo_Course/course",
"type": "course_updates",
"id": "block_id_123"
}
]
}
```
**Response Values**
```json
{
"status": "pending"
}
```
"""
try:
course_key = CourseKey.from_string(course_id)
except (InvalidKeyError, IndexError):
return JsonResponse(
{"error": "Invalid course id, it does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
# Check course author permissions
if not has_course_author_access(request.user, course_key):
self.permission_denied(request)
if not enable_course_optimizer_check_prev_run_links(course_key):
return JsonResponse(
{
"error": "Course optimizer check for previous run links is not enabled."
},
status=status.HTTP_400_BAD_REQUEST,
)
action = request.data.get("action")
if not action or action not in ["all", "single"]:
return JsonResponse(
{"error": 'Invalid or missing action. Must be "all" or "single".'},
status=status.HTTP_400_BAD_REQUEST,
)
if action == "single":
data = request.data.get("data")
if not data or not isinstance(data, list):
return JsonResponse(
{
'data': "This field is required when action is 'single'."
},
status=status.HTTP_400_BAD_REQUEST,
)
update_course_rerun_links.delay(
request.user.id,
course_id,
action,
request.data.get("data", []),
request.LANGUAGE_CODE,
)
return JsonResponse({"status": UserTaskStatus.PENDING})
@view_auth_classes()
class RerunLinkUpdateStatusView(DeveloperErrorViewMixin, APIView):
"""
View for checking the status of the course link update task and returning the results.
"""
@apidocs.schema(
parameters=[
apidocs.string_parameter(
"course_id", apidocs.ParameterLocation.PATH, description="Course ID"
),
],
responses={
200: "OK",
401: "The requester is not authenticated.",
403: "The requester cannot access the specified course.",
404: "The requested course does not exist.",
},
)
def get(self, request: Request, course_id: str):
"""
**Use Case**
GET handler to return the status of the course link update task from UserTaskStatus.
If no task has been started for the course, return 'uninitiated'.
If the task was successful, the updated links results are also returned.
Possible statuses:
'pending', 'in_progress', 'completed', 'failed', 'uninitiated'
**Example Request**
GET /api/contentstore/v0/rerun_link_update_status/{course_id}
**Example Response - Task In Progress**
```json
{
"status": "pending"
}
```
**Example Response - Task Completed**
```json
{
"status": "completed",
"results": [
{
"id": "block_id_123",
"type": "course_updates",
"new_url": "http://localhost:18000/course/course-v1:edX+DemoX+2024_Q2/course",
"success": true
},
{
"id": "block_id_456",
"type": "course_updates",
"new_url": "http://localhost:18000/course/course-v1:edX+DemoX+2024_Q2/progress",
"success": true
}
]
}
```
**Example Response - Task Failed**
```json
{
"status": "failed",
"error": "Target course run not found or inaccessible"
}
```
"""
try:
course_key = CourseKey.from_string(course_id)
except (InvalidKeyError, IndexError):
return JsonResponse(
{"error": "Invalid course id, it does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
# Check course author permissions
if not has_course_author_access(request.user, course_key):
self.permission_denied(request)
if not enable_course_optimizer_check_prev_run_links(course_key):
return JsonResponse(
{
"error": "Course optimizer check for previous run links is not enabled."
},
status=status.HTTP_400_BAD_REQUEST,
)
data = get_course_link_update_data(request, course_id)
serializer = CourseRerunLinkUpdateStatusSerializer(data)
return Response(serializer.data)

View File

@@ -1261,7 +1261,7 @@ def _scan_course_for_links(course_key):
# and it doesn't contain user-facing links to scan.
if block.category == 'drag-and-drop-v2':
continue
block_id = str(block.usage_key)
block_id = str(block.location)
block_info = get_block_info(block)
block_data = block_info['data']
url_list = extract_content_URLs_from_course(block_data)
@@ -1342,7 +1342,7 @@ def _scan_course_updates_for_links(course):
course_updates.append(
{
"displayName": update.get("date", "Unknown"),
"block_id": str(usage_key),
"block_id": update.get("id", str(usage_key)),
"urls": url_list,
}
)
@@ -1753,3 +1753,533 @@ def handle_unlink_upstream_container(upstream_container_key_string: str) -> None
upstream_container_key=upstream_container_key,
):
make_copied_tags_editable(str(link.downstream_usage_key))
class CourseLinkUpdateTask(UserTask): # pylint: disable=abstract-method
"""
Base class for course link update tasks.
"""
@staticmethod
def calculate_total_steps(arguments_dict):
"""
Get the number of in-progress steps in the link update process, as shown in the UI.
For reference, these are:
1. Scanning
2. Updating
"""
return 2
@classmethod
def generate_name(cls, arguments_dict):
"""
Create a name for this particular task instance.
Arguments:
arguments_dict (dict): The arguments given to the task function
Returns:
str: The generated name
"""
key = arguments_dict["course_id"]
return f"Course link update of {key}"
@shared_task(base=CourseLinkUpdateTask, bind=True)
def update_course_rerun_links(
self, user_id, course_id, action, data=None, language=None
):
"""
Updates course links to point to the latest re-run.
"""
set_code_owner_attribute_from_module(__name__)
return _update_course_rerun_links(
self, user_id, course_id, action, data, language
)
def _update_course_rerun_links(
task_instance, user_id, course_id, action, data, language
):
"""
Updates course links to point to the latest re-run.
Args:
task_instance: The Celery task instance
user_id: ID of the user requesting the update
course_id: String representation of the course key
action: 'all' or 'single'
data: List of specific links to update (when action='single')
language: Language code for translations
"""
user = _validate_user(task_instance, user_id, language)
if not user:
return
task_instance.status.set_state(UserTaskStatus.IN_PROGRESS)
course_key = CourseKey.from_string(course_id)
prev_run_course_key = get_previous_run_course_key(course_key)
try:
task_instance.status.set_state("Scanning")
if action == "all":
url_list = _scan_course_for_links(course_key)
links_to_update = []
# Filter only course-specific links that need updating
for block_id, url in url_list:
if _course_link_update_required(url, course_key, prev_run_course_key):
links_to_update.append(
{
"id": block_id,
"url": url,
"type": _determine_link_type(block_id),
}
)
else:
# Process only single link updates
links_to_update = data or []
task_instance.status.increment_completed_steps()
task_instance.status.set_state("Updating")
updated_links = []
for link_data in links_to_update:
try:
new_url = _update_link_to_latest_rerun(
link_data, course_key, prev_run_course_key, user
)
updated_links.append(
{
"original_url": link_data.get("url", ""),
"new_url": new_url,
"type": link_data.get("type", "unknown"),
"id": link_data.get("id", ""),
"success": True,
}
)
except Exception as e: # pylint: disable=broad-except
LOGGER.error(
f'Failed to update link {link_data.get("url", "")}: {str(e)}'
)
updated_links.append(
{
"original_url": link_data.get("url", ""),
"new_url": link_data.get("url", ""),
"type": link_data.get("type", "unknown"),
"id": link_data.get("id", ""),
"success": False,
"error_message": str(e),
}
)
task_instance.status.increment_completed_steps()
file_name = f"{str(course_key)}_link_updates"
results_file = NamedTemporaryFile(prefix=file_name + ".", suffix=".json")
with open(results_file.name, "w") as file:
json.dump(updated_links, file, indent=4)
artifact = UserTaskArtifact(
status=task_instance.status, name="LinkUpdateResults"
)
artifact.file.save(
name=os.path.basename(results_file.name), content=File(results_file)
)
artifact.save()
# Update the existing broken links file to reflect the updated links
_update_broken_links_file_with_updated_links(course_key, updated_links)
task_instance.status.succeed()
except Exception as e: # pylint: disable=broad-except
LOGGER.exception(
"Error updating links for course %s", course_key, exc_info=True
)
if task_instance.status.state != UserTaskStatus.FAILED:
task_instance.status.fail({"raw_error_msg": str(e)})
def _course_link_update_required(url, course_key, prev_run_course_key):
"""
Checks if a course link needs to be updated for a re-run.
Args:
url: The URL to check
course_key: The current course key
Returns:
bool: True if the link needs updating
"""
if not url or not course_key:
return False
course_id_match = contains_previous_course_reference(url, prev_run_course_key)
if not course_id_match:
return False
# Check if it's the same org and course but different run
if (
prev_run_course_key.org == course_key.org
and prev_run_course_key.course == course_key.course
and prev_run_course_key.run != course_key.run
):
return True
return False
def _determine_link_type(block_id):
"""
Determines the type of link based on block_id and URL.
Args:
block_id: The block ID containing the link
url: The URL
Returns:
str: The type of link ('course_updates', 'handouts', 'custom_pages', 'course_content')
"""
if not block_id:
return "course_content"
block_id_str = str(block_id)
if isinstance(block_id, int):
return "course_updates"
if "course_info" in block_id_str and "handouts" in block_id_str:
return "handouts"
if "static_tab" in block_id_str:
return "custom_pages"
return "course_content"
def _update_link_to_latest_rerun(link_data, course_key, prev_run_course_key, user):
"""
Updates a single link to point to the latest course re-run.
Args:
link_data: Dictionary containing link information
course_key: The current course key
prev_run_course_key: The previous course run key
user: The authenticated user making the request
Returns:
str: The updated URL
"""
original_url = link_data.get("url", "")
block_id = link_data.get("id", "")
link_type = link_data.get("type", "course_content")
if not original_url:
return original_url
prev_run_course_org = prev_run_course_key.org if prev_run_course_key else None
prev_run_course_course = (
prev_run_course_key.course if prev_run_course_key else None
)
if prev_run_course_key == course_key:
return original_url
# Validate url based on previous-run org
if (
prev_run_course_org != course_key.org
or prev_run_course_course != course_key.course
):
return original_url
new_url = original_url.replace(str(prev_run_course_key), str(course_key))
# condition because we're showing handouts as updates
if link_type == "course_updates" and "handouts" in str(block_id):
link_type = "handouts"
_update_block_content_with_new_url(
block_id, original_url, new_url, link_type, course_key, user
)
return new_url
def _update_course_updates_link(block_id, old_url, new_url, course_key, user):
"""
Updates course updates with the new URL.
Args:
block_id: The ID of the block containing the link (can be usage key or update ID)
old_url: The original URL to replace
new_url: The new URL to use
course_key: The current course key
user: The authenticated user making the request
"""
store = modulestore()
course_updates = store.get_item(course_key.make_usage_key("course_info", "updates"))
if hasattr(course_updates, "items"):
for update in course_updates.items:
update_matches = False
if "course_info" in str(block_id) and "updates" in str(block_id):
update_matches = True
else:
try:
update_matches = update.get("id", None) == int(block_id)
except (ValueError, TypeError):
update_matches = False
if update_matches and "content" in update:
update["content"] = update["content"].replace(old_url, new_url)
store.update_item(course_updates, user.id)
LOGGER.info(
f"Updated course updates with new URL: {old_url} -> {new_url}"
)
def _update_handouts_link(block_id, old_url, new_url, course_key, user):
"""
Updates course handouts with the new URL.
Args:
block_id: The ID of the block containing the link
old_url: The original URL to replace
new_url: The new URL to use
course_key: The current course key
user: The authenticated user making the request
"""
store = modulestore()
handouts = store.get_item(course_key.make_usage_key("course_info", "handouts"))
if hasattr(handouts, "data") and old_url in handouts.data:
handouts.data = handouts.data.replace(old_url, new_url)
store.update_item(handouts, user.id)
LOGGER.info(f"Updated handouts with new URL: {old_url} -> {new_url}")
def _update_custom_pages_link(block_id, old_url, new_url, course_key, user):
"""
Updates custom pages (static tabs) with the new URL.
Args:
block_id: The ID of the block containing the link (usage key string)
old_url: The original URL to replace
new_url: The new URL to use
course_key: The current course key
user: The authenticated user making the request
"""
store = modulestore()
try:
usage_key = UsageKey.from_string(block_id)
static_tab = store.get_item(usage_key)
if hasattr(static_tab, "data") and old_url in static_tab.data:
static_tab.data = static_tab.data.replace(old_url, new_url)
store.update_item(static_tab, user.id)
LOGGER.info(
f"Updated static tab {block_id} with new URL: {old_url} -> {new_url}"
)
except InvalidKeyError:
LOGGER.warning(f"Invalid usage key for static tab: {block_id}")
def _update_course_content_link(block_id, old_url, new_url, course_key, user):
"""
Updates course content blocks with the new URL.
Args:
block_id: The ID of the block containing the link (usage key string)
old_url: The original URL to replace
new_url: The new URL to use
course_key: The current course key
user: The authenticated user making the request
"""
store = modulestore()
try:
usage_key = UsageKey.from_string(block_id)
block = store.get_item(usage_key)
if hasattr(block, "data") and old_url in block.data:
block.data = block.data.replace(old_url, new_url)
store.update_item(block, user.id)
store.publish(block.location, user.id)
LOGGER.info(
f"Updated block {block_id} data with new URL: {old_url} -> {new_url}"
)
except InvalidKeyError:
LOGGER.warning(f"Invalid usage key for block: {block_id}")
def _update_block_content_with_new_url(block_id, old_url, new_url, link_type, course_key, user):
"""
Updates the content of a block in the modulestore to replace old URL with new URL.
Args:
block_id: The ID of the block containing the link
old_url: The original URL to replace
new_url: The new URL to use
link_type: The type of link ('course_content', 'course_updates', 'handouts', 'custom_pages')
course_key: The current course key
user: The authenticated user making the request
"""
if link_type == "course_updates":
_update_course_updates_link(block_id, old_url, new_url, course_key, user)
elif link_type == "handouts":
_update_handouts_link(block_id, old_url, new_url, course_key, user)
elif link_type == "custom_pages":
_update_custom_pages_link(block_id, old_url, new_url, course_key, user)
else:
_update_course_content_link(block_id, old_url, new_url, course_key, user)
def _update_broken_links_file_with_updated_links(course_key, updated_links):
"""
Updates the existing broken links file to reflect the status of updated links.
This function finds the latest broken links file for the course and updates it
to remove successfully updated links or update their status.
Args:
course_key: The current course key
updated_links: List of updated link results from the link update task
"""
try:
# Find the latest broken links task artifact for this course
latest_artifact = UserTaskArtifact.objects.filter(
name="BrokenLinks", status__name__contains=str(course_key)
).order_by("-created").first()
if not latest_artifact or not latest_artifact.file:
LOGGER.debug(f"No broken links file found for course {course_key}")
return
# Read the existing broken links file
try:
with latest_artifact.file.open("r") as file:
existing_broken_links = json.load(file)
except (json.JSONDecodeError, IOError) as e:
LOGGER.error(
f"Failed to read broken links file for course {course_key}: {e}"
)
return
successful_results = []
for result in updated_links:
if not result.get("success"):
continue
original_url = result.get("original_url") or _get_original_url_from_updated_result(result, course_key)
if not original_url:
continue
successful_results.append(
{
"original_url": original_url,
"new_url": result.get("new_url"),
"type": result.get("type"),
"id": str(result.get("id")) if result.get("id") is not None else None,
}
)
updated_broken_links = []
for link in existing_broken_links:
if len(link) >= 3:
block_id, url, link_state = link[0], link[1], link[2]
applied = False
for res in successful_results:
if res["original_url"] != url:
continue
if _update_result_applies_to_block(res, block_id) and res.get('id') == str(block_id):
new_url = res["new_url"]
updated_broken_links.append([block_id, new_url, link_state])
applied = True
break
if not applied:
updated_broken_links.append(link)
else:
updated_broken_links.append(link)
# Create a new temporary file with updated data
file_name = f"{course_key}_updated"
updated_file = NamedTemporaryFile(prefix=file_name + ".", suffix=".json")
with open(updated_file.name, "w") as file:
json.dump(updated_broken_links, file, indent=4)
# Update the existing artifact with the new file
latest_artifact.file.save(
name=os.path.basename(updated_file.name), content=File(updated_file)
)
latest_artifact.save()
LOGGER.info(f"Successfully updated broken links file for course {course_key}")
except Exception as e: # pylint: disable=broad-except
LOGGER.error(f"Failed to update broken links file for course {course_key}: {e}")
def _get_original_url_from_updated_result(update_result, course_key):
"""
Reconstruct the original URL from an update result.
Args:
update_result: The update result containing new_url and other info
course_key: The current course key
Returns:
str: The original URL before update, or None if it cannot be determined
"""
try:
new_url = update_result.get("new_url", "")
if not new_url or str(course_key) not in new_url:
return None
prev_run_course_key = get_previous_run_course_key(course_key)
if not prev_run_course_key:
return None
return new_url.replace(str(course_key), str(prev_run_course_key))
except Exception as e: # pylint: disable=broad-except
LOGGER.debug(
f"Failed to reconstruct original URL from update result: {e}"
)
return None
def _update_result_applies_to_block(result_entry, block_id):
"""
Determine if a given update result applies to a specific broken-link block id.
The task update results contain a 'type' and an 'id' indicating where the
replacement was applied. A single URL may appear in multiple places (course
content, course_updates, handouts, custom pages). We should only apply the
replacement to broken-link entries that match the same target area.
"""
try:
result_type = (result_entry.get("type") or "course_content").lower()
result_id = result_entry.get("id")
block_id_str = str(block_id) if block_id is not None else ""
result_id_str = str(result_id) if result_id is not None else None
if result_id_str and block_id_str == result_id_str:
return True
is_course_info = "course_info" in block_id_str
is_updates_section = "updates" in block_id_str
is_handouts_section = "handouts" in block_id_str
is_static_tab = "static_tab" in block_id_str
block_category = (
"course_updates" if is_course_info and is_updates_section else
"handouts" if is_course_info and is_handouts_section else
"custom_pages" if is_static_tab else
"course_content"
)
return block_category == result_type
except Exception: # pylint: disable=broad-except
return False

View File

@@ -669,7 +669,7 @@ def use_legacy_logged_out_home():
# after creating a course rerun.
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2025-07-21
# .. toggle_target_removal_date: None
# .. toggle_target_removal_date: 2026-02-25
ENABLE_COURSE_OPTIMIZER_CHECK_PREV_RUN_LINKS = CourseWaffleFlag(
f'{CONTENTSTORE_NAMESPACE}.enable_course_optimizer_check_prev_run_links',
__name__,