From f95bc042371cd5866a90bd54fca1e795a8317232 Mon Sep 17 00:00:00 2001 From: Muhammad Arslan Abdul Rauf Date: Wed, 18 Feb 2026 21:05:34 +0500 Subject: [PATCH 01/11] feat: library export support being added --- .../CONFIGURATION.md | 103 +++++++++++ src/ol_openedx_git_auto_export/README.rst | 59 ++++++- .../ol_openedx_git_auto_export/admin.py | 64 +++++-- .../ol_openedx_git_auto_export/app.py | 32 ++++ .../ol_openedx_git_auto_export/constants.py | 4 + .../management/commands/migrate_giturl.py | 14 +- .../migrations/0002_contentgitrepository.py | 49 ++++++ .../0003_migrate_course_to_content_repo.py | 94 ++++++++++ .../0004_delete_coursegitrepository.py | 15 ++ .../ol_openedx_git_auto_export/models.py | 17 +- .../settings/common.py | 12 ++ .../settings/production.py | 12 ++ .../ol_openedx_git_auto_export/signals.py | 116 ++++++++++++- .../ol_openedx_git_auto_export/tasks.py | 161 +++++++++++++----- .../ol_openedx_git_auto_export/utils.py | 94 +++++++++- 15 files changed, 767 insertions(+), 79 deletions(-) create mode 100644 src/ol_openedx_git_auto_export/CONFIGURATION.md create mode 100644 src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py create mode 100644 src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0003_migrate_course_to_content_repo.py create mode 100644 src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0004_delete_coursegitrepository.py diff --git a/src/ol_openedx_git_auto_export/CONFIGURATION.md b/src/ol_openedx_git_auto_export/CONFIGURATION.md new file mode 100644 index 000000000..ac762bd6a --- /dev/null +++ b/src/ol_openedx_git_auto_export/CONFIGURATION.md @@ -0,0 +1,103 @@ +# Configuration Guide: Feature Flags + +This document describes the available feature flags for controlling git auto-export behavior for courses and libraries. + +## Feature Flags Overview + +### Course-Specific Flags + +#### `ENABLE_GIT_AUTO_EXPORT` +- **Type**: Boolean +- **Default**: `False` +- **Purpose**: Controls automatic git export for courses when they are published +- **Scope**: Courses only (unless library flag not set, see below) +- **Location**: `settings.FEATURES['ENABLE_GIT_AUTO_EXPORT']` + +**Example**: +```python +FEATURES['ENABLE_GIT_AUTO_EXPORT'] = True +``` + +#### `ENABLE_AUTO_GITHUB_REPO_CREATION` +- **Type**: Boolean +- **Default**: `False` +- **Purpose**: Controls automatic GitHub repository creation for new courses +- **Scope**: Courses only (unless library flag not set, see below) +- **Location**: `settings.FEATURES['ENABLE_AUTO_GITHUB_REPO_CREATION']` + +**Example**: +```python +FEATURES['ENABLE_AUTO_GITHUB_REPO_CREATION'] = True +``` + +### Library-Specific Flags + +#### `ENABLE_GIT_AUTO_LIBRARY_EXPORT` +- **Type**: Boolean +- **Default**: False +- **Purpose**: Controls automatic git export for libraries when they are updated +- **Scope**: Libraries only +- **Location**: `settings.FEATURES['ENABLE_GIT_AUTO_LIBRARY_EXPORT']` + +**Example**: +```python +# Enable library export separately from courses +FEATURES['ENABLE_GIT_AUTO_LIBRARY_EXPORT'] = True + +# Or disable library export while keeping course export enabled +FEATURES['ENABLE_GIT_AUTO_EXPORT'] = True +FEATURES['ENABLE_GIT_AUTO_LIBRARY_EXPORT'] = False +``` + +#### `ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION` +- **Type**: Boolean +- **Default**: False +- **Purpose**: Controls automatic GitHub repository creation for new libraries +- **Scope**: Libraries only +- **Location**: `settings.FEATURES['ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION']` + +**Example**: +```python +# Enable library repo creation separately from courses +FEATURES['ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION'] = True + +# Or disable library repo creation while keeping course repo creation enabled +FEATURES['ENABLE_AUTO_GITHUB_REPO_CREATION'] = True +FEATURES['ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION'] = False +``` + +### Required Settings (for both courses and libraries) + +#### `ENABLE_EXPORT_GIT` +- **Type**: Boolean +- **Purpose**: Master switch for git export functionality +- **Note**: Must be enabled for any git export to work +- **Location**: `settings.FEATURES['ENABLE_EXPORT_GIT']` + +#### `GITHUB_ORG_API_URL` +- **Type**: String (URL) +- **Purpose**: GitHub organization API URL for creating repositories +- **Example**: `https://api.github.com/orgs/your-org` +- **Location**: `settings.GITHUB_ORG_API_URL` + +#### `GITHUB_ACCESS_TOKEN` +- **Type**: String (Token) +- **Purpose**: GitHub personal access token with repo creation permissions +- **Location**: `settings.GITHUB_ACCESS_TOKEN` +- **Security**: Should be stored securely (e.g., environment variable, secrets management) + +#### `GIT_REPO_EXPORT_DIR` +- **Type**: String (Path) +- **Purpose**: Directory path for git export operations +- **Default**: `/openedx/export_course_repos` +- **Location**: `settings.GIT_REPO_EXPORT_DIR` + +## Security Considerations + +- **Never commit `GITHUB_ACCESS_TOKEN` to version control** +- Use environment variables or secrets management +- Ensure GitHub token has minimum required permissions: + - `repo` scope for repository creation + - `write:org` if creating repos in an organization +- Rotate tokens regularly +- Use different tokens for different environments (dev/staging/prod) diff --git a/src/ol_openedx_git_auto_export/README.rst b/src/ol_openedx_git_auto_export/README.rst index fca1ab47b..ec7e005c3 100644 --- a/src/ol_openedx_git_auto_export/README.rst +++ b/src/ol_openedx_git_auto_export/README.rst @@ -47,7 +47,7 @@ https://help.github.com/articles/adding-a-new-ssh-key-to-your-github-account) Studio/CMS UI settings ---------------------- -- Open studio admin at `/admin/ol_openedx_git_auto_export/coursegitrepository/` +- Open studio admin at ``/admin/ol_openedx_git_auto_export/contentgitrepository/`` - Add your course_id and in the GIT URL, add your OLX git repo. For example ``git@github.com:/edx4edxlite.git``. - Make a change to the course content and publish. - When using Tutor, attach with the CMS container using ``tutor dev/local start cms`` and enter ``yes`` to the prompt to add the GitHub to known hosts. @@ -55,3 +55,60 @@ Studio/CMS UI settings - Commit user should be the one that published the change. - If user is not available, it should be the default one set in ``GIT_EXPORT_DEFAULT_IDENT``. - Test commit count increase on your OLX repo. +Library Support +=============== + +This plugin supports automatic git export for both **courses** and **content libraries**. + +Library v1 (Legacy Libraries) +------------------------------ + +**Library Key Format:** ``library-v1:org+library`` + +**Supported Operations:** + +- ✅ **Auto-export on update**: When you update/publish a library v1, changes are automatically exported to git +- ✅ **Auto-repo creation**: Library v1 does NOT have a creation signal in Open edX, so the GitHub repository is automatically created on the first library update signal + +**Setup for Library v1:** + +1. Enable the library feature flags in your config: + + .. code-block:: + + "FEATURES": { + "ENABLE_GIT_AUTO_LIBRARY_EXPORT": true, + "ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION": true + } + +2. Make changes to your library and publish - on the first update, a GitHub repository will be automatically created and the changes will be exported to git + +Library v2 (Content Libraries) +------------------------------- + +**Library Key Format:** ``lib:org:slug`` + +**Supported Operations:** + +- ✅ **Auto-export on update**: When you update a library v2, changes are automatically exported to git +- ✅ **Auto-repo creation**: When you create a new library v2, a GitHub repository can be automatically created + +**Setup for Library v2:** + +1. Enable the library feature flags in your config: + + .. code-block:: + + "FEATURES": { + "ENABLE_GIT_AUTO_LIBRARY_EXPORT": true, + "ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION": true + } + +2. When creating a new library v2, if ``ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION`` is enabled, a GitHub repository will be created automatically +3. Updates to the library will be automatically exported to git + +**Important Notes:** + +- Library v1 does not emit a creation signal, so the GitHub repository is automatically created on the first library update +- Library v2 uses the new event architecture and supports both creation and update signals for immediate repository creation +- Both library types support automatic git export on updates once configured diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/admin.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/admin.py index eb9d578c3..3855e2c2c 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/admin.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/admin.py @@ -3,32 +3,74 @@ """ from django.contrib import admin +from django.contrib.admin import SimpleListFilter +from django.db.models import Q +from opaque_keys.edx.locator import LibraryLocator -from ol_openedx_git_auto_export.models import CourseGitRepository +from ol_openedx_git_auto_export.models import ContentGitRepository -@admin.register(CourseGitRepository) -class CourseGitRepositoryAdmin(admin.ModelAdmin): +class ContentTypeFilter(SimpleListFilter): + """Filter for content type (Course or Library).""" + + title = "content type" + parameter_name = "content_type" + + def lookups(self, request, model_admin): # noqa: ARG002 + """Return filter options.""" + return ( + ("course", "Course"), + ("library", "Library"), + ) + + def queryset(self, request, queryset): # noqa: ARG002 + """Filter the queryset based on the selected content type.""" + if self.value() == "course": + # Filter for courses (exclude libraries) + return queryset.exclude(content_key__startswith="library-v1:").exclude( + content_key__startswith="lib:" # lib v2 + ) + elif self.value() == "library": + # Filter for libraries + return queryset.filter( + Q(content_key__startswith="library-v1:") + | Q(content_key__startswith="lib:") # lib v2 + ) + return queryset + + +@admin.register(ContentGitRepository) +class ContentGitRepositoryAdmin(admin.ModelAdmin): """ - Admin interface for the CourseGitRepository model. + Admin interface for the ContentGitRepository model. + + This model supports both courses and libraries. """ list_display = ( - "course_key", + "content_key", "git_url", "is_export_enabled", + "content_type_display", ) - search_fields = ("course_key", "git_url") - list_filter = ("is_export_enabled",) + search_fields = ("content_key", "git_url") + list_filter = ("is_export_enabled", ContentTypeFilter) list_per_page = 50 + @admin.display(description="Content Type") + def content_type_display(self, obj): + """Display whether the content is a course or library.""" + if isinstance(obj.content_key, LibraryLocator): + return "Library" + return "Course" + def has_delete_permission(self, request, obj=None): # noqa: ARG002 """ - Disable delete permission for CourseGitRepository objects. + Disable delete permission for ContentGitRepository objects. - Deleting a CourseGitRepository could lead to orphaned repositories - on GitHub and loss of course export functionality. + Deleting a ContentGitRepository could lead to orphaned repositories + on GitHub and loss of export functionality. - To stop exporting a course, set `is_export_enabled` to False + To stop exporting content, set `is_export_enabled` to False """ return False diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py index 58d79d19b..08f551df1 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py @@ -36,6 +36,38 @@ class GitAutoExportConfig(AppConfig): PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.COURSE_CREATED", # noqa: E501 PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_course_created", # noqa: E501 }, + # Library Signals + # NOTE: Library v1 (library-v1:) only has LIBRARY_UPDATED, no creation signal # noqa: E501 + # Library v2 (lib:) has: + # - CONTENT_LIBRARY_CREATED/UPDATED: for library metadata changes + # - LIBRARY_BLOCK_CREATED/UPDATED: for block/component changes + { + PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_updated", + PluginSignals.SIGNAL_PATH: "xmodule.modulestore.django.LIBRARY_UPDATED", # noqa: E501, library v1 update + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_updated", # noqa: E501 + }, + # lib V2 - Library-level signals + { + PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_v2_created", # noqa: E501 + PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.CONTENT_LIBRARY_CREATED", # noqa: E501, library v2 only + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_v2_created", # noqa: E501 + }, + { + PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_v2_updated", # noqa: E501 + PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.CONTENT_LIBRARY_UPDATED", # noqa: E501, library v2 metadata only + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_v2_updated", # noqa: E501 + }, + # lib V2 - Block-level signals (for component add/update/delete) + { + PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_block_created", # noqa: E501 + PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.LIBRARY_BLOCK_CREATED", # noqa: E501 + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_block_created", # noqa: E501 + }, + { + PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_block_updated", # noqa: E501 + PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.LIBRARY_BLOCK_UPDATED", # noqa: E501 + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_block_updated", # noqa: E501 + }, ], }, }, diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/constants.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/constants.py index 16def68a1..1b8a0b768 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/constants.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/constants.py @@ -3,6 +3,10 @@ GITHUB_ORG = "GITHUB_ORG" GITHUB_ACCESS_TOKEN = "GITHUB_ACCESS_TOKEN" # noqa: S105 +# Library-specific feature flags +ENABLE_GIT_AUTO_LIBRARY_EXPORT = "ENABLE_GIT_AUTO_LIBRARY_EXPORT" +ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION = "ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION" + COURSE_RERUN_STATE_SUCCEEDED = "succeeded" REPOSITORY_NAME_MAX_LENGTH = 100 # Max length from GitHub for repo name diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/management/commands/migrate_giturl.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/management/commands/migrate_giturl.py index 197f0a94a..38a26ca11 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/management/commands/migrate_giturl.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/management/commands/migrate_giturl.py @@ -1,9 +1,9 @@ """ Django management command for migrating git URLs -from course advance settings to CourseGitRepository model. +from course advance settings to ContentGitRepository model. This command scans existing courses in the modulestore for git URLs and migrates them -to the CourseGitRepository model. It handles duplicate git URLs by creating new +to the ContentGitRepository model. It handles duplicate git URLs by creating new GitHub repositories for courses that would otherwise share the same repository. Usage: @@ -20,7 +20,7 @@ The command will: 1. Query all courses (or specified courses) from CourseOverview 2. Extract git URLs from the course modulestore data -3. Create CourseGitRepository records for courses with git URLs +3. Create ContentGitRepository records for courses with git URLs 4. Handle duplicate git URLs by creating new GitHub repositories 5. Create new GitHub repositories for courses without git URLs @@ -33,7 +33,7 @@ from celery import group from django.core.management.base import BaseCommand -from ol_openedx_git_auto_export.models import CourseGitRepository +from ol_openedx_git_auto_export.models import ContentGitRepository from ol_openedx_git_auto_export.tasks import ( async_create_github_repo, ) @@ -44,7 +44,7 @@ class Command(BaseCommand): help = """ - Migrate git URL from course(s) advanced settings to CourseGitRepository model + Migrate git URL from course(s) advanced settings to ContentGitRepository model """ def add_arguments(self, parser): @@ -126,8 +126,8 @@ def handle(self, *args, **options): # noqa: ARG002 migration_logs["existing_repos"].append( {"course_id": str(course.id), "giturl": giturl} ) - CourseGitRepository.objects.get_or_create( - course_key=course.id, + ContentGitRepository.objects.get_or_create( + content_key=course.id, defaults={"git_url": giturl}, ) continue diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py new file mode 100644 index 000000000..4e829f391 --- /dev/null +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py @@ -0,0 +1,49 @@ +# Generated migration for ContentGitRepository model + +import django.utils.timezone +import model_utils.fields +import opaque_keys.edx.django.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("ol_openedx_git_auto_export", "0001_initial"), + ] # type: ignore # noqa: PGH003 + + operations = [ + migrations.CreateModel( + name="ContentGitRepository", + fields=[ + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ( + "content_key", + opaque_keys.edx.django.models.LearningContextKeyField( + max_length=255, primary_key=True, serialize=False + ), + ), + ("git_url", models.CharField(max_length=255)), + ("is_export_enabled", models.BooleanField(default=True)), + ], + options={ + "verbose_name": "Content Git Repository", + "verbose_name_plural": "Content Git Repositories", + "ordering": ["-created"], + }, + ), + ] diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0003_migrate_course_to_content_repo.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0003_migrate_course_to_content_repo.py new file mode 100644 index 000000000..24718ba49 --- /dev/null +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0003_migrate_course_to_content_repo.py @@ -0,0 +1,94 @@ +# Generated migration to migrate data from CourseGitRepository to ContentGitRepository + +from django.db import migrations +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import LibraryLocator + + +def migrate_course_to_content_repos(apps, schema_editor): # noqa: ARG001 + """ + Migrate all CourseGitRepository entries to ContentGitRepository. + + This function: + 1. Copies all data from CourseGitRepository to ContentGitRepository + 2. Preserves all fields including created/modified timestamps + 3. Skips entries that already exist in ContentGitRepository + """ + CourseGitRepository = apps.get_model( + "ol_openedx_git_auto_export", "CourseGitRepository" + ) + ContentGitRepository = apps.get_model( + "ol_openedx_git_auto_export", "ContentGitRepository" + ) + + migrated_count = 0 + skipped_count = 0 + + for course_repo in CourseGitRepository.objects.all(): + # Check if already migrated + if not ContentGitRepository.objects.filter( + content_key=course_repo.course_key + ).exists(): + ContentGitRepository.objects.create( + content_key=course_repo.course_key, + git_url=course_repo.git_url, + is_export_enabled=course_repo.is_export_enabled, + created=course_repo.created, + modified=course_repo.modified, + ) + migrated_count += 1 + else: + skipped_count += 1 + + +def reverse_migration(apps, schema_editor): # noqa: ARG001 + """ + Reverse migration: Copy ContentGitRepository entries back to CourseGitRepository. + + Only migrates entries that have CourseKey (not LibraryLocator). + """ + + CourseGitRepository = apps.get_model( + "ol_openedx_git_auto_export", "CourseGitRepository" + ) + ContentGitRepository = apps.get_model( + "ol_openedx_git_auto_export", "ContentGitRepository" + ) + + migrated_count = 0 + skipped_count = 0 + + for content_repo in ContentGitRepository.objects.all(): + # Parse the content_key to check if it's a course + try: + content_key = CourseKey.from_string(str(content_repo.content_key)) + is_library = isinstance(content_key, LibraryLocator) + + # Only migrate courses, not libraries + if not is_library: + if not CourseGitRepository.objects.filter( + course_key=content_repo.content_key + ).exists(): + CourseGitRepository.objects.create( + course_key=content_repo.content_key, + git_url=content_repo.git_url, + is_export_enabled=content_repo.is_export_enabled, + created=content_repo.created, + modified=content_repo.modified, + ) + migrated_count += 1 + else: + skipped_count += 1 + except Exception: # noqa: BLE001 + # Skip entries that can't be parsed + skipped_count += 1 + + +class Migration(migrations.Migration): + dependencies = [ + ("ol_openedx_git_auto_export", "0002_contentgitrepository"), + ] + + operations = [ + migrations.RunPython(migrate_course_to_content_repos, reverse_migration), + ] diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0004_delete_coursegitrepository.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0004_delete_coursegitrepository.py new file mode 100644 index 000000000..c7cd11718 --- /dev/null +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0004_delete_coursegitrepository.py @@ -0,0 +1,15 @@ +# Generated migration to remove deprecated CourseGitRepository model + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("ol_openedx_git_auto_export", "0003_migrate_course_to_content_repo"), + ] + + operations = [ + migrations.DeleteModel( + name="CourseGitRepository", + ), + ] diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/models.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/models.py index 8d9ae896a..af1efb519 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/models.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/models.py @@ -4,22 +4,25 @@ from django.db import models from model_utils.models import TimeStampedModel -from opaque_keys.edx.django.models import CourseKeyField +from opaque_keys.edx.django.models import LearningContextKeyField -class CourseGitRepository(TimeStampedModel): +class ContentGitRepository(TimeStampedModel): """ - Model to store Git repository information for courses. + Model to store Git repository information for courses and libraries. + + This model uses LearningContextKeyField which supports both CourseKey + and LibraryLocator, making it suitable for both courses and libraries. """ - course_key = CourseKeyField(max_length=255, primary_key=True) + content_key = LearningContextKeyField(max_length=255, primary_key=True) git_url = models.CharField(max_length=255) is_export_enabled = models.BooleanField(default=True) class Meta: - verbose_name = "Course Git Repository" - verbose_name_plural = "Course Git Repositories" + verbose_name = "Content Git Repository" + verbose_name_plural = "Content Git Repositories" ordering = ["-created"] def __str__(self): - return f"CourseGitRepository (course_key={self.course_key})" + return f"ContentGitRepository (content_key={self.content_key})" 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 40b1335bb..bd0c8d5ec 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 @@ -1,8 +1,10 @@ """Common settings unique to the git auto export plugin.""" from ol_openedx_git_auto_export.constants import ( + ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION, ENABLE_AUTO_GITHUB_REPO_CREATION, ENABLE_GIT_AUTO_EXPORT, + ENABLE_GIT_AUTO_LIBRARY_EXPORT, ) @@ -14,9 +16,19 @@ 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") + + # Course-specific settings settings.FEATURES[ENABLE_GIT_AUTO_EXPORT] = env_tokens.get("FEATURES", {}).get( ENABLE_GIT_AUTO_EXPORT, True ) settings.FEATURES[ENABLE_AUTO_GITHUB_REPO_CREATION] = env_tokens.get( "FEATURES", {} ).get(ENABLE_AUTO_GITHUB_REPO_CREATION, False) + + # Library-specific settings (disabled by default) + settings.FEATURES[ENABLE_GIT_AUTO_LIBRARY_EXPORT] = env_tokens.get( + "FEATURES", {} + ).get(ENABLE_GIT_AUTO_LIBRARY_EXPORT, False) + settings.FEATURES[ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION] = env_tokens.get( + "FEATURES", {} + ).get(ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION, False) 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 ad850bd03..caf518b49 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 @@ -1,8 +1,10 @@ """Production settings unique to the git auto export plugin.""" from ol_openedx_git_auto_export.constants import ( + ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION, ENABLE_AUTO_GITHUB_REPO_CREATION, ENABLE_GIT_AUTO_EXPORT, + ENABLE_GIT_AUTO_LIBRARY_EXPORT, ) @@ -14,9 +16,19 @@ 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") + + # Course-specific settings settings.FEATURES[ENABLE_GIT_AUTO_EXPORT] = env_tokens.get("FEATURES", {}).get( ENABLE_GIT_AUTO_EXPORT, True ) settings.FEATURES[ENABLE_AUTO_GITHUB_REPO_CREATION] = env_tokens.get( "FEATURES", {} ).get(ENABLE_AUTO_GITHUB_REPO_CREATION, False) + + # Library-specific settings (disabled by default) + settings.FEATURES[ENABLE_GIT_AUTO_LIBRARY_EXPORT] = env_tokens.get( + "FEATURES", {} + ).get(ENABLE_GIT_AUTO_LIBRARY_EXPORT, False) + settings.FEATURES[ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION] = env_tokens.get( + "FEATURES", {} + ).get(ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION, False) 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 ef943e483..bd3fe1cad 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 @@ -1,9 +1,9 @@ """ Signal handlers for the git auto-export plugin. -This module contains Django signal handlers that respond to course publishing, +This module contains Django signal handlers that respond to course/library publishing, creation, and rerun events to automatically create GitHub repositories and -export course content to them. +export content to them. """ import logging @@ -20,6 +20,7 @@ ) from ol_openedx_git_auto_export.utils import ( export_course_to_git, + export_library_to_git, is_auto_repo_creation_enabled, ) @@ -57,4 +58,113 @@ 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_course=True) + async_create_github_repo.delay(str(instance.course_key), export_content=True) + + +# Library Signal Receivers +def listen_for_library_updated(sender, library_key, **kwargs): # noqa: ARG001 + """ + Receives library update signal and performs export workflow. + + This is triggered when a library is updated/published in Studio. + + Args: + sender: The signal sender + library_key: LibraryLocator - The key of the library that was updated + **kwargs: Additional signal parameters + """ + log.info("Library v1 updated signal received for library: %s", library_key) + export_library_to_git(library_key) + + +def listen_for_library_v2_created(**kwargs): + """ + Handle library v2 created signal to create a GitHub repository for the library. + + NOTE: This is ONLY for Library v2 (lib:org:slug format). + Library v1 (library-v1:org+library format) does NOT have a creation signal. + + This is triggered when a new library v2 is created in Studio via the + CONTENT_LIBRARY_CREATED signal from openedx_events. + + Args: + **kwargs: Signal parameters including 'content_library' with ContentLibraryData + """ + content_library = kwargs.get("content_library") + if content_library: + library_key = content_library.library_key + 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) + + +def listen_for_library_v2_updated(**kwargs): + """ + Handle library v2 metadata updated signal to export content to GitHub repository. + + This is triggered when a library v2 metadata (title, description, etc.) is updated + in Studio via the CONTENT_LIBRARY_UPDATED signal from openedx_events. + + Note: This does NOT fire when blocks/components are added or modified. + For block changes, use listen_for_library_block_updated. + + Args: + **kwargs: Signal parameters including 'content_library' with ContentLibraryData + """ + content_library = kwargs.get("content_library") + if content_library: + library_key = content_library.library_key + log.info( + "Library v2 metadata updated signal received for library: %s", library_key + ) + + export_library_to_git(library_key) + + +def listen_for_library_block_created(**kwargs): + """ + Handle library block created signal to export content to GitHub repository. + + This is triggered when a new block/component is added to a v2 library + via the LIBRARY_BLOCK_CREATED signal from openedx_events. + + Args: + **kwargs: Signal parameters including 'library_block' with LibraryBlockData + """ + library_block = kwargs.get("library_block") + if library_block: + # Extract library key from the usage key + usage_key = library_block.usage_key + library_key = usage_key.context_key + log.info( + "Library block created signal received for block %s in library: %s", + usage_key, + library_key, + ) + + export_library_to_git(library_key) + + +def listen_for_library_block_updated(**kwargs): + """ + Handle library block updated signal to export content to GitHub repository. + + This is triggered when a block/component in a v2 library is modified + via the LIBRARY_BLOCK_UPDATED signal from openedx_events. + + Args: + **kwargs: Signal parameters including 'library_block' with LibraryBlockData + """ + library_block = kwargs.get("library_block") + if library_block: + # Extract library key from the usage key + usage_key = library_block.usage_key + library_key = usage_key.context_key + log.info( + "Library block updated signal received for block %s in library: %s", + usage_key, + library_key, + ) + + export_library_to_git(library_key) 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 71420302f..da98cbd68 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 @@ -8,16 +8,19 @@ import requests from celery import shared_task # pylint: disable=import-error from celery.utils.log import get_task_logger -from cms.djangoapps.contentstore.git_export_utils import GitExportError, export_to_git +from cms.djangoapps.contentstore.git_export_utils import GitExportError from django.conf import settings -from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.keys import LearningContextKey +from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2 +from openedx.core.djangoapps.content_libraries.api import get_library from rest_framework import status from xmodule.modulestore.django import modulestore -from ol_openedx_git_auto_export.models import CourseGitRepository +from ol_openedx_git_auto_export.models import ContentGitRepository from ol_openedx_git_auto_export.utils import ( clear_stale_git_lock, - export_course_to_git, + export_content_to_git, + export_to_git, github_repo_name_format, is_auto_repo_creation_enabled, ) @@ -26,47 +29,78 @@ @shared_task -def async_export_to_git(course_key_string, user=None): +def async_export_to_git(content_key_string, user=None): + """Export a course or library to Git. + + Args: + content_key_string (str): String representation of CourseKey or LibraryLocator + user: Optional user for git export """ - Exports a course to Git. - """ # noqa: D401 - course_key = CourseKey.from_string(course_key_string) - course_module = modulestore().get_course(course_key) + # Parse as LearningContextKey to support all learning contexts + try: + content_key = LearningContextKey.from_string(content_key_string) + is_v1_library = isinstance(content_key, LibraryLocator) + is_v2_library = isinstance(content_key, LibraryLocatorV2) + except Exception: + LOGGER.exception("Failed to parse content key: %s", content_key_string) + return + + # Get the content module (course or library) + if is_v2_library: + # V2 libraries use content_libraries API + content_module = get_library(content_key) + content_type = "library" + elif is_v1_library: + # V1 libraries use modulestore + content_module = modulestore().get_library(content_key) + content_type = "library" + else: + content_module = modulestore().get_course(content_key) + content_type = "course" try: - course_repo = CourseGitRepository.objects.get(course_key=course_key) - if course_repo.is_export_enabled: + content_repo = ContentGitRepository.objects.get(content_key=content_key) + + if content_repo.is_export_enabled: LOGGER.info( - "Starting async course content export to git (course id: %s)", - course_module.id, + "Starting async %s content export to git (%s id: %s)", + content_type, + content_type, + content_module.id if hasattr(content_module, "id") else content_key, ) # Remove any stale .git/index.lock left by a previously crashed worker. # Dirty working-tree files from a prior crash are cleaned by the # `git reset --hard origin/` + `git clean` inside export_to_git. - clear_stale_git_lock(course_repo.git_url) - export_to_git(course_module.id, course_repo.git_url, user=user) + clear_stale_git_lock(content_repo.git_url) + export_to_git(content_key, content_repo.git_url, user=user) else: LOGGER.info( - "Git export is disabled for course %s. Skipping export.", - course_key_string, + "Git export is disabled for %s %s. Skipping export.", + content_type, + content_key_string, ) except GitExportError: LOGGER.exception( - "Failed async course content export to git (course id: %s)", - course_module.id, + "Failed async %s content export to git (%s id: %s)", + content_type, + content_type, + content_module.id if hasattr(content_module, "id") else content_key, ) - except CourseGitRepository.DoesNotExist: + except ContentGitRepository.DoesNotExist: LOGGER.exception( - "CourseGitRepository does not exist for course %s." - "Creating repository and exporting course content.", - course_key_string, + "Git repository does not exist for %s %s. " + "Creating repository and exporting content.", + content_type, + content_key_string, ) if is_auto_repo_creation_enabled(): - async_create_github_repo.delay(str(course_key), export_course=True) + async_create_github_repo.delay(str(content_key), export_content=True) except Exception: LOGGER.exception( - "Unknown error occured during async course content export to git (course id: %s)", # noqa: E501 - course_module.id, + "Unknown error occurred during async %s content export to git (%s id: %s)", + content_type, + content_type, + content_module.id if hasattr(content_module, "id") else content_key, ) @@ -75,13 +109,13 @@ def async_export_to_git(course_key_string, user=None): autoretry_for=(requests.exceptions.RequestException,), retry_kwargs={"max_retries": 3, "countdown": 10}, ) -def async_create_github_repo(self, course_key_str, export_course=False): # noqa: FBT002 +def async_create_github_repo(self, content_key_str, export_content=False): # noqa: FBT002 """ - Create a GitHub repository for the given course key. + Create a GitHub repository for the given course or library key. Args: - course_key_str (str): The course key for which to create the repository. - export_course (bool): Whether to export the course content + content_key_str (str): The course/library key for which to create repository. + export_content (bool): Whether to export the content after creating the repo. Returns: @@ -89,16 +123,45 @@ def async_create_github_repo(self, course_key_str, export_course=False): # noqa and the SSH URL of the created repository or an error message. """ - course_key = CourseKey.from_string(course_key_str) - course_id_slugified = github_repo_name_format(str(course_key)) + # Parse as LearningContextKey to support all learning contexts + try: + content_key = LearningContextKey.from_string(content_key_str) + is_v1_library = isinstance(content_key, LibraryLocator) + is_v2_library = isinstance(content_key, LibraryLocatorV2) + is_library = is_v1_library or is_v2_library + except Exception: + LOGGER.exception("Failed to parse content key: %s", content_key_str) + return False, f"Invalid content key: {content_key_str}" + + content_type = "library" if is_library else "course" + content_id_slugified = github_repo_name_format(str(content_key)) response_msg = "" - if CourseGitRepository.objects.filter(course_key=course_key).exists(): - response_msg = f"GitHub repository already exists for course {course_key}. Skipping creation." # noqa: E501 + + # Check if repository already exists + if ContentGitRepository.objects.filter(content_key=content_key).exists(): + response_msg = f"GitHub repository already exists for {content_type} {content_key}. Skipping creation." # noqa: E501 LOGGER.info(response_msg) return False, response_msg - course_module = modulestore().get_course(course_key) + # Get the content module (course or library) + if is_v2_library: + # V2 libraries use content_libraries API + content_module = get_library(content_key) + url_path = f"library/{content_key_str}" + elif is_v1_library: + # V1 libraries use modulestore + content_module = modulestore().get_library(content_key) + url_path = f"library/{content_key_str}" + else: + content_module = modulestore().get_course(content_key) + url_path = f"course/{content_key_str}" + + if is_v2_library: + display_name = content_module.title + else: + display_name = content_module.display_name + url = f"{settings.GITHUB_ORG_API_URL}/repos" # https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api?apiVersion=2022-11-28 headers = { @@ -106,9 +169,9 @@ def async_create_github_repo(self, course_key_str, export_course=False): # noqa "Authorization": f"Bearer {settings.GITHUB_ACCESS_TOKEN}", "X-GitHub-Api-Version": "2022-11-28", } - repo_desc = f"{course_module.display_name}, exported from https://{settings.CMS_BASE}/course/{course_key_str}" + repo_desc = f"{display_name}, exported from https://{settings.CMS_BASE}/{url_path}" payload = { - "name": course_id_slugified, + "name": content_id_slugified, "description": repo_desc, "private": True, "has_issues": False, @@ -118,15 +181,16 @@ def async_create_github_repo(self, course_key_str, export_course=False): # noqa } response = requests.post(url, headers=headers, json=payload, timeout=30) if response.status_code != status.HTTP_201_CREATED: - response_msg = f"Failed to create GitHub repository for course {course_key}: {response.json()}" # noqa: E501 + response_msg = f"Failed to create GitHub repository for {content_type} {content_key}: {response.json()}" # noqa: E501 LOGGER.error(response_msg) # Retry the task if we haven't exceeded max retries max_retries = self.retry_kwargs.get("max_retries", 3) if self.request.retries < max_retries: LOGGER.info( - "Retrying GitHub repository creation for course %s (attempt %d/%d)", - course_key, + "Retrying GitHub repository creation for %s %s (attempt %d/%d)", + content_type, + content_key, self.request.retries + 1, max_retries, ) @@ -138,23 +202,26 @@ def async_create_github_repo(self, course_key_str, export_course=False): # noqa repo_data = response.json() ssh_url = repo_data.get("ssh_url") if ssh_url: - CourseGitRepository.objects.create( - course_key=course_key, + # Use the new ContentGitRepository model + ContentGitRepository.objects.create( + content_key=content_key, git_url=ssh_url, ) LOGGER.info( - "GitHub repository created for course %s: %s", - course_key, + "GitHub repository created for %s %s: %s", + content_type, + content_key, ssh_url, ) else: response_msg = f""" - Failed to retrieve SSH URL from GitHub response for course {course_key}. + Failed to retrieve SSH URL from GitHub response + for {content_type} {content_key}. Response data: {repo_data} """ LOGGER.error(response_msg) - if ssh_url and export_course: - export_course_to_git(course_key) + if ssh_url and export_content: + export_content_to_git(content_key) return True, response_msg or ssh_url 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 9303ce8b1..75cdf9fa9 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 @@ -7,17 +7,23 @@ import re from pathlib import Path +from cms.djangoapps.contentstore.git_export_utils import ( + export_to_git as platform_export_to_git, +) from django.conf import settings from django.contrib.auth.models import User from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured +from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2 from xmodule.modulestore.django import modulestore from ol_openedx_git_auto_export.constants import ( + ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION, ENABLE_AUTO_GITHUB_REPO_CREATION, ENABLE_GIT_AUTO_EXPORT, EXPORT_DEBOUNCE_CACHE_KEY, EXPORT_DEBOUNCE_DELAY, + ENABLE_GIT_AUTO_LIBRARY_EXPORT, REPOSITORY_NAME_MAX_LENGTH, ) @@ -141,12 +147,57 @@ def clear_stale_git_lock(git_url): index_lock.unlink() -def is_auto_repo_creation_enabled(): +def export_library_to_git(library_key): + """ + Export the library to a Git repository. + + Args: + library_key (LibraryLocator | LibraryLocatorV2): The library key to export. + """ + from ol_openedx_git_auto_export.tasks import async_export_to_git # noqa: PLC0415 + + # Check library-specific flag + library_export_enabled = settings.FEATURES.get( + ENABLE_GIT_AUTO_LIBRARY_EXPORT, + False, + ) + + if settings.FEATURES.get("ENABLE_EXPORT_GIT") and library_export_enabled: + get_or_create_git_export_repo_dir() + log.info( + "Library updated with auto-export enabled. Starting export... (library id: %s)", # noqa: E501 + library_key, + ) + + # Libraries don't have published_by field, so we don't pass user + async_export_to_git.delay(str(library_key), user=None) + else: + log.info( + "Library auto-export is disabled. Skipping export for library: %s", + library_key, + ) + + +def export_content_to_git(content_key): + """ + Export either a course or library to a Git repository. + + Args: + content_key: CourseKey, LibraryLocator, or LibraryLocatorV2 - The key + of the content to export. + """ + if isinstance(content_key, (LibraryLocator, LibraryLocatorV2)): + export_library_to_git(content_key) + else: + export_course_to_git(content_key) + + +def is_auto_repo_creation_enabled(is_library=False): # noqa: FBT002 """ Check if automatic GitHub repository creation is enabled. Args: - course_key (CourseKey): The course key of the course to check. + is_library (bool): Whether checking for library (True) or course (False). Returns: bool: True if automatic GitHub repository creation is enabled, False otherwise. @@ -154,7 +205,19 @@ def is_auto_repo_creation_enabled(): Raises: ImproperlyConfigured: If GITHUB_ORG_API_URL or GITHUB_ACCESS_TOKEN is not set. """ - if not settings.FEATURES.get(ENABLE_AUTO_GITHUB_REPO_CREATION): + # Check library-specific flag first if it's a library + if is_library: + library_repo_enabled = settings.FEATURES.get( + ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION, + settings.FEATURES.get(ENABLE_AUTO_GITHUB_REPO_CREATION, False), + ) + if not library_repo_enabled: + log.info( + "GitHub library repo creation is disabled. " + "Skipping library repo creation ...", + ) + return False + elif not settings.FEATURES.get(ENABLE_AUTO_GITHUB_REPO_CREATION): log.info( "GitHub repo creation is disabled. Skipping GitHub repo creation ...", ) @@ -165,3 +228,28 @@ def is_auto_repo_creation_enabled(): raise ImproperlyConfigured(error_msg) return True + + +def export_to_git(content_key, repo, user=None, rdir=None): + """ + Export course or library content to a git repository. + + This function wraps the platform's git export functionality from + cms.djangoapps.contentstore.git_export_utils. + + Args: + content_key: CourseKey or LibraryLocator for the content to export + repo (str): Git repository URL (e.g., 'git@github.com:org/repo.git') + user: Optional username for git commit identity + rdir: Optional custom directory name for the repository + + Raises: + GitExportError: For various git operation failures + """ + + return platform_export_to_git( + content_key=content_key, + repo=repo, + user=user or "", + rdir=rdir, + ) From 918b378fdd40e73b88de3e733b5cef1cae98e3a7 Mon Sep 17 00:00:00 2001 From: Muhammad Arslan Abdul Rauf Date: Thu, 19 Feb 2026 16:53:49 +0500 Subject: [PATCH 02/11] fix: user added for lib v2 --- .../ol_openedx_git_auto_export/signals.py | 4 ++-- .../ol_openedx_git_auto_export/tasks.py | 3 +-- .../ol_openedx_git_auto_export/utils.py | 16 ++++++++++++++-- 3 files changed, 17 insertions(+), 6 deletions(-) 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 bd3fe1cad..41da1fbb2 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 @@ -138,7 +138,7 @@ def listen_for_library_block_created(**kwargs): usage_key = library_block.usage_key library_key = usage_key.context_key log.info( - "Library block created signal received for block %s in library: %s", + "Library v2 block created signal received for block %s in library: %s", usage_key, library_key, ) @@ -162,7 +162,7 @@ def listen_for_library_block_updated(**kwargs): usage_key = library_block.usage_key library_key = usage_key.context_key log.info( - "Library block updated signal received for block %s in library: %s", + "Library v2 block updated signal received for block %s in library: %s", usage_key, library_key, ) 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 da98cbd68..79df418dd 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 @@ -128,12 +128,11 @@ def async_create_github_repo(self, content_key_str, export_content=False): # no content_key = LearningContextKey.from_string(content_key_str) is_v1_library = isinstance(content_key, LibraryLocator) is_v2_library = isinstance(content_key, LibraryLocatorV2) - is_library = is_v1_library or is_v2_library except Exception: LOGGER.exception("Failed to parse content key: %s", content_key_str) return False, f"Invalid content key: {content_key_str}" - content_type = "library" if is_library else "course" + content_type = "library" if is_v1_library or is_v2_library else "course" content_id_slugified = github_repo_name_format(str(content_key)) response_msg = "" 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 75cdf9fa9..255eb117a 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 @@ -15,6 +15,7 @@ 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 xmodule.modulestore.django import modulestore from ol_openedx_git_auto_export.constants import ( @@ -169,8 +170,19 @@ def export_library_to_git(library_key): library_key, ) - # Libraries don't have published_by field, so we don't pass user - async_export_to_git.delay(str(library_key), user=None) + # Get publisher username + user = None + if isinstance(library_key, LibraryLocatorV2): + # V2 libraries have published_by in their metadata + library_metadata = get_library(library_key) + user = ( + library_metadata.published_by if library_metadata.published_by else None + ) + else: + # V1 libraries don't have published_by field + pass + + async_export_to_git.delay(str(library_key), user=user) else: log.info( "Library auto-export is disabled. Skipping export for library: %s", From 600c9810d6d021eb41e47b81a8205f41b3cb4a88 Mon Sep 17 00:00:00 2001 From: Muhammad Arslan Abdul Rauf Date: Thu, 19 Feb 2026 17:45:47 +0500 Subject: [PATCH 03/11] fix: minor fixes and docs updates --- src/ol_openedx_git_auto_export/README.rst | 2 +- .../ol_openedx_git_auto_export/admin.py | 4 ++-- .../ol_openedx_git_auto_export/app.py | 2 ++ .../ol_openedx_git_auto_export/signals.py | 1 - .../ol_openedx_git_auto_export/tasks.py | 2 +- .../ol_openedx_git_auto_export/utils.py | 5 ++--- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ol_openedx_git_auto_export/README.rst b/src/ol_openedx_git_auto_export/README.rst index ec7e005c3..e4db142ea 100644 --- a/src/ol_openedx_git_auto_export/README.rst +++ b/src/ol_openedx_git_auto_export/README.rst @@ -81,7 +81,7 @@ Library v1 (Legacy Libraries) "ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION": true } -2. Make changes to your library and publish - on the first update, a GitHub repository will be automatically created and the changes will be exported to git +2. Make changes to your library and publish Library v2 (Content Libraries) ------------------------------- diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/admin.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/admin.py index 3855e2c2c..be9a489ff 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/admin.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/admin.py @@ -5,7 +5,7 @@ from django.contrib import admin from django.contrib.admin import SimpleListFilter from django.db.models import Q -from opaque_keys.edx.locator import LibraryLocator +from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2 from ol_openedx_git_auto_export.models import ContentGitRepository @@ -60,7 +60,7 @@ class ContentGitRepositoryAdmin(admin.ModelAdmin): @admin.display(description="Content Type") def content_type_display(self, obj): """Display whether the content is a course or library.""" - if isinstance(obj.content_key, LibraryLocator): + if isinstance(obj.content_key, (LibraryLocator, LibraryLocatorV2)): return "Library" return "Course" diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py index 08f551df1..15e9a903a 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py @@ -68,6 +68,8 @@ class GitAutoExportConfig(AppConfig): PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.LIBRARY_BLOCK_UPDATED", # noqa: E501 PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_block_updated", # noqa: E501 }, + # NOTE: There are many other signals as well which we might have to add # noqa: E501 + # see: https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py ], }, }, 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 41da1fbb2..dcb889246 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 @@ -107,7 +107,6 @@ def listen_for_library_v2_updated(**kwargs): in Studio via the CONTENT_LIBRARY_UPDATED signal from openedx_events. Note: This does NOT fire when blocks/components are added or modified. - For block changes, use listen_for_library_block_updated. Args: **kwargs: Signal parameters including 'content_library' with ContentLibraryData 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 79df418dd..f12bf32e0 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 @@ -93,7 +93,7 @@ def async_export_to_git(content_key_string, user=None): content_type, content_key_string, ) - if is_auto_repo_creation_enabled(): + if is_auto_repo_creation_enabled(is_library=is_v1_library or is_v2_library): async_create_github_repo.delay(str(content_key), export_content=True) except Exception: LOGGER.exception( 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 255eb117a..b96e3ca71 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 @@ -220,8 +220,7 @@ def is_auto_repo_creation_enabled(is_library=False): # noqa: FBT002 # Check library-specific flag first if it's a library if is_library: library_repo_enabled = settings.FEATURES.get( - ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION, - settings.FEATURES.get(ENABLE_AUTO_GITHUB_REPO_CREATION, False), + ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION, False ) if not library_repo_enabled: log.info( @@ -229,7 +228,7 @@ def is_auto_repo_creation_enabled(is_library=False): # noqa: FBT002 "Skipping library repo creation ...", ) return False - elif not settings.FEATURES.get(ENABLE_AUTO_GITHUB_REPO_CREATION): + elif not settings.FEATURES.get(ENABLE_AUTO_GITHUB_REPO_CREATION, False): log.info( "GitHub repo creation is disabled. Skipping GitHub repo creation ...", ) From a378c05055635936ea1f5ca8316c74ff6a9d539f Mon Sep 17 00:00:00 2001 From: Muhammad Arslan Abdul Rauf Date: Fri, 20 Feb 2026 17:37:21 +0500 Subject: [PATCH 04/11] fix: migrations fixed from data-migration to renaming model and field --- .../migrations/0002_contentgitrepository.py | 45 ++------- .../0003_migrate_course_to_content_repo.py | 97 +++---------------- .../0004_delete_coursegitrepository.py | 15 --- 3 files changed, 20 insertions(+), 137 deletions(-) delete mode 100644 src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0004_delete_coursegitrepository.py diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py index 4e829f391..d61e85521 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py @@ -1,49 +1,16 @@ -# Generated migration for ContentGitRepository model +# Generated migration for renaming CourseGitRepository to ContentGitRepository -import django.utils.timezone -import model_utils.fields -import opaque_keys.edx.django.models -from django.db import migrations, models +from django.db import migrations class Migration(migrations.Migration): dependencies = [ ("ol_openedx_git_auto_export", "0001_initial"), - ] # type: ignore # noqa: PGH003 + ] operations = [ - migrations.CreateModel( - name="ContentGitRepository", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "content_key", - opaque_keys.edx.django.models.LearningContextKeyField( - max_length=255, primary_key=True, serialize=False - ), - ), - ("git_url", models.CharField(max_length=255)), - ("is_export_enabled", models.BooleanField(default=True)), - ], - options={ - "verbose_name": "Content Git Repository", - "verbose_name_plural": "Content Git Repositories", - "ordering": ["-created"], - }, + migrations.RenameModel( + old_name="CourseGitRepository", + new_name="ContentGitRepository", ), ] diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0003_migrate_course_to_content_repo.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0003_migrate_course_to_content_repo.py index 24718ba49..547784ab3 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0003_migrate_course_to_content_repo.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0003_migrate_course_to_content_repo.py @@ -1,87 +1,7 @@ -# Generated migration to migrate data from CourseGitRepository to ContentGitRepository +# Generated migration to rename course_key field to content_key and change type +import opaque_keys.edx.django.models from django.db import migrations -from opaque_keys.edx.keys import CourseKey -from opaque_keys.edx.locator import LibraryLocator - - -def migrate_course_to_content_repos(apps, schema_editor): # noqa: ARG001 - """ - Migrate all CourseGitRepository entries to ContentGitRepository. - - This function: - 1. Copies all data from CourseGitRepository to ContentGitRepository - 2. Preserves all fields including created/modified timestamps - 3. Skips entries that already exist in ContentGitRepository - """ - CourseGitRepository = apps.get_model( - "ol_openedx_git_auto_export", "CourseGitRepository" - ) - ContentGitRepository = apps.get_model( - "ol_openedx_git_auto_export", "ContentGitRepository" - ) - - migrated_count = 0 - skipped_count = 0 - - for course_repo in CourseGitRepository.objects.all(): - # Check if already migrated - if not ContentGitRepository.objects.filter( - content_key=course_repo.course_key - ).exists(): - ContentGitRepository.objects.create( - content_key=course_repo.course_key, - git_url=course_repo.git_url, - is_export_enabled=course_repo.is_export_enabled, - created=course_repo.created, - modified=course_repo.modified, - ) - migrated_count += 1 - else: - skipped_count += 1 - - -def reverse_migration(apps, schema_editor): # noqa: ARG001 - """ - Reverse migration: Copy ContentGitRepository entries back to CourseGitRepository. - - Only migrates entries that have CourseKey (not LibraryLocator). - """ - - CourseGitRepository = apps.get_model( - "ol_openedx_git_auto_export", "CourseGitRepository" - ) - ContentGitRepository = apps.get_model( - "ol_openedx_git_auto_export", "ContentGitRepository" - ) - - migrated_count = 0 - skipped_count = 0 - - for content_repo in ContentGitRepository.objects.all(): - # Parse the content_key to check if it's a course - try: - content_key = CourseKey.from_string(str(content_repo.content_key)) - is_library = isinstance(content_key, LibraryLocator) - - # Only migrate courses, not libraries - if not is_library: - if not CourseGitRepository.objects.filter( - course_key=content_repo.content_key - ).exists(): - CourseGitRepository.objects.create( - course_key=content_repo.content_key, - git_url=content_repo.git_url, - is_export_enabled=content_repo.is_export_enabled, - created=content_repo.created, - modified=content_repo.modified, - ) - migrated_count += 1 - else: - skipped_count += 1 - except Exception: # noqa: BLE001 - # Skip entries that can't be parsed - skipped_count += 1 class Migration(migrations.Migration): @@ -90,5 +10,16 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython(migrate_course_to_content_repos, reverse_migration), + migrations.RenameField( + model_name="contentgitrepository", + old_name="course_key", + new_name="content_key", + ), + migrations.AlterField( + model_name="contentgitrepository", + name="content_key", + field=opaque_keys.edx.django.models.LearningContextKeyField( + max_length=255, primary_key=True, serialize=False + ), + ), ] diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0004_delete_coursegitrepository.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0004_delete_coursegitrepository.py deleted file mode 100644 index c7cd11718..000000000 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0004_delete_coursegitrepository.py +++ /dev/null @@ -1,15 +0,0 @@ -# Generated migration to remove deprecated CourseGitRepository model - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("ol_openedx_git_auto_export", "0003_migrate_course_to_content_repo"), - ] - - operations = [ - migrations.DeleteModel( - name="CourseGitRepository", - ), - ] From 944a1b8f1a1ba4943a0f0f4df52d5f77d8cd5d69 Mon Sep 17 00:00:00 2001 From: Muhammad Arslan Abdul Rauf Date: Fri, 20 Feb 2026 17:40:57 +0500 Subject: [PATCH 05/11] chore: disable E501 for app.py --- .../ol_openedx_git_auto_export/app.py | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py index 15e9a903a..4d43a872d 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py @@ -1,6 +1,7 @@ """ AppConfig for ol_openedx_git_auto_export app """ +# ruff: noqa: E501 from django.apps import AppConfig from edx_django_utils.plugins import PluginSettings, PluginSignals @@ -28,47 +29,47 @@ class GitAutoExportConfig(AppConfig): PluginSignals.RECEIVERS: [ { PluginSignals.RECEIVER_FUNC_NAME: "listen_for_course_publish", - PluginSignals.SIGNAL_PATH: "xmodule.modulestore.django.COURSE_PUBLISHED", # noqa: E501 - PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_course_publish", # noqa: E501 + PluginSignals.SIGNAL_PATH: "xmodule.modulestore.django.COURSE_PUBLISHED", + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_course_publish", }, { PluginSignals.RECEIVER_FUNC_NAME: "listen_for_course_created", - PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.COURSE_CREATED", # noqa: E501 - PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_course_created", # noqa: E501 + PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.COURSE_CREATED", + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_course_created", }, # Library Signals - # NOTE: Library v1 (library-v1:) only has LIBRARY_UPDATED, no creation signal # noqa: E501 + # NOTE: Library v1 (library-v1:) only has LIBRARY_UPDATED, no creation signal # Library v2 (lib:) has: # - CONTENT_LIBRARY_CREATED/UPDATED: for library metadata changes # - LIBRARY_BLOCK_CREATED/UPDATED: for block/component changes { PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_updated", - PluginSignals.SIGNAL_PATH: "xmodule.modulestore.django.LIBRARY_UPDATED", # noqa: E501, library v1 update - PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_updated", # noqa: E501 + PluginSignals.SIGNAL_PATH: "xmodule.modulestore.django.LIBRARY_UPDATED", # library v1 update + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_updated", }, # lib V2 - Library-level signals { - PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_v2_created", # noqa: E501 - PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.CONTENT_LIBRARY_CREATED", # noqa: E501, library v2 only - PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_v2_created", # noqa: E501 + PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_v2_created", + PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.CONTENT_LIBRARY_CREATED", # library v2 only + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_v2_created", }, { - PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_v2_updated", # noqa: E501 - PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.CONTENT_LIBRARY_UPDATED", # noqa: E501, library v2 metadata only - PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_v2_updated", # noqa: E501 + PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_v2_updated", + PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.CONTENT_LIBRARY_UPDATED", # library v2 metadata only + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_v2_updated", }, # lib V2 - Block-level signals (for component add/update/delete) { - PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_block_created", # noqa: E501 - PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.LIBRARY_BLOCK_CREATED", # noqa: E501 - PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_block_created", # noqa: E501 + PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_block_created", + PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.LIBRARY_BLOCK_CREATED", + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_block_created", }, { - PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_block_updated", # noqa: E501 - PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.LIBRARY_BLOCK_UPDATED", # noqa: E501 - PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_block_updated", # noqa: E501 + PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_block_updated", + PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.LIBRARY_BLOCK_UPDATED", + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_block_updated", }, - # NOTE: There are many other signals as well which we might have to add # noqa: E501 + # NOTE: There are many other signals as well which we might have to add # see: https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py ], }, From f09f31d4ec3cd7dc072d215c7a86d02806097b40 Mon Sep 17 00:00:00 2001 From: Muhammad Arslan Abdul Rauf Date: Mon, 23 Feb 2026 14:46:19 +0500 Subject: [PATCH 06/11] fix: created/updated signals replaced with published and some code changes --- .../ol_openedx_git_auto_export/app.py | 24 ++++---- .../ol_openedx_git_auto_export/signals.py | 38 ++++++------ .../ol_openedx_git_auto_export/tasks.py | 6 +- .../ol_openedx_git_auto_export/utils.py | 60 ++++--------------- 4 files changed, 44 insertions(+), 84 deletions(-) diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py index 4d43a872d..7ce3f324b 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/app.py @@ -41,11 +41,11 @@ class GitAutoExportConfig(AppConfig): # NOTE: Library v1 (library-v1:) only has LIBRARY_UPDATED, no creation signal # Library v2 (lib:) has: # - CONTENT_LIBRARY_CREATED/UPDATED: for library metadata changes - # - LIBRARY_BLOCK_CREATED/UPDATED: for block/component changes + # - LIBRARY_BLOCK_PUBLISHED: for block/component changes { - PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_updated", + PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_v1_updated", PluginSignals.SIGNAL_PATH: "xmodule.modulestore.django.LIBRARY_UPDATED", # library v1 update - PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_updated", + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_v1_updated", }, # lib V2 - Library-level signals { @@ -58,19 +58,19 @@ class GitAutoExportConfig(AppConfig): PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.CONTENT_LIBRARY_UPDATED", # library v2 metadata only PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_v2_updated", }, - # lib V2 - Block-level signals (for component add/update/delete) + # lib V2 - Block-level signals (for component publish) + # Note: PUBLISHED signals capture all changes including deletions after publish { - PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_block_created", - PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.LIBRARY_BLOCK_CREATED", - PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_block_created", + PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_block_published", + PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.LIBRARY_BLOCK_PUBLISHED", + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_block_published", }, + # lib V2 - Container-level signals (for container publish) { - PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_block_updated", - PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.LIBRARY_BLOCK_UPDATED", - PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_block_updated", + PluginSignals.RECEIVER_FUNC_NAME: "listen_for_library_container_published", + PluginSignals.SIGNAL_PATH: "openedx_events.content_authoring.signals.LIBRARY_CONTAINER_PUBLISHED", + PluginSignals.DISPATCH_UID: "ol_openedx_git_auto_export.signals.listen_for_library_container_published", }, - # NOTE: There are many other signals as well which we might have to add - # see: https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py ], }, }, 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 dcb889246..27ee2326a 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 @@ -62,7 +62,7 @@ def listen_for_course_rerun_state_post_save(sender, instance, **kwargs): # noqa # Library Signal Receivers -def listen_for_library_updated(sender, library_key, **kwargs): # noqa: ARG001 +def listen_for_library_v1_updated(sender, library_key, **kwargs): # noqa: ARG001 """ Receives library update signal and performs export workflow. @@ -121,12 +121,12 @@ def listen_for_library_v2_updated(**kwargs): export_library_to_git(library_key) -def listen_for_library_block_created(**kwargs): +def listen_for_library_block_published(**kwargs): """ - Handle library block created signal to export content to GitHub repository. + Handle library block published signal to export content to GitHub repository. This is triggered when a new block/component is added to a v2 library - via the LIBRARY_BLOCK_CREATED signal from openedx_events. + via the LIBRARY_BLOCK_PUBLISHED signal from openedx_events. Args: **kwargs: Signal parameters including 'library_block' with LibraryBlockData @@ -137,7 +137,7 @@ def listen_for_library_block_created(**kwargs): usage_key = library_block.usage_key library_key = usage_key.context_key log.info( - "Library v2 block created signal received for block %s in library: %s", + "Library v2 block published signal received for block %s in library: %s", usage_key, library_key, ) @@ -145,24 +145,26 @@ def listen_for_library_block_created(**kwargs): export_library_to_git(library_key) -def listen_for_library_block_updated(**kwargs): +def listen_for_library_container_published(**kwargs): """ - Handle library block updated signal to export content to GitHub repository. + Handle library container published signal to export content to GitHub repository. - This is triggered when a block/component in a v2 library is modified - via the LIBRARY_BLOCK_UPDATED signal from openedx_events. + This is triggered when a new container is added to a v2 library + via the LIBRARY_CONTAINER_PUBLISHED signal from openedx_events. Args: - **kwargs: Signal parameters including 'library_block' with LibraryBlockData - """ - library_block = kwargs.get("library_block") - if library_block: - # Extract library key from the usage key - usage_key = library_block.usage_key - library_key = usage_key.context_key + **kwargs: Signal parameters including 'library_container' with + LibraryContainerData + """ + library_container = kwargs.get("library_container") + if library_container: + # Extract library key from the container key + container_key = library_container.container_key + library_key = container_key.lib_key log.info( - "Library v2 block updated signal received for block %s in library: %s", - usage_key, + "Library v2 container published signal received for " + "container %s in library: %s", + container_key, library_key, ) 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 f12bf32e0..886d9a81a 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 @@ -8,7 +8,7 @@ import requests from celery import shared_task # pylint: disable=import-error from celery.utils.log import get_task_logger -from cms.djangoapps.contentstore.git_export_utils import GitExportError +from cms.djangoapps.contentstore.git_export_utils import GitExportError, export_to_git from django.conf import settings from opaque_keys.edx.keys import LearningContextKey from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2 @@ -19,8 +19,6 @@ from ol_openedx_git_auto_export.models import ContentGitRepository from ol_openedx_git_auto_export.utils import ( clear_stale_git_lock, - export_content_to_git, - export_to_git, github_repo_name_format, is_auto_repo_creation_enabled, ) @@ -221,6 +219,6 @@ def async_create_github_repo(self, content_key_str, export_content=False): # no LOGGER.error(response_msg) if ssh_url and export_content: - export_content_to_git(content_key) + async_export_to_git(content_key_str) return True, response_msg or ssh_url 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 b96e3ca71..57d405d73 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 @@ -7,14 +7,11 @@ import re from pathlib import Path -from cms.djangoapps.contentstore.git_export_utils import ( - export_to_git as platform_export_to_git, -) from django.conf import settings from django.contrib.auth.models import User from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured -from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2 +from opaque_keys.edx.locator import LibraryLocatorV2 from openedx.core.djangoapps.content_libraries.api import get_library from xmodule.modulestore.django import modulestore @@ -100,9 +97,7 @@ def export_course_to_git(course_key): """ from ol_openedx_git_auto_export.tasks import async_export_to_git # noqa: PLC0415 - if settings.FEATURES.get("ENABLE_EXPORT_GIT") and settings.FEATURES.get( - ENABLE_GIT_AUTO_EXPORT - ): + if is_auto_export_enabled(): get_or_create_git_export_repo_dir() course_module = modulestore().get_course(course_key) log.info( @@ -157,13 +152,7 @@ def export_library_to_git(library_key): """ from ol_openedx_git_auto_export.tasks import async_export_to_git # noqa: PLC0415 - # Check library-specific flag - library_export_enabled = settings.FEATURES.get( - ENABLE_GIT_AUTO_LIBRARY_EXPORT, - False, - ) - - if settings.FEATURES.get("ENABLE_EXPORT_GIT") and library_export_enabled: + if is_auto_export_enabled(is_library=True): get_or_create_git_export_repo_dir() log.info( "Library updated with auto-export enabled. Starting export... (library id: %s)", # noqa: E501 @@ -190,18 +179,14 @@ def export_library_to_git(library_key): ) -def export_content_to_git(content_key): - """ - Export either a course or library to a Git repository. +def is_auto_export_enabled(is_library=False): # noqa: FBT002 + git_export_enabled = settings.FEATURES.get("ENABLE_EXPORT_GIT") + if is_library: + return git_export_enabled and settings.FEATURES.get( + ENABLE_GIT_AUTO_LIBRARY_EXPORT, False + ) - Args: - content_key: CourseKey, LibraryLocator, or LibraryLocatorV2 - The key - of the content to export. - """ - if isinstance(content_key, (LibraryLocator, LibraryLocatorV2)): - export_library_to_git(content_key) - else: - export_course_to_git(content_key) + return git_export_enabled and settings.FEATURES.get(ENABLE_GIT_AUTO_EXPORT, False) def is_auto_repo_creation_enabled(is_library=False): # noqa: FBT002 @@ -239,28 +224,3 @@ def is_auto_repo_creation_enabled(is_library=False): # noqa: FBT002 raise ImproperlyConfigured(error_msg) return True - - -def export_to_git(content_key, repo, user=None, rdir=None): - """ - Export course or library content to a git repository. - - This function wraps the platform's git export functionality from - cms.djangoapps.contentstore.git_export_utils. - - Args: - content_key: CourseKey or LibraryLocator for the content to export - repo (str): Git repository URL (e.g., 'git@github.com:org/repo.git') - user: Optional username for git commit identity - rdir: Optional custom directory name for the repository - - Raises: - GitExportError: For various git operation failures - """ - - return platform_export_to_git( - content_key=content_key, - repo=repo, - user=user or "", - rdir=rdir, - ) From a0c9d5a81ce32b078c4da53761a924e43147912b Mon Sep 17 00:00:00 2001 From: Muhammad Arslan Abdul Rauf Date: Tue, 24 Feb 2026 20:44:35 +0500 Subject: [PATCH 07/11] fix: enums added for course and library with util to get content details --- .../ol_openedx_git_auto_export/admin.py | 25 +++--- .../ol_openedx_git_auto_export/constants.py | 19 ++++ .../migrations/0002_contentgitrepository.py | 13 +++ .../0003_migrate_course_to_content_repo.py | 25 ------ .../ol_openedx_git_auto_export/tasks.py | 86 +++++++------------ .../ol_openedx_git_auto_export/utils.py | 44 +++++++++- 6 files changed, 122 insertions(+), 90 deletions(-) delete mode 100644 src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0003_migrate_course_to_content_repo.py diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/admin.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/admin.py index be9a489ff..7fa97d9a7 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/admin.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/admin.py @@ -7,6 +7,11 @@ from django.db.models import Q from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2 +from ol_openedx_git_auto_export.constants import ( + LIBRARY_V1_PREFIX, + LIBRARY_V2_PREFIX, + ContentType, +) from ol_openedx_git_auto_export.models import ContentGitRepository @@ -19,22 +24,22 @@ class ContentTypeFilter(SimpleListFilter): def lookups(self, request, model_admin): # noqa: ARG002 """Return filter options.""" return ( - ("course", "Course"), - ("library", "Library"), + (ContentType.COURSE.value, ContentType.COURSE.display_name), + (ContentType.LIBRARY.value, ContentType.LIBRARY.display_name), ) def queryset(self, request, queryset): # noqa: ARG002 """Filter the queryset based on the selected content type.""" - if self.value() == "course": + if self.value() == ContentType.COURSE.value: # Filter for courses (exclude libraries) - return queryset.exclude(content_key__startswith="library-v1:").exclude( - content_key__startswith="lib:" # lib v2 + return queryset.exclude(content_key__startswith=LIBRARY_V1_PREFIX).exclude( + content_key__startswith=LIBRARY_V2_PREFIX ) - elif self.value() == "library": + elif self.value() == ContentType.LIBRARY.value: # Filter for libraries return queryset.filter( - Q(content_key__startswith="library-v1:") - | Q(content_key__startswith="lib:") # lib v2 + Q(content_key__startswith=LIBRARY_V1_PREFIX) + | Q(content_key__startswith=LIBRARY_V2_PREFIX) ) return queryset @@ -61,8 +66,8 @@ class ContentGitRepositoryAdmin(admin.ModelAdmin): def content_type_display(self, obj): """Display whether the content is a course or library.""" if isinstance(obj.content_key, (LibraryLocator, LibraryLocatorV2)): - return "Library" - return "Course" + return ContentType.LIBRARY.display_name + return ContentType.COURSE.display_name def has_delete_permission(self, request, obj=None): # noqa: ARG002 """ diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/constants.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/constants.py index 1b8a0b768..57e8b9641 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/constants.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/constants.py @@ -1,3 +1,22 @@ +from enum import StrEnum + + +class ContentType(StrEnum): + """Enumeration for content types (Course or Library).""" + + COURSE = "course" + LIBRARY = "library" + + @property + def display_name(self): + """Return the human-readable display name.""" + return self.value.capitalize() + + +# Library key prefixes for different versions +LIBRARY_V1_PREFIX = "library-v1:" +LIBRARY_V2_PREFIX = "lib:" + ENABLE_GIT_AUTO_EXPORT = "ENABLE_GIT_AUTO_EXPORT" ENABLE_AUTO_GITHUB_REPO_CREATION = "ENABLE_AUTO_GITHUB_REPO_CREATION" GITHUB_ORG = "GITHUB_ORG" diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py index d61e85521..a8d7f5bc4 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py @@ -1,5 +1,6 @@ # Generated migration for renaming CourseGitRepository to ContentGitRepository +import opaque_keys.edx.django.models from django.db import migrations @@ -13,4 +14,16 @@ class Migration(migrations.Migration): old_name="CourseGitRepository", new_name="ContentGitRepository", ), + migrations.RenameField( + model_name="contentgitrepository", + old_name="course_key", + new_name="content_key", + ), + migrations.AlterField( + model_name="contentgitrepository", + name="content_key", + field=opaque_keys.edx.django.models.LearningContextKeyField( + max_length=255, primary_key=True, serialize=False + ), + ), ] diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0003_migrate_course_to_content_repo.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0003_migrate_course_to_content_repo.py deleted file mode 100644 index 547784ab3..000000000 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0003_migrate_course_to_content_repo.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated migration to rename course_key field to content_key and change type - -import opaque_keys.edx.django.models -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("ol_openedx_git_auto_export", "0002_contentgitrepository"), - ] - - operations = [ - migrations.RenameField( - model_name="contentgitrepository", - old_name="course_key", - new_name="content_key", - ), - migrations.AlterField( - model_name="contentgitrepository", - name="content_key", - field=opaque_keys.edx.django.models.LearningContextKeyField( - max_length=255, primary_key=True, serialize=False - ), - ), - ] 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 886d9a81a..7d46f4312 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 @@ -11,14 +11,12 @@ from cms.djangoapps.contentstore.git_export_utils import GitExportError, export_to_git from django.conf import settings from opaque_keys.edx.keys import LearningContextKey -from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2 -from openedx.core.djangoapps.content_libraries.api import get_library from rest_framework import status -from xmodule.modulestore.django import modulestore from ol_openedx_git_auto_export.models import ContentGitRepository from ol_openedx_git_auto_export.utils import ( clear_stale_git_lock, + get_content_info, github_repo_name_format, is_auto_repo_creation_enabled, ) @@ -37,34 +35,22 @@ def async_export_to_git(content_key_string, user=None): # Parse as LearningContextKey to support all learning contexts try: content_key = LearningContextKey.from_string(content_key_string) - is_v1_library = isinstance(content_key, LibraryLocator) - is_v2_library = isinstance(content_key, LibraryLocatorV2) + content_info = get_content_info(content_key) except Exception: LOGGER.exception("Failed to parse content key: %s", content_key_string) return - # Get the content module (course or library) - if is_v2_library: - # V2 libraries use content_libraries API - content_module = get_library(content_key) - content_type = "library" - elif is_v1_library: - # V1 libraries use modulestore - content_module = modulestore().get_library(content_key) - content_type = "library" - else: - content_module = modulestore().get_course(content_key) - content_type = "course" - try: content_repo = ContentGitRepository.objects.get(content_key=content_key) if content_repo.is_export_enabled: LOGGER.info( "Starting async %s content export to git (%s id: %s)", - content_type, - content_type, - content_module.id if hasattr(content_module, "id") else content_key, + content_info["content_type"], + content_info["content_type"], + content_info["content_module"].id + if hasattr(content_info["content_module"], "id") + else content_key, ) # Remove any stale .git/index.lock left by a previously crashed worker. # Dirty working-tree files from a prior crash are cleaned by the @@ -74,31 +60,35 @@ def async_export_to_git(content_key_string, user=None): else: LOGGER.info( "Git export is disabled for %s %s. Skipping export.", - content_type, + content_info["content_type"], content_key_string, ) except GitExportError: LOGGER.exception( "Failed async %s content export to git (%s id: %s)", - content_type, - content_type, - content_module.id if hasattr(content_module, "id") else content_key, + content_info["content_type"], + content_info["content_type"], + content_info["content_module"].id + if hasattr(content_info["content_module"], "id") + else content_key, ) except ContentGitRepository.DoesNotExist: LOGGER.exception( "Git repository does not exist for %s %s. " "Creating repository and exporting content.", - content_type, + content_info["content_type"], content_key_string, ) - if is_auto_repo_creation_enabled(is_library=is_v1_library or is_v2_library): + if is_auto_repo_creation_enabled(is_library=content_info["is_library"]): async_create_github_repo.delay(str(content_key), export_content=True) except Exception: LOGGER.exception( "Unknown error occurred during async %s content export to git (%s id: %s)", - content_type, - content_type, - content_module.id if hasattr(content_module, "id") else content_key, + content_info["content_type"], + content_info["content_type"], + content_info["content_module"].id + if hasattr(content_info["content_module"], "id") + else content_key, ) @@ -124,40 +114,28 @@ def async_create_github_repo(self, content_key_str, export_content=False): # no # Parse as LearningContextKey to support all learning contexts try: content_key = LearningContextKey.from_string(content_key_str) - is_v1_library = isinstance(content_key, LibraryLocator) - is_v2_library = isinstance(content_key, LibraryLocatorV2) + content_info = get_content_info(content_key) except Exception: LOGGER.exception("Failed to parse content key: %s", content_key_str) return False, f"Invalid content key: {content_key_str}" - content_type = "library" if is_v1_library or is_v2_library else "course" content_id_slugified = github_repo_name_format(str(content_key)) - response_msg = "" # Check if repository already exists if ContentGitRepository.objects.filter(content_key=content_key).exists(): - response_msg = f"GitHub repository already exists for {content_type} {content_key}. Skipping creation." # noqa: E501 + response_msg = f"GitHub repository already exists for {content_info['content_type']} {content_key}. Skipping creation." # noqa: E501 LOGGER.info(response_msg) return False, response_msg - # Get the content module (course or library) - if is_v2_library: - # V2 libraries use content_libraries API - content_module = get_library(content_key) - url_path = f"library/{content_key_str}" - elif is_v1_library: - # V1 libraries use modulestore - content_module = modulestore().get_library(content_key) - url_path = f"library/{content_key_str}" - else: - content_module = modulestore().get_course(content_key) - url_path = f"course/{content_key_str}" + # Determine URL path based on content type + url_path = f"{content_info['content_type']}/{content_key_str}" - if is_v2_library: - display_name = content_module.title + # Get display name (v2 libraries use 'title', others use 'display_name') + if content_info["is_v2_library"]: + display_name = content_info["content_module"].title else: - display_name = content_module.display_name + display_name = content_info["content_module"].display_name url = f"{settings.GITHUB_ORG_API_URL}/repos" # https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api?apiVersion=2022-11-28 @@ -178,7 +156,7 @@ def async_create_github_repo(self, content_key_str, export_content=False): # no } response = requests.post(url, headers=headers, json=payload, timeout=30) if response.status_code != status.HTTP_201_CREATED: - response_msg = f"Failed to create GitHub repository for {content_type} {content_key}: {response.json()}" # noqa: E501 + response_msg = f"Failed to create GitHub repository for {content_info['content_type']} {content_key}: {response.json()}" # noqa: E501 LOGGER.error(response_msg) # Retry the task if we haven't exceeded max retries @@ -186,7 +164,7 @@ def async_create_github_repo(self, content_key_str, export_content=False): # no if self.request.retries < max_retries: LOGGER.info( "Retrying GitHub repository creation for %s %s (attempt %d/%d)", - content_type, + content_info["content_type"], content_key, self.request.retries + 1, max_retries, @@ -206,14 +184,14 @@ def async_create_github_repo(self, content_key_str, export_content=False): # no ) LOGGER.info( "GitHub repository created for %s %s: %s", - content_type, + content_info["content_type"], content_key, ssh_url, ) else: response_msg = f""" Failed to retrieve SSH URL from GitHub response - for {content_type} {content_key}. + for {content_info["content_type"]} {content_key}. Response data: {repo_data} """ LOGGER.error(response_msg) 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 57d405d73..3ef16b4e0 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 @@ -11,7 +11,7 @@ from django.contrib.auth.models import User from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured -from opaque_keys.edx.locator import LibraryLocatorV2 +from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2 from openedx.core.djangoapps.content_libraries.api import get_library from xmodule.modulestore.django import modulestore @@ -23,11 +23,53 @@ EXPORT_DEBOUNCE_DELAY, ENABLE_GIT_AUTO_LIBRARY_EXPORT, REPOSITORY_NAME_MAX_LENGTH, + ContentType, ) log = logging.getLogger(__name__) +def get_content_info(content_key): + """ + Get information about a content item (course or library). + + Args: + content_key: A LearningContextKey + + Returns: + dict: Dictionary containing: + - content_type: The ContentType enum value (str) + - content_module: The actual course/library object + - is_v1_library: Boolean flag + - is_v2_library: Boolean flag + - is_library: Boolean flag (True if v1 or v2 library) + """ + 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) + 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 + else: + # Courses use modulestore + content_module = modulestore().get_course(content_key) + content_type = ContentType.COURSE.value + + return { + "content_type": content_type, + "content_module": content_module, + "is_v1_library": is_v1_library, + "is_v2_library": is_v2_library, + "is_library": is_v1_library or is_v2_library, + } + + def get_publisher_username(course_module): """ Return the username of the user who published the course. From 0fa6bbcd077b493aa39b625faa8dc336432d1ea5 Mon Sep 17 00:00:00 2001 From: Muhammad Arslan Abdul Rauf Date: Mon, 9 Mar 2026 17:19:48 +0500 Subject: [PATCH 08/11] fix: missing migration added, version bump and some docs --- src/ol_openedx_git_auto_export/CONFIGURATION.md | 2 +- .../migrations/0002_contentgitrepository.py | 8 ++++++++ .../ol_openedx_git_auto_export/utils.py | 9 +++++++++ src/ol_openedx_git_auto_export/pyproject.toml | 2 +- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/ol_openedx_git_auto_export/CONFIGURATION.md b/src/ol_openedx_git_auto_export/CONFIGURATION.md index ac762bd6a..dc827038b 100644 --- a/src/ol_openedx_git_auto_export/CONFIGURATION.md +++ b/src/ol_openedx_git_auto_export/CONFIGURATION.md @@ -8,7 +8,7 @@ This document describes the available feature flags for controlling git auto-exp #### `ENABLE_GIT_AUTO_EXPORT` - **Type**: Boolean -- **Default**: `False` +- **Default**: `True` - **Purpose**: Controls automatic git export for courses when they are published - **Scope**: Courses only (unless library flag not set, see below) - **Location**: `settings.FEATURES['ENABLE_GIT_AUTO_EXPORT']` diff --git a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py index a8d7f5bc4..fb22e5b0f 100644 --- a/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py +++ b/src/ol_openedx_git_auto_export/ol_openedx_git_auto_export/migrations/0002_contentgitrepository.py @@ -26,4 +26,12 @@ class Migration(migrations.Migration): max_length=255, primary_key=True, serialize=False ), ), + migrations.AlterModelOptions( + name="contentgitrepository", + options={ + "ordering": ["-created"], + "verbose_name": "Content Git Repository", + "verbose_name_plural": "Content Git Repositories", + }, + ), ] 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 3ef16b4e0..ccd56a752 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 @@ -222,6 +222,15 @@ def export_library_to_git(library_key): def is_auto_export_enabled(is_library=False): # noqa: FBT002 + """ + Check if automatic Git export is enabled. + + Args: + is_library (bool): Whether checking for library (True) or course (False). + + Returns: + bool: True if automatic Git export is enabled, False otherwise. + """ git_export_enabled = settings.FEATURES.get("ENABLE_EXPORT_GIT") if is_library: return git_export_enabled and settings.FEATURES.get( diff --git a/src/ol_openedx_git_auto_export/pyproject.toml b/src/ol_openedx_git_auto_export/pyproject.toml index 8dc2ab1ed..af5893158 100644 --- a/src/ol_openedx_git_auto_export/pyproject.toml +++ b/src/ol_openedx_git_auto_export/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ol-openedx-git-auto-export" -version = "0.7.2" +version = "0.8.0" description = "A plugin that auto saves the course OLX to git when an author publishes it" authors = [ {name = "MIT Office of Digital Learning"} From 8041b192b93db835ed809100235990dda19b0105 Mon Sep 17 00:00:00 2001 From: Muhammad Arslan Abdul Rauf Date: Thu, 30 Apr 2026 19:39:38 +0500 Subject: [PATCH 09/11] fix: replace content_key with context_key --- .../ol_openedx_git_auto_export/tasks.py | 58 +++++++++---------- src/ol_openedx_git_auto_export/pyproject.toml | 2 +- 2 files changed, 30 insertions(+), 30 deletions(-) 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 7d46f4312..562a6abf6 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 @@ -25,23 +25,23 @@ @shared_task -def async_export_to_git(content_key_string, user=None): +def async_export_to_git(context_key_string, user=None): """Export a course or library to Git. Args: - content_key_string (str): String representation of CourseKey or LibraryLocator + context_key_string (str): String representation of LearningContextKey user: Optional user for git export """ # Parse as LearningContextKey to support all learning contexts try: - content_key = LearningContextKey.from_string(content_key_string) - content_info = get_content_info(content_key) + context_key = LearningContextKey.from_string(context_key_string) + content_info = get_content_info(context_key) except Exception: - LOGGER.exception("Failed to parse content key: %s", content_key_string) + LOGGER.exception("Failed to parse content key: %s", context_key_string) return try: - content_repo = ContentGitRepository.objects.get(content_key=content_key) + content_repo = ContentGitRepository.objects.get(content_key=context_key) if content_repo.is_export_enabled: LOGGER.info( @@ -50,18 +50,18 @@ def async_export_to_git(content_key_string, user=None): content_info["content_type"], content_info["content_module"].id if hasattr(content_info["content_module"], "id") - else content_key, + else context_key, ) # Remove any stale .git/index.lock left by a previously crashed worker. # Dirty working-tree files from a prior crash are cleaned by the # `git reset --hard origin/` + `git clean` inside export_to_git. clear_stale_git_lock(content_repo.git_url) - export_to_git(content_key, content_repo.git_url, user=user) + export_to_git(context_key, content_repo.git_url, user=user) else: LOGGER.info( "Git export is disabled for %s %s. Skipping export.", content_info["content_type"], - content_key_string, + context_key_string, ) except GitExportError: LOGGER.exception( @@ -70,17 +70,17 @@ def async_export_to_git(content_key_string, user=None): content_info["content_type"], content_info["content_module"].id if hasattr(content_info["content_module"], "id") - else content_key, + else context_key, ) except ContentGitRepository.DoesNotExist: LOGGER.exception( "Git repository does not exist for %s %s. " "Creating repository and exporting content.", content_info["content_type"], - content_key_string, + context_key_string, ) if is_auto_repo_creation_enabled(is_library=content_info["is_library"]): - async_create_github_repo.delay(str(content_key), export_content=True) + async_create_github_repo.delay(str(context_key), export_content=True) except Exception: LOGGER.exception( "Unknown error occurred during async %s content export to git (%s id: %s)", @@ -88,7 +88,7 @@ def async_export_to_git(content_key_string, user=None): content_info["content_type"], content_info["content_module"].id if hasattr(content_info["content_module"], "id") - else content_key, + else context_key, ) @@ -97,12 +97,12 @@ def async_export_to_git(content_key_string, user=None): autoretry_for=(requests.exceptions.RequestException,), retry_kwargs={"max_retries": 3, "countdown": 10}, ) -def async_create_github_repo(self, content_key_str, export_content=False): # noqa: FBT002 +def async_create_github_repo(self, context_key_str, export_content=False): # noqa: FBT002 """ Create a GitHub repository for the given course or library key. Args: - content_key_str (str): The course/library key for which to create repository. + context_key_str (str): The course/library key for which to create repository. export_content (bool): Whether to export the content after creating the repo. @@ -113,23 +113,23 @@ def async_create_github_repo(self, content_key_str, export_content=False): # no # Parse as LearningContextKey to support all learning contexts try: - content_key = LearningContextKey.from_string(content_key_str) - content_info = get_content_info(content_key) + context_key = LearningContextKey.from_string(context_key_str) + content_info = get_content_info(context_key) except Exception: - LOGGER.exception("Failed to parse content key: %s", content_key_str) - return False, f"Invalid content key: {content_key_str}" + LOGGER.exception("Failed to parse context key: %s", context_key_str) + return False, f"Invalid context key: {context_key_str}" - content_id_slugified = github_repo_name_format(str(content_key)) + content_id_slugified = github_repo_name_format(str(context_key)) response_msg = "" # Check if repository already exists - if ContentGitRepository.objects.filter(content_key=content_key).exists(): - response_msg = f"GitHub repository already exists for {content_info['content_type']} {content_key}. Skipping creation." # noqa: E501 + if ContentGitRepository.objects.filter(content_key=context_key).exists(): + response_msg = f"GitHub repository already exists for {content_info['content_type']} {context_key}. Skipping creation." # noqa: E501 LOGGER.info(response_msg) return False, response_msg # Determine URL path based on content type - url_path = f"{content_info['content_type']}/{content_key_str}" + url_path = 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"]: @@ -156,7 +156,7 @@ def async_create_github_repo(self, content_key_str, export_content=False): # no } response = requests.post(url, headers=headers, json=payload, timeout=30) if response.status_code != status.HTTP_201_CREATED: - response_msg = f"Failed to create GitHub repository for {content_info['content_type']} {content_key}: {response.json()}" # noqa: E501 + response_msg = f"Failed to create GitHub repository for {content_info['content_type']} {context_key}: {response.json()}" # noqa: E501 LOGGER.error(response_msg) # Retry the task if we haven't exceeded max retries @@ -165,7 +165,7 @@ def async_create_github_repo(self, content_key_str, export_content=False): # no LOGGER.info( "Retrying GitHub repository creation for %s %s (attempt %d/%d)", content_info["content_type"], - content_key, + context_key, self.request.retries + 1, max_retries, ) @@ -179,24 +179,24 @@ def async_create_github_repo(self, content_key_str, export_content=False): # no if ssh_url: # Use the new ContentGitRepository model ContentGitRepository.objects.create( - content_key=content_key, + content_key=context_key, git_url=ssh_url, ) LOGGER.info( "GitHub repository created for %s %s: %s", content_info["content_type"], - content_key, + context_key, ssh_url, ) else: response_msg = f""" Failed to retrieve SSH URL from GitHub response - for {content_info["content_type"]} {content_key}. + for {content_info["content_type"]} {context_key}. Response data: {repo_data} """ LOGGER.error(response_msg) if ssh_url and export_content: - async_export_to_git(content_key_str) + async_export_to_git(context_key_str) return True, response_msg or ssh_url diff --git a/src/ol_openedx_git_auto_export/pyproject.toml b/src/ol_openedx_git_auto_export/pyproject.toml index af5893158..b9d57c640 100644 --- a/src/ol_openedx_git_auto_export/pyproject.toml +++ b/src/ol_openedx_git_auto_export/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ol-openedx-git-auto-export" -version = "0.8.0" +version = "0.8.1" description = "A plugin that auto saves the course OLX to git when an author publishes it" authors = [ {name = "MIT Office of Digital Learning"} From 8be5a1677c71c4e85825ca80e49887f965b7979b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 10:28:54 +0000 Subject: [PATCH 10/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../ol_openedx_git_auto_export/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ccd56a752..af54ecf3c 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 @@ -19,9 +19,9 @@ ENABLE_AUTO_GITHUB_LIBRARY_REPO_CREATION, ENABLE_AUTO_GITHUB_REPO_CREATION, ENABLE_GIT_AUTO_EXPORT, + ENABLE_GIT_AUTO_LIBRARY_EXPORT, EXPORT_DEBOUNCE_CACHE_KEY, EXPORT_DEBOUNCE_DELAY, - ENABLE_GIT_AUTO_LIBRARY_EXPORT, REPOSITORY_NAME_MAX_LENGTH, ContentType, ) From 83b98a5239b7d4cf71ae0a975437a5c1996a7a62 Mon Sep 17 00:00:00 2001 From: Muhammad Arslan Abdul Rauf Date: Fri, 22 May 2026 15:28:49 +0500 Subject: [PATCH 11/11] docs: flag scopes updated --- src/ol_openedx_git_auto_export/CONFIGURATION.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ol_openedx_git_auto_export/CONFIGURATION.md b/src/ol_openedx_git_auto_export/CONFIGURATION.md index dc827038b..32c2f6392 100644 --- a/src/ol_openedx_git_auto_export/CONFIGURATION.md +++ b/src/ol_openedx_git_auto_export/CONFIGURATION.md @@ -10,7 +10,7 @@ This document describes the available feature flags for controlling git auto-exp - **Type**: Boolean - **Default**: `True` - **Purpose**: Controls automatic git export for courses when they are published -- **Scope**: Courses only (unless library flag not set, see below) +- **Scope**: Courses only - **Location**: `settings.FEATURES['ENABLE_GIT_AUTO_EXPORT']` **Example**: @@ -22,7 +22,7 @@ FEATURES['ENABLE_GIT_AUTO_EXPORT'] = True - **Type**: Boolean - **Default**: `False` - **Purpose**: Controls automatic GitHub repository creation for new courses -- **Scope**: Courses only (unless library flag not set, see below) +- **Scope**: Courses only - **Location**: `settings.FEATURES['ENABLE_AUTO_GITHUB_REPO_CREATION']` **Example**: