Skip to content

Commit 07b29ca

Browse files
committed
feat: add ol-openedx-course-propagator
1 parent eaa8072 commit 07b29ca

File tree

15 files changed

+465
-0
lines changed

15 files changed

+465
-0
lines changed

src/ol_openedx_course_sync/BUILD

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
python_sources(
2+
name="ol_openedx_course_sync_source",
3+
dependencies=[
4+
"src/ol_openedx_course_sync/migrations:ol_openedx_course_sync_migrations",
5+
"//:external_dependencies#edx-opaque-keys",
6+
"//:external_dependencies#celery",
7+
],
8+
)
9+
10+
python_distribution(
11+
name="ol_openedx_course_sync_package",
12+
dependencies=[":ol_openedx_course_sync_source"],
13+
provides=setup_py(
14+
name="ol-openedx-course-sync",
15+
version="0.1.0",
16+
description="An edX plugin to sync changes in a course to its target/child/rerun courses",
17+
license="BSD-3-Clause",
18+
author="MIT Office of Digital Learning",
19+
entry_points={
20+
"lms.djangoapp": [],
21+
"cms.djangoapp": [
22+
"ol_openedx_course_sync=ol_openedx_course_sync.apps:OLOpenEdxCourseSyncConfig",
23+
],
24+
},
25+
),
26+
)
+28
Original file line numberDiff line numberDiff line change
@@ -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.

src/ol_openedx_course_sync/README.rst

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
OL Open edX Course Sync
2+
=======================
3+
4+
A plugin to enable course updates to sync with the target/child/rerun courses.
5+
6+
Version Compatibility
7+
---------------------
8+
9+
It only supports the latest releases of Open edX.
10+
11+
Installing The Plugin
12+
---------------------
13+
14+
For detailed installation instructions, please refer to the `plugin installation guide <../../docs#installation-guide>`_.
15+
16+
Installation required in:
17+
18+
* CMS
19+
20+
Usage
21+
-----
22+
23+
1. Install the plugin and run the migrations in the CMS.
24+
2. Add the parent/source organization in the CMS admin model `CourseSyncParentOrg`.
25+
#. Course sync will only work for this organization. It will treat all the courses under this organization as parent/source courses.
26+
3. The plugin will automatically add course reruns created from the CMS as the target/child courses for any of the parent courses from the organization added above.
27+
#. The organization can be different for the reruns.
28+
4. Target/child/rerun courses can be managed in the CMS admin model `CourseSyncMap`.
29+
#. You can update the comma-separated target course list.
30+
5. Now, any changes made in the parent/source course will be synced to the target/child/rerun courses.
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""
2+
ol-openedx-course-sync plugin
3+
"""
4+
5+
default_app_config = "ol_openedx_course_sync.apps.OLOpenEdxCourseSyncConfig"

src/ol_openedx_course_sync/admin.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""
2+
Django admin for ol-openedx-course-sync plugin
3+
"""
4+
5+
from django.contrib import admin
6+
from ol_openedx_course_sync.models import CourseSyncMap, CourseSyncParentOrg
7+
8+
9+
class CourseSyncParentOrgAdmin(admin.ModelAdmin):
10+
list_display = ("organization",)
11+
12+
13+
class CourseSyncMapAdmin(admin.ModelAdmin):
14+
list_display = ("source_course", "target_courses")
15+
search_fields = ("source_course", "target_courses")
16+
17+
18+
admin.site.register(CourseSyncParentOrg, CourseSyncParentOrgAdmin)
19+
admin.site.register(CourseSyncMap, CourseSyncMapAdmin)

src/ol_openedx_course_sync/apps.py

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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+
name = "ol_openedx_course_sync"
12+
verbose_name = "Open edX Course Sync"
13+
14+
plugin_app = {
15+
PluginSignals.CONFIG: {
16+
ProjectType.CMS: {
17+
PluginSignals.RECEIVERS: [
18+
{
19+
PluginSignals.RECEIVER_FUNC_NAME: "listen_for_course_publish",
20+
PluginSignals.SIGNAL_PATH: "xmodule.modulestore.django.COURSE_PUBLISHED", # noqa: E501
21+
PluginSignals.DISPATCH_UID: "ol_openedx_course_sync.signals.listen_for_course_publish", # noqa: E501
22+
}
23+
],
24+
},
25+
},
26+
}
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""
2+
Constants for ol-openedx-course-sync plugin
3+
"""
4+
5+
COURSE_RERUN_STATE_SUCCEEDED = "succeeded"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Generated by Django 4.2.20 on 2025-05-14 09:11
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="CourseSyncMap",
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(
28+
max_length=255, unique=True
29+
),
30+
),
31+
(
32+
"target_courses",
33+
models.TextField(
34+
blank=True,
35+
help_text="Comma separated list of target course keys",
36+
),
37+
),
38+
],
39+
),
40+
migrations.CreateModel(
41+
name="CourseSyncParentOrg",
42+
fields=[
43+
(
44+
"id",
45+
models.AutoField(
46+
auto_created=True,
47+
primary_key=True,
48+
serialize=False,
49+
verbose_name="ID",
50+
),
51+
),
52+
("organization", models.CharField(max_length=255, unique=True)),
53+
],
54+
),
55+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
python_sources(name="ol_openedx_course_sync_migrations")

src/ol_openedx_course_sync/migrations/__init__.py

Whitespace-only changes.

src/ol_openedx_course_sync/models.py

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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+
11+
12+
class CourseSyncParentOrg(models.Model):
13+
"""
14+
Model for source course organizations
15+
16+
Any source course that is part of this organization
17+
will sync changes with the child/rerun courses. This model
18+
will help us exclude any organizations, where we don't
19+
want to sync source course.
20+
"""
21+
22+
organization = models.CharField(max_length=255, unique=True)
23+
24+
def __str__(self):
25+
return f"{self.organization} Course Sync Parent Org"
26+
27+
28+
class CourseSyncMap(models.Model):
29+
"""
30+
Model to keep track of source and target courses.
31+
32+
Any changes in the source course sync with the target courses.
33+
Target courses are autopopulated for all the reruns of source
34+
courses that are part of any organization added in `CourseSyncParentOrg`.
35+
"""
36+
37+
source_course = CourseKeyField(max_length=255, unique=True)
38+
target_courses = models.TextField(
39+
blank=True, help_text="Comma separated list of target course keys"
40+
)
41+
42+
def __str__(self):
43+
return f"{self.source_course} Course Sync Map"
44+
45+
def save(self, *args, **kwargs):
46+
"""
47+
Override save method to perform custom validations.
48+
"""
49+
self.full_clean()
50+
super().save(*args, **kwargs)
51+
52+
def clean(self):
53+
"""
54+
Override clean method to perform custom validations.
55+
"""
56+
super().clean()
57+
58+
conflicting_targets = CourseSyncMap.objects.filter(
59+
target_courses__contains=self.source_course
60+
)
61+
if conflicting_targets:
62+
raise ValidationError(
63+
{
64+
"source_course": f"This course is already used as target course of: " # noqa: E501
65+
f"{', '.join(str(ct.source_course) for ct in conflicting_targets)}"
66+
}
67+
)
68+
69+
conflicting_sources = CourseSyncMap.objects.filter(
70+
source_course__in=self.target_course_keys
71+
)
72+
if conflicting_sources:
73+
raise ValidationError(
74+
{
75+
"target_courses": f"These course(s) are already used as source courses: " # noqa:E501
76+
f"{', '.join(str(cs.source_course) for cs in conflicting_sources)}"
77+
}
78+
)
79+
80+
if self.target_course_keys:
81+
query = models.Q()
82+
for key in self.target_course_keys:
83+
query |= models.Q(**{"target_courses__contains": key})
84+
duplicate_targets = CourseSyncMap.objects.filter(query)
85+
86+
if self.pk:
87+
duplicate_targets = duplicate_targets.exclude(pk=self.pk)
88+
89+
if duplicate_targets:
90+
raise ValidationError(
91+
{
92+
"target_courses": f"Some of these course(s) are already used as target course(s) for: " # noqa:E501
93+
f"{', '.join(str(dt.source_course) for dt in duplicate_targets)}" # noqa:E501
94+
}
95+
)
96+
97+
@property
98+
def target_course_keys(self):
99+
"""
100+
Returns a list of target course keys.
101+
"""
102+
return [key for key in self.target_courses.strip().split(",") if key]

src/ol_openedx_course_sync/signals.py

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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 CourseSyncMap, CourseSyncParentOrg
13+
from ol_openedx_course_sync.tasks import async_course_sync
14+
from xmodule.modulestore.django import SignalHandler
15+
16+
log = logging.getLogger(__name__)
17+
18+
19+
@receiver(SignalHandler.course_published)
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 CourseSyncParentOrg.objects.filter(organization=course_key.org).exists():
29+
return
30+
31+
course_sync_map = CourseSyncMap.objects.filter(source_course=course_key).first()
32+
if not (course_sync_map and course_sync_map.target_courses):
33+
log.info("No mapping found for course %s. Skipping copy.", str(course_key))
34+
return
35+
36+
source_course = str(course_sync_map.source_course)
37+
user_id = None
38+
target_keys = [
39+
key for key in course_sync_map.target_courses.strip().split(",") if key
40+
]
41+
for target_course_key in target_keys:
42+
log.info(
43+
"Initializing async course content sync from %s to %s",
44+
source_course,
45+
target_course_key,
46+
)
47+
# Call the async task to copy the course content
48+
async_course_sync.delay(user_id, source_course, target_course_key)
49+
50+
51+
@receiver(post_save, sender=CourseRerunState)
52+
def listen_for_course_rerun_state_post_save(sender, instance, **kwargs): # noqa: ARG001
53+
"""
54+
Listen for `CourseRerunState` post_save and update target courses in `CourseSyncMap`
55+
"""
56+
if instance.state != COURSE_RERUN_STATE_SUCCEEDED:
57+
return
58+
59+
if not CourseSyncParentOrg.objects.filter(
60+
organization=instance.source_course_key.org
61+
).exists():
62+
return
63+
64+
try:
65+
course_sync_map, _ = CourseSyncMap.objects.get_or_create(
66+
source_course=instance.source_course_key
67+
)
68+
except ValidationError:
69+
log.exception(
70+
"Failed to create CourseSyncMap for %s",
71+
instance.source_course_key,
72+
)
73+
return
74+
75+
target_courses = course_sync_map.target_course_keys
76+
target_courses.append(str(instance.course_key))
77+
course_sync_map.target_courses = ",".join(target_courses)
78+
79+
try:
80+
course_sync_map.save()
81+
except ValidationError:
82+
log.exception(
83+
"Failed to update CourseSyncMap for %s",
84+
instance.source_course_key,
85+
)
86+
else:
87+
log.info(
88+
"Added course %s to target courses for %s",
89+
instance.course_key,
90+
instance.source_course_key,
91+
)

0 commit comments

Comments
 (0)