ol-openedx-course-sync 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """
2
+ ol-openedx-course-sync plugin
3
+ """
4
+
5
+ default_app_config = "ol_openedx_course_sync.apps.OLOpenEdxCourseSyncConfig"
@@ -0,0 +1,91 @@
1
+ """
2
+ Django admin for ol-openedx-course-sync plugin
3
+ """
4
+
5
+ import logging
6
+
7
+ from django import forms
8
+ from django.contrib import admin
9
+ from organizations.models import Organization
10
+
11
+ from ol_openedx_course_sync.models import CourseSyncMapping, CourseSyncOrganization
12
+ from ol_openedx_course_sync.tasks import async_course_sync
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ class CourseSyncOrganizationForm(forms.ModelForm):
18
+ """
19
+ Form for CourseSyncOrganization model
20
+ """
21
+
22
+ class Meta:
23
+ model = CourseSyncOrganization
24
+ fields = "__all__" # noqa: DJ007
25
+
26
+ def __init__(self, *args, **kwargs):
27
+ super().__init__(*args, **kwargs)
28
+ org_choices = [
29
+ (org.name, org.name) for org in Organization.objects.filter(active=True)
30
+ ]
31
+ self.fields["organization"] = forms.ChoiceField(choices=org_choices)
32
+
33
+
34
+ class CourseSyncOrganizationAdmin(admin.ModelAdmin):
35
+ """
36
+ Admin for CourseSyncOrganization model
37
+ """
38
+
39
+ form = CourseSyncOrganizationForm
40
+ list_display = ("organization", "is_active")
41
+
42
+ def has_delete_permission(self, request, obj=None):
43
+ """
44
+ Disable delete permission if CourseSyncMapping is not clean
45
+ """
46
+ if obj and not obj.can_be_deleted():
47
+ return False
48
+ return super().has_delete_permission(request, obj)
49
+
50
+
51
+ class CourseSyncMappingAdmin(admin.ModelAdmin):
52
+ """
53
+ Admin for CourseSyncMapping model
54
+ """
55
+
56
+ list_display = ("source_course", "target_course", "is_active")
57
+ search_fields = ("source_course", "target_course")
58
+ list_filter = ("is_active",)
59
+ actions = ("sync_course_content",)
60
+
61
+ def get_readonly_fields(self, request, obj=None): # noqa: ARG002
62
+ """
63
+ Make source_course readonly if object already exists.
64
+ """
65
+ if obj:
66
+ return (*self.readonly_fields, "source_course")
67
+ return self.readonly_fields
68
+
69
+ @admin.action(description="Sync Course Content")
70
+ def sync_course_content(self, request, queryset):
71
+ """
72
+ Sync course content for selected CourseSyncMapping(s)
73
+ """
74
+ for course_sync_mapping in queryset:
75
+ log.info(
76
+ "Initializing course content sync through admin actions from %s to %s",
77
+ course_sync_mapping.source_course,
78
+ course_sync_mapping.target_course,
79
+ )
80
+ async_course_sync.delay(
81
+ str(course_sync_mapping.source_course),
82
+ str(course_sync_mapping.target_course),
83
+ )
84
+ self.message_user(
85
+ request,
86
+ "Course sync started",
87
+ )
88
+
89
+
90
+ admin.site.register(CourseSyncOrganization, CourseSyncOrganizationAdmin)
91
+ admin.site.register(CourseSyncMapping, CourseSyncMappingAdmin)
@@ -0,0 +1,30 @@
1
+ """
2
+ App configuration for ol-openedx-course-sync plugin
3
+ """
4
+
5
+ from django.apps import AppConfig
6
+ from edx_django_utils.plugins import PluginSignals
7
+ from openedx.core.djangoapps.plugins.constants import ProjectType
8
+
9
+
10
+ class OLOpenEdxCourseSyncConfig(AppConfig):
11
+ """
12
+ App configuration for the ol-openedx-course-sync app.
13
+ """
14
+
15
+ name = "ol_openedx_course_sync"
16
+ verbose_name = "Open edX Course Sync"
17
+
18
+ plugin_app = {
19
+ PluginSignals.CONFIG: {
20
+ ProjectType.CMS: {
21
+ PluginSignals.RECEIVERS: [
22
+ {
23
+ PluginSignals.RECEIVER_FUNC_NAME: "listen_for_course_publish",
24
+ PluginSignals.SIGNAL_PATH: "xmodule.modulestore.django.COURSE_PUBLISHED", # noqa: E501
25
+ PluginSignals.DISPATCH_UID: "ol_openedx_course_sync.signals.listen_for_course_publish", # noqa: E501
26
+ }
27
+ ],
28
+ },
29
+ },
30
+ }
@@ -0,0 +1,6 @@
1
+ """
2
+ Constants for ol-openedx-course-sync plugin
3
+ """
4
+
5
+ COURSE_RERUN_STATE_SUCCEEDED = "succeeded"
6
+ STATIC_TAB_TYPE = "static_tab"
@@ -0,0 +1,54 @@
1
+ # Generated by Django 4.2.20 on 2025-05-23 09:28
2
+
3
+ import opaque_keys.edx.django.models
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+ initial = True
9
+
10
+ dependencies = [] # type: ignore[var-annotated]
11
+
12
+ operations = [
13
+ migrations.CreateModel(
14
+ name="CourseSyncMapping",
15
+ fields=[
16
+ (
17
+ "id",
18
+ models.AutoField(
19
+ auto_created=True,
20
+ primary_key=True,
21
+ serialize=False,
22
+ verbose_name="ID",
23
+ ),
24
+ ),
25
+ (
26
+ "source_course",
27
+ opaque_keys.edx.django.models.CourseKeyField(max_length=255),
28
+ ),
29
+ (
30
+ "target_course",
31
+ opaque_keys.edx.django.models.CourseKeyField(
32
+ max_length=255, unique=True
33
+ ),
34
+ ),
35
+ ("is_active", models.BooleanField(default=True)),
36
+ ],
37
+ ),
38
+ migrations.CreateModel(
39
+ name="CourseSyncOrganization",
40
+ fields=[
41
+ (
42
+ "id",
43
+ models.AutoField(
44
+ auto_created=True,
45
+ primary_key=True,
46
+ serialize=False,
47
+ verbose_name="ID",
48
+ ),
49
+ ),
50
+ ("organization", models.CharField(max_length=255, unique=True)),
51
+ ("is_active", models.BooleanField(default=True)),
52
+ ],
53
+ ),
54
+ ]
File without changes
@@ -0,0 +1,101 @@
1
+ """
2
+ Models for ol-openedx-course-sync plugin
3
+ """
4
+
5
+ from django.core.exceptions import ValidationError
6
+ from django.db import models
7
+ from opaque_keys.edx.django.models import (
8
+ CourseKeyField,
9
+ )
10
+ from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
11
+
12
+
13
+ class CourseSyncOrganization(models.Model):
14
+ """
15
+ Model for source course organizations
16
+
17
+ Any course that is part of this organization
18
+ will sync the content changes to the target/rerun courses.
19
+ """
20
+
21
+ organization = models.CharField(max_length=255, unique=True)
22
+ is_active = models.BooleanField(default=True)
23
+
24
+ class Meta:
25
+ app_label = "ol_openedx_course_sync"
26
+
27
+ def __str__(self):
28
+ return f"{self.organization} Course Sync Organization"
29
+
30
+ def delete(self, *args, **kwargs):
31
+ """
32
+ Override delete method to perform custom validations.
33
+ """
34
+ if not self.can_be_deleted():
35
+ raise ValidationError( # noqa: TRY003
36
+ "Cannot delete organization with existing CourseSyncMapping objects." # noqa: EM101
37
+ )
38
+ super().delete(*args, **kwargs)
39
+
40
+ def can_be_deleted(self):
41
+ """
42
+ Check if the organization can be deleted.
43
+ """
44
+ return not CourseSyncMapping.objects.filter(
45
+ models.Q(source_course__contains=self.organization)
46
+ | models.Q(target_course__contains=self.organization)
47
+ ).exists()
48
+
49
+
50
+ class CourseSyncMapping(models.Model):
51
+ """
52
+ Model to keep track of source and target courses.
53
+ """
54
+
55
+ source_course = CourseKeyField(max_length=255)
56
+ target_course = CourseKeyField(max_length=255, unique=True)
57
+ is_active = models.BooleanField(default=True)
58
+
59
+ class Meta:
60
+ app_label = "ol_openedx_course_sync"
61
+
62
+ def __str__(self):
63
+ return f"{self.source_course} Course Sync Mapping"
64
+
65
+ def save(self, *args, **kwargs):
66
+ """
67
+ Override save method to perform custom validations.
68
+ """
69
+ self.full_clean()
70
+ super().save(*args, **kwargs)
71
+
72
+ def clean(self):
73
+ """
74
+ Override clean method to perform custom validations.
75
+ """
76
+ super().clean()
77
+
78
+ if not CourseOverview.objects.filter(id=self.source_course).exists():
79
+ raise ValidationError({"source_course": "Source course does not exist"})
80
+
81
+ if not CourseOverview.objects.filter(id=self.target_course).exists():
82
+ raise ValidationError({"target_course": "Target course does not exist"})
83
+
84
+ conflicting_target = CourseSyncMapping.objects.filter(
85
+ target_course=self.source_course
86
+ ).first()
87
+ if conflicting_target:
88
+ raise ValidationError(
89
+ {
90
+ "source_course": f"This course is already used as target course of: " # noqa: E501
91
+ f"{conflicting_target.source_course}"
92
+ }
93
+ )
94
+
95
+ conflicting_source = CourseSyncMapping.objects.filter(
96
+ source_course=self.target_course
97
+ ).first()
98
+ if conflicting_source:
99
+ raise ValidationError(
100
+ {"target_course": "This course is already a source course"}
101
+ )
File without changes
@@ -0,0 +1,10 @@
1
+ """Common settings unique to the course sync plugin."""
2
+
3
+
4
+ def plugin_settings(settings):
5
+ """Configure settings for the course sync plugin."""
6
+ # .. setting_name: OL_OPENEDX_COURSE_SYNC_SERVICE_WORKER_USERNAME
7
+ # .. setting_default: ""
8
+ # .. setting_description: The username of the service worker that
9
+ # will be used to sync courses.
10
+ settings.OL_OPENEDX_COURSE_SYNC_SERVICE_WORKER_USERNAME = ""
@@ -0,0 +1,10 @@
1
+ """Production settings unique to the course sync plugin."""
2
+
3
+
4
+ def plugin_settings(settings):
5
+ """Configure settings for the course sync plugin."""
6
+ # .. setting_name: OL_OPENEDX_COURSE_SYNC_SERVICE_WORKER_USERNAME
7
+ # .. setting_default: ""
8
+ # .. setting_description: The username of the service worker that
9
+ # will be used to sync courses.
10
+ settings.OL_OPENEDX_COURSE_SYNC_SERVICE_WORKER_USERNAME = ""
@@ -0,0 +1,89 @@
1
+ """
2
+ Signal handlers for ol-openedx-course-sync plugin
3
+ """
4
+
5
+ import logging
6
+
7
+ from common.djangoapps.course_action_state.models import CourseRerunState
8
+ from django.conf import settings
9
+ from django.core.exceptions import ImproperlyConfigured, ValidationError
10
+ from django.db.models.signals import post_save
11
+ from django.dispatch import receiver
12
+
13
+ from ol_openedx_course_sync.constants import COURSE_RERUN_STATE_SUCCEEDED
14
+ from ol_openedx_course_sync.models import CourseSyncMapping, CourseSyncOrganization
15
+ from ol_openedx_course_sync.tasks import async_course_sync
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ def listen_for_course_publish(
21
+ sender, # noqa: ARG001
22
+ course_key,
23
+ **kwargs, # noqa: ARG001
24
+ ):
25
+ """
26
+ Listen for course publish signal and trigger course sync task
27
+ """
28
+ if not CourseSyncOrganization.objects.filter(
29
+ organization=course_key.org, is_active=True
30
+ ).exists():
31
+ return
32
+
33
+ if not getattr(settings, "OL_OPENEDX_COURSE_SYNC_SERVICE_WORKER_USERNAME", None):
34
+ error_msg = (
35
+ "OL_OPENEDX_COURSE_SYNC_SERVICE_WORKER_USERNAME is not set. "
36
+ "Course sync will not be performed."
37
+ )
38
+ raise ImproperlyConfigured(error_msg)
39
+
40
+ course_sync_mappings = CourseSyncMapping.objects.filter(
41
+ source_course=course_key, is_active=True
42
+ )
43
+ if not course_sync_mappings:
44
+ log.info("No mapping found for course %s. Skipping sync.", str(course_key))
45
+ return
46
+
47
+ for course_sync_mapping in course_sync_mappings:
48
+ log.info(
49
+ "Initializing course content sync from %s to %s",
50
+ course_sync_mapping.source_course,
51
+ course_sync_mapping.target_course,
52
+ )
53
+ async_course_sync.delay(
54
+ str(course_sync_mapping.source_course),
55
+ str(course_sync_mapping.target_course),
56
+ )
57
+
58
+
59
+ @receiver(post_save, sender=CourseRerunState)
60
+ def listen_for_course_rerun_state_post_save(sender, instance, **kwargs): # noqa: ARG001
61
+ """
62
+ Listen for `CourseRerunState` post_save and
63
+ create target courses in `CourseSyncMapping`
64
+ """
65
+ if instance.state != COURSE_RERUN_STATE_SUCCEEDED:
66
+ return
67
+
68
+ if not CourseSyncOrganization.objects.filter(
69
+ organization=instance.source_course_key.org, is_active=True
70
+ ).exists():
71
+ return
72
+
73
+ try:
74
+ course_sync_mapping = CourseSyncMapping.objects.create(
75
+ source_course=instance.source_course_key,
76
+ target_course=instance.course_key,
77
+ )
78
+ except ValidationError:
79
+ log.exception(
80
+ "Failed to create CourseSyncMapping for %s",
81
+ instance.source_course_key,
82
+ )
83
+ else:
84
+ # Trigger course sync to sync the published changes.
85
+ # When a course clone or rerun is created, published changes are not synced.
86
+ async_course_sync.delay(
87
+ str(course_sync_mapping.source_course),
88
+ str(course_sync_mapping.target_course),
89
+ )
@@ -0,0 +1,106 @@
1
+ """
2
+ Tasks for the ol-openedx-course-sync plugin.
3
+ """
4
+
5
+ from celery import shared_task # pylint: disable=import-error
6
+ from celery.utils.log import get_task_logger
7
+ from celery_utils.persist_on_failure import LoggedPersistOnFailureTask
8
+ from django.conf import settings
9
+ from django.contrib.auth import get_user_model
10
+ from edx_django_utils.cache import TieredCache, get_cache_key
11
+ from edxval.api import copy_course_videos
12
+ from opaque_keys.edx.locator import CourseLocator
13
+ from xmodule.modulestore import ModuleStoreEnum
14
+ from xmodule.modulestore.django import SignalHandler, modulestore
15
+
16
+ from ol_openedx_course_sync.apps import OLOpenEdxCourseSyncConfig
17
+ from ol_openedx_course_sync.utils import (
18
+ copy_course_content,
19
+ copy_static_tabs,
20
+ update_default_tabs,
21
+ )
22
+
23
+ User = get_user_model()
24
+ logger = get_task_logger(__name__)
25
+
26
+
27
+ @shared_task(
28
+ base=LoggedPersistOnFailureTask,
29
+ autoretry_for=(Exception,),
30
+ max_retries=3,
31
+ default_retry_delay=30,
32
+ )
33
+ def async_course_sync(source_course_id, dest_course_id):
34
+ """
35
+ Sync course content from source course to destination course.
36
+ """
37
+ logger.info("Starting course sync from %s to %s", source_course_id, dest_course_id)
38
+ source_course_key = CourseLocator.from_string(source_course_id)
39
+ dest_course_key = CourseLocator.from_string(dest_course_id)
40
+
41
+ cache_key = get_cache_key(
42
+ course_sync_service_worker=settings.OL_OPENEDX_COURSE_SYNC_SERVICE_WORKER_USERNAME
43
+ )
44
+ cache_value = TieredCache.get_cached_response(cache_key)
45
+ if not cache_value.is_found:
46
+ user = User.objects.filter(
47
+ username=settings.OL_OPENEDX_COURSE_SYNC_SERVICE_WORKER_USERNAME
48
+ ).first()
49
+ TieredCache.set_all_tiers(cache_key, user)
50
+ else:
51
+ user = cache_value.value
52
+
53
+ if not user:
54
+ logger.error(
55
+ "Service worker user %s not found. Cannot perform course sync.",
56
+ settings.OL_OPENEDX_COURSE_SYNC_SERVICE_WORKER_USERNAME,
57
+ )
58
+ return
59
+
60
+ logger.info(
61
+ "Copying draft course content from %s to %s", source_course_key, dest_course_key
62
+ )
63
+ # Copy draft branch content
64
+ copy_course_content(
65
+ source_course_key, dest_course_key, ModuleStoreEnum.BranchName.draft, user.id
66
+ )
67
+
68
+ logger.info(
69
+ "Copying course assets from %s to %s",
70
+ source_course_key,
71
+ dest_course_key,
72
+ )
73
+ # Copy course assets and videos.
74
+ # These steps are taken from the course_rerun task in edx-platform.
75
+ module_store = modulestore()
76
+ if module_store.contentstore:
77
+ module_store.contentstore.delete_all_course_assets(dest_course_key)
78
+ module_store.contentstore.copy_all_course_assets(
79
+ source_course_key, dest_course_key
80
+ )
81
+ copy_course_videos(source_course_key, dest_course_key)
82
+
83
+ logger.info(
84
+ "Copying published course content from %s to %s",
85
+ source_course_key,
86
+ dest_course_key,
87
+ )
88
+ # copy published branch content
89
+ copy_course_content(
90
+ source_course_key,
91
+ dest_course_key,
92
+ ModuleStoreEnum.BranchName.published,
93
+ user.id,
94
+ )
95
+
96
+ logger.info("Syncing static tabs from %s to %s", source_course_key, dest_course_key)
97
+ copy_static_tabs(source_course_key, dest_course_key, user)
98
+ update_default_tabs(source_course_key, dest_course_key, user)
99
+
100
+ # trigger course publish signal to trigger outline and relevant updates
101
+ SignalHandler.course_published.send(
102
+ sender=OLOpenEdxCourseSyncConfig, course_key=dest_course_key
103
+ )
104
+ logger.debug(
105
+ "Finished course sync from %s to %s", source_course_key, dest_course_key
106
+ )
@@ -0,0 +1,138 @@
1
+ """
2
+ Utilities for the ol-openedx-course-sync plugin
3
+ """
4
+
5
+ from uuid import uuid4
6
+
7
+ from cms.djangoapps.contentstore.utils import duplicate_block
8
+ from xmodule.modulestore.django import modulestore
9
+ from xmodule.modulestore.exceptions import ItemNotFoundError
10
+ from xmodule.tabs import CourseTabList, StaticTab
11
+
12
+ from ol_openedx_course_sync.constants import STATIC_TAB_TYPE
13
+
14
+
15
+ def copy_course_content(source_course_key, target_course_key, branch, user_id=None):
16
+ """
17
+ Copy course content from source_course to target_course
18
+ on the specified branch.
19
+ """
20
+ module_store = modulestore()
21
+ subtree_list = [module_store.make_course_usage_key(source_course_key)]
22
+ source_course_key_for_branch = source_course_key.for_branch(branch)
23
+ target_course_key_for_branch = target_course_key.for_branch(branch)
24
+
25
+ source_modulestore = module_store._get_modulestore_for_courselike(source_course_key) # noqa: SLF001
26
+ target_modulestore = module_store._get_modulestore_for_courselike(target_course_key) # noqa: SLF001
27
+ if source_modulestore == target_modulestore:
28
+ source_modulestore.copy(
29
+ user_id,
30
+ source_course_key_for_branch,
31
+ target_course_key_for_branch,
32
+ subtree_list,
33
+ )
34
+
35
+
36
+ def copy_static_tabs(source_course_key, target_course_key, user):
37
+ """
38
+ Copy static tabs from source to target course.
39
+
40
+ Args:
41
+ source_course_key (CourseLocator): The course key of the source course.
42
+ target_course_key (CourseLocator): The course key of the target course.
43
+ user (User): The user performing the update.
44
+ """
45
+ store = modulestore()
46
+ source_course = store.get_course(source_course_key)
47
+ target_course = store.get_course(target_course_key)
48
+
49
+ # If we need to update the static tabs, we will delete the
50
+ # old static tabs and create new ones to handle the tab ordering.
51
+ for tab in target_course.tabs:
52
+ if tab.type != STATIC_TAB_TYPE:
53
+ continue
54
+
55
+ tab_usage_key = target_course.id.make_usage_key(STATIC_TAB_TYPE, tab.url_slug)
56
+ existing_tabs = target_course.tabs or []
57
+
58
+ # Remove the tab from the target course tabs list
59
+ target_course.tabs = [
60
+ tab
61
+ for tab in existing_tabs
62
+ if tab.get("url_slug") != tab_usage_key.block_id
63
+ ]
64
+ store.update_item(target_course, user.id)
65
+ try:
66
+ store.get_item(tab_usage_key, user.id)
67
+ except ItemNotFoundError:
68
+ # If the tab does not exist, we can skip deletion
69
+ continue
70
+ store.delete_item(tab_usage_key, user.id)
71
+
72
+ # Now copy the static tabs from the source course to the target course
73
+ # Steps:
74
+ # 1. Iterate through the static tabs in the source course.
75
+ # 2. For each static tab, create a new usage key for the target course.
76
+ # 3. Duplicate the block from the source course to the target course.
77
+ # 4. Update the target course's tabs list with the new tab
78
+ target_course_usage_key = target_course.usage_key
79
+ for source_tab in source_course.tabs:
80
+ if source_tab.type != STATIC_TAB_TYPE:
81
+ continue
82
+
83
+ # Create a new usage key for the destination tab
84
+ target_tab_usage_key = target_course_usage_key.replace(
85
+ category=STATIC_TAB_TYPE, name=uuid4().hex
86
+ )
87
+ source_tab_usage_key = source_course.id.make_usage_key(
88
+ STATIC_TAB_TYPE, source_tab.url_slug
89
+ )
90
+
91
+ duplicate_block(
92
+ target_course_usage_key,
93
+ source_tab_usage_key,
94
+ user,
95
+ dest_usage_key=target_tab_usage_key,
96
+ display_name=source_tab.name,
97
+ shallow=True,
98
+ )
99
+
100
+ source_tab_dict = source_tab.to_json()
101
+ source_tab_dict["url_slug"] = target_tab_usage_key.block_id
102
+ target_course.tabs.append(
103
+ StaticTab(
104
+ tab_dict=source_tab_dict,
105
+ name=source_tab.name,
106
+ url_slug=target_tab_usage_key.block_id,
107
+ )
108
+ )
109
+
110
+ store.update_item(target_course, user.id)
111
+
112
+
113
+ def update_default_tabs(source_course_key, target_course_key, user):
114
+ """
115
+ Update the `is_hidden` tab state for the default tabs like wiki, and progress tab.
116
+
117
+ Args:
118
+ source_course_key (CourseLocator): The course key of the source course.
119
+ target_course_key (CourseLocator): The course key of the target course.
120
+ user (User): The user performing the update.
121
+ """
122
+ store = modulestore()
123
+ source_course = store.get_course(source_course_key)
124
+ target_course = store.get_course(target_course_key)
125
+ is_updated = False
126
+
127
+ for tab in source_course.tabs:
128
+ if tab.type == STATIC_TAB_TYPE:
129
+ continue
130
+
131
+ target_course_tab = CourseTabList.get_tab_by_type(target_course.tabs, tab.type)
132
+ if tab.is_hidden == target_course_tab.is_hidden:
133
+ continue
134
+ target_course_tab.is_hidden = tab.is_hidden
135
+ is_updated = True
136
+
137
+ if is_updated:
138
+ store.update_item(target_course, user.id)
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: ol-openedx-course-sync
3
+ Version: 0.4.0
4
+ Summary: An Open edX plugin to sync course changes to its reruns.
5
+ Author: MIT Office of Digital Learning
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENCE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: django>2.0
10
+ Requires-Dist: djangorestframework>=3.14.0
11
+ Requires-Dist: edx-django-utils>4.0.0
12
+ Requires-Dist: edx-opaque-keys
13
+ Requires-Dist: openedx-events
14
+ Description-Content-Type: text/x-rst
15
+
16
+ OL Open edX Course Sync
17
+ =======================
18
+
19
+ An Open edX plugin to sync course changes to its reruns.
20
+
21
+ Version Compatibility
22
+ ---------------------
23
+
24
+ It supports Open edX releases from `Sumac` and onwards.
25
+
26
+ Installing The Plugin
27
+ ---------------------
28
+
29
+ For detailed installation instructions, please refer to the `plugin installation guide <../../docs#installation-guide>`_.
30
+
31
+ Installation required in:
32
+
33
+ * CMS
34
+
35
+ Configuration
36
+ =============
37
+
38
+ * Add a setting ``OL_OPENEDX_COURSE_SYNC_SERVICE_WORKER_USERNAME`` for the service worker and all the sync operations will be done on behalf of this user.
39
+
40
+ * For Tutor, you can run:
41
+
42
+ .. code-block:: bash
43
+
44
+ tutor config save --set OL_OPENEDX_COURSE_SYNC_SERVICE_WORKER_USERNAME={USERNAME}
45
+
46
+ * If you have a ``private.py`` for the CMS settings, you can add it to ``cms/envs/private.py``.
47
+
48
+ Usage
49
+ -----
50
+
51
+ * Install the plugin and run the migrations in the CMS.
52
+ * Add the parent/source organization in the CMS admin model `CourseSyncOrganization`.
53
+ * Course sync will only work for this organization. It will treat all the courses under this organization as parent/source courses.
54
+ * The plugin will automatically add course re-runs created from the CMS as the child courses.
55
+ * The organization can be different for the reruns.
56
+ * Target/rerun courses can be managed in the CMS admin model `CourseSyncMapping`.
57
+ * Now, any changes made in the source course will be synced to the target courses.
@@ -0,0 +1,18 @@
1
+ ol_openedx_course_sync/__init__.py,sha256=nETfBgWsxXNBycNTq2Uf0n0Jv3pI1B3zndwtfw0cjfI,116
2
+ ol_openedx_course_sync/admin.py,sha256=0D7IP6OG9MX3rWSQhD5UK-D_fIVgNUBN1qHeQrPoRAE,2767
3
+ ol_openedx_course_sync/apps.py,sha256=_hDAxj48F3nARJ4FmP5ONSloN6ox-c_dCSaca7CEanI,957
4
+ ol_openedx_course_sync/constants.py,sha256=c_9EyNS27BU9wZi_dI3uTbVeObcl3McaYpbGnG2bTjE,127
5
+ ol_openedx_course_sync/models.py,sha256=NCOuJVW5g6GxPP_oEdOAURP-NBGXtnTABiuNjbKo7-s,3193
6
+ ol_openedx_course_sync/signals.py,sha256=Foq7C1NEOl7FP3GX9RcZtOcn05RW84hPEzHt9HTYL0I,2955
7
+ ol_openedx_course_sync/tasks.py,sha256=LaVjyRv6iTI93p3983fTHp8BdS-hIyVq4vq_dB1dxEk,3621
8
+ ol_openedx_course_sync/utils.py,sha256=ScOdJaqZ4oOMnP3QOEWXn_MRphMj88u3fvZ1rPlyn1o,5037
9
+ ol_openedx_course_sync/migrations/0001_initial.py,sha256=O-j6B6SctPZ63nzuNX6WNf5fJ6jJFe_HZDoj9Lbt-gI,1677
10
+ ol_openedx_course_sync/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ ol_openedx_course_sync/settings/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ ol_openedx_course_sync/settings/common.py,sha256=U5A6fDciDihofo2fDLKul-i0xV6_wJXPlUOQ_iIbi8A,416
13
+ ol_openedx_course_sync/settings/production.py,sha256=n5K9vicUD7rUQC-P21tppY4g3JTv3-T37-AWr-igSwo,420
14
+ ol_openedx_course_sync-0.4.0.dist-info/METADATA,sha256=QBjZRnR8q6UNddT_ZneAxJsgylcZpmWaFG1NpQIvM9I,1902
15
+ ol_openedx_course_sync-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
+ ol_openedx_course_sync-0.4.0.dist-info/entry_points.txt,sha256=KIm6L9U3HifBgJidmyFU7Wyap0b0krOF9vxVDvnWulU,95
17
+ ol_openedx_course_sync-0.4.0.dist-info/licenses/LICENCE,sha256=YfuyC7jc8ov7-UJZI-iUXi3g0HdeAaDG-IQ90-McB9M,1496
18
+ ol_openedx_course_sync-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [cms.djangoapp]
2
+ ol_openedx_course_sync = ol_openedx_course_sync.apps:OLOpenEdxCourseSyncConfig
@@ -0,0 +1,28 @@
1
+ Copyright (C) 2025 MIT Open Learning
2
+
3
+ All rights reserved.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ * Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ * Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ * Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.