Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/ol_openedx_uai_content_customization/CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
Changelog
=========

[0.2.0] - 2026-07-07
---------------------

Changed
~~~~~~~
- ``generate_uai_course_versions`` now reads a **single** CSV file instead of
two. The ``--edx-videos-csv`` argument has been removed. Add an
``edx_video_id`` column directly to the processed-videos CSV to provide the
Open edX video UUID for each row.

[0.1.0] - 2026-05-15
---------------------

Expand Down
74 changes: 33 additions & 41 deletions src/ol_openedx_uai_content_customization/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -87,43 +87,39 @@ Usage
Prerequisites
~~~~~~~~~~~~~

You will need two CSV files:

1. **Processed videos CSV** — produced by the video customization
workflow. Required columns:

- ``course_key`` — the Open edX course key of the **source course to
clone** (e.g. ``course-v1:UAI_SOURCE+UAI.2+1T2026``). This course
**must already exist** in the CMS modulestore before the command runs.
The command validates all source keys up-front and aborts with an error
if any are missing.
- ``industry`` — one of: ``Healthcare``, ``Finance``, ``Energy``,
``Original``
- ``duration`` — ``short`` or ``long``
- ``video_file_name`` — file name matching the ``name`` column in the edX videos CSV
- ``video_title`` — display name for the subsection/unit/video
- ``module_name`` — used to build the course display name
- ``course_intro`` — optional introduction content for the generated course.
If this value contains HTML tags, it is used as-is. If it is plain text,
the command HTML-escapes it and wraps it in ``<p>...</p>``.

Intro resolution precedence for each generated
``(course_key, industry, duration)`` variant:

1. exact match on ``(course_key, industry, duration)``
2. fallback to ``(course_key, industry)`` (industry-only intro, reused for
both short and long)
3. fallback to ``(course_key, Original industry)`` (reused across all
industries and durations for that source course)

If no intro is resolved, no Introduction section is created for that
variant.

2. **Open edX videos CSV** — exported from Studio / OVS after uploading
the customized videos. Required columns:

- ``name`` — video file name (matches ``video_file_name`` above)
- ``video_id`` — the Open edX UUID for the video
You will need a single CSV file:

**Processed videos CSV** — produced by the video customization
workflow. Required columns:

- ``course_key`` — the Open edX course key of the **source course to
clone** (e.g. ``course-v1:UAI_SOURCE+UAI.2+1T2026``). This course
**must already exist** in the CMS modulestore before the command runs.
The command validates all source keys up-front and aborts with an error
if any are missing.
- ``industry`` — one of: ``Healthcare``, ``Finance``, ``Energy``,
``Original``
- ``duration`` — ``short`` or ``long``
- ``video_file_name`` — file name of the video (for reference/display)
- ``video_title`` — display name for the subsection/unit/video
- ``module_name`` — used to build the course display name
- ``edx_video_id`` — the Open edX UUID for the video (exported from
Studio / OVS after uploading the customized video)
- ``course_intro`` — optional introduction content for the generated course.
If this value contains HTML tags, it is used as-is. If it is plain text,
the command HTML-escapes it and wraps it in ``<p>...</p>``.

Intro resolution precedence for each generated
``(course_key, industry, duration)`` variant:

1. exact match on ``(course_key, industry, duration)``
2. fallback to ``(course_key, industry)`` (industry-only intro, reused for
both short and long)
3. fallback to ``(course_key, Original industry)`` (reused across all
industries and durations for that source course)

If no intro is resolved, no Introduction section is created for that
variant.

Running the Command
~~~~~~~~~~~~~~~~~~~
Expand All @@ -135,7 +131,6 @@ shell):

python manage.py generate_uai_course_versions \
--processed-videos-csv /path/to/processed_videos.csv \
--edx-videos-csv /path/to/edx_videos.csv \
[--username studio_worker] \
[--dry-run]

Expand All @@ -145,9 +140,6 @@ Options
``--processed-videos-csv``
Path to the processed video metadata CSV file. **Required.**

``--edx-videos-csv``
Path to the Open edX videos CSV file. **Required.**

``--username``
Username of the platform user under whose authority the courses are
created. Defaults to ``studio_worker``.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@
CSV_COL_MODULE_NAME = "module_name"
CSV_COL_COURSE_INTRO = "course_intro"

CSV_COL_ASSET_NAME = "name"
CSV_COL_ASSET_VIDEO_ID = "video_id"
CSV_COL_EDX_VIDEO_ID = "edx_video_id"

REQUIRED_CUSTOMIZED_CSV_COLS = [
CSV_COL_COURSE_KEY,
Expand All @@ -46,9 +45,5 @@
CSV_COL_VIDEO_TITLE,
CSV_COL_MODULE_NAME,
CSV_COL_COURSE_INTRO,
]

REQUIRED_ASSET_CSV_COLS = [
CSV_COL_ASSET_NAME,
CSV_COL_ASSET_VIDEO_ID,
CSV_COL_EDX_VIDEO_ID,
]
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
from opaque_keys.edx.keys import CourseKey

from ol_openedx_uai_content_customization.constants import (
CSV_COL_ASSET_NAME,
CSV_COL_ASSET_VIDEO_ID,
CSV_COL_COURSE_INTRO,
CSV_COL_COURSE_KEY,
CSV_COL_DURATION,
Expand Down Expand Up @@ -58,21 +56,6 @@ def validate_csv_columns(fieldnames, required_cols, csv_label):
raise ValueError(msg)


def build_video_id_map(video_asset_rows):
"""
Build a mapping of video file name → Open edX video ID.

Args:
video_asset_rows: Rows from the Open edX video asset CSV.

Returns:
dict mapping file name (e.g. "v004_h264.mp4") to video UUID string.
"""
return {
row[CSV_COL_ASSET_NAME]: row[CSV_COL_ASSET_VIDEO_ID] for row in video_asset_rows
}


def resolve_duration_code(duration_value):
"""
Convert a duration cell value into a Short/Full code.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
"""
Management command: generate_uai_course_versions

Reads two CSV files and uses Open edX modulestore APIs to build industry- and
length-specific variants of UAI courses by cloning a base course:
Reads a single CSV file and uses Open edX modulestore APIs to build industry-
and length-specific variants of UAI courses by cloning a base course.

- Processed videos CSV (produced by the video-generation workflow)
- Open edX videos CSV (exported from Studio / OVS)

For each unique (course_key, industry, duration) combination the command:
1. Clones the source course (identified by the ``course_key`` CSV column)
Expand All @@ -22,7 +21,6 @@
Usage:
python manage.py generate_uai_course_versions \\
--processed-videos-csv /path/to/processed_videos.csv \\
--edx-videos-csv /path/to/edx_videos.csv \\
[--username studio_worker] \\
[--dry-run]

Expand All @@ -48,6 +46,7 @@
BLOCK_TYPE_SEQUENTIAL,
BLOCK_TYPE_VERTICAL,
BLOCK_TYPE_VIDEO,
CSV_COL_EDX_VIDEO_ID,
CSV_COL_MODULE_NAME,
CSV_COL_VIDEO_FILE_NAME,
CSV_COL_VIDEO_TITLE,
Expand All @@ -56,13 +55,11 @@
INTRO_SUBSECTION_DISPLAY_NAME,
INTRO_UNIT_DISPLAY_NAME,
LECTURES_SECTION_DISPLAY_NAME,
REQUIRED_ASSET_CSV_COLS,
REQUIRED_CUSTOMIZED_CSV_COLS,
)
from ol_openedx_uai_content_customization.csv_utils import (
build_course_intro_lookup,
build_new_course_key,
build_video_id_map,
group_videos_by_course,
parse_csv,
resolve_course_intro,
Expand All @@ -82,7 +79,7 @@ class Command(BaseCommand):
"""Generate industry/length-specific UAI courses using Open edX modulestore APIs."""

help = (
"Generate industry and length-specific UAI courses by reading two CSV files "
"Generate industry and length-specific UAI courses by reading a CSV file "
"and creating courses in the CMS modulestore."
)

Expand All @@ -92,11 +89,6 @@ def add_arguments(self, parser):
required=True,
help="Path to the processed video metadata CSV file.",
)
parser.add_argument(
"--edx-videos-csv",
required=True,
help="Path to the Open edX videos CSV file (exported from Studio/OVS).",
)
parser.add_argument(
"--username",
default="studio_worker",
Expand All @@ -111,7 +103,6 @@ def add_arguments(self, parser):

def handle(self, *args, **options): # noqa: ARG002, PLR0915
processed_videos_csv = options["processed_videos_csv"]
edx_videos_csv = options["edx_videos_csv"]
username = options["username"]
dry_run = options["dry_run"]

Expand All @@ -129,28 +120,17 @@ def handle(self, *args, **options): # noqa: ARG002, PLR0915
processed_video_rows, processed_video_fieldnames = parse_csv(
processed_videos_csv
)
edx_video_rows, edx_video_fieldnames = parse_csv(edx_videos_csv)

try:
validate_csv_columns(
processed_video_fieldnames,
REQUIRED_CUSTOMIZED_CSV_COLS,
"processed videos CSV",
)
validate_csv_columns(
edx_video_fieldnames,
REQUIRED_ASSET_CSV_COLS,
"edX videos CSV",
)
except ValueError as exc:
raise CommandError(str(exc)) from exc

video_id_map = build_video_id_map(edx_video_rows)

self.stdout.write(
f"Loaded {len(processed_video_rows)} processed video rows "
f"and {len(edx_video_rows)} edX video rows."
)
self.stdout.write(f"Loaded {len(processed_video_rows)} processed video rows.")

course_groups = group_videos_by_course(processed_video_rows)
course_intro_lookup = build_course_intro_lookup(processed_video_rows)
Expand Down Expand Up @@ -196,8 +176,10 @@ def handle(self, *args, **options): # noqa: ARG002, PLR0915
for video in videos:
vid_file = video[CSV_COL_VIDEO_FILE_NAME]
vid_title = video[CSV_COL_VIDEO_TITLE]
mapped_id = video_id_map.get(vid_file, "<NOT FOUND>")
self.stdout.write(f" - {vid_title} ({vid_file} -> {mapped_id})")
edx_video_id = video.get(CSV_COL_EDX_VIDEO_ID, "<NOT SET>")
self.stdout.write(
f" - {vid_title} ({vid_file} -> {edx_video_id})"
)
skipped += 1
continue

Expand All @@ -208,7 +190,6 @@ def handle(self, *args, **options): # noqa: ARG002, PLR0915
display_name,
intro_by_course_key[new_course_key],
videos,
video_id_map,
user,
)
created += 1
Expand Down Expand Up @@ -273,7 +254,6 @@ def _create_course( # noqa: PLR0913
display_name,
course_intro,
videos,
video_id_map,
user,
):
"""
Expand Down Expand Up @@ -353,7 +333,7 @@ def _create_course( # noqa: PLR0913
for video in videos:
title = video[CSV_COL_VIDEO_TITLE]
vid_file = video[CSV_COL_VIDEO_FILE_NAME]
edx_video_id = video_id_map.get(vid_file)
edx_video_id = video.get(CSV_COL_EDX_VIDEO_ID)

if not edx_video_id:
log.warning(
Expand Down
2 changes: 1 addition & 1 deletion src/ol_openedx_uai_content_customization/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "ol-openedx-uai-content-customization"
version = "0.1.2"
version = "0.2.0"
description = "An Open edX plugin to generate industry/length-specific UAI custom courses from video CSV data."
authors = [
{name = "MIT Office of Digital Learning"}
Expand Down
39 changes: 10 additions & 29 deletions src/ol_openedx_uai_content_customization/tests/test_csv_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from ol_openedx_uai_content_customization.csv_utils import (
build_course_intro_lookup,
build_new_course_key,
build_video_id_map,
group_videos_by_course,
normalize_course_intro,
parse_csv,
Expand All @@ -16,27 +15,29 @@

def test_parse_csv_returns_list_of_dicts(tmp_path):
"""Each row in the CSV is returned as a dict keyed by column header."""
csv_text = "name,video_id\nv004_h264.mp4,abc-123\nv005_h264.mp4,def-456\n"
csv_file = tmp_path / "assets.csv"
csv_text = (
"video_file_name,edx_video_id\nv004_h264.mp4,abc-123\nv005_h264.mp4,def-456\n"
)
csv_file = tmp_path / "videos.csv"
csv_file.write_text(csv_text)

rows, fieldnames = parse_csv(str(csv_file))

assert len(rows) == 2 # noqa: PLR2004
assert rows[0]["name"] == "v004_h264.mp4"
assert rows[0]["video_id"] == "abc-123"
assert fieldnames == ["name", "video_id"]
assert rows[0]["video_file_name"] == "v004_h264.mp4"
assert rows[0]["edx_video_id"] == "abc-123"
assert fieldnames == ["video_file_name", "edx_video_id"]


def test_parse_csv_empty_file(tmp_path):
"""A CSV with only a header row returns an empty row list but keeps the headers."""
csv_file = tmp_path / "empty.csv"
csv_file.write_text("name,video_id\n")
csv_file.write_text("video_file_name,edx_video_id\n")

rows, fieldnames = parse_csv(str(csv_file))

assert rows == []
assert fieldnames == ["name", "video_id"]
assert fieldnames == ["video_file_name", "edx_video_id"]


@pytest.mark.parametrize(
Expand All @@ -58,27 +59,6 @@ def test_validate_csv_columns(fieldnames, required, should_raise):
validate_csv_columns(fieldnames, required, "test CSV")


@pytest.mark.parametrize(
("rows", "expected"),
[
(
[
{"name": "v004_h264.mp4", "video_id": "abc-123"},
{"name": "v005_h264.mp4", "video_id": "def-456"},
],
{
"v004_h264.mp4": "abc-123",
"v005_h264.mp4": "def-456",
},
),
([], {}),
],
)
def test_build_video_id_map(rows, expected):
"""Map video asset rows to edX video IDs."""
assert build_video_id_map(rows) == expected


@pytest.mark.parametrize(
("duration_value", "expected"),
[
Expand Down Expand Up @@ -164,6 +144,7 @@ def _make_row(course_key, industry, duration, video_file="v001.mp4", title="Titl
"video_title": title,
"module_name": "Module 2",
"course_intro": "",
"edx_video_id": "aaaaaaaa-0000-0000-0000-aaaaaaaaaaaa",
}


Expand Down
Loading
Loading