Skip to content

Commit 637f32b

Browse files
committed
feat: Subsection(sequential) prerequisites lost during course export/import
* feat: adds prerequisite element to exported OLX. e.g. <sequential default_time_limit_minutes="0" display_name="Subsection 2" due="null" hide_after_due="false" show_correctness="always" start="2030-01-01T00:00:00Z"> <prerequisite> <required_sequential url_name="c129cef268e340c9a92e65c224ddcaf9"/> <min_score>80</min_score> <min_completion>90</min_completion> </prerequisite> <vertical url_name="07665b20eaab4a8b888ed6ab5de6188d"/> </sequential> This PR introduces support for subsection (sequential) prerequisites in the OLX course format. Course authors can now define prerequisite relationships between subsections with configurable completion criteria including minimum score and completion percentage requirements. Issue Link: #36995
1 parent 900706b commit 637f32b

File tree

7 files changed

+747
-29
lines changed

7 files changed

+747
-29
lines changed

cms/djangoapps/contentstore/tasks.py

Lines changed: 274 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""
22
This file contains celery tasks for contentstore views
33
"""
4-
4+
from pytz import UTC
5+
from datetime import datetime
56
import asyncio
67
import base64
78
import json
@@ -99,6 +100,7 @@
99100
from .outlines_regenerate import CourseOutlineRegenerate
100101
from .toggles import bypass_olx_failure_enabled
101102
from .utils import course_import_olx_validation_is_enabled
103+
import logging
102104

103105
User = get_user_model()
104106

@@ -466,6 +468,7 @@ def create_export_tarball(course_block, course_key, context, status=None):
466468
return export_file
467469

468470

471+
469472
class CourseImportTask(UserTask): # pylint: disable=abstract-method
470473
"""
471474
Base class for course and library import tasks.
@@ -529,11 +532,271 @@ def sync_discussion_settings(course_key, user):
529532
except Exception as exc: # pylint: disable=broad-except
530533
LOGGER.info(f'Course import {course.id}: DiscussionsConfiguration sync failed: {exc}')
531534

535+
# ========== PREREQUISITE IMPORT FUNCTIONS ==========
536+
537+
538+
539+
def _set_prerequisite_fields(sequential, is_prerequisite=False, serves_for_location=None):
540+
"""
541+
Set prerequisite-related fields on a sequential block.
542+
This function tries multiple possible field names.
543+
"""
544+
field_set_success = False
545+
546+
# Try different field names for is_prerequisite ## TODO DEBUGGING ONLY I WILL HAVE TO CLEAN IT LATER
547+
possible_prereq_fields = [
548+
'is_prereq', 'is_prerequisite', 'prereq', 'prerequisite',
549+
'is_gating_prerequisite', 'gating_prereq'
550+
]
551+
552+
for field_name in possible_prereq_fields:
553+
if hasattr(sequential, field_name):
554+
setattr(sequential, field_name, is_prerequisite)
555+
logging.info(f"MOOVE: ✓ Set {field_name} = {is_prerequisite}")
556+
field_set_success = True
557+
break
558+
559+
# Try different field names for serves_as_prereq_for ## TODO DEBUGGING ONLY I WILL HAVE TO CLEAN IT LATER
560+
if serves_for_location:
561+
possible_serves_fields = [
562+
'serves_as_prereq_for', 'prereq_for', 'gating_prereq_for',
563+
'required_for', 'milestone_for'
564+
]
565+
566+
for field_name in possible_serves_fields:
567+
if hasattr(sequential, field_name):
568+
setattr(sequential, field_name, str(serves_for_location))
569+
logging.info(f"MOOVE: ✓ Set {field_name} = {serves_for_location}")
570+
field_set_success = True
571+
break
572+
573+
if not field_set_success:
574+
logging.warning("MOOVE: ✗ No prerequisite fields found to set")
575+
576+
return field_set_success
577+
578+
def _find_sequential_by_url_name(store, course_key, url_name):
579+
"""
580+
Find a sequential block by its url_name (block_id).
581+
"""
582+
logging.info(f"MOOVE: Looking for sequential with url_name: {url_name}")
583+
584+
# Method 1: Try qualifier search
585+
try:
586+
sequentials = store.get_items(
587+
course_key,
588+
qualifiers={'category': 'sequential', 'name': url_name}
589+
)
590+
if sequentials:
591+
logging.info(f"MOOVE: ✓ Found via qualifier: {sequentials[0].location}")
592+
return sequentials[0]
593+
except Exception as e:
594+
logging.info(f"MOOVE: Qualifier search failed: {e}")
595+
596+
# Method 2: Manual search by block_id
597+
all_sequentials = store.get_items(
598+
course_key,
599+
qualifiers={'category': 'sequential'}
600+
)
601+
602+
for sequential in all_sequentials:
603+
if sequential.location.block_id == url_name:
604+
logging.info(f"MOOVE: ✓ Found via block_id match: {sequential.location}")
605+
return sequential
606+
607+
# Method 3: Try to construct the usage key directly
608+
try:
609+
sequential_key = BlockUsageLocator(
610+
course_key=course_key,
611+
block_type='sequential',
612+
block_id=url_name
613+
)
614+
sequential = store.get_item(sequential_key)
615+
if sequential:
616+
logging.info(f"MOOVE: ✓ Found via direct key: {sequential.location}")
617+
return sequential
618+
except Exception as e:
619+
logging.info(f"MOOVE: Direct key lookup failed: {e}")
620+
621+
logging.warning(f"MOOVE: ✗ Sequential not found with url_name: {url_name} after all methods")
622+
623+
return None
624+
625+
def _store_prerequisite_fallback(store, user_id, gated_sequential, required_sequential, prerequisite_info):
626+
"""
627+
Fallback method to store prerequisite information when GatingService is not available.
628+
"""
629+
try:
630+
logging.info("MOOVE: === FALLBACK PREREQUISITE STORAGE ===")
631+
632+
# Store the relationship information on the GATED sequential
633+
if hasattr(gated_sequential, 'imported_prerequisite'):
634+
gated_sequential.imported_prerequisite = {
635+
**prerequisite_info,
636+
'required_sequential': str(required_sequential.location),
637+
'setup_time': datetime.now(UTC).isoformat(),
638+
'using_fallback': True
639+
}
640+
641+
# Mark the REQUIRED sequential as being a prerequisite - THIS IS CRITICAL
642+
if hasattr(required_sequential, 'is_prerequisite'):
643+
required_sequential.is_prerequisite = True
644+
logging.info(f"MOOVE: Fallback - Set is_prerequisite = True on {required_sequential.location}")
645+
else:
646+
logging.warning("MOOVE: Fallback - Required sequential doesn't have is_prerequisite field")
647+
648+
# Store which sequential this serves as prerequisite for
649+
if hasattr(required_sequential, 'prerequisite_usage_key'):
650+
required_sequential.prerequisite_usage_key = str(gated_sequential.location)
651+
logging.info(f"MOOVE: Fallback - Set prerequisite_usage_key = {gated_sequential.location}")
652+
653+
# Update both blocks in the modulestore
654+
logging.info(f"MOOVE: Updating gated sequential: {gated_sequential.location}")
655+
store.update_item(gated_sequential, user_id)
656+
657+
logging.info(f"MOOVE: Updating required sequential: {required_sequential.location}")
658+
store.update_item(required_sequential, user_id)
659+
660+
return True
661+
662+
except Exception as e:
663+
logging.error(f"MOOVE: Fallback storage failed: {e}")
664+
return False
665+
666+
667+
def _setup_prerequisite_relationship(store, user_id, sequential, prerequisite_info):
668+
"""
669+
Set up prerequisite relationship for a sequential block.
670+
"""
671+
try:
672+
required_sequential_url = prerequisite_info.get('required_sequential_url')
673+
min_score = prerequisite_info.get('min_score')
674+
min_completion = prerequisite_info.get('min_completion')
675+
676+
if not required_sequential_url:
677+
return False
678+
679+
# Find the required sequential
680+
required_sequential = _find_sequential_by_url_name(
681+
store, sequential.location.course_key, required_sequential_url
682+
)
683+
684+
if not required_sequential:
685+
return False
686+
687+
# METHOD 1: Set the prerequisite field
688+
if hasattr(required_sequential, 'is_prerequisite'):
689+
required_sequential.is_prerequisite = True
690+
store.update_item(required_sequential, user_id)
691+
692+
# METHOD 2: Use the gating API to create the actual relationship
693+
try:
694+
success = _create_proper_gating_milestone(
695+
store, user_id,
696+
sequential, required_sequential,
697+
min_score,
698+
min_completion
699+
)
700+
701+
if success:
702+
return True
703+
else:
704+
return _store_prerequisite_fallback(store, user_id, sequential, required_sequential, prerequisite_info)
705+
706+
except Exception:
707+
return _store_prerequisite_fallback(store, user_id, sequential, required_sequential, prerequisite_info)
708+
709+
return True
710+
711+
except Exception:
712+
return False
713+
714+
def _process_course_prerequisites(course_key, user_id):
715+
"""
716+
Process all prerequisite relationships in the course after import.
717+
"""
718+
719+
try:
720+
store = modulestore()
721+
722+
# Get all sequential blocks
723+
sequentials = store.get_items(
724+
course_key,
725+
qualifiers={'category': 'sequential'}
726+
)
727+
728+
logging.info(f"MOOVE: Found {len(sequentials)} sequential blocks to process")
729+
730+
prerequisite_count = 0
731+
for sequential in sequentials:
732+
if hasattr(sequential, 'prerequisite') and sequential.prerequisite:
733+
prerequisite_info = sequential.prerequisite
734+
required_sequential_url = prerequisite_info.get('required_sequential_url')
735+
736+
if required_sequential_url:
737+
logging.info(f"MOOVE: ✓ Processing prerequisite for {sequential.location}")
738+
739+
# Try multiple times with delays (in case of timing issues)
740+
max_retries = 3
741+
for attempt in range(max_retries):
742+
logging.info(f"MOOVE: Attempt {attempt + 1} to find required sequential")
743+
required_sequential = _find_sequential_by_url_name(
744+
store, course_key, required_sequential_url
745+
)
746+
747+
if required_sequential:
748+
logging.info(f"MOOVE: ✓ Found required sequential on attempt {attempt + 1}")
749+
success = _setup_prerequisite_relationship(
750+
store, user_id, sequential, prerequisite_info
751+
)
752+
if success:
753+
prerequisite_count += 1
754+
logging.info(f"MOOVE: ✓ Successfully processed prerequisite for {sequential.location}")
755+
else:
756+
logging.error(f"MOOVE: ✗ Failed to process prerequisite for {sequential.location}")
757+
break
758+
else:
759+
if attempt < max_retries - 1:
760+
logging.info(f"MOOVE: Required sequential not found, retrying... (attempt {attempt + 1}/{max_retries})")
761+
import time
762+
time.sleep(1) # Wait 1 second before retry
763+
else:
764+
logging.error(f"MOOVE: ✗ Required sequential not found after {max_retries} attempts: {required_sequential_url}")
765+
766+
return prerequisite_count
767+
768+
except Exception as e:
769+
logging.error(f"MOOVE: Error processing prerequisites for course {course_key}: {str(e)}")
770+
return 0
771+
772+
773+
def _create_proper_gating_milestone(store, user_id, gated_sequential, required_sequential, min_score, min_completion):
774+
"""
775+
Create the proper gating milestones that Studio actually checks.
776+
"""
777+
try:
778+
from openedx.core.lib.gating import api as gating_api
779+
780+
course_key = gated_sequential.location.course_key
781+
782+
# Create the prerequisite milestone on the required sequential
783+
gating_api.add_prerequisite(course_key, required_sequential.location)
784+
785+
# Create the gating relationship
786+
gating_api.set_required_content(
787+
course_key,
788+
gated_sequential.location,
789+
required_sequential.location,
790+
min_score,
791+
min_completion
792+
)
793+
794+
return True
795+
796+
except Exception:
797+
return False
532798

533799
@shared_task(base=CourseImportTask, bind=True)
534-
# Note: The decorator @set_code_owner_attribute cannot be used here because the UserTaskMixin
535-
# does stack inspection and can't handle additional decorators.
536-
# lint-amnesty, pylint: disable=too-many-statements
537800
def import_olx(self, user_id, course_key_string, archive_path, archive_name, language):
538801
"""
539802
Import a course or library from a provided OLX .tar.gz or .zip archive.
@@ -732,7 +995,7 @@ def read_chunk():
732995
LOGGER.info(f'{log_prefix}: Extracted file verified. Updating course started')
733996

734997
courselike_items = import_func(
735-
modulestore(), user.id,
998+
modulestore(), user_id,
736999
settings.GITHUB_REPO_ROOT, [dirpath],
7371000
load_error_blocks=False,
7381001
static_content_store=contentstore(),
@@ -743,6 +1006,11 @@ def read_chunk():
7431006
new_location = courselike_items[0].location
7441007
LOGGER.debug('new course at %s', new_location)
7451008

1009+
# Process prerequisites after successful import
1010+
if is_course:
1011+
prerequisite_count = _process_course_prerequisites(courselike_key, user_id)
1012+
LOGGER.info(f'{log_prefix}: Import completed with {prerequisite_count} prerequisites processed')
1013+
7461014
LOGGER.info(f'{log_prefix}: Course import successful')
7471015
set_custom_attribute('course_import_completed', True)
7481016
except (CourseImportException, InvalidProctoringProvider, DuplicateCourseError) as known_exe:
@@ -769,10 +1037,10 @@ def read_chunk():
7691037
from .views.entrance_exam import add_entrance_exam_milestone
7701038
add_entrance_exam_milestone(course.id, entrance_exam_chapter)
7711039
LOGGER.info(f'Course import {course.id}: Entrance exam imported')
1040+
7721041
if is_course:
7731042
sync_discussion_settings(courselike_key, user)
7741043

775-
7761044
@shared_task
7771045
@set_code_owner_attribute
7781046
def update_all_outlines_from_modulestore_task():

cms/djangoapps/contentstore/utils.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@
116116

117117
from .models import ComponentLink, ContainerLink
118118

119+
from openedx.core.lib.gating.services import GatingService
120+
119121
IMPORTABLE_FILE_TYPES = ('.tar.gz', '.zip')
120122
log = logging.getLogger(__name__)
121123

@@ -1319,7 +1321,8 @@ def load_services_for_studio(runtime, user):
13191321
"settings": SettingsService(),
13201322
"lti-configuration": ConfigurationService(CourseAllowPIISharingInLTIFlag),
13211323
"teams_configuration": TeamsConfigurationService(),
1322-
"library_tools": LegacyLibraryToolsService(modulestore(), user.id)
1324+
"library_tools": LegacyLibraryToolsService(modulestore(), user.id),
1325+
'gating': GatingService(),
13231326
}
13241327

13251328
runtime._services.update(services) # lint-amnesty, pylint: disable=protected-access

0 commit comments

Comments
 (0)