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.
- ol_openedx_uai_content_customization-0.1.0/.gitignore +14 -0
- ol_openedx_uai_content_customization-0.1.0/LICENSE.txt +28 -0
- ol_openedx_uai_content_customization-0.1.0/PKG-INFO +189 -0
- ol_openedx_uai_content_customization-0.1.0/README.rst +176 -0
- ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/__init__.py +0 -0
- ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/apps.py +23 -0
- ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/constants.py +47 -0
- ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/csv_utils.py +154 -0
- ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/management/__init__.py +0 -0
- ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/management/commands/__init__.py +0 -0
- ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/management/commands/generate_uai_course_versions.py +326 -0
- ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/modulestore_utils.py +171 -0
- ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/settings/__init__.py +0 -0
- ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/settings/common.py +5 -0
- ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/settings/production.py +5 -0
- ol_openedx_uai_content_customization-0.1.0/pyproject.toml +35 -0
|
@@ -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
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
@@ -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
|
+
)
|
ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/modulestore_utils.py
ADDED
|
@@ -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
|
+
)
|
ol_openedx_uai_content_customization-0.1.0/ol_openedx_uai_content_customization/settings/__init__.py
ADDED
|
File without changes
|
|
@@ -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
|
+
]
|