ol-openedx-course-sync 0.1.0__tar.gz

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.
Files changed (23) hide show
  1. ol-openedx-course-sync-0.1.0/MANIFEST.in +1 -0
  2. ol-openedx-course-sync-0.1.0/PKG-INFO +7 -0
  3. ol-openedx-course-sync-0.1.0/__init__.py +0 -0
  4. ol-openedx-course-sync-0.1.0/backend_shim.py +31 -0
  5. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync/__init__.py +5 -0
  6. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync/admin.py +90 -0
  7. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync/apps.py +30 -0
  8. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync/constants.py +5 -0
  9. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync/migrations/0001_initial.py +54 -0
  10. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync/migrations/__init__.py +0 -0
  11. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync/models.py +101 -0
  12. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync/signals.py +80 -0
  13. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync/tasks.py +57 -0
  14. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync/utils.py +27 -0
  15. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync.egg-info/PKG-INFO +7 -0
  16. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync.egg-info/SOURCES.txt +21 -0
  17. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync.egg-info/dependency_links.txt +1 -0
  18. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync.egg-info/entry_points.txt +2 -0
  19. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync.egg-info/namespace_packages.txt +1 -0
  20. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync.egg-info/requires.txt +3 -0
  21. ol-openedx-course-sync-0.1.0/ol_openedx_course_sync.egg-info/top_level.txt +2 -0
  22. ol-openedx-course-sync-0.1.0/setup.cfg +4 -0
  23. ol-openedx-course-sync-0.1.0/setup.py +33 -0
@@ -0,0 +1 @@
1
+ include *.py
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.1
2
+ Name: ol-openedx-course-sync
3
+ Version: 0.1.0
4
+ Summary: An Open edX plugin to sync course changes to its reruns.
5
+ Author: MIT Office of Digital Learning
6
+ License: BSD-3-Clause
7
+ Requires-Python: >=3.8
File without changes
@@ -0,0 +1,31 @@
1
+
2
+ # DO NOT EDIT THIS FILE -- AUTOGENERATED BY PANTS
3
+
4
+ import errno
5
+ import os
6
+ import setuptools.build_meta
7
+
8
+ backend = setuptools.build_meta.__legacy__
9
+
10
+ dist_dir = "dist/"
11
+ build_wheel = True
12
+ build_sdist = True
13
+ wheel_config_settings = {
14
+ }
15
+ sdist_config_settings = {
16
+ }
17
+
18
+ # Python 2.7 doesn't have the exist_ok arg on os.makedirs().
19
+ try:
20
+ os.makedirs(dist_dir)
21
+ except OSError as e:
22
+ if e.errno != errno.EEXIST:
23
+ raise
24
+
25
+ wheel_path = backend.build_wheel(dist_dir, wheel_config_settings) if build_wheel else None
26
+ sdist_path = backend.build_sdist(dist_dir, sdist_config_settings) if build_sdist else None
27
+
28
+ if wheel_path:
29
+ print("wheel: {wheel_path}".format(wheel_path=wheel_path))
30
+ if sdist_path:
31
+ print("sdist: {sdist_path}".format(sdist_path=sdist_path))
@@ -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,90 @@
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 ol_openedx_course_sync.models import CourseSyncMapping, CourseSyncOrganization
10
+ from ol_openedx_course_sync.tasks import async_course_sync
11
+ from organizations.models import Organization
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ class CourseSyncOrganizationForm(forms.ModelForm):
17
+ """
18
+ Form for CourseSyncOrganization model
19
+ """
20
+
21
+ class Meta:
22
+ model = CourseSyncOrganization
23
+ fields = "__all__" # noqa: DJ007
24
+
25
+ def __init__(self, *args, **kwargs):
26
+ super().__init__(*args, **kwargs)
27
+ org_choices = [
28
+ (org.name, org.name) for org in Organization.objects.filter(active=True)
29
+ ]
30
+ self.fields["organization"] = forms.ChoiceField(choices=org_choices)
31
+
32
+
33
+ class CourseSyncOrganizationAdmin(admin.ModelAdmin):
34
+ """
35
+ Admin for CourseSyncOrganization model
36
+ """
37
+
38
+ form = CourseSyncOrganizationForm
39
+ list_display = ("organization", "is_active")
40
+
41
+ def has_delete_permission(self, request, obj=None):
42
+ """
43
+ Disable delete permission if CourseSyncMapping is not clean
44
+ """
45
+ if obj and not obj.can_be_deleted():
46
+ return False
47
+ return super().has_delete_permission(request, obj)
48
+
49
+
50
+ class CourseSyncMappingAdmin(admin.ModelAdmin):
51
+ """
52
+ Admin for CourseSyncMapping model
53
+ """
54
+
55
+ list_display = ("source_course", "target_course", "is_active")
56
+ search_fields = ("source_course", "target_course")
57
+ list_filter = ("is_active",)
58
+ actions = ("sync_course_content",)
59
+
60
+ def get_readonly_fields(self, request, obj=None): # noqa: ARG002
61
+ """
62
+ Make source_course readonly if object already exists.
63
+ """
64
+ if obj:
65
+ return (*self.readonly_fields, "source_course")
66
+ return self.readonly_fields
67
+
68
+ @admin.action(description="Sync Course Content")
69
+ def sync_course_content(self, request, queryset):
70
+ """
71
+ Sync course content for selected CourseSyncMapping(s)
72
+ """
73
+ for course_sync_mapping in queryset:
74
+ log.info(
75
+ "Initializing course content sync through admin actions from %s to %s",
76
+ course_sync_mapping.source_course,
77
+ course_sync_mapping.target_course,
78
+ )
79
+ async_course_sync.delay(
80
+ str(course_sync_mapping.source_course),
81
+ str(course_sync_mapping.target_course),
82
+ )
83
+ self.message_user(
84
+ request,
85
+ "Course sync started",
86
+ )
87
+
88
+
89
+ admin.site.register(CourseSyncOrganization, CourseSyncOrganizationAdmin)
90
+ 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,5 @@
1
+ """
2
+ Constants for ol-openedx-course-sync plugin
3
+ """
4
+
5
+ COURSE_RERUN_STATE_SUCCEEDED = "succeeded"
@@ -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
+ ]
@@ -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
+ )
@@ -0,0 +1,80 @@
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.core.exceptions import ValidationError
9
+ from django.db.models.signals import post_save
10
+ from django.dispatch import receiver
11
+ from ol_openedx_course_sync.constants import COURSE_RERUN_STATE_SUCCEEDED
12
+ from ol_openedx_course_sync.models import CourseSyncMapping, CourseSyncOrganization
13
+ from ol_openedx_course_sync.tasks import async_course_sync
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ def listen_for_course_publish(
19
+ sender, # noqa: ARG001
20
+ course_key,
21
+ **kwargs, # noqa: ARG001
22
+ ):
23
+ """
24
+ Listen for course publish signal and trigger course sync task
25
+ """
26
+ if not CourseSyncOrganization.objects.filter(
27
+ organization=course_key.org, is_active=True
28
+ ).exists():
29
+ return
30
+
31
+ course_sync_mappings = CourseSyncMapping.objects.filter(
32
+ source_course=course_key, is_active=True
33
+ )
34
+ if not course_sync_mappings:
35
+ log.info("No mapping found for course %s. Skipping sync.", str(course_key))
36
+ return
37
+
38
+ for course_sync_mapping in course_sync_mappings:
39
+ log.info(
40
+ "Initializing course content sync from %s to %s",
41
+ course_sync_mapping.source_course,
42
+ course_sync_mapping.target_course,
43
+ )
44
+ async_course_sync.delay(
45
+ str(course_sync_mapping.source_course),
46
+ str(course_sync_mapping.target_course),
47
+ )
48
+
49
+
50
+ @receiver(post_save, sender=CourseRerunState)
51
+ def listen_for_course_rerun_state_post_save(sender, instance, **kwargs): # noqa: ARG001
52
+ """
53
+ Listen for `CourseRerunState` post_save and
54
+ create target courses in `CourseSyncMapping`
55
+ """
56
+ if instance.state != COURSE_RERUN_STATE_SUCCEEDED:
57
+ return
58
+
59
+ if not CourseSyncOrganization.objects.filter(
60
+ organization=instance.source_course_key.org, is_active=True
61
+ ).exists():
62
+ return
63
+
64
+ try:
65
+ course_sync_mapping = CourseSyncMapping.objects.create(
66
+ source_course=instance.source_course_key,
67
+ target_course=instance.course_key,
68
+ )
69
+ except ValidationError:
70
+ log.exception(
71
+ "Failed to create CourseSyncMapping for %s",
72
+ instance.source_course_key,
73
+ )
74
+ else:
75
+ # Trigger course sync to sync the published changes.
76
+ # When a course clone or rerun is created, published changes are not synced.
77
+ async_course_sync.delay(
78
+ str(course_sync_mapping.source_course),
79
+ str(course_sync_mapping.target_course),
80
+ )
@@ -0,0 +1,57 @@
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 ol_openedx_course_sync.apps import OLOpenEdxCourseSyncConfig
9
+ from ol_openedx_course_sync.utils import copy_course_content
10
+ from opaque_keys.edx.locator import CourseLocator
11
+ from xmodule.modulestore import ModuleStoreEnum
12
+ from xmodule.modulestore.django import SignalHandler
13
+
14
+ logger = get_task_logger(__name__)
15
+
16
+
17
+ @shared_task(
18
+ base=LoggedPersistOnFailureTask,
19
+ autoretry_for=(Exception,),
20
+ max_retries=3,
21
+ default_retry_delay=30,
22
+ )
23
+ def async_course_sync(source_course_id, dest_course_id):
24
+ """
25
+ Sync course content from source course to destination course.
26
+ """
27
+ logger.info("Starting course sync from %s to %s", source_course_id, dest_course_id)
28
+ source_course_key = CourseLocator.from_string(source_course_id)
29
+ dest_course_key = CourseLocator.from_string(dest_course_id)
30
+
31
+ logger.info(
32
+ "Copying draft course content from %s to %s", source_course_key, dest_course_key
33
+ )
34
+ # Copy draft branch content
35
+ copy_course_content(
36
+ source_course_key, dest_course_key, ModuleStoreEnum.BranchName.draft
37
+ )
38
+
39
+ logger.info(
40
+ "Copying published course content from %s to %s",
41
+ source_course_key,
42
+ dest_course_key,
43
+ )
44
+ # copy published branch content
45
+ copy_course_content(
46
+ source_course_key,
47
+ dest_course_key,
48
+ ModuleStoreEnum.BranchName.published,
49
+ )
50
+
51
+ # trigger course publish signal to trigger outline and relevant updates
52
+ SignalHandler.course_published.send(
53
+ sender=OLOpenEdxCourseSyncConfig, course_key=dest_course_key
54
+ )
55
+ logger.debug(
56
+ "Finished course sync from %s to %s", source_course_key, dest_course_key
57
+ )
@@ -0,0 +1,27 @@
1
+ """
2
+ Utilities for the ol-openedx-course-sync plugin
3
+ """
4
+
5
+ from xmodule.modulestore.django import modulestore
6
+
7
+
8
+ def copy_course_content(source_course_key, target_course_key, branch):
9
+ """
10
+ Copy course content from source_course to target_course
11
+ on the specified branch.
12
+ """
13
+ module_store = modulestore()
14
+ subtree_list = [module_store.make_course_usage_key(source_course_key)]
15
+ source_course_key_for_branch = source_course_key.for_branch(branch)
16
+ target_course_key_for_branch = target_course_key.for_branch(branch)
17
+
18
+ source_modulestore = module_store._get_modulestore_for_courselike(source_course_key) # noqa: SLF001
19
+ target_modulestore = module_store._get_modulestore_for_courselike(target_course_key) # noqa: SLF001
20
+ if source_modulestore == target_modulestore:
21
+ user_id = None
22
+ source_modulestore.copy(
23
+ user_id,
24
+ source_course_key_for_branch,
25
+ target_course_key_for_branch,
26
+ subtree_list,
27
+ )
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.1
2
+ Name: ol-openedx-course-sync
3
+ Version: 0.1.0
4
+ Summary: An Open edX plugin to sync course changes to its reruns.
5
+ Author: MIT Office of Digital Learning
6
+ License: BSD-3-Clause
7
+ Requires-Python: >=3.8
@@ -0,0 +1,21 @@
1
+ MANIFEST.in
2
+ __init__.py
3
+ backend_shim.py
4
+ setup.py
5
+ ol_openedx_course_sync/__init__.py
6
+ ol_openedx_course_sync/admin.py
7
+ ol_openedx_course_sync/apps.py
8
+ ol_openedx_course_sync/constants.py
9
+ ol_openedx_course_sync/models.py
10
+ ol_openedx_course_sync/signals.py
11
+ ol_openedx_course_sync/tasks.py
12
+ ol_openedx_course_sync/utils.py
13
+ ol_openedx_course_sync.egg-info/PKG-INFO
14
+ ol_openedx_course_sync.egg-info/SOURCES.txt
15
+ ol_openedx_course_sync.egg-info/dependency_links.txt
16
+ ol_openedx_course_sync.egg-info/entry_points.txt
17
+ ol_openedx_course_sync.egg-info/namespace_packages.txt
18
+ ol_openedx_course_sync.egg-info/requires.txt
19
+ ol_openedx_course_sync.egg-info/top_level.txt
20
+ ol_openedx_course_sync/migrations/0001_initial.py
21
+ ol_openedx_course_sync/migrations/__init__.py
@@ -0,0 +1,2 @@
1
+ [cms.djangoapp]
2
+ ol_openedx_course_sync = ol_openedx_course_sync.apps:OLOpenEdxCourseSyncConfig
@@ -0,0 +1,3 @@
1
+ Django>2.0
2
+ celery>=4.4.7
3
+ edx-django-utils>4.0.0
@@ -0,0 +1,2 @@
1
+
2
+ ol_openedx_course_sync
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,33 @@
1
+
2
+ # DO NOT EDIT THIS FILE -- AUTOGENERATED BY PANTS
3
+ # Target: src/ol_openedx_course_sync:ol_openedx_course_sync_package
4
+
5
+ from setuptools import setup
6
+
7
+ setup(**{
8
+ 'author': 'MIT Office of Digital Learning',
9
+ 'description': 'An Open edX plugin to sync course changes to its reruns.',
10
+ 'entry_points': {
11
+ 'cms.djangoapp': [
12
+ 'ol_openedx_course_sync = ol_openedx_course_sync.apps:OLOpenEdxCourseSyncConfig',
13
+ ],
14
+ },
15
+ 'install_requires': (
16
+ 'Django>2.0',
17
+ 'celery>=4.4.7',
18
+ 'edx-django-utils>4.0.0',
19
+ ),
20
+ 'license': 'BSD-3-Clause',
21
+ 'name': 'ol-openedx-course-sync',
22
+ 'namespace_packages': (
23
+ ),
24
+ 'package_data': {
25
+ },
26
+ 'packages': (
27
+ '',
28
+ 'ol_openedx_course_sync',
29
+ 'ol_openedx_course_sync.migrations',
30
+ ),
31
+ 'python_requires': '>=3.8',
32
+ 'version': '0.1.0',
33
+ })