ol-openedx-uai-content-customization 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 (16) hide show
  1. ol_openedx_uai_content_customization-0.1.0/.gitignore +14 -0
  2. ol_openedx_uai_content_customization-0.1.0/LICENSE.txt +28 -0
  3. ol_openedx_uai_content_customization-0.1.0/PKG-INFO +189 -0
  4. ol_openedx_uai_content_customization-0.1.0/README.rst +176 -0
  5. ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/__init__.py +0 -0
  6. ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/apps.py +23 -0
  7. ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/constants.py +47 -0
  8. ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/csv_utils.py +154 -0
  9. ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/management/__init__.py +0 -0
  10. ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/management/commands/__init__.py +0 -0
  11. ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/management/commands/generate_uai_course_versions.py +326 -0
  12. ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/modulestore_utils.py +171 -0
  13. ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/settings/__init__.py +0 -0
  14. ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/settings/common.py +5 -0
  15. ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/settings/production.py +5 -0
  16. ol_openedx_uai_content_customization-0.1.0/pyproject.toml +35 -0
@@ -0,0 +1,14 @@
1
+ # Build artifacts
2
+ /dist/
3
+ /.pids
4
+ # Python files
5
+ /.venv/
6
+ /.idea/
7
+ __pycache__/
8
+ *.egg-info/
9
+ *.DS_Store
10
+ # Test Runtime
11
+ ol_test_requirements.txt
12
+ **/.coverage
13
+ **/test_root
14
+ coverage.xml
@@ -0,0 +1,28 @@
1
+ Copyright (C) 2026 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.
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.4
2
+ Name: ol-openedx-uai-content-customization
3
+ Version: 0.1.0
4
+ Summary: An Open edX plugin to generate industry/length-specific UAI custom courses from video CSV data.
5
+ Author: MIT Office of Digital Learning
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE.txt
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: django>=4.0
10
+ Requires-Dist: edx-django-utils>4.0.0
11
+ Requires-Dist: edx-opaque-keys
12
+ Description-Content-Type: text/x-rst
13
+
14
+ OL Open edX UAI Content Customization
15
+ ======================================
16
+
17
+ An Open edX CMS plugin that automates the generation of industry- and
18
+ length-specific UAI course variants using direct Open edX modulestore APIs.
19
+
20
+ Version Compatibility
21
+ ---------------------
22
+
23
+ Supports Open edX releases from **Sumac** and onwards.
24
+
25
+ Installing The Plugin
26
+ ---------------------
27
+
28
+ For detailed installation instructions, please refer to the
29
+ `plugin installation guide <../../docs#installation-guide>`_.
30
+
31
+ Installation required in:
32
+
33
+ * CMS
34
+
35
+ Overview
36
+ --------
37
+
38
+ For each unique (course_key, industry, duration) row group in the CSV the
39
+ command clones the source course into a new UAI-specific key, removes every
40
+ existing section from the clone, and rebuilds the content from the CSV data.
41
+ This produces multiple industry- and length-specific variants per source
42
+ course while preserving all course settings (grading policy, certificates,
43
+ pacing, advanced settings) from the original.
44
+
45
+ Supported industry/length combinations:
46
+
47
+ +--------------+------+-------------+--------+
48
+ | Industry | Code | Length code | Length |
49
+ +==============+======+=============+========+
50
+ | Healthcare | HC | S | Short |
51
+ +--------------+------+-------------+--------+
52
+ | Healthcare | HC | F | Full |
53
+ +--------------+------+-------------+--------+
54
+ | Finance | F | S | Short |
55
+ +--------------+------+-------------+--------+
56
+ | Finance | F | F | Full |
57
+ +--------------+------+-------------+--------+
58
+ | Energy | E | S | Short |
59
+ +--------------+------+-------------+--------+
60
+ | Energy | E | F | Full |
61
+ +--------------+------+-------------+--------+
62
+ | Original | — | S | Short |
63
+ +--------------+------+-------------+--------+
64
+ | Original | — | F | Full |
65
+ +--------------+------+-------------+--------+
66
+
67
+ Course Key Format
68
+ ~~~~~~~~~~~~~~~~~
69
+
70
+ .. code-block:: text
71
+
72
+ course-v1:ORG+NUMBER.<DURATION>[.<INDUSTRY>]+RUN
73
+
74
+ For the **Original** industry, no industry code is appended:
75
+
76
+ .. code-block:: text
77
+
78
+ course-v1:UAI_SOURCE+UAI.3.S+1T2026 ← Original, Short
79
+ course-v1:UAI_SOURCE+UAI.3.F+1T2026 ← Original, Full
80
+ course-v1:UAI_SOURCE+UAI.3.S.HC+1T2026 ← Healthcare, Short
81
+
82
+ Course Structure
83
+ ~~~~~~~~~~~~~~~~
84
+
85
+ Each generated course has the following structure::
86
+
87
+ Course (<display name>)
88
+ └── Lectures (section)
89
+ └── <Video Title> (subsection)
90
+ └── <Video Title> (unit)
91
+ └── <Video Title> (video block with edX video ID)
92
+
93
+ Usage
94
+ -----
95
+
96
+ Prerequisites
97
+ ~~~~~~~~~~~~~
98
+
99
+ You will need two CSV files:
100
+
101
+ 1. **Processed videos CSV** — produced by the video customization
102
+ workflow. Required columns:
103
+
104
+ - ``course_key`` — the Open edX course key of the **source course to
105
+ clone** (e.g. ``course-v1:UAI_SOURCE+UAI.2+1T2026``). This course
106
+ **must already exist** in the CMS modulestore before the command runs.
107
+ The command validates all source keys up-front and aborts with an error
108
+ if any are missing.
109
+ - ``industry`` — one of: ``Healthcare``, ``Finance``, ``Energy``,
110
+ ``Original industry``
111
+ - ``duration`` — ``short`` or ``long``
112
+ - ``video_file_name`` — file name matching the ``name`` column in the edX videos CSV
113
+ - ``video_title`` — display name for the subsection/unit/video
114
+ - ``module_name`` — used to build the course display name
115
+
116
+ 2. **Open edX videos CSV** — exported from Studio / OVS after uploading
117
+ the customized videos. Required columns:
118
+
119
+ - ``name`` — video file name (matches ``video_file_name`` above)
120
+ - ``video_id`` — the Open edX UUID for the video
121
+
122
+ Running the Command
123
+ ~~~~~~~~~~~~~~~~~~~
124
+
125
+ Run the management command from inside the CMS container (e.g. Tutor dev
126
+ shell):
127
+
128
+ .. code-block:: bash
129
+
130
+ python manage.py generate_uai_course_versions \
131
+ --processed-videos-csv /path/to/processed_videos.csv \
132
+ --edx-videos-csv /path/to/edx_videos.csv \
133
+ [--username studio_worker] \
134
+ [--dry-run]
135
+
136
+ Options
137
+ ~~~~~~~
138
+
139
+ ``--processed-videos-csv``
140
+ Path to the processed video metadata CSV file. **Required.**
141
+
142
+ ``--edx-videos-csv``
143
+ Path to the Open edX videos CSV file. **Required.**
144
+
145
+ ``--username``
146
+ Username of the platform user under whose authority the courses are
147
+ created. Defaults to ``studio_worker``.
148
+
149
+ ``--dry-run``
150
+ Print what would be created without writing anything to the modulestore.
151
+ Use this to verify CSV mapping before committing.
152
+
153
+ How It Works
154
+ ~~~~~~~~~~~~
155
+
156
+ For each unique ``(course_key, industry, duration)`` group the command:
157
+
158
+ 1. **Validates** all source course keys against the live modulestore before
159
+ making any writes (fail-fast — aborts if any source is missing).
160
+ 2. **Clones** the source course into the new UAI-specific key, inheriting all
161
+ course settings.
162
+ 3. **Deletes** every existing section (chapter) from the clone.
163
+ 4. **Rebuilds** the content tree from the CSV rows::
164
+
165
+ Course (cloned — settings inherited)
166
+ └── Lectures (section)
167
+ └── <Video Title> (subsection)
168
+ └── <Video Title> (unit)
169
+ └── <Video Title> (video block)
170
+
171
+ 5. **Publishes** the course.
172
+
173
+ .. note::
174
+
175
+ Course creation is **not atomic** (MongoDB is not covered by Django
176
+ transactions). If a run fails partway through, already-created courses
177
+ remain; subsequent runs will skip them with a ``DuplicateCourseError``
178
+ warning.
179
+
180
+ Development
181
+ -----------
182
+
183
+ .. code-block:: bash
184
+
185
+ # Install dependencies
186
+ uv sync --dev
187
+
188
+ # Run tests (requires Open edX environment — see AGENTS.md)
189
+ ./run_edx_integration_tests.sh --plugin ol_openedx_uai_content_customization --skip-build
@@ -0,0 +1,176 @@
1
+ OL Open edX UAI Content Customization
2
+ ======================================
3
+
4
+ An Open edX CMS plugin that automates the generation of industry- and
5
+ length-specific UAI course variants using direct Open edX modulestore APIs.
6
+
7
+ Version Compatibility
8
+ ---------------------
9
+
10
+ Supports Open edX releases from **Sumac** and onwards.
11
+
12
+ Installing The Plugin
13
+ ---------------------
14
+
15
+ For detailed installation instructions, please refer to the
16
+ `plugin installation guide <../../docs#installation-guide>`_.
17
+
18
+ Installation required in:
19
+
20
+ * CMS
21
+
22
+ Overview
23
+ --------
24
+
25
+ For each unique (course_key, industry, duration) row group in the CSV the
26
+ command clones the source course into a new UAI-specific key, removes every
27
+ existing section from the clone, and rebuilds the content from the CSV data.
28
+ This produces multiple industry- and length-specific variants per source
29
+ course while preserving all course settings (grading policy, certificates,
30
+ pacing, advanced settings) from the original.
31
+
32
+ Supported industry/length combinations:
33
+
34
+ +--------------+------+-------------+--------+
35
+ | Industry | Code | Length code | Length |
36
+ +==============+======+=============+========+
37
+ | Healthcare | HC | S | Short |
38
+ +--------------+------+-------------+--------+
39
+ | Healthcare | HC | F | Full |
40
+ +--------------+------+-------------+--------+
41
+ | Finance | F | S | Short |
42
+ +--------------+------+-------------+--------+
43
+ | Finance | F | F | Full |
44
+ +--------------+------+-------------+--------+
45
+ | Energy | E | S | Short |
46
+ +--------------+------+-------------+--------+
47
+ | Energy | E | F | Full |
48
+ +--------------+------+-------------+--------+
49
+ | Original | — | S | Short |
50
+ +--------------+------+-------------+--------+
51
+ | Original | — | F | Full |
52
+ +--------------+------+-------------+--------+
53
+
54
+ Course Key Format
55
+ ~~~~~~~~~~~~~~~~~
56
+
57
+ .. code-block:: text
58
+
59
+ course-v1:ORG+NUMBER.<DURATION>[.<INDUSTRY>]+RUN
60
+
61
+ For the **Original** industry, no industry code is appended:
62
+
63
+ .. code-block:: text
64
+
65
+ course-v1:UAI_SOURCE+UAI.3.S+1T2026 ← Original, Short
66
+ course-v1:UAI_SOURCE+UAI.3.F+1T2026 ← Original, Full
67
+ course-v1:UAI_SOURCE+UAI.3.S.HC+1T2026 ← Healthcare, Short
68
+
69
+ Course Structure
70
+ ~~~~~~~~~~~~~~~~
71
+
72
+ Each generated course has the following structure::
73
+
74
+ Course (<display name>)
75
+ └── Lectures (section)
76
+ └── <Video Title> (subsection)
77
+ └── <Video Title> (unit)
78
+ └── <Video Title> (video block with edX video ID)
79
+
80
+ Usage
81
+ -----
82
+
83
+ Prerequisites
84
+ ~~~~~~~~~~~~~
85
+
86
+ You will need two CSV files:
87
+
88
+ 1. **Processed videos CSV** — produced by the video customization
89
+ workflow. Required columns:
90
+
91
+ - ``course_key`` — the Open edX course key of the **source course to
92
+ clone** (e.g. ``course-v1:UAI_SOURCE+UAI.2+1T2026``). This course
93
+ **must already exist** in the CMS modulestore before the command runs.
94
+ The command validates all source keys up-front and aborts with an error
95
+ if any are missing.
96
+ - ``industry`` — one of: ``Healthcare``, ``Finance``, ``Energy``,
97
+ ``Original industry``
98
+ - ``duration`` — ``short`` or ``long``
99
+ - ``video_file_name`` — file name matching the ``name`` column in the edX videos CSV
100
+ - ``video_title`` — display name for the subsection/unit/video
101
+ - ``module_name`` — used to build the course display name
102
+
103
+ 2. **Open edX videos CSV** — exported from Studio / OVS after uploading
104
+ the customized videos. Required columns:
105
+
106
+ - ``name`` — video file name (matches ``video_file_name`` above)
107
+ - ``video_id`` — the Open edX UUID for the video
108
+
109
+ Running the Command
110
+ ~~~~~~~~~~~~~~~~~~~
111
+
112
+ Run the management command from inside the CMS container (e.g. Tutor dev
113
+ shell):
114
+
115
+ .. code-block:: bash
116
+
117
+ python manage.py generate_uai_course_versions \
118
+ --processed-videos-csv /path/to/processed_videos.csv \
119
+ --edx-videos-csv /path/to/edx_videos.csv \
120
+ [--username studio_worker] \
121
+ [--dry-run]
122
+
123
+ Options
124
+ ~~~~~~~
125
+
126
+ ``--processed-videos-csv``
127
+ Path to the processed video metadata CSV file. **Required.**
128
+
129
+ ``--edx-videos-csv``
130
+ Path to the Open edX videos CSV file. **Required.**
131
+
132
+ ``--username``
133
+ Username of the platform user under whose authority the courses are
134
+ created. Defaults to ``studio_worker``.
135
+
136
+ ``--dry-run``
137
+ Print what would be created without writing anything to the modulestore.
138
+ Use this to verify CSV mapping before committing.
139
+
140
+ How It Works
141
+ ~~~~~~~~~~~~
142
+
143
+ For each unique ``(course_key, industry, duration)`` group the command:
144
+
145
+ 1. **Validates** all source course keys against the live modulestore before
146
+ making any writes (fail-fast — aborts if any source is missing).
147
+ 2. **Clones** the source course into the new UAI-specific key, inheriting all
148
+ course settings.
149
+ 3. **Deletes** every existing section (chapter) from the clone.
150
+ 4. **Rebuilds** the content tree from the CSV rows::
151
+
152
+ Course (cloned — settings inherited)
153
+ └── Lectures (section)
154
+ └── <Video Title> (subsection)
155
+ └── <Video Title> (unit)
156
+ └── <Video Title> (video block)
157
+
158
+ 5. **Publishes** the course.
159
+
160
+ .. note::
161
+
162
+ Course creation is **not atomic** (MongoDB is not covered by Django
163
+ transactions). If a run fails partway through, already-created courses
164
+ remain; subsequent runs will skip them with a ``DuplicateCourseError``
165
+ warning.
166
+
167
+ Development
168
+ -----------
169
+
170
+ .. code-block:: bash
171
+
172
+ # Install dependencies
173
+ uv sync --dev
174
+
175
+ # Run tests (requires Open edX environment — see AGENTS.md)
176
+ ./run_edx_integration_tests.sh --plugin ol_openedx_uai_content_customization --skip-build
@@ -0,0 +1,23 @@
1
+ """App configuration for ol-openedx-uai-content-customization plugin."""
2
+
3
+ from django.apps import AppConfig
4
+ from edx_django_utils.plugins import PluginSettings
5
+ from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
6
+
7
+
8
+ class OLOpenEdxUaiContentCustomizationConfig(AppConfig):
9
+ """App configuration for the ol-openedx-uai-content-customization plugin."""
10
+
11
+ name = "ol_openedx_uai_content_customization"
12
+ verbose_name = "OL Open edX UAI Content Customization"
13
+
14
+ plugin_app = {
15
+ PluginSettings.CONFIG: {
16
+ ProjectType.CMS: {
17
+ SettingsType.PRODUCTION: {
18
+ PluginSettings.RELATIVE_PATH: "settings.production"
19
+ },
20
+ SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: "settings.common"},
21
+ },
22
+ },
23
+ }
@@ -0,0 +1,47 @@
1
+ """Constants for ol-openedx-uai-content-customization plugin."""
2
+
3
+ INDUSTRY_CODES = {
4
+ "Healthcare": "HC",
5
+ "Finance": "F",
6
+ "Energy": "E",
7
+ "Original industry": "",
8
+ }
9
+
10
+ DURATION_CODE_SHORT = "S"
11
+ DURATION_CODE_FULL = "F"
12
+
13
+ DURATION_CODES = {
14
+ "short": DURATION_CODE_SHORT,
15
+ "long": DURATION_CODE_FULL,
16
+ }
17
+
18
+ LECTURES_SECTION_DISPLAY_NAME = "Lectures"
19
+
20
+ BLOCK_TYPE_CHAPTER = "chapter"
21
+ BLOCK_TYPE_SEQUENTIAL = "sequential"
22
+ BLOCK_TYPE_VERTICAL = "vertical"
23
+ BLOCK_TYPE_VIDEO = "video"
24
+
25
+ CSV_COL_COURSE_KEY = "course_key"
26
+ CSV_COL_INDUSTRY = "industry"
27
+ CSV_COL_DURATION = "duration"
28
+ CSV_COL_VIDEO_FILE_NAME = "video_file_name"
29
+ CSV_COL_VIDEO_TITLE = "video_title"
30
+ CSV_COL_MODULE_NAME = "module_name"
31
+
32
+ CSV_COL_ASSET_NAME = "name"
33
+ CSV_COL_ASSET_VIDEO_ID = "video_id"
34
+
35
+ REQUIRED_CUSTOMIZED_CSV_COLS = [
36
+ CSV_COL_COURSE_KEY,
37
+ CSV_COL_INDUSTRY,
38
+ CSV_COL_DURATION,
39
+ CSV_COL_VIDEO_FILE_NAME,
40
+ CSV_COL_VIDEO_TITLE,
41
+ CSV_COL_MODULE_NAME,
42
+ ]
43
+
44
+ REQUIRED_ASSET_CSV_COLS = [
45
+ CSV_COL_ASSET_NAME,
46
+ CSV_COL_ASSET_VIDEO_ID,
47
+ ]
@@ -0,0 +1,154 @@
1
+ """CSV parsing and video-mapping utilities for ol-openedx-uai-content-customization."""
2
+
3
+ import csv
4
+ from collections import defaultdict
5
+ from pathlib import Path
6
+
7
+ from opaque_keys.edx.keys import CourseKey
8
+
9
+ from ol_openedx_uai_content_customization.constants import (
10
+ CSV_COL_ASSET_NAME,
11
+ CSV_COL_ASSET_VIDEO_ID,
12
+ CSV_COL_COURSE_KEY,
13
+ CSV_COL_DURATION,
14
+ CSV_COL_INDUSTRY,
15
+ DURATION_CODES,
16
+ INDUSTRY_CODES,
17
+ )
18
+
19
+
20
+ def parse_csv(path):
21
+ """
22
+ Read a CSV file and return a ``(rows, fieldnames)`` tuple.
23
+
24
+ Returns:
25
+ tuple: A 2-tuple ``(rows, fieldnames)`` where *rows* is a list of
26
+ ``dict`` objects (one per data row) and *fieldnames* is the list of
27
+ column header strings as they appear in the file. Both are empty
28
+ lists when the file contains no header row at all.
29
+ """
30
+ with Path(path).open(encoding="utf-8", newline="") as f:
31
+ reader = csv.DictReader(f)
32
+ fieldnames = list(reader.fieldnames or [])
33
+ rows = list(reader)
34
+ fieldnames = [col.lower() for col in fieldnames]
35
+ rows = [{col.lower(): value for col, value in row.items()} for row in rows]
36
+ return rows, fieldnames
37
+
38
+
39
+ def validate_csv_columns(fieldnames, required_cols, csv_label):
40
+ """
41
+ Raise ValueError if any required column is absent from the CSV headers.
42
+
43
+ Args:
44
+ fieldnames: List of column header strings returned by parse_csv().
45
+ required_cols: Iterable of column names that must be present.
46
+ csv_label: Human-readable label used in the error message.
47
+
48
+ Raises:
49
+ ValueError: describing which columns are missing.
50
+ """
51
+ actual_cols = {col.lower() for col in fieldnames}
52
+ missing = [col for col in required_cols if col.lower() not in actual_cols]
53
+ if missing:
54
+ msg = f"{csv_label} is missing required columns: {', '.join(missing)}"
55
+ raise ValueError(msg)
56
+
57
+
58
+ def build_video_id_map(video_asset_rows):
59
+ """
60
+ Build a mapping of video file name → Open edX video ID.
61
+
62
+ Args:
63
+ video_asset_rows: Rows from the Open edX video asset CSV.
64
+
65
+ Returns:
66
+ dict mapping file name (e.g. "v004_h264.mp4") to video UUID string.
67
+ """
68
+ return {
69
+ row[CSV_COL_ASSET_NAME]: row[CSV_COL_ASSET_VIDEO_ID] for row in video_asset_rows
70
+ }
71
+
72
+
73
+ def resolve_duration_code(duration_value):
74
+ """
75
+ Convert a duration cell value into a Short/Full code.
76
+
77
+ The CSV must provide explicit values: "short" or "long"
78
+ (case-insensitive).
79
+
80
+ Args:
81
+ duration_value: Raw string from the Duration column.
82
+
83
+ Returns:
84
+ "S" or "F"
85
+
86
+ Raises:
87
+ ValueError: if the value is not one of "short" or "long".
88
+ """
89
+ value = str(duration_value).strip().lower()
90
+
91
+ if value in DURATION_CODES:
92
+ return DURATION_CODES[value]
93
+
94
+ msg = (
95
+ "Unrecognised duration value "
96
+ f"{duration_value!r}. Expected one of: {', '.join(DURATION_CODES)}"
97
+ )
98
+ raise ValueError(msg)
99
+
100
+
101
+ def build_new_course_key(original_key, industry, duration_value):
102
+ """
103
+ Generate a new course key for the given industry and duration.
104
+
105
+ Format: course-v1:ORG+NUMBER.<DURATION>[.<INDUSTRY>]+RUN
106
+
107
+ For "Original industry" no industry code is appended, so the format is:
108
+ course-v1:ORG+NUMBER.<DURATION>+RUN
109
+
110
+ Args:
111
+ original_key: e.g. "course-v1:UAI_SOURCE+UAI.2+1T2026"
112
+ industry: Industry name string as it appears in the CSV.
113
+ duration_value: Raw Duration column value.
114
+
115
+ Returns:
116
+ New course key string.
117
+ """
118
+ parsed = CourseKey.from_string(original_key)
119
+ org = parsed.org
120
+ number = parsed.course
121
+ run = parsed.run
122
+
123
+ dur_code = resolve_duration_code(duration_value)
124
+
125
+ if industry not in INDUSTRY_CODES:
126
+ known = ", ".join(INDUSTRY_CODES)
127
+ msg = f"Unrecognised industry {industry!r}. Must be one of: {known}"
128
+ raise ValueError(msg)
129
+ ind_code = INDUSTRY_CODES[industry]
130
+
131
+ if ind_code:
132
+ new_number = f"{number}.{dur_code}.{ind_code}"
133
+ else:
134
+ new_number = f"{number}.{dur_code}"
135
+
136
+ return f"course-v1:{org}+{new_number}+{run}"
137
+
138
+
139
+ def group_videos_by_course(customized_rows):
140
+ """
141
+ Group video rows by (original_course_key, industry, duration).
142
+
143
+ Returns:
144
+ dict mapping (course_key, industry, duration) → list of row dicts.
145
+ """
146
+ groups = defaultdict(list)
147
+ for row in customized_rows:
148
+ key = (
149
+ row[CSV_COL_COURSE_KEY],
150
+ row[CSV_COL_INDUSTRY],
151
+ row[CSV_COL_DURATION],
152
+ )
153
+ groups[key].append(row)
154
+ return groups
@@ -0,0 +1,326 @@
1
+ """
2
+ Management command: generate_uai_course_versions
3
+
4
+ Reads two CSV files and uses Open edX modulestore APIs to build industry- and
5
+ length-specific variants of UAI courses by cloning a base course:
6
+
7
+ - Processed videos CSV (produced by the video-generation workflow)
8
+ - Open edX videos CSV (exported from Studio / OVS)
9
+
10
+ For each unique (course_key, industry, duration) combination the command:
11
+ 1. Clones the source course (identified by the ``course_key`` CSV column)
12
+ into a new UAI-specific course key, preserving all course settings.
13
+ 2. Deletes every existing section from the clone.
14
+ 3. Rebuilds the content tree from the CSV data:
15
+
16
+ Course (cloned, settings inherited)
17
+ └── Lectures (chapter)
18
+ └── <Video Title> (sequential)
19
+ └── <Video Title> (vertical)
20
+ └── <Video Title> (video block)
21
+
22
+ Usage:
23
+ python manage.py generate_uai_course_versions \\
24
+ --processed-videos-csv /path/to/processed_videos.csv \\
25
+ --edx-videos-csv /path/to/edx_videos.csv \\
26
+ [--username studio_worker] \\
27
+ [--dry-run]
28
+
29
+ Note: this command writes to the MongoDB-backed modulestore. Django's
30
+ transaction.atomic() does NOT cover MongoDB, so course creation is not
31
+ atomic. If a run fails partway through, already-created courses will remain
32
+ and subsequent runs will raise DuplicateCourseError for them (skipped with a
33
+ warning).
34
+ """
35
+
36
+ import logging
37
+
38
+ from django.contrib.auth import get_user_model
39
+ from django.core.management.base import BaseCommand, CommandError
40
+ from opaque_keys import InvalidKeyError
41
+ from opaque_keys.edx.keys import CourseKey
42
+ from xmodule.modulestore.django import modulestore
43
+ from xmodule.modulestore.exceptions import DuplicateCourseError
44
+
45
+ from ol_openedx_uai_content_customization.constants import (
46
+ BLOCK_TYPE_CHAPTER,
47
+ BLOCK_TYPE_SEQUENTIAL,
48
+ BLOCK_TYPE_VERTICAL,
49
+ BLOCK_TYPE_VIDEO,
50
+ CSV_COL_MODULE_NAME,
51
+ CSV_COL_VIDEO_FILE_NAME,
52
+ CSV_COL_VIDEO_TITLE,
53
+ LECTURES_SECTION_DISPLAY_NAME,
54
+ REQUIRED_ASSET_CSV_COLS,
55
+ REQUIRED_CUSTOMIZED_CSV_COLS,
56
+ )
57
+ from ol_openedx_uai_content_customization.csv_utils import (
58
+ build_new_course_key,
59
+ build_video_id_map,
60
+ group_videos_by_course,
61
+ parse_csv,
62
+ validate_csv_columns,
63
+ )
64
+ from ol_openedx_uai_content_customization.modulestore_utils import (
65
+ create_content_block,
66
+ delete_course_sections,
67
+ get_or_clone_course_in_modulestore,
68
+ save_video_block_with_edx_video_id,
69
+ )
70
+
71
+ log = logging.getLogger(__name__)
72
+
73
+
74
+ class Command(BaseCommand):
75
+ """Generate industry/length-specific UAI courses using Open edX modulestore APIs."""
76
+
77
+ help = (
78
+ "Generate industry and length-specific UAI courses by reading two CSV files "
79
+ "and creating courses in the CMS modulestore."
80
+ )
81
+
82
+ def add_arguments(self, parser):
83
+ parser.add_argument(
84
+ "--processed-videos-csv",
85
+ required=True,
86
+ help="Path to the processed video metadata CSV file.",
87
+ )
88
+ parser.add_argument(
89
+ "--edx-videos-csv",
90
+ required=True,
91
+ help="Path to the Open edX videos CSV file (exported from Studio/OVS).",
92
+ )
93
+ parser.add_argument(
94
+ "--username",
95
+ default="studio_worker",
96
+ help="Username of the platform user under whose authority courses are"
97
+ " created (default: studio_worker).",
98
+ )
99
+ parser.add_argument(
100
+ "--dry-run",
101
+ action="store_true",
102
+ help="Log what would be created without writing to the modulestore.",
103
+ )
104
+
105
+ def handle(self, *args, **options): # noqa: ARG002, PLR0915
106
+ processed_videos_csv = options["processed_videos_csv"]
107
+ edx_videos_csv = options["edx_videos_csv"]
108
+ username = options["username"]
109
+ dry_run = options["dry_run"]
110
+
111
+ if dry_run:
112
+ self.stdout.write(
113
+ self.style.WARNING("DRY RUN — no changes will be written.")
114
+ )
115
+
116
+ User = get_user_model()
117
+ try:
118
+ user = User.objects.get(username=username)
119
+ except User.DoesNotExist:
120
+ msg = f"No user found with username {username!r}."
121
+ raise CommandError(msg) # noqa: B904
122
+ processed_video_rows, processed_video_fieldnames = parse_csv(
123
+ processed_videos_csv
124
+ )
125
+ edx_video_rows, edx_video_fieldnames = parse_csv(edx_videos_csv)
126
+
127
+ try:
128
+ validate_csv_columns(
129
+ processed_video_fieldnames,
130
+ REQUIRED_CUSTOMIZED_CSV_COLS,
131
+ "processed videos CSV",
132
+ )
133
+ validate_csv_columns(
134
+ edx_video_fieldnames,
135
+ REQUIRED_ASSET_CSV_COLS,
136
+ "edX videos CSV",
137
+ )
138
+ except ValueError as exc:
139
+ raise CommandError(str(exc)) from exc
140
+
141
+ video_id_map = build_video_id_map(edx_video_rows)
142
+
143
+ self.stdout.write(
144
+ f"Loaded {len(processed_video_rows)} processed video rows "
145
+ f"and {len(edx_video_rows)} edX video rows."
146
+ )
147
+
148
+ course_groups = group_videos_by_course(processed_video_rows)
149
+ self.stdout.write(f"Found {len(course_groups)} course variant(s) to create.")
150
+
151
+ self._validate_source_course_keys(course_groups)
152
+
153
+ created, skipped = 0, 0
154
+
155
+ for (orig_key, industry, duration), videos in course_groups.items():
156
+ source_key = CourseKey.from_string(orig_key) # safe — already validated
157
+ try:
158
+ new_course_key = build_new_course_key(orig_key, industry, duration)
159
+ except ValueError as exc:
160
+ self.stdout.write(
161
+ self.style.WARNING(
162
+ f" Skipping [{industry}, {duration}] from {orig_key}: {exc}"
163
+ )
164
+ )
165
+ skipped += 1
166
+ continue
167
+
168
+ display_name = self._build_display_name(videos)
169
+
170
+ self.stdout.write(
171
+ f"\n-> {orig_key} [{industry}, {duration}] -> {new_course_key}"
172
+ )
173
+ self.stdout.write(f" Display name : {display_name}")
174
+ self.stdout.write(f" Videos : {len(videos)}")
175
+
176
+ if dry_run:
177
+ for video in videos:
178
+ vid_file = video[CSV_COL_VIDEO_FILE_NAME]
179
+ vid_title = video[CSV_COL_VIDEO_TITLE]
180
+ mapped_id = video_id_map.get(vid_file, "<NOT FOUND>")
181
+ self.stdout.write(f" - {vid_title} ({vid_file} -> {mapped_id})")
182
+ skipped += 1
183
+ continue
184
+
185
+ try:
186
+ self._create_course(
187
+ source_key,
188
+ new_course_key,
189
+ display_name,
190
+ videos,
191
+ video_id_map,
192
+ user,
193
+ )
194
+ created += 1
195
+ except DuplicateCourseError:
196
+ self.stdout.write(
197
+ self.style.WARNING(
198
+ f" Course {new_course_key} already exists — skipping."
199
+ )
200
+ )
201
+ skipped += 1
202
+ except Exception as exc:
203
+ log.exception("Failed to create course %s", new_course_key)
204
+ msg = f"Failed to create course {new_course_key}: {exc}"
205
+ raise CommandError(msg) from exc
206
+
207
+ self.stdout.write(
208
+ self.style.SUCCESS(f"\nDone. Created: {created} Skipped: {skipped}")
209
+ )
210
+
211
+ def _build_display_name(self, videos):
212
+ """
213
+ Return the module name from the first video row as the course display name.
214
+
215
+ Falls back to "UAI Course" when no module name is available.
216
+ """
217
+ return (
218
+ videos[0].get(CSV_COL_MODULE_NAME, "").strip()
219
+ if videos
220
+ else "UAI Short Course"
221
+ )
222
+
223
+ def _validate_source_course_keys(self, course_groups):
224
+ """
225
+ Raise CommandError if any source course key in the CSV is invalid or absent.
226
+
227
+ Collects all failures before raising so the operator sees every problem
228
+ in a single run rather than one at a time.
229
+
230
+ Args:
231
+ course_groups: dict keyed by (orig_key, industry, duration).
232
+ """
233
+ store = modulestore()
234
+ unique_keys = sorted({orig_key for orig_key, _, _ in course_groups})
235
+ missing = []
236
+ for key_str in unique_keys:
237
+ try:
238
+ parsed = CourseKey.from_string(key_str)
239
+ except InvalidKeyError:
240
+ missing.append(f"{key_str!r} (invalid course key format)")
241
+ continue
242
+ if not store.has_course(parsed):
243
+ missing.append(key_str)
244
+ if missing:
245
+ missing_list = "\n".join(f" - {k}" for k in missing)
246
+ msg = f"Source course(s) not found in the modulestore:\n{missing_list}"
247
+ raise CommandError(msg)
248
+
249
+ def _create_course( # noqa: PLR0913
250
+ self, source_key, course_key_str, display_name, videos, video_id_map, user
251
+ ):
252
+ """
253
+ Clone the source course, strip its sections, then populate with UAI content.
254
+
255
+ The clone inherits all course settings (grading, certificates, pacing,
256
+ advanced settings) from the source. After cloning, every existing
257
+ section is removed and a fresh "Lectures" section is built from the CSV
258
+ data with one subsection → unit → video block per video row.
259
+
260
+ All modulestore writes are wrapped in bulk_operations for performance.
261
+ """
262
+ parsed_key = CourseKey.from_string(course_key_str)
263
+ user_id = user.id
264
+ store = modulestore()
265
+ with store.bulk_operations(parsed_key):
266
+ course = get_or_clone_course_in_modulestore(
267
+ source_key,
268
+ parsed_key.org,
269
+ parsed_key.course,
270
+ parsed_key.run,
271
+ display_name,
272
+ user_id,
273
+ )
274
+ delete_course_sections(course, user_id)
275
+ section = create_content_block(
276
+ course,
277
+ BLOCK_TYPE_CHAPTER,
278
+ LECTURES_SECTION_DISPLAY_NAME,
279
+ user_id,
280
+ )
281
+
282
+ unmapped = []
283
+
284
+ for video in videos:
285
+ title = video[CSV_COL_VIDEO_TITLE]
286
+ vid_file = video[CSV_COL_VIDEO_FILE_NAME]
287
+ edx_video_id = video_id_map.get(vid_file)
288
+
289
+ if not edx_video_id:
290
+ log.warning(
291
+ "No Open edX video ID found for file '%s'"
292
+ " (title: '%s') - skipping.",
293
+ vid_file,
294
+ title,
295
+ )
296
+ unmapped.append(vid_file)
297
+ continue
298
+
299
+ subsection = create_content_block(
300
+ section,
301
+ BLOCK_TYPE_SEQUENTIAL,
302
+ title,
303
+ user_id,
304
+ )
305
+ unit = create_content_block(
306
+ subsection,
307
+ BLOCK_TYPE_VERTICAL,
308
+ title,
309
+ user_id,
310
+ )
311
+ video_block = create_content_block(
312
+ unit,
313
+ BLOCK_TYPE_VIDEO,
314
+ title,
315
+ user_id,
316
+ )
317
+ save_video_block_with_edx_video_id(video_block, user, edx_video_id)
318
+
319
+ store.publish(course.location, user_id)
320
+ if unmapped:
321
+ self.stdout.write(
322
+ self.style.WARNING(
323
+ f" Warning: {len(unmapped)} video file(s) had no"
324
+ f" matching Open edX video ID: " + ", ".join(unmapped)
325
+ )
326
+ )
@@ -0,0 +1,171 @@
1
+ """
2
+ Modulestore wrappers for creating UAI course content.
3
+
4
+ All functions that write to the modulestore live here so that the management
5
+ command stays thin and these helpers can be mocked cleanly in tests.
6
+ """
7
+
8
+ import logging
9
+
10
+ from xmodule.modulestore import ModuleStoreEnum
11
+ from xmodule.modulestore.django import modulestore
12
+ from xmodule.modulestore.inheritance import own_metadata
13
+
14
+ from ol_openedx_uai_content_customization.constants import (
15
+ BLOCK_TYPE_CHAPTER,
16
+ BLOCK_TYPE_VIDEO,
17
+ )
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ def get_or_clone_course_in_modulestore( # noqa: PLR0913
23
+ source_course_key, dest_org, dest_number, dest_run, display_name, user_id
24
+ ):
25
+ """
26
+ Get or clone a course in the modulestore.
27
+
28
+ If the destination course already exists, it is returned as-is.
29
+ Otherwise, a new course is cloned from the source course key with
30
+ the given destination parameters.
31
+ """
32
+ store = modulestore()
33
+ with store.default_store(ModuleStoreEnum.Type.split):
34
+ dest_course_key = store.make_course_key(dest_org, dest_number, dest_run)
35
+
36
+ # if the course already exists, we assume it was cloned previously
37
+ # and return it rather than erroring out. This allows the cloning
38
+ # operation to be idempotent and the management command to be
39
+ # re-runnable without manual cleanup.
40
+ if store.has_course(dest_course_key):
41
+ return store.get_course(dest_course_key)
42
+
43
+ course = store.clone_course(
44
+ source_course_key,
45
+ dest_course_key,
46
+ user_id,
47
+ fields={"display_name": display_name},
48
+ )
49
+ log.info(
50
+ "Cloned course %s -> course-v1:%s+%s+%s (%s)",
51
+ source_course_key,
52
+ dest_org,
53
+ dest_number,
54
+ dest_run,
55
+ display_name,
56
+ )
57
+ return course
58
+
59
+
60
+ def delete_course_sections(course, user_id):
61
+ """
62
+ Delete all top-level sections (chapters) from the course.
63
+
64
+ Called immediately after cloning the base course so the new course starts
65
+ empty before UAI-specific content is added. In the Split modulestore the
66
+ course structure is versioned: old sections become part of the version
67
+ history and are not surfaced once the new sections are published.
68
+
69
+ Args:
70
+ course: Course XBlock descriptor.
71
+ user_id: ID of the user performing the operation.
72
+
73
+ Returns:
74
+ Number of sections deleted.
75
+ """
76
+ store = modulestore()
77
+ sections = store.get_items(
78
+ course.id,
79
+ qualifiers={"category": BLOCK_TYPE_CHAPTER},
80
+ )
81
+ for section in sections:
82
+ store.delete_item(section.location, user_id)
83
+ log.debug("Deleted cloned section %s", section.location)
84
+ count = len(sections)
85
+ log.info("Deleted %d cloned section(s) from course %s", count, course.id)
86
+ return count
87
+
88
+
89
+ def create_content_block(parent, block_type, display_name, user_id, **extra_fields):
90
+ """
91
+ Add a content block under the given parent XBlock.
92
+
93
+ Args:
94
+ parent: Parent XBlock descriptor that will own the new child.
95
+ block_type: XBlock category to create (e.g. chapter, sequential,
96
+ vertical, video).
97
+ display_name: Display name for the created block.
98
+ user_id: ID of the user performing the operation.
99
+ **extra_fields: Optional additional fields for block creation.
100
+
101
+ Returns:
102
+ The newly-created child XBlock descriptor.
103
+ """
104
+ store = modulestore()
105
+ fields = {"display_name": display_name, **extra_fields}
106
+ block = store.create_child(
107
+ user_id,
108
+ parent.location,
109
+ block_type,
110
+ fields=fields,
111
+ )
112
+
113
+ if block_type == BLOCK_TYPE_VIDEO and "edx_video_id" in extra_fields:
114
+ log.debug(
115
+ "Created %s block '%s' (edx_video_id=%s) under %s",
116
+ block_type,
117
+ display_name,
118
+ extra_fields["edx_video_id"],
119
+ parent.location,
120
+ )
121
+ else:
122
+ log.debug(
123
+ "Created %s block '%s' under %s",
124
+ block_type,
125
+ display_name,
126
+ parent.location,
127
+ )
128
+
129
+ return block
130
+
131
+
132
+ def save_video_block_with_edx_video_id(video_block, user, edx_video_id):
133
+ """
134
+ Save a newly-created video block using the CMS callback pipeline.
135
+
136
+ This mirrors Studio's save flow and normalizes legacy fallback fields so
137
+ an explicit ``edx_video_id`` is used as the canonical playback source.
138
+
139
+ Args:
140
+ video_block: Video XBlock descriptor created in modulestore.
141
+ user: Django user object performing the operation.
142
+ edx_video_id: VAL/Open edX video identifier to bind to the block.
143
+
144
+ Returns:
145
+ Updated video block descriptor returned by save callback utility.
146
+ """
147
+ from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( # noqa: PLC0415
148
+ save_xblock_with_callback,
149
+ )
150
+
151
+ old_metadata = own_metadata(video_block)
152
+ video_block.edx_video_id = edx_video_id.strip()
153
+
154
+ # Keep legacy fallback metadata empty to avoid default placeholder IDs.
155
+ for field_name, value in (
156
+ ("youtube_id_0_75", ""),
157
+ ("youtube_id_1_0", ""),
158
+ ("youtube_id_1_25", ""),
159
+ ("youtube_id_1_5", ""),
160
+ ("sub", ""),
161
+ ("source", ""),
162
+ ("html5_sources", []),
163
+ ):
164
+ if hasattr(video_block, field_name):
165
+ setattr(video_block, field_name, value)
166
+
167
+ return save_xblock_with_callback(
168
+ video_block,
169
+ user,
170
+ old_metadata=old_metadata,
171
+ )
@@ -0,0 +1,5 @@
1
+ """Common settings for the ol-openedx-uai-content-customization plugin."""
2
+
3
+
4
+ def plugin_settings(_settings):
5
+ """Configure common settings for the UAI content customization plugin."""
@@ -0,0 +1,5 @@
1
+ """Production settings for the ol-openedx-uai-content-customization plugin."""
2
+
3
+
4
+ def plugin_settings(_settings):
5
+ """Configure production settings for the UAI content customization plugin."""
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "ol-openedx-uai-content-customization"
3
+ version = "0.1.0"
4
+ description = "An Open edX plugin to generate industry/length-specific UAI custom courses from video CSV data."
5
+ authors = [
6
+ {name = "MIT Office of Digital Learning"}
7
+ ]
8
+ license = "BSD-3-Clause"
9
+ readme = "README.rst"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "Django>=4.0",
13
+ "edx-django-utils>4.0.0",
14
+ "edx-opaque-keys",
15
+ ]
16
+
17
+ [project.entry-points."cms.djangoapp"]
18
+ ol_openedx_uai_content_customization = "ol_openedx_uai_content_customization.apps:OLOpenEdxUaiContentCustomizationConfig"
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["ol_openedx_uai_content_customization"]
26
+ include = [
27
+ "ol_openedx_uai_content_customization/**/*.py",
28
+ ]
29
+
30
+ [tool.hatch.build.targets.sdist]
31
+ include = [
32
+ "ol_openedx_uai_content_customization/**/*",
33
+ "README.rst",
34
+ "pyproject.toml",
35
+ ]