From 9c1a9fe3c2c7ca27168db3929b795b5c82a8c5f2 Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Tue, 15 Sep 2026 10:52:30 -0400 Subject: [PATCH 01/10] Skip unreferenced static files when ingesting edX course archives Everything under an OLX export's static/ directory was ingested, including assets left over from earlier offerings. u-lab carries 27 syllabus files back to a 2014 edition, and they dominated retrieval for the questions they are worst at answering. documents_from_olx now skips, in addition to staff-only subtrees: - static files no block refers to, matched on filename against the course text so that "asset-v1:...+type@asset+block/" links count as well as "/static/", and percent-encoded and entity-escaped spellings do too - legacy transcripts (subs_.srt.sjson) whose video id no block declares, read from video elements rather than by matching raw attribute text - the asset manifests and info/updates.items.json, which list or mention every asset and would otherwise keep all of them alive; live announcements in that file still count as references, deleted ones do not Staff-only files are excluded as reference sources too, so an asset only a hidden block mentions is unreferenced. unpublish_staff_only_files becomes unpublish_excluded_files and walks the same exclusion function, so what ingestion skips and what the cleanup removes cannot drift. It gains --dry-run and a --report CSV written by the command process, since the tasks fan out across workers. audit_olx_references reports the same filter against an extracted archive, so the per-course numbers can be reproduced without S3. Co-Authored-By: Claude Opus 5 (1M context) --- learning_resources/etl/edx_shared.py | 60 ++++-- learning_resources/etl/edx_shared_test.py | 120 ++++++++--- learning_resources/etl/utils.py | 176 ++++++++++++++- learning_resources/etl/utils_test.py | 201 +++++++++++++++++- .../commands/audit_olx_references.py | 61 ++++++ .../commands/unpublish_excluded_files.py | 108 ++++++++++ .../commands/unpublish_excluded_files_test.py | 93 ++++++++ .../commands/unpublish_staff_only_files.py | 69 ------ learning_resources/tasks.py | 33 ++- learning_resources/tasks_test.py | 25 ++- main/celery.py | 4 +- 11 files changed, 815 insertions(+), 135 deletions(-) create mode 100644 learning_resources/management/commands/audit_olx_references.py create mode 100644 learning_resources/management/commands/unpublish_excluded_files.py create mode 100644 learning_resources/management/commands/unpublish_excluded_files_test.py delete mode 100644 learning_resources/management/commands/unpublish_staff_only_files.py diff --git a/learning_resources/etl/edx_shared.py b/learning_resources/etl/edx_shared.py index 71fff4dbf3..f9bd0aa023 100644 --- a/learning_resources/etl/edx_shared.py +++ b/learning_resources/etl/edx_shared.py @@ -15,10 +15,10 @@ from learning_resources.etl.loaders import load_content_files from learning_resources.etl.utils import ( calc_checksum, + excluded_olx_paths, get_bucket_by_name, get_edx_module_id, get_s3_prefix_for_source, - staff_only_olx_paths, transform_content_files, ) from learning_resources.models import ContentFile, LearningResourceRun @@ -369,20 +369,29 @@ def sync_edx_course_files( ) -def unpublish_staff_only_content_files( - etl_source: str, ids: list[int], keys: list[str] -) -> int: +def unpublish_excluded_content_files( + etl_source: str, + ids: list[int], + keys: list[str], + *, + dry_run: bool = False, + report: bool = False, +) -> tuple[int, list[dict]]: """ - Unpublish (and deindex) content files under staff-only OLX subtrees for the - runs matching the given archive keys, without re-extracting anything. + Unpublish (and deindex) content files the course does not use — staff-only + subtrees, asset manifests and unreferenced static files — for the runs + matching the given archive keys, without re-extracting anything. Args: etl_source(str): The edx ETL source ids(list of int): list of course ids to process keys(list[str]): list of S3 archive keys to search through + dry_run(bool): count the rows but leave them published and deindex nothing + report(bool): return a row per excluded file for the caller to write out Returns: - int: number of content files unpublished + tuple of (int, list of dict): number of content files unpublished (or + that would be, under dry_run), and the report rows when requested """ from learning_resources_search import tasks as search_tasks from vector_search import tasks as vector_tasks @@ -390,6 +399,7 @@ def unpublish_staff_only_content_files( bucket = get_bucket_by_name(settings.COURSE_ARCHIVE_BUCKET_NAME) run_lookup = build_run_lookup(etl_source, ids) total = 0 + rows = [] for key in keys: matching_runs = run_lookup.get(extract_run_id_from_key(etl_source, key)) if not matching_runs: @@ -408,23 +418,41 @@ def unpublish_staff_only_content_files( if olx_path is None: continue try: - hidden_paths = staff_only_olx_paths(olx_path) + excluded_paths = excluded_olx_paths(olx_path) except ElementTree.ParseError: log.exception("Malformed OLX in %s, skipping", key) continue - hidden_keys = {get_edx_module_id(str(path), run) for path in hidden_paths} - if not hidden_keys: + excluded_keys = { + get_edx_module_id(str(path), run): str(path.relative_to(olx_path)) + for path in excluded_paths + } + if not excluded_keys: continue # scoped to this run: keys embed the run_id, but never rely on that alone - hidden_files = ContentFile.objects.filter(run=run, key__in=hidden_keys) - unpublished = hidden_files.filter(published=True).update(published=False) + excluded_files = ContentFile.objects.filter(run=run, key__in=excluded_keys) + if report: + rows.extend( + { + "run_id": run.run_id, + "key": content_key, + "source_path": excluded_keys.get(content_key, ""), + "published": published, + } + for content_key, published in excluded_files.values_list( + "key", "published" + ) + ) + if dry_run: + total += excluded_files.filter(published=True).count() + continue + unpublished = excluded_files.filter(published=True).update(published=False) total += unpublished log.info( - "Unpublished %d staff-only content files for %s", unpublished, run.run_id + "Unpublished %d excluded content files for %s", unpublished, run.run_id ) - # dispatched whenever hidden rows exist, not only when this call flipped + # dispatched whenever excluded rows exist, not only when this call flipped # them, so a re-run after a failed deindex task cleans up the indexes - if hidden_files.exists(): + if excluded_files.exists(): search_tasks.deindex_run_content_files.delay(run.id, unpublished_only=True) vector_tasks.remove_unpublished_run_content_files.delay(run.id) - return total + return total, rows diff --git a/learning_resources/etl/edx_shared_test.py b/learning_resources/etl/edx_shared_test.py index 1d9cc34aa6..b5157d8ccd 100644 --- a/learning_resources/etl/edx_shared_test.py +++ b/learning_resources/etl/edx_shared_test.py @@ -19,7 +19,7 @@ normalize_run_id, process_course_archive, sync_edx_course_files, - unpublish_staff_only_content_files, + unpublish_excluded_content_files, ) from learning_resources.etl.utils import get_edx_module_id, get_s3_prefix_for_source from learning_resources.factories import ( @@ -1670,6 +1670,8 @@ def _staff_only_archive(tmp_path) -> Path: "vertical/v_staff.xml": '', "html/h_staff.xml": '', "html/h_staff.html": "

staff

", + # nothing links this, so it is from an earlier offering + "static/stale_syllabus.pdf": "stale", } for rel, text in files.items(): (olx / rel).parent.mkdir(parents=True, exist_ok=True) @@ -1712,7 +1714,7 @@ def mock_deindex_tasks(mocker): ) -def test_unpublish_staff_only_content_files(staff_only_run, mock_deindex_tasks): +def test_unpublish_excluded_content_files(staff_only_run, mock_deindex_tasks): """Only the matching run's staff-only content files are unpublished and deindexed""" run = staff_only_run.run other_run = LearningResourceRunFactory.create( @@ -1728,7 +1730,7 @@ def test_unpublish_staff_only_content_files(staff_only_run, mock_deindex_tasks): for cf_key in run_keys.values(): ContentFileFactory.create(run=other_run, key=cf_key, published=True) - unpublished = unpublish_staff_only_content_files( + unpublished, _ = unpublish_excluded_content_files( staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key] ) @@ -1744,7 +1746,7 @@ def test_unpublish_staff_only_content_files(staff_only_run, mock_deindex_tasks): mock_deindex_tasks.qdrant.assert_called_once_with(run.id) -def test_unpublish_staff_only_content_files_nothing_hidden( +def test_unpublish_excluded_content_files_nothing_hidden( staff_only_run, mock_deindex_tasks ): """No deindex tasks are queued when a run has no staff-only content files""" @@ -1753,21 +1755,18 @@ def test_unpublish_staff_only_content_files_nothing_hidden( run=run, key=get_edx_module_id("course/html/h_ok.xml", run), published=True ) - assert ( - unpublish_staff_only_content_files( - staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key] - ) - == 0 - ) + assert unpublish_excluded_content_files( + staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key] + ) == (0, []) mock_deindex_tasks.opensearch.assert_not_called() -def test_unpublish_staff_only_content_files_malformed_archive( +def test_unpublish_excluded_content_files_malformed_archive( staff_only_run, mock_deindex_tasks, mocker ): """A malformed archive is skipped without touching its content files""" mocker.patch( - "learning_resources.etl.edx_shared.staff_only_olx_paths", + "learning_resources.etl.edx_shared.excluded_olx_paths", side_effect=ElementTree.ParseError("bad"), ) ContentFileFactory.create( @@ -1776,17 +1775,14 @@ def test_unpublish_staff_only_content_files_malformed_archive( published=True, ) - assert ( - unpublish_staff_only_content_files( - staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key] - ) - == 0 - ) + assert unpublish_excluded_content_files( + staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key] + ) == (0, []) assert ContentFile.objects.filter(run=staff_only_run.run, published=True).exists() mock_deindex_tasks.opensearch.assert_not_called() -def test_unpublish_staff_only_content_files_rerun_redeindexes( +def test_unpublish_excluded_content_files_rerun_redeindexes( staff_only_run, mock_deindex_tasks ): """A re-run with already-unpublished hidden files still queues the deindex tasks""" @@ -1795,11 +1791,85 @@ def test_unpublish_staff_only_content_files_rerun_redeindexes( run=run, key=get_edx_module_id("course/html/h_staff.xml", run), published=False ) - assert ( - unpublish_staff_only_content_files( - staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key] - ) - == 0 - ) + assert unpublish_excluded_content_files( + staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key] + ) == (0, []) mock_deindex_tasks.opensearch.assert_called_once_with(run.id, unpublished_only=True) mock_deindex_tasks.qdrant.assert_called_once_with(run.id) + + +def test_unpublish_excluded_content_files_drops_unreferenced_static( + staff_only_run, mock_deindex_tasks +): + """A static file no block refers to is unpublished alongside staff-only files""" + run = staff_only_run.run + stale_key = get_edx_module_id("course/static/stale_syllabus.pdf", run) + ContentFileFactory.create(run=run, key=stale_key, published=True) + + unpublished, _ = unpublish_excluded_content_files( + staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key] + ) + + assert unpublished == 1 + assert not ContentFile.objects.filter( + run=run, key=stale_key, published=True + ).exists() + + +def test_unpublish_excluded_content_files_dry_run(staff_only_run, mock_deindex_tasks): + """A dry run counts the rows but changes nothing and deindexes nothing""" + run = staff_only_run.run + ContentFileFactory.create( + run=run, + key=get_edx_module_id("course/static/stale_syllabus.pdf", run), + published=True, + ) + + unpublished, _ = unpublish_excluded_content_files( + staff_only_run.source, + [staff_only_run.course.id], + [staff_only_run.key], + dry_run=True, + ) + + assert unpublished == 1 + assert ContentFile.objects.filter(run=run, published=True).count() == 1 + mock_deindex_tasks.opensearch.assert_not_called() + mock_deindex_tasks.qdrant.assert_not_called() + + +def test_unpublish_excluded_content_files_report_rows( + staff_only_run, mock_deindex_tasks +): + """Report rows name the run, key and source path of every excluded file""" + run = staff_only_run.run + stale_key = get_edx_module_id("course/static/stale_syllabus.pdf", run) + ContentFileFactory.create(run=run, key=stale_key, published=True) + + _, rows = unpublish_excluded_content_files( + staff_only_run.source, + [staff_only_run.course.id], + [staff_only_run.key], + dry_run=True, + report=True, + ) + + assert [row["key"] for row in rows] == [stale_key] + assert rows[0]["run_id"] == run.run_id + assert rows[0]["source_path"] == "static/stale_syllabus.pdf" + assert rows[0]["published"] is True + + +def test_unpublish_excluded_content_files_no_report_by_default( + staff_only_run, mock_deindex_tasks +): + """Rows are only collected when asked for, since they cross the result backend""" + ContentFileFactory.create( + run=staff_only_run.run, + key=get_edx_module_id("course/static/stale_syllabus.pdf", staff_only_run.run), + published=True, + ) + _, rows = unpublish_excluded_content_files( + staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key] + ) + assert rows == [] diff --git a/learning_resources/etl/utils.py b/learning_resources/etl/utils.py index bd2a76eb41..a97f12be68 100644 --- a/learning_resources/etl/utils.py +++ b/learning_resources/etl/utils.py @@ -2,6 +2,7 @@ import base64 import glob +import html import json import logging import math @@ -18,6 +19,7 @@ from io import BytesIO from pathlib import Path from tempfile import TemporaryDirectory +from urllib.parse import unquote import boto3 import pypdfium2 as pdfium @@ -380,11 +382,179 @@ def staff_only_olx_paths(olx_path: str | Path) -> set[Path]: return hidden +REFERENCE_SCAN_EXTENSIONS = frozenset({".xml", ".html", ".htm", ".json", ".txt", ".md"}) + +# Asset manifests list every file in the export and updates.items.json is mostly +# an archive of deleted announcements. None of them describes current course +# content, and treating them as references keeps every stale asset alive. +NON_CONTENT_OLX_FILES = ( + "policies/assets.json", + "assets/assets.xml", + "info/updates.items.json", +) +COURSE_UPDATES_FILE = "info/updates.items.json" + +# A legacy transcript is named for its video's id rather than for anything the +# course text contains, so the id is the only link back to the block using it. +VIDEO_ID_ATTRIBUTES = ("sub", "youtube", "youtube_id_1_0") +LEGACY_TRANSCRIPT_RE = re.compile( + r"^(?:[a-z]{2}(?:[-_][a-z]{2})?_)?subs_(.+)\.srt\.sjson$", re.IGNORECASE +) + + +def normalize_asset_ref(text: str) -> str: + """Collapse the spellings a filename takes on disk vs. in a reference""" + return re.sub(r"[\s_]", "", unquote(html.unescape(text)).lower()) + + +def _olx_reference_sources(root: Path, skip: set[Path]) -> list[Path]: + """Files whose text may legitimately refer to a static asset""" + sources = [] + for path in root.rglob("*"): + if not path.is_file() or path.suffix.lower() not in REFERENCE_SCAN_EXTENSIONS: + continue + relative = path.relative_to(root) + if ( + relative.parts[0] == "static" + or relative.as_posix() in NON_CONTENT_OLX_FILES + or path in skip + or any("draft" in part for part in relative.parts[:-1]) + ): + continue + sources.append(path) + return sources + + +def _live_course_updates(root: Path) -> str: + """ + Text of the announcements the course team has not deleted. The whole file is + excluded as a reference source because edX keeps deleted announcements in + the export, but a live one still counts. + """ + try: + items = json.loads((root / COURSE_UPDATES_FILE).read_text(errors="ignore")) + except (OSError, ValueError): + return "" + return "\n".join( + item.get("content") or "" + for item in items + if isinstance(item, dict) and item.get("status") != "deleted" + ) + + +def _olx_video_ids(sources: list[Path]) -> set[str] | None: + """ + Video ids declared anywhere in the course, or None if any source could not + be parsed. None means "ids unknown", and callers keep every legacy + transcript rather than drop one whose video they failed to read. + """ + ids = set() + for path in sources: + if path.suffix.lower() != ".xml": + continue + try: + element = ElementTree.parse(path).getroot() + except ElementTree.ParseError: + log.warning("Malformed XML in %s, keeping all legacy transcripts", path) + return None + # iter() finds