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.
- ol_openedx_course_sync/__init__.py +5 -0
- ol_openedx_course_sync/admin.py +91 -0
- ol_openedx_course_sync/apps.py +30 -0
- ol_openedx_course_sync/constants.py +6 -0
- ol_openedx_course_sync/migrations/0001_initial.py +54 -0
- ol_openedx_course_sync/migrations/__init__.py +0 -0
- ol_openedx_course_sync/models.py +101 -0
- ol_openedx_course_sync/settings/__init__.py +0 -0
- ol_openedx_course_sync/settings/common.py +10 -0
- ol_openedx_course_sync/settings/production.py +10 -0
- ol_openedx_course_sync/signals.py +89 -0
- ol_openedx_course_sync/tasks.py +106 -0
- ol_openedx_course_sync/utils.py +138 -0
- ol_openedx_course_sync-0.4.0.dist-info/METADATA +57 -0
- ol_openedx_course_sync-0.4.0.dist-info/RECORD +18 -0
- ol_openedx_course_sync-0.4.0.dist-info/WHEEL +4 -0
- ol_openedx_course_sync-0.4.0.dist-info/entry_points.txt +2 -0
- ol_openedx_course_sync-0.4.0.dist-info/licenses/LICENCE +28 -0
|
@@ -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,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,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.
|