diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/exceptions.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/exceptions.py new file mode 100644 index 000000000..8e056e2a4 --- /dev/null +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/exceptions.py @@ -0,0 +1,15 @@ +""" +Custom exceptions for the ol_openedx_git_auto_export app. +""" + + +class ContentNotFoundError(Exception): + """ + Raised when a course or library cannot be found via the modulestore or + the content_libraries API. + + This is expected to happen transiently right after a course/library is + created: the creation signal (e.g. CONTENT_LIBRARY_CREATED) can fire + before the DB transaction that wrote the row has committed, so an async + Celery task racing against that commit finds nothing. + """ diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/settings/common.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/settings/common.py index bd0c8d5ec..1013f4992 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/settings/common.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/settings/common.py @@ -16,6 +16,9 @@ def plugin_settings(settings): ) settings.GITHUB_ORG_API_URL = env_tokens.get("GITHUB_ORG_API_URL", "") settings.GITHUB_ACCESS_TOKEN = env_tokens.get("GITHUB_ACCESS_TOKEN") + settings.GIT_AUTO_EXPORT_AUTHORING_URL_PREFIX = env_tokens.get( + "GIT_AUTO_EXPORT_AUTHORING_URL_PREFIX", "authoring" + ) # Course-specific settings settings.FEATURES[ENABLE_GIT_AUTO_EXPORT] = env_tokens.get("FEATURES", {}).get( diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/settings/production.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/settings/production.py index caf518b49..c5f8c5d4d 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/settings/production.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/settings/production.py @@ -16,6 +16,9 @@ def plugin_settings(settings): ) settings.GITHUB_ORG_API_URL = env_tokens.get("GITHUB_ORG_API_URL", "") settings.GITHUB_ACCESS_TOKEN = env_tokens.get("GITHUB_ACCESS_TOKEN") + settings.GIT_AUTO_EXPORT_AUTHORING_URL_PREFIX = env_tokens.get( + "GIT_AUTO_EXPORT_AUTHORING_URL_PREFIX", "authoring" + ) # Course-specific settings settings.FEATURES[ENABLE_GIT_AUTO_EXPORT] = env_tokens.get("FEATURES", {}).get( diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/signals.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/signals.py index 27ee2326a..dc72c0967 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/signals.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/signals.py @@ -9,6 +9,7 @@ import logging from common.djangoapps.course_action_state.models import CourseRerunState +from django.db import transaction from django.db.models.signals import post_save from django.dispatch import receiver @@ -45,7 +46,7 @@ def listen_for_course_created(**kwargs): course_key = kwargs.get("course").course_key if is_auto_repo_creation_enabled(): - async_create_github_repo.delay(str(course_key)) + transaction.on_commit(lambda: async_create_github_repo.delay(str(course_key))) @receiver(post_save, sender=CourseRerunState) @@ -58,7 +59,10 @@ def listen_for_course_rerun_state_post_save(sender, instance, **kwargs): # noqa return if is_auto_repo_creation_enabled(): - async_create_github_repo.delay(str(instance.course_key), export_content=True) + course_key = instance.course_key + transaction.on_commit( + lambda: async_create_github_repo.delay(str(course_key), export_content=True) + ) # Library Signal Receivers @@ -96,7 +100,18 @@ def listen_for_library_v2_created(**kwargs): log.info("Library v2 created signal received for library: %s", library_key) if is_auto_repo_creation_enabled(is_library=True): - async_create_github_repo.delay(str(library_key), export_content=True) + # CONTENT_LIBRARY_CREATED fires synchronously inside the request's + # DB transaction (Studio views run with ATOMIC_REQUESTS=True). If we + # dispatch the Celery task immediately, a worker can query the + # library (via a separate DB connection) before this transaction + # commits, raising a spurious "library not found". + # Deferring the dispatch to on_commit ensures the row is visible + # by the time the task runs. + transaction.on_commit( + lambda: async_create_github_repo.delay( + str(library_key), export_content=True + ) + ) def listen_for_library_v2_updated(**kwargs): diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/tasks.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/tasks.py index 562a6abf6..b5dec349d 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/tasks.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/tasks.py @@ -13,6 +13,7 @@ from opaque_keys.edx.keys import LearningContextKey from rest_framework import status +from ol_openedx_git_auto_export.exceptions import ContentNotFoundError from ol_openedx_git_auto_export.models import ContentGitRepository from ol_openedx_git_auto_export.utils import ( clear_stale_git_lock, @@ -32,10 +33,18 @@ def async_export_to_git(context_key_string, user=None): context_key_string (str): String representation of LearningContextKey user: Optional user for git export """ - # Parse as LearningContextKey to support all learning contexts try: context_key = LearningContextKey.from_string(context_key_string) content_info = get_content_info(context_key) + except ContentNotFoundError: + # Expected transiently if this task races the DB transaction that + # created the content (e.g. dispatched from a signal handler before + # commit). + LOGGER.warning( + "Content %s not found yet; skipping this export attempt. ", + context_key_string, + ) + return except Exception: LOGGER.exception("Failed to parse content key: %s", context_key_string) return @@ -115,6 +124,10 @@ def async_create_github_repo(self, context_key_str, export_content=False): # no try: context_key = LearningContextKey.from_string(context_key_str) content_info = get_content_info(context_key) + except ContentNotFoundError as exc: + response_msg = f"Content {context_key_str} not found: {exc}" + LOGGER.warning(response_msg) + return False, response_msg except Exception: LOGGER.exception("Failed to parse context key: %s", context_key_str) return False, f"Invalid context key: {context_key_str}" @@ -129,7 +142,10 @@ def async_create_github_repo(self, context_key_str, export_content=False): # no return False, response_msg # Determine URL path based on content type - url_path = f"{content_info['content_type']}/{context_key_str}" + url_path = ( + f"{settings.GIT_AUTO_EXPORT_AUTHORING_URL_PREFIX}" + f"/{content_info['content_type']}/{context_key_str}" + ) # Get display name (v2 libraries use 'title', others use 'display_name') if content_info["is_v2_library"]: diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/utils.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/utils.py index af54ecf3c..1e53ddcc8 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/utils.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/utils.py @@ -12,7 +12,10 @@ from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2 -from openedx.core.djangoapps.content_libraries.api import get_library +from openedx.core.djangoapps.content_libraries.api import ( + ContentLibraryNotFound, + get_library, +) from xmodule.modulestore.django import modulestore from ol_openedx_git_auto_export.constants import ( @@ -25,6 +28,7 @@ REPOSITORY_NAME_MAX_LENGTH, ContentType, ) +from ol_openedx_git_auto_export.exceptions import ContentNotFoundError log = logging.getLogger(__name__) @@ -43,23 +47,36 @@ def get_content_info(content_key): - is_v1_library: Boolean flag - is_v2_library: Boolean flag - is_library: Boolean flag (True if v1 or v2 library) + + Raises: + ContentNotFoundError: If the course/library isn't found. """ is_v1_library = isinstance(content_key, LibraryLocator) is_v2_library = isinstance(content_key, LibraryLocatorV2) # Get the content module based on type if is_v2_library: - # V2 libraries use content_libraries API - content_module = get_library(content_key) + # V2 libraries use content_libraries API, which raises + # ContentLibraryNotFound (a DoesNotExist alias) rather than + # returning None. + try: + content_module = get_library(content_key) + except ContentLibraryNotFound as exc: + msg = f"Library {content_key} not found via content_libraries API." + raise ContentNotFoundError(msg) from exc content_type = ContentType.LIBRARY.value elif is_v1_library: - # V1 libraries use modulestore content_module = modulestore().get_library(content_key) content_type = ContentType.LIBRARY.value + if content_module is None: + msg = f"Library {content_key} not found in modulestore." + raise ContentNotFoundError(msg) else: - # Courses use modulestore content_module = modulestore().get_course(content_key) content_type = ContentType.COURSE.value + if content_module is None: + msg = f"Course {content_key} not found in modulestore." + raise ContentNotFoundError(msg) return { "content_type": content_type,