Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
"""
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
)
)
Comment thread
marslanabdulrauf marked this conversation as resolved.


def listen_for_library_v2_updated(**kwargs):
Expand Down
20 changes: 18 additions & 2 deletions src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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}"
Expand All @@ -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"]:
Expand Down
27 changes: 22 additions & 5 deletions src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -25,6 +28,7 @@
REPOSITORY_NAME_MAX_LENGTH,
ContentType,
)
from ol_openedx_git_auto_export.exceptions import ContentNotFoundError

log = logging.getLogger(__name__)

Expand All @@ -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,
Expand Down
Loading