From 97bf31862b59dc8e2d9d3620599692642656f616 Mon Sep 17 00:00:00 2001
From: Matt Bertrand
Date: Tue, 22 Sep 2026 13:54:38 -0400
Subject: [PATCH 1/4] Mark the Django session cookie Secure by default (#3968)
---
env/backend.env | 1 +
main/settings.py | 1 +
main/settings_test.py | 12 ++++++++++++
3 files changed, 14 insertions(+)
diff --git a/env/backend.env b/env/backend.env
index 82f45d76b3..10ef493e41 100644
--- a/env/backend.env
+++ b/env/backend.env
@@ -11,6 +11,7 @@ CORS_ALLOWED_ORIGINS='["http://open.odl.local:8062"]'
CSRF_TRUSTED_ORIGINS='["http://open.odl.local:8062", "http://api.open.odl.local:8063"]'
CSRF_COOKIE_DOMAIN=open.odl.local
CSRF_COOKIE_SECURE=False
+SESSION_COOKIE_SECURE=False
MITOL_COOKIE_DOMAIN=open.odl.local
MITOL_COOKIE_NAME=mitlearn
diff --git a/main/settings.py b/main/settings.py
index 0cbd76c3b5..ccaf2b7632 100644
--- a/main/settings.py
+++ b/main/settings.py
@@ -216,6 +216,7 @@
SESSION_COOKIE_DOMAIN = get_string("SESSION_COOKIE_DOMAIN", None)
SESSION_COOKIE_NAME = get_string("SESSION_COOKIE_NAME", "sessionid")
+SESSION_COOKIE_SECURE = get_bool("SESSION_COOKIE_SECURE", True) # noqa: FBT003
if COOKIE_TOMBSTONES:
tombstone_middleware = "main.middleware.cookie_tombstones.CookieTombstoneMiddleware"
diff --git a/main/settings_test.py b/main/settings_test.py
index a1ffc99e1b..ae48751e45 100644
--- a/main/settings_test.py
+++ b/main/settings_test.py
@@ -170,6 +170,18 @@ def test_secure_proxy_ssl_header(self):
settings_vars = self.reload_settings()
assert "SECURE_PROXY_SSL_HEADER" not in settings_vars
+ def test_session_cookie_secure(self):
+ """SESSION_COOKIE_SECURE is on by default and can be turned off for local dev"""
+ with mock.patch.dict("os.environ", REQUIRED_SETTINGS, clear=True):
+ assert self.reload_settings()["SESSION_COOKIE_SECURE"] is True
+
+ with mock.patch.dict(
+ "os.environ",
+ {**REQUIRED_SETTINGS, "SESSION_COOKIE_SECURE": "False"},
+ clear=True,
+ ):
+ assert self.reload_settings()["SESSION_COOKIE_SECURE"] is False
+
def test_x_forwarded_proto_makes_request_secure(self):
"""Only X-Forwarded-Proto: https marks a request as secure"""
factory = RequestFactory()
From 2ef6c7e1dbb79153ba5931928893e91c941fd999 Mon Sep 17 00:00:00 2001
From: Matt Bertrand
Date: Tue, 22 Sep 2026 14:09:47 -0400
Subject: [PATCH 2/4] Skip unreferenced static files when ingesting edX course
archives (#3942)
---
learning_resources/etl/edx_shared.py | 85 +++--
learning_resources/etl/edx_shared_test.py | 119 +++++-
learning_resources/etl/utils.py | 242 +++++++++++-
learning_resources/etl/utils_test.py | 344 +++++++++++++++++-
.../commands/audit_olx_references.py | 65 ++++
.../commands/unpublish_excluded_files.py | 112 ++++++
.../commands/unpublish_staff_only_files.py | 69 ----
learning_resources/tasks.py | 33 +-
learning_resources/tasks_test.py | 35 +-
main/celery.py | 4 +-
10 files changed, 962 insertions(+), 146 deletions(-)
create mode 100644 learning_resources/management/commands/audit_olx_references.py
create mode 100644 learning_resources/management/commands/unpublish_excluded_files.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..4f940ec255 100644
--- a/learning_resources/etl/edx_shared.py
+++ b/learning_resources/etl/edx_shared.py
@@ -11,14 +11,15 @@
from django.core.cache import caches
from django.db.models import Prefetch, Q
+from learning_resources.constants import VALID_TEXT_FILE_TYPES
from learning_resources.etl.constants import ETLSource
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,32 +370,42 @@ 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
+) -> 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
Returns:
- int: number of content files unpublished
+ list of dict: a row per run whose archive excludes content files it has,
+ counting the excluded rows, the ones this call unpublished (or would
+ have, under dry_run) and the run's content files in total. Counts,
+ not paths, so the payload stays small enough to cross the celery
+ result backend for every run at once.
"""
from learning_resources_search import tasks as search_tasks
from vector_search import tasks as vector_tasks
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:
continue
run = matching_runs[0]
+ if not ContentFile.objects.filter(run=run).exists():
+ # a run with no content files has none to unpublish, and its archive
+ # is a download and an extract to find that out
+ continue
with TemporaryDirectory() as tempdir:
tarpath = Path(tempdir, key.rsplit("/", maxsplit=1)[-1])
bucket.download_file(key, tarpath)
@@ -408,23 +419,55 @@ 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:
+ ingestable = [
+ path
+ for path in olx_path.rglob("*")
+ if path.is_file()
+ and path.suffix.lower() in VALID_TEXT_FILE_TYPES
+ and not any(
+ "draft" in part for part in path.relative_to(olx_path).parts[:-1]
+ )
+ ]
+ # get_edx_module_id writes a space as an underscore, so "foo bar.pdf"
+ # and "foo_bar.pdf" are one row; it stays if either path is ingested
+ excluded_keys = {
+ get_edx_module_id(str(path), run)
+ for path in ingestable
+ if path in excluded_paths
+ } - {
+ get_edx_module_id(str(path), run)
+ for path in ingestable
+ if path not 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)
- total += unpublished
- log.info(
- "Unpublished %d staff-only content files for %s", unpublished, run.run_id
- )
- # dispatched whenever hidden 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():
+ excluded_files = ContentFile.objects.filter(run=run, key__in=excluded_keys)
+ excluded = excluded_files.count()
+ if not excluded:
+ continue
+ if dry_run:
+ unpublished = excluded_files.filter(published=True).count()
+ else:
+ unpublished = excluded_files.filter(published=True).update(published=False)
+ log.info(
+ "Unpublished %d excluded content files for %s", unpublished, run.run_id
+ )
+ # 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
search_tasks.deindex_run_content_files.delay(run.id, unpublished_only=True)
vector_tasks.remove_unpublished_run_content_files.delay(run.id)
- return total
+ rows.append(
+ {
+ "run_id": run.run_id,
+ "excluded": excluded,
+ "unpublished": unpublished,
+ "total": ContentFile.objects.filter(run=run).count(),
+ }
+ )
+ return rows
diff --git a/learning_resources/etl/edx_shared_test.py b/learning_resources/etl/edx_shared_test.py
index 1d9cc34aa6..f60a5f378e 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 (
@@ -1660,7 +1660,9 @@ def _staff_only_archive(tmp_path) -> Path:
"course/run.xml": '',
"chapter/ok.xml": '',
"sequential/seq_ok.xml": '',
- "vertical/v_ok.xml": '',
+ "vertical/v_ok.xml": (
+ ''
+ ),
"html/h_ok.xml": '',
"html/h_ok.html": "ok
",
"chapter/staff.xml": (
@@ -1670,6 +1672,13 @@ 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",
+ # the video id keeps subs_ABC123; its space-spelled twin is a stale copy
+ # that get_edx_module_id folds onto the same content file key
+ "video/vid.xml": '',
+ "static/subs_ABC123.srt.sjson": "{}",
+ "static/subs ABC123.srt.sjson": "{}",
}
for rel, text in files.items():
(olx / rel).parent.mkdir(parents=True, exist_ok=True)
@@ -1712,7 +1721,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,11 +1737,11 @@ 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(
+ rows = unpublish_excluded_content_files(
staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key]
)
- assert unpublished == 1
+ assert [row["unpublished"] for row in rows] == [1]
assert not ContentFile.objects.filter(
run=run, key=run_keys["html/h_staff.xml"], published=True
).exists()
@@ -1744,7 +1753,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"""
@@ -1754,20 +1763,20 @@ def test_unpublish_staff_only_content_files_nothing_hidden(
)
assert (
- unpublish_staff_only_content_files(
+ 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(
@@ -1777,16 +1786,16 @@ def test_unpublish_staff_only_content_files_malformed_archive(
)
assert (
- unpublish_staff_only_content_files(
+ 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 +1804,87 @@ def test_unpublish_staff_only_content_files_rerun_redeindexes(
run=run, key=get_edx_module_id("course/html/h_staff.xml", run), published=False
)
+ rows = unpublish_excluded_content_files(
+ staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key]
+ )
+
+ # still counted as excluded, but this call had nothing left to flip
+ assert rows == [{"run_id": run.run_id, "excluded": 1, "unpublished": 0, "total": 1}]
+ 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)
+
+ rows = unpublish_excluded_content_files(
+ staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key]
+ )
+
+ assert [row["unpublished"] for row in rows] == [1]
+ assert not ContentFile.objects.filter(
+ run=run, key=stale_key, published=True
+ ).exists()
+
+
+def test_unpublish_excluded_content_files_keeps_colliding_key(
+ staff_only_run, mock_deindex_tasks
+):
+ """
+ get_edx_module_id folds "subs ABC123.srt.sjson" onto the transcript the video
+ declares, and that one row belongs to the file ingestion keeps
+ """
+ run = staff_only_run.run
+ shared_key = get_edx_module_id("course/static/subs ABC123.srt.sjson", run)
+ assert shared_key == get_edx_module_id("course/static/subs_ABC123.srt.sjson", run)
+ ContentFileFactory.create(run=run, key=shared_key, published=True)
+
+ assert (
+ unpublish_excluded_content_files(
+ staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key]
+ )
+ == []
+ )
+ assert ContentFile.objects.filter(run=run, key=shared_key, published=True).exists()
+
+
+def test_unpublish_excluded_content_files_skips_runs_without_content_files(
+ staff_only_run, mock_deindex_tasks, mocker
+):
+ """A run with no content files is skipped before its archive is downloaded"""
+ excluded = mocker.patch("learning_resources.etl.edx_shared.excluded_olx_paths")
+
assert (
- unpublish_staff_only_content_files(
+ 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)
+ excluded.assert_not_called()
+
+
+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,
+ )
+
+ rows = unpublish_excluded_content_files(
+ staff_only_run.source,
+ [staff_only_run.course.id],
+ [staff_only_run.key],
+ dry_run=True,
+ )
+
+ assert [row["unpublished"] for row in rows] == [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()
diff --git a/learning_resources/etl/utils.py b/learning_resources/etl/utils.py
index bd2a76eb41..e4c3ec7e1c 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
@@ -10,14 +11,17 @@
import re
import tarfile
import uuid
+from bisect import bisect_left
from collections import Counter
from collections.abc import Generator
from datetime import UTC, datetime
from decimal import Decimal
from hashlib import md5
from io import BytesIO
+from itertools import accumulate
from pathlib import Path
from tempfile import TemporaryDirectory
+from urllib.parse import unquote
import boto3
import pypdfium2 as pdfium
@@ -355,15 +359,12 @@ def staff_only_olx_paths(olx_path: str | Path) -> set[Path]:
course = _parse_olx_block(root, "", "course")
if course is None:
return set()
- hidden: set[Path] = set()
- seen: set[tuple[str, str]] = set()
+ hidden: dict[tuple[str, str], set[Path]] = {}
+ visible: set[tuple[str, str]] = set()
+ seen: set[tuple[str, str, bool]] = set()
stack = [(course, "course", course.get("url_name"), False)]
while stack:
pointer, tag, url_name, staff_only = stack.pop()
- if url_name:
- if (tag, url_name) in seen:
- continue
- seen.add((tag, url_name))
# pointer file wins when present; otherwise the element is the block itself
element = _parse_olx_block(root, tag, url_name) if url_name else None
if element is None:
@@ -372,19 +373,236 @@ def staff_only_olx_paths(olx_path: str | Path) -> set[Path]:
pointer.get("visible_to_staff_only"),
element.get("visible_to_staff_only"),
)
- if staff_only and url_name:
- hidden.update(_hidden_block_files(root, tag, url_name, element))
+ if url_name:
+ if (tag, url_name, staff_only) in seen:
+ continue
+ seen.add((tag, url_name, staff_only))
+ # a block can hang under two parents; seeing it anywhere a learner
+ # can reach makes it visible, whichever path the walk took first
+ if staff_only:
+ hidden[tag, url_name] = _hidden_block_files(
+ root, tag, url_name, element
+ )
+ else:
+ visible.add((tag, url_name))
stack.extend(
(child, child.tag, child.get("url_name"), staff_only) for child in element
)
- return hidden
+ return {
+ path
+ for block, files in hidden.items()
+ if block not in visible
+ for path in files
+ }
+
+
+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",
+)
+
+# 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
+)
+
+
+# A reference spells an asset name with the punctuation edX rewrote into it, or
+# with none of it: a file stored as "my file.pdf" is linked as my_file.pdf and as
+# my%20file.pdf, and a file re-uploaded under a flattened asset key is linked
+# with the + and @ of that key written as underscores. So the punctuation cannot
+# be part of the comparison, but the places it sat are still where a name starts.
+NAME_BREAK = "\x00"
+SEPARATORS = re.compile(r"(?:[^\w.\-]|_)+")
+
+
+def normalize_asset_ref(text: str) -> str:
+ """Strip the punctuation of a filename that a reference may spell differently"""
+ return SEPARATORS.sub("", unquote(html.unescape(text)).lower())
+
+
+def _reference_index(texts: list[str]) -> tuple[str, list[int]]:
+ """
+ Normalize the course text the same way, and return it with the offsets where
+ a name can start, i.e. everywhere the text had punctuation but an underscore.
+ Underscores do not break a name because that is what edX writes a space or a
+ +/@ as, so they are the one thing both sides drop.
+ """
+ stripped = NAME_BREAK.join(
+ SEPARATORS.sub(
+ NAME_BREAK, unquote(html.unescape(text)).lower().replace("_", "")
+ )
+ for text in texts
+ )
+ segments = stripped.split(NAME_BREAK)
+ return "".join(segments), list(accumulate(map(len, segments), initial=0))
+
+
+def _name_starts_at(blob: str, starts: list[int], name: str) -> bool:
+ """
+ Whether the course text spells a filename where a name can start, rather than
+ only inside a longer one. Without this a reference to final_exam.srt reads as
+ a reference to exam.srt too, and pulls that file back out of the staff-only
+ set.
+ """
+ start = blob.find(name)
+ while start != -1:
+ index = bisect_left(starts, start)
+ if index < len(starts) and starts[index] == start:
+ return True
+ start = blob.find(name, start + 1)
+ return False
+
+
+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 / "info/updates.items.json").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 ')
+ _write_olx(olx, "html/h_staff.html", 'key')
+ _write_olx(olx, "static/answers.pdf", "answers")
+ assert "static/answers.pdf" not in _olx_source_paths(olx)
+
+
+def test_documents_from_olx_drafts_do_not_keep_assets(tmp_path):
+ """Drafts are not ingested, so their links do not keep an asset alive"""
+ olx = _reference_olx(tmp_path, **{"draft_only.pdf": "pdf"})
+ _write_olx(olx, "html/h.html", "no links
")
+ _write_olx(olx, "drafts/html/d.html", 'd')
+ assert "static/draft_only.pdf" not in _olx_source_paths(olx)
+
+
+@pytest.mark.parametrize(
+ "video_xml",
+ [
+ # every spelling of the attribute is valid XML and must be honoured
+ '',
+ "",
+ '',
+ '',
+ '',
+ '',
+ ],
+)
+def test_documents_from_olx_keeps_legacy_transcripts(tmp_path, video_xml):
+ """subs_.srt.sjson is named for the video id, never for its own filename"""
+ olx = _reference_olx(
+ tmp_path,
+ **{"subs_AbC123.srt.sjson": "{}", "es_subs_AbC123.srt.sjson": "{}"},
+ )
+ _write_olx(olx, "html/h.html", "no links
")
+ _write_olx(olx, "vertical/v.xml", '')
+ _write_olx(olx, "video/vid.xml", video_xml)
+ paths = _olx_source_paths(olx)
+ assert "static/subs_AbC123.srt.sjson" in paths
+ assert "static/es_subs_AbC123.srt.sjson" in paths
+
+
+def test_documents_from_olx_drops_orphaned_legacy_transcripts(tmp_path):
+ """A legacy transcript whose video is gone belongs to an earlier offering"""
+ olx = _reference_olx(
+ tmp_path, **{"subs_Current.srt.sjson": "{}", "subs_Removed.srt.sjson": "{}"}
+ )
+ _write_olx(olx, "html/h.html", "no links
")
+ _write_olx(olx, "vertical/v.xml", '')
+ _write_olx(olx, "video/vid.xml", '')
+ paths = _olx_source_paths(olx)
+ assert "static/subs_Current.srt.sjson" in paths
+ assert "static/subs_Removed.srt.sjson" not in paths
+
+
+def test_documents_from_olx_keeps_inline_video_transcripts(tmp_path):
+ """A inline in a parent declares its transcripts just as a file does"""
+ olx = _reference_olx(tmp_path, **{"subs_Inline.srt.sjson": "{}"})
+ _write_olx(olx, "html/h.html", "no links
")
+ _write_olx(
+ olx,
+ "vertical/v.xml",
+ '',
+ )
+ assert "static/subs_Inline.srt.sjson" in _olx_source_paths(olx)
+
+
+def test_documents_from_olx_unparsable_xml_keeps_legacy_transcripts(tmp_path):
+ """Unknown video ids must not be read as "no video uses this transcript\""""
+ olx = _reference_olx(tmp_path, **{"subs_AbC123.srt.sjson": "{}"})
+ _write_olx(olx, "html/h.html", "no links
")
+ _write_olx(olx, "tabs/broken.xml", "
+
+ The excluded total counts archive paths, so it is an upper bound on what
+ unpublish_excluded_files would unpublish rather than a row count: several
+ paths can collapse onto one ContentFile key.
+ """
+
+ help = "Report the files an OLX course tree contains but does not use"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "olx_paths", nargs="+", help="Paths to extracted OLX course directories"
+ )
+
+ def handle(self, *args, **options): # noqa: ARG002
+ """Print per-archive exclusion counts split by location and file class"""
+ for olx_path in options["olx_paths"]:
+ root = Path(olx_path)
+ ingestable = {
+ path
+ for path in root.rglob("*")
+ if path.is_file()
+ and path.suffix.lower() in VALID_TEXT_FILE_TYPES
+ and not any(
+ "draft" in part for part in path.relative_to(root).parts[:-1]
+ )
+ }
+ excluded = excluded_olx_paths(root) & ingestable
+ static = {
+ path for path in excluded if path.relative_to(root).parts[0] == "static"
+ }
+ transcripts = {
+ path for path in static if path.suffix.lower() in TRANSCRIPT_EXTENSIONS
+ }
+ percent = 100 * len(excluded) / len(ingestable) if ingestable else 0
+ self.stdout.write(f"\n=== {root}")
+ self.stdout.write(f" ingestable files : {len(ingestable)}")
+ self.stdout.write(
+ f" excluded : {len(excluded)} ({percent:.0f}%)"
+ )
+ self.stdout.write(f" under static/ : {len(static)}")
+ self.stdout.write(f" transcripts : {len(transcripts)}")
+ self.stdout.write(f" documents : {len(static - transcripts)}")
+ self.stdout.write(
+ f" elsewhere : {len(excluded - static)}"
+ " (staff-only blocks, manifests, announcements)"
+ )
diff --git a/learning_resources/management/commands/unpublish_excluded_files.py b/learning_resources/management/commands/unpublish_excluded_files.py
new file mode 100644
index 0000000000..f261380a44
--- /dev/null
+++ b/learning_resources/management/commands/unpublish_excluded_files.py
@@ -0,0 +1,112 @@
+"""Unpublish edX content files that the course itself does not use"""
+
+import csv
+from operator import itemgetter
+from pathlib import Path
+
+from django.core.management import BaseCommand
+
+from learning_resources.etl.constants import ETLSource
+from learning_resources.tasks import unpublish_all_excluded_files
+from main import settings
+from main.utils import now_in_utc
+
+EDX_SOURCES = [
+ ETLSource.mitxonline.name,
+ ETLSource.mit_edx.name,
+ ETLSource.xpro.name,
+ ETLSource.oll.name,
+]
+
+
+REPORT_FIELDS = ("etl_source", "run_id", "excluded", "unpublished", "total")
+
+
+def _sum(rows, field):
+ return sum(row[field] for row in rows)
+
+
+class Command(BaseCommand):
+ """
+ Walk each course's current archive and unpublish the content files it
+ excludes — staff-only subtrees, asset manifests, and static files no block
+ refers to — then deindex them. Nothing is re-extracted or re-embedded.
+ """
+
+ help = "Unpublish unused edX content files from existing archives"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--source",
+ dest="sources",
+ action="append",
+ choices=EDX_SOURCES,
+ help="ETL source to process (repeatable). Default: all edX sources",
+ )
+ parser.add_argument(
+ "-c",
+ "--chunk-size",
+ dest="chunk_size",
+ default=settings.LEARNING_COURSE_ITERATOR_CHUNK_SIZE,
+ type=int,
+ help="Chunk size for batch task",
+ )
+ parser.add_argument(
+ "--resource-ids",
+ dest="learning_resource_ids",
+ required=False,
+ help="If set, only process the learning resources with these ids",
+ )
+ parser.add_argument(
+ "--dry-run",
+ dest="dry_run",
+ action="store_true",
+ help="Report what would be unpublished without changing anything",
+ )
+ parser.add_argument(
+ "--report",
+ dest="report",
+ required=False,
+ help="Also write the per-run counts as CSV to this path",
+ )
+
+ def handle(self, *args, **options): # noqa: ARG002
+ """Run the unpublish tasks"""
+ resource_ids = (
+ options["learning_resource_ids"].split(",")
+ if options["learning_resource_ids"]
+ else None
+ )
+ start = now_in_utc()
+ report = []
+ for source in options["sources"] or EDX_SOURCES:
+ task = unpublish_all_excluded_files.delay(
+ etl_source=source,
+ chunk_size=options["chunk_size"],
+ learning_resource_ids=resource_ids,
+ dry_run=options["dry_run"],
+ )
+ self.stdout.write(f"Started task {task} for {source}, waiting...")
+ rows = [row for chunk in task.get() or [] for row in chunk or []]
+ for row in sorted(rows, key=itemgetter("run_id")):
+ self.stdout.write(
+ f"{source} run {row['run_id']}: {row['excluded']} out of "
+ f"{row['total']} content files excluded"
+ )
+ verb = "would unpublish" if options["dry_run"] else "unpublished"
+ self.stdout.write(
+ f"{source} summary: {_sum(rows, 'excluded')} out of "
+ f"{_sum(rows, 'total')} content files excluded across "
+ f"{len(rows)} run(s), {verb} {_sum(rows, 'unpublished')}"
+ )
+ report.extend({"etl_source": source, **row} for row in rows)
+
+ if options["report"]:
+ with Path(options["report"]).open("w", newline="") as report_file:
+ writer = csv.DictWriter(report_file, fieldnames=REPORT_FIELDS)
+ writer.writeheader()
+ writer.writerows(sorted(report, key=itemgetter("etl_source", "run_id")))
+ self.stdout.write(f"Wrote {len(report)} rows to {options['report']}")
+
+ total_seconds = (now_in_utc() - start).total_seconds()
+ self.stdout.write(f"Finished in {total_seconds} seconds")
diff --git a/learning_resources/management/commands/unpublish_staff_only_files.py b/learning_resources/management/commands/unpublish_staff_only_files.py
deleted file mode 100644
index a03e57d2c8..0000000000
--- a/learning_resources/management/commands/unpublish_staff_only_files.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""Unpublish edX content files that sit under staff-only OLX subtrees"""
-
-from django.core.management import BaseCommand
-
-from learning_resources.etl.constants import ETLSource
-from learning_resources.tasks import unpublish_all_staff_only_files
-from main import settings
-from main.utils import now_in_utc
-
-EDX_SOURCES = [
- ETLSource.mitxonline.name,
- ETLSource.mit_edx.name,
- ETLSource.xpro.name,
- ETLSource.oll.name,
-]
-
-
-class Command(BaseCommand):
- """
- Walk each course's current archive and unpublish content files under
- visible_to_staff_only subtrees, then deindex them. Nothing is re-extracted
- or re-embedded.
- """
-
- help = "Unpublish staff-only edX content files from existing archives"
-
- def add_arguments(self, parser):
- parser.add_argument(
- "--source",
- dest="sources",
- action="append",
- choices=EDX_SOURCES,
- help="ETL source to process (repeatable). Default: all edX sources",
- )
- parser.add_argument(
- "-c",
- "--chunk-size",
- dest="chunk_size",
- default=settings.LEARNING_COURSE_ITERATOR_CHUNK_SIZE,
- type=int,
- help="Chunk size for batch task",
- )
- parser.add_argument(
- "--resource-ids",
- dest="learning_resource_ids",
- required=False,
- help="If set, only process the learning resources with these ids",
- )
-
- def handle(self, *args, **options): # noqa: ARG002
- """Run the unpublish tasks"""
- resource_ids = (
- options["learning_resource_ids"].split(",")
- if options["learning_resource_ids"]
- else None
- )
- start = now_in_utc()
- for source in options["sources"] or EDX_SOURCES:
- task = unpublish_all_staff_only_files.delay(
- etl_source=source,
- chunk_size=options["chunk_size"],
- learning_resource_ids=resource_ids,
- )
- self.stdout.write(f"Started task {task} for {source}, waiting...")
- results = task.get()
- total = sum(count or 0 for count in results or [])
- self.stdout.write(f"{source}: unpublished {total} content files")
- total_seconds = (now_in_utc() - start).total_seconds()
- self.stdout.write(f"Finished in {total_seconds} seconds")
diff --git a/learning_resources/tasks.py b/learning_resources/tasks.py
index e9a6eb86d2..57f834a8fe 100644
--- a/learning_resources/tasks.py
+++ b/learning_resources/tasks.py
@@ -33,7 +33,7 @@
get_most_recent_course_archives,
sync_edx_archive,
sync_edx_course_files,
- unpublish_staff_only_content_files,
+ unpublish_excluded_content_files,
)
from learning_resources.etl.loaders import (
load_learning_materials,
@@ -236,27 +236,38 @@ def _content_file_resource_ids(etl_source: str, learning_resource_ids):
@app.task(acks_late=True, reject_on_worker_lost=True)
-def unpublish_staff_only_files(ids: list[int], etl_source: str, keys: list[str]):
- """Unpublish staff-only content files for a chunk of courses"""
- return unpublish_staff_only_content_files(etl_source, ids, keys)
+def unpublish_excluded_files(
+ ids: list[int], etl_source: str, keys: list[str], *, dry_run: bool = False
+):
+ """Unpublish unused content files for a chunk of courses, a row per run"""
+ return unpublish_excluded_content_files(etl_source, ids, keys, dry_run=dry_run)
@app.task(bind=True)
-def unpublish_all_staff_only_files(
- self, *, etl_source, chunk_size=None, learning_resource_ids=None
+def unpublish_all_excluded_files(
+ self, *, etl_source, chunk_size=None, learning_resource_ids=None, dry_run=False
):
- """Fan out unpublish_staff_only_files over an edX source's current archives"""
+ """Fan out unpublish_excluded_files over an edX source's current archives"""
if chunk_size is None:
chunk_size = settings.LEARNING_COURSE_ITERATOR_CHUNK_SIZE
archive_keys = get_most_recent_course_archives(etl_source)
+ # drops whole courses with nothing to unpublish; the runs of the ones that
+ # remain are guarded in unpublish_excluded_content_files, as a course keeps
+ # runs whose archives would otherwise be downloaded for no rows. Not applied
+ # to the ingestion fan-out, where a course with no content files yet is
+ # exactly the one that needs its archive read.
+ resource_ids = (
+ _content_file_resource_ids(etl_source, learning_resource_ids)
+ .filter(runs__content_files__isnull=False)
+ .distinct()
+ )
return self.replace(
celery.group(
[
- unpublish_staff_only_files.si(ids, etl_source, archive_keys)
- for ids in chunks(
- _content_file_resource_ids(etl_source, learning_resource_ids),
- chunk_size=chunk_size,
+ unpublish_excluded_files.si(
+ ids, etl_source, archive_keys, dry_run=dry_run
)
+ for ids in chunks(resource_ids, chunk_size=chunk_size)
]
)
)
diff --git a/learning_resources/tasks_test.py b/learning_resources/tasks_test.py
index f89211ebf0..6a2203868a 100644
--- a/learning_resources/tasks_test.py
+++ b/learning_resources/tasks_test.py
@@ -1555,11 +1555,11 @@ def test_get_podcast_transcripts(mocker):
@mock_aws
-def test_unpublish_all_staff_only_files(
+def test_unpublish_all_excluded_files(
settings, mocker, mocked_celery, mock_course_archive_bucket
):
- """unpublish_all_staff_only_files fans out one task per chunk of course ids"""
- mock_task = mocker.patch("learning_resources.tasks.unpublish_staff_only_files.si")
+ """Only courses whose runs have content files are fanned out"""
+ mock_task = mocker.patch("learning_resources.tasks.unpublish_excluded_files.si")
mocker.patch("learning_resources.tasks.load_course_blocklist", return_value=[])
mocker.patch(
"learning_resources.tasks.get_most_recent_course_archives",
@@ -1570,25 +1570,36 @@ def test_unpublish_all_staff_only_files(
courses = factories.CourseFactory.create_batch(
3, etl_source=etl_source, platform=PlatformType.mitxonline.name
)
+ # the third course has no content files, so its archive is never downloaded
+ with_files = courses[:2]
+ for course in with_files:
+ factories.ContentFileFactory.create(
+ run=factories.LearningResourceRunFactory.create(
+ learning_resource=course.learning_resource
+ )
+ )
with pytest.raises(mocked_celery.replace_exception_class):
- tasks.unpublish_all_staff_only_files.delay(
+ tasks.unpublish_all_excluded_files.delay(
etl_source=etl_source, chunk_size=2, learning_resource_ids=None
)
- assert mock_task.call_count == 2
+ assert mock_task.call_count == 1
called_ids = sorted(
rid for call in mock_task.call_args_list for rid in call.args[0]
)
- assert called_ids == sorted(c.learning_resource_id for c in courses)
- mock_task.assert_any_call(ANY, etl_source, ["foo.tar.gz"])
+ assert called_ids == sorted(c.learning_resource_id for c in with_files)
+ mock_task.assert_any_call(ANY, etl_source, ["foo.tar.gz"], dry_run=False)
-def test_unpublish_staff_only_files_task(mocker):
- """unpublish_staff_only_files task delegates to edx_shared"""
+def test_unpublish_excluded_files_task(mocker):
+ """unpublish_excluded_files task delegates to edx_shared"""
mock_fn = mocker.patch(
- "learning_resources.tasks.unpublish_staff_only_content_files", return_value=3
+ "learning_resources.tasks.unpublish_excluded_content_files",
+ return_value=[{"run_id": "r", "excluded": 3, "unpublished": 3, "total": 9}],
)
- assert tasks.unpublish_staff_only_files([1, 2], "mitxonline", ["k"]) == 3
- mock_fn.assert_called_once_with("mitxonline", [1, 2], ["k"])
+ assert tasks.unpublish_excluded_files([1, 2], "mitxonline", ["k"]) == [
+ {"run_id": "r", "excluded": 3, "unpublished": 3, "total": 9}
+ ]
+ mock_fn.assert_called_once_with("mitxonline", [1, 2], ["k"], dry_run=False)
@pytest.mark.parametrize(
diff --git a/main/celery.py b/main/celery.py
index 6a83648815..8fedd258f1 100644
--- a/main/celery.py
+++ b/main/celery.py
@@ -30,8 +30,8 @@
"learning_resources.tasks.import_all_xpro_files": {"queue": "edx_content"},
"learning_resources.tasks.import_all_mit_edx_files": {"queue": "edx_content"},
"learning_resources.tasks.import_all_mitxonline_files": {"queue": "edx_content"},
- "learning_resources.tasks.unpublish_staff_only_files": {"queue": "edx_content"},
- "learning_resources.tasks.unpublish_all_staff_only_files": {"queue": "edx_content"},
+ "learning_resources.tasks.unpublish_excluded_files": {"queue": "edx_content"},
+ "learning_resources.tasks.unpublish_all_excluded_files": {"queue": "edx_content"},
"learning_resources_search.tasks.index_run_content_files": {"queue": "edx_content"},
"learning_resources_search.tasks.deindex_run_content_files": {
"queue": "edx_content"
From f7f668d4b24ee54d112e8e33d520759ffc6bb00e Mon Sep 17 00:00:00 2001
From: Anastasia Beglova
Date: Tue, 22 Sep 2026 15:52:15 -0400
Subject: [PATCH 3/4] credential metadata task (#3950)
---
frontends/api/src/generated/v0/api.ts | 142 ++++-
learning_resources/admin.py | 11 +
learning_resources/credentials.py | 117 ++++-
learning_resources/credentials_store.py | 162 ++++++
learning_resources/credentials_store_test.py | 248 +++++++++
learning_resources/credentials_test.py | 310 +++++++++++
learning_resources/factories.py | 12 +
.../commands/generate_credential_metadata.py | 57 ++
.../0126_credential_metadata_store.py | 59 +++
learning_resources/models.py | 35 ++
learning_resources/models_test.py | 51 +-
learning_resources/permissions.py | 6 +-
learning_resources/serializers_test.py | 18 +
learning_resources/tasks.py | 138 ++++-
learning_resources/tasks_test.py | 495 +++++++++++++++++-
learning_resources/views.py | 151 ++++--
learning_resources/views_credential_test.py | 271 +++++++++-
main/utils.py | 48 ++
main/utils_test.py | 110 ++++
openapi/specs/v0.yaml | 26 +-
20 files changed, 2397 insertions(+), 70 deletions(-)
create mode 100644 learning_resources/credentials_store.py
create mode 100644 learning_resources/credentials_store_test.py
create mode 100644 learning_resources/management/commands/generate_credential_metadata.py
create mode 100644 learning_resources/migrations/0126_credential_metadata_store.py
diff --git a/frontends/api/src/generated/v0/api.ts b/frontends/api/src/generated/v0/api.ts
index 57cdd85e51..bef5eb1592 100644
--- a/frontends/api/src/generated/v0/api.ts
+++ b/frontends/api/src/generated/v0/api.ts
@@ -3504,7 +3504,7 @@ export const CredentialMetadataApiAxiosParamCreator = function (
) {
return {
/**
- * Generate Open Badges credential metadata for a learning resource. Limited to MITx Online courses.
+ * Read or generate Open Badges credential metadata for a learning resource. Limited to MITx Online courses.
* @summary Generate credential metadata
* @param {CredentialMetadataRequestRequest} CredentialMetadataRequestRequest
* @param {*} [options] Override http request option.
@@ -3553,6 +3553,59 @@ export const CredentialMetadataApiAxiosParamCreator = function (
configuration,
)
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ }
+ },
+ /**
+ * Read or generate Open Badges credential metadata for a learning resource. Limited to MITx Online courses.
+ * @summary Get stored credential metadata
+ * @param {string} resource_readable_id The readable id of the learning resource to fetch stored metadata for
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ credentialMetadataRetrieve: async (
+ resource_readable_id: string,
+ options: RawAxiosRequestConfig = {},
+ ): Promise => {
+ // verify required parameter 'resource_readable_id' is not null or undefined
+ assertParamExists(
+ "credentialMetadataRetrieve",
+ "resource_readable_id",
+ resource_readable_id,
+ )
+ const localVarPath = `/api/v0/credential_metadata/`
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL)
+ let baseOptions
+ if (configuration) {
+ baseOptions = configuration.baseOptions
+ }
+
+ const localVarRequestOptions = {
+ method: "GET",
+ ...baseOptions,
+ ...options,
+ }
+ const localVarHeaderParameter = {} as any
+ const localVarQueryParameter = {} as any
+
+ if (resource_readable_id !== undefined) {
+ localVarQueryParameter["resource_readable_id"] = resource_readable_id
+ }
+
+ localVarHeaderParameter["Accept"] = "application/json"
+
+ setSearchParams(localVarUrlObj, localVarQueryParameter)
+ let headersFromBaseOptions =
+ baseOptions && baseOptions.headers ? baseOptions.headers : {}
+ localVarRequestOptions.headers = {
+ ...localVarHeaderParameter,
+ ...headersFromBaseOptions,
+ ...options.headers,
+ }
+
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
@@ -3571,7 +3624,7 @@ export const CredentialMetadataApiFp = function (
CredentialMetadataApiAxiosParamCreator(configuration)
return {
/**
- * Generate Open Badges credential metadata for a learning resource. Limited to MITx Online courses.
+ * Read or generate Open Badges credential metadata for a learning resource. Limited to MITx Online courses.
* @summary Generate credential metadata
* @param {CredentialMetadataRequestRequest} CredentialMetadataRequestRequest
* @param {*} [options] Override http request option.
@@ -3604,6 +3657,40 @@ export const CredentialMetadataApiFp = function (
configuration,
)(axios, localVarOperationServerBasePath || basePath)
},
+ /**
+ * Read or generate Open Badges credential metadata for a learning resource. Limited to MITx Online courses.
+ * @summary Get stored credential metadata
+ * @param {string} resource_readable_id The readable id of the learning resource to fetch stored metadata for
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async credentialMetadataRetrieve(
+ resource_readable_id: string,
+ options?: RawAxiosRequestConfig,
+ ): Promise<
+ (
+ axios?: AxiosInstance,
+ basePath?: string,
+ ) => AxiosPromise
+ > {
+ const localVarAxiosArgs =
+ await localVarAxiosParamCreator.credentialMetadataRetrieve(
+ resource_readable_id,
+ options,
+ )
+ const localVarOperationServerIndex = configuration?.serverIndex ?? 0
+ const localVarOperationServerBasePath =
+ operationServerMap[
+ "CredentialMetadataApi.credentialMetadataRetrieve"
+ ]?.[localVarOperationServerIndex]?.url
+ return (axios, basePath) =>
+ createRequestFunction(
+ localVarAxiosArgs,
+ globalAxios,
+ BASE_PATH,
+ configuration,
+ )(axios, localVarOperationServerBasePath || basePath)
+ },
}
}
@@ -3618,7 +3705,7 @@ export const CredentialMetadataApiFactory = function (
const localVarFp = CredentialMetadataApiFp(configuration)
return {
/**
- * Generate Open Badges credential metadata for a learning resource. Limited to MITx Online courses.
+ * Read or generate Open Badges credential metadata for a learning resource. Limited to MITx Online courses.
* @summary Generate credential metadata
* @param {CredentialMetadataApiCredentialMetadataCreateRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
@@ -3635,6 +3722,24 @@ export const CredentialMetadataApiFactory = function (
)
.then((request) => request(axios, basePath))
},
+ /**
+ * Read or generate Open Badges credential metadata for a learning resource. Limited to MITx Online courses.
+ * @summary Get stored credential metadata
+ * @param {CredentialMetadataApiCredentialMetadataRetrieveRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ credentialMetadataRetrieve(
+ requestParameters: CredentialMetadataApiCredentialMetadataRetrieveRequest,
+ options?: RawAxiosRequestConfig,
+ ): AxiosPromise {
+ return localVarFp
+ .credentialMetadataRetrieve(
+ requestParameters.resource_readable_id,
+ options,
+ )
+ .then((request) => request(axios, basePath))
+ },
}
}
@@ -3645,12 +3750,22 @@ export interface CredentialMetadataApiCredentialMetadataCreateRequest {
readonly CredentialMetadataRequestRequest: CredentialMetadataRequestRequest
}
+/**
+ * Request parameters for credentialMetadataRetrieve operation in CredentialMetadataApi.
+ */
+export interface CredentialMetadataApiCredentialMetadataRetrieveRequest {
+ /**
+ * The readable id of the learning resource to fetch stored metadata for
+ */
+ readonly resource_readable_id: string
+}
+
/**
* CredentialMetadataApi - object-oriented interface
*/
export class CredentialMetadataApi extends BaseAPI {
/**
- * Generate Open Badges credential metadata for a learning resource. Limited to MITx Online courses.
+ * Read or generate Open Badges credential metadata for a learning resource. Limited to MITx Online courses.
* @summary Generate credential metadata
* @param {CredentialMetadataApiCredentialMetadataCreateRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
@@ -3667,6 +3782,25 @@ export class CredentialMetadataApi extends BaseAPI {
)
.then((request) => request(this.axios, this.basePath))
}
+
+ /**
+ * Read or generate Open Badges credential metadata for a learning resource. Limited to MITx Online courses.
+ * @summary Get stored credential metadata
+ * @param {CredentialMetadataApiCredentialMetadataRetrieveRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ public credentialMetadataRetrieve(
+ requestParameters: CredentialMetadataApiCredentialMetadataRetrieveRequest,
+ options?: RawAxiosRequestConfig,
+ ) {
+ return CredentialMetadataApiFp(this.configuration)
+ .credentialMetadataRetrieve(
+ requestParameters.resource_readable_id,
+ options,
+ )
+ .then((request) => request(this.axios, this.basePath))
+ }
}
/**
diff --git a/learning_resources/admin.py b/learning_resources/admin.py
index eab25890c8..6204a9c421 100644
--- a/learning_resources/admin.py
+++ b/learning_resources/admin.py
@@ -338,6 +338,16 @@ def has_delete_permission(self, request, obj=None): # noqa: ARG002
return False
+class CredentialMetadataAdmin(admin.ModelAdmin):
+ """CredentialMetadata Admin"""
+
+ model = models.CredentialMetadata
+ list_display = ("learning_resource", "description", "created_on", "updated_on")
+ search_fields = ("learning_resource__readable_id", "learning_resource__title")
+ readonly_fields = ("created_on", "updated_on")
+ raw_id_fields = ("learning_resource",)
+
+
admin.site.register(models.LearningResourceTopic, LearningResourceTopicAdmin)
admin.site.register(models.LearningResourceInstructor, LearningResourceInstructorAdmin)
admin.site.register(models.LearningResource, LearningResourceAdmin)
@@ -360,3 +370,4 @@ def has_delete_permission(self, request, obj=None): # noqa: ARG002
admin.site.register(
models.CredentialMetadataGenerationLog, CredentialMetadataGenerationLogAdmin
)
+admin.site.register(models.CredentialMetadata, CredentialMetadataAdmin)
diff --git a/learning_resources/credentials.py b/learning_resources/credentials.py
index b988ea8546..aac48c24de 100644
--- a/learning_resources/credentials.py
+++ b/learning_resources/credentials.py
@@ -15,6 +15,7 @@
from typing_extensions import TypedDict
from learning_resources.constants import CredentialMetadataField
+from learning_resources.credentials_store import save_credential_metadata
from learning_resources.etl.constants import MARKETING_PAGE_FILE_TYPE
from learning_resources.models import (
ContentFile,
@@ -277,10 +278,6 @@ async def _retrieve_chunks(
) -> list[tuple[str, str]]:
"""
Retrieve the resource's most relevant content-file chunks.
-
- Retrieval is best-effort: metadata plus the marketing page is a viable
- degraded context, so a Qdrant outage returns a thinner draft rather than an
- error.
"""
try:
chunks = await async_content_file_chunks_for_resource(
@@ -290,8 +287,7 @@ async def _retrieve_chunks(
)
except Exception:
logger.exception(
- "Content file retrieval failed for %s; generating from metadata"
- " and marketing page alone",
+ "Content file retrieval failed for %s; skipping generation",
resource.readable_id,
)
return []
@@ -328,6 +324,38 @@ async def build_credential_context(
)
+def _missing_context_sources(
+ context: CredentialContext, *, retrieved: bool
+) -> list[str]:
+ """
+ Return the sources the configurations ask for that the context lacks.
+
+ Generating without them is worse than not generating: the output reads
+ like any other result, but a description written from the resource's own
+ metadata alone, or criteria with none of the course's content behind them,
+ is not something to issue a credential from. Returning empty-handed
+ leaves the resource with no stored metadata, so the daily sweep picks it
+ up again once its marketing page is scraped or its content indexed.
+
+ Args:
+ context (CredentialContext): the assembled sources
+ retrieved (bool): whether content retrieval was attempted. A
+ configuration with a blank retrieval_query is asking for
+ generation from the marketing page alone, so empty chunks are not
+ a missing source -- nothing was asked for.
+
+ Returns:
+ list of str: the missing sources, named for a log line and an error
+ message. Empty means the context is complete.
+ """
+ missing = []
+ if not context.marketing_page:
+ missing.append("marketing page")
+ if retrieved and not context.chunks:
+ missing.append("course content")
+ return missing
+
+
def _get_llm(config: CredentialMetadataConfiguration) -> ChatLiteLLM:
"""
Get the ChatLiteLLM instance for a field's configuration.
@@ -416,11 +444,28 @@ async def _generate_field(
return FieldOutcome(response=response, error=error)
+def _active_configs(fields: list[str] | None) -> list[CredentialMetadataConfiguration]:
+ """
+ Return the active configurations to generate, narrowed to `fields`.
+
+ Args:
+ fields (list of str | None): the fields to generate, or None for
+ every active configuration
+
+ Returns:
+ list of CredentialMetadataConfiguration: the configurations to run
+ """
+ configs = CredentialMetadataConfiguration.objects.filter(is_active=True)
+ if fields is not None:
+ configs = configs.filter(field__in=fields)
+ return list(configs)
+
+
async def generate_credential_metadata(
- resource: LearningResource, user=None
+ resource: LearningResource, user=None, fields: list[str] | None = None
) -> CredentialMetadata:
"""
- Generate every configured credential metadata field for a resource.
+ Generate configured credential metadata fields for a resource.
The fields share one context and are independent, so they are generated
concurrently: run in sequence they take about as long as the sum of their
@@ -429,33 +474,45 @@ async def generate_credential_metadata(
Args:
resource (LearningResource): the resource to generate metadata for
user (User): the user the generation is logged against
+ fields (list of str): the fields to generate, defaulting to every
+ active configuration. A caller filling in what a partial row is
+ missing passes that subset, so the fields already stored are
+ neither billed for a second time nor overwritten
Returns:
CredentialMetadata: the generated fields -- description (str) and
- criteria (list[str]) -- and one error per configured field that is
+ criteria (list[str]) -- and one error per requested field that is
missing from them. A field with no active configuration appears in
neither: nothing was asked of it, so there is nothing to explain.
"""
- configs = await db_sync_to_async(
- lambda: list(CredentialMetadataConfiguration.objects.filter(is_active=True))
- )()
+ configs = await db_sync_to_async(_active_configs)(fields)
if not configs:
logger.warning(
- "No active CredentialMetadataConfiguration; nothing to generate for %s",
+ "No active CredentialMetadataConfiguration%s; nothing to generate for %s",
+ f" for {', '.join(fields)}" if fields is not None else "",
resource.readable_id,
)
return CredentialMetadata(fields={}, errors={})
- context = await build_credential_context(resource, retrieval_query(configs))
+ query = retrieval_query(configs)
+ context = await build_credential_context(resource, query)
+ missing = _missing_context_sources(context, retrieved=bool(query))
+ if missing:
+ sources = " and ".join(missing)
+ logger.warning(
+ "Not generating credential metadata for %s: missing its %s",
+ resource.readable_id,
+ sources,
+ )
+ detail = f"Nothing was generated: the course is missing its {sources}."
+ return CredentialMetadata(
+ fields={}, errors={config.field: detail for config in configs}
+ )
+
outcomes = await asyncio.gather(
*[_generate_field(resource, config, context, user=user) for config in configs]
)
- # Each response schema keys its value by the field name, so no field needs
- # a case of its own here. An empty value is left out entirely, like a
- # failure: a caller prepopulating a form must not overwrite a good value
- # with a blank one -- and every field left out says why, so that a caller
- # can tell a failed generation from one that produced nothing.
fields, errors = {}, {}
for config, outcome in zip(configs, outcomes):
value = (outcome.response or {}).get(config.field)
@@ -468,3 +525,25 @@ async def generate_credential_metadata(
else:
errors[config.field] = f"The model returned no {config.field}."
return CredentialMetadata(fields=fields, errors=errors)
+
+
+async def generate_and_save_credential_metadata(
+ resource: LearningResource, user=None, fields: list[str] | None = None
+) -> CredentialMetadata:
+ """
+ Generate a resource's credential metadata and store what was generated.
+
+ Args:
+ resource (LearningResource): the resource to generate metadata for
+ user (User): the user the generation is logged against
+ fields (list of str): the fields to generate, defaulting to every
+ active configuration -- see `generate_credential_metadata`
+
+ Returns:
+ CredentialMetadata: exactly what `generate_credential_metadata`
+ returned. Nothing is stored when it generated nothing, so a failed
+ run leaves the previous values in force.
+ """
+ generated = await generate_credential_metadata(resource, user=user, fields=fields)
+ await db_sync_to_async(save_credential_metadata)(resource, generated.fields)
+ return generated
diff --git a/learning_resources/credentials_store.py b/learning_resources/credentials_store.py
new file mode 100644
index 0000000000..9e9816cecf
--- /dev/null
+++ b/learning_resources/credentials_store.py
@@ -0,0 +1,162 @@
+"""
+Read and write the credential metadata currently in force for a resource.
+"""
+
+import logging
+
+from django.db.models import Q
+
+from learning_resources.models import (
+ CredentialMetadata,
+ CredentialMetadataConfiguration,
+ LearningResource,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def storable_credential_metadata_fields() -> set[str]:
+ """
+ Return the CredentialMetadata columns a generated field can be written to.
+
+ Returns:
+ set of str: the model's own field names
+ """
+ return {field.name for field in CredentialMetadata._meta.concrete_fields} # noqa: SLF001
+
+
+def active_credential_metadata_fields() -> list[str]:
+ """
+ Return the stored fields a generation would currently produce.
+
+ Generation runs only is_active configurations, and a field with none is
+ left out of both the generated fields and the errors -- nothing was asked
+ of it, so there is nothing to explain. Its column therefore keeps its
+ default however many times the resource is generated for. Anything
+ deciding whether a resource still needs generating has to ask what is
+ configured, not what the model has columns for: a predicate that always
+ demanded every column would requeue every affected course on every sweep,
+ paying to regenerate the fields that are still active each time.
+
+ Returns:
+ list of str: the active configurations' fields that have a column to
+ store them in, sorted. Empty means a generation would produce
+ nothing at all.
+ """
+ configured = (
+ CredentialMetadataConfiguration.objects.filter(is_active=True)
+ .values_list("field", flat=True)
+ .distinct()
+ )
+ storable = storable_credential_metadata_fields()
+ return sorted(field for field in configured if field in storable)
+
+
+def _empty_value(field: str):
+ """Return the stored field's own default, which is what "missing" means."""
+ return CredentialMetadata._meta.get_field(field).get_default() # noqa: SLF001
+
+
+def incomplete_credential_metadata_query(fields: list[str]) -> Q:
+ """
+ Return a LearningResource filter for metadata missing any of `fields`.
+
+ "Missing" is per field and taken from the column's own default, so adding
+ a field needs no case here.
+
+ Args:
+ fields (list of str): the stored fields that must be present, from
+ active_credential_metadata_fields()
+
+ Returns:
+ Q: matches a resource with no metadata row at all, or one whose row
+ still holds the default for one of `fields`
+ """
+ query = Q(credential_metadata__isnull=True)
+ for field in fields:
+ query |= Q(**{f"credential_metadata__{field}": _empty_value(field)})
+ return query
+
+
+def missing_credential_metadata_fields(
+ resource: LearningResource, fields: list[str]
+) -> list[str]:
+ """
+ Return which of `fields` the resource has no stored value for.
+
+
+ Args:
+ resource (LearningResource): the resource to look up
+ fields (list of str): the stored fields to check, from
+ active_credential_metadata_fields()
+
+ Returns:
+ list of str: the subset of `fields` still holding the column default,
+ in the order given. Every field when the resource has no metadata
+ row at all.
+ """
+ stored = stored_credential_metadata(resource)
+ if not stored:
+ return list(fields)
+ return [field for field in fields if getattr(stored, field) == _empty_value(field)]
+
+
+def stored_credential_metadata(
+ resource: LearningResource,
+) -> CredentialMetadata | None:
+ """
+ Return the resource's stored credential metadata, or None if it has none.
+
+ Args:
+ resource (LearningResource): the resource to look up
+
+ Returns:
+ CredentialMetadata | None: the stored row, or None when nothing has
+ been generated for the resource yet
+ """
+ return CredentialMetadata.objects.filter(learning_resource=resource).first()
+
+
+def save_credential_metadata(
+ resource: LearningResource, fields: dict
+) -> CredentialMetadata | None:
+ """
+ Store the generated credential metadata fields for a resource.
+
+ Only the fields actually generated are written. A field that failed is
+ left alone rather than blanked, so a partial generation cannot destroy a
+ good value from an earlier run -- the same omit-empties rule
+ `generate_credential_metadata` applies to its own return value.
+
+ Args:
+ resource (LearningResource): the resource the metadata belongs to
+ fields (dict): the generated fields, keyed by CredentialMetadataField
+ name. Unknown keys are ignored.
+
+ Returns:
+ CredentialMetadata | None: the stored row, or None when there was
+ nothing to store. Nothing is written for empty `fields`: an empty
+ row would read as "generated nothing" and be skipped by every
+ later sweep, so one provider outage would permanently poison the
+ resources it hit.
+ """
+ storable = storable_credential_metadata_fields()
+ unknown = set(fields) - storable
+ if unknown:
+ logger.warning(
+ "Ignoring credential metadata field(s) %s for %s: not stored fields",
+ ", ".join(sorted(unknown)),
+ resource.readable_id,
+ )
+ # Whitelisted, not passed through: `fields` keys come from
+ # CredentialMetadataField, and adding a member there without a column
+ # would otherwise raise FieldError on every resource in the sweep.
+ defaults = {
+ key: value for key, value in fields.items() if key in storable and value
+ }
+ if not defaults:
+ return None
+ stored, _ = CredentialMetadata.objects.update_or_create(
+ learning_resource=resource, defaults=defaults
+ )
+ return stored
diff --git a/learning_resources/credentials_store_test.py b/learning_resources/credentials_store_test.py
new file mode 100644
index 0000000000..0ffc8ca4e1
--- /dev/null
+++ b/learning_resources/credentials_store_test.py
@@ -0,0 +1,248 @@
+"""Tests for reading and writing stored credential metadata"""
+
+import pytest
+
+from learning_resources.constants import CredentialMetadataField
+from learning_resources.credentials_store import (
+ active_credential_metadata_fields,
+ incomplete_credential_metadata_query,
+ missing_credential_metadata_fields,
+ save_credential_metadata,
+ stored_credential_metadata,
+)
+from learning_resources.factories import (
+ CredentialMetadataConfigurationFactory,
+ CredentialMetadataFactory,
+ LearningResourceFactory,
+)
+from learning_resources.models import (
+ CredentialMetadata,
+ CredentialMetadataConfiguration,
+ LearningResource,
+)
+
+pytestmark = pytest.mark.django_db
+
+
+@pytest.fixture
+def resource():
+ """Return a course to store metadata against"""
+ return LearningResourceFactory.create(is_course=True)
+
+
+def test_stored_credential_metadata_none(resource):
+ """A resource with no metadata reads as None, not an exception"""
+ assert stored_credential_metadata(resource) is None
+
+
+def test_stored_credential_metadata(resource):
+ """A stored row is read back"""
+ stored = CredentialMetadataFactory.create(learning_resource=resource)
+
+ assert stored_credential_metadata(resource) == stored
+
+
+def test_save_credential_metadata_creates(resource):
+ """Generated fields are stored"""
+ saved = save_credential_metadata(
+ resource, {"description": "A course.", "criteria": ["Did a thing"]}
+ )
+
+ assert saved.learning_resource == resource
+ assert saved.description == "A course."
+ assert saved.criteria == ["Did a thing"]
+
+
+def test_save_credential_metadata_updates(resource):
+ """A second save replaces the stored values rather than adding a row"""
+ save_credential_metadata(resource, {"description": "First", "criteria": ["One"]})
+ save_credential_metadata(resource, {"description": "Second", "criteria": ["Two"]})
+
+ stored = stored_credential_metadata(resource)
+ assert CredentialMetadata.objects.filter(learning_resource=resource).count() == 1
+ assert stored.description == "Second"
+ assert stored.criteria == ["Two"]
+
+
+def test_save_credential_metadata_keeps_a_field_that_failed(resource):
+ """
+ A field missing from a later generation keeps its previous value.
+
+ The generator omits a field it could not produce, so writing the whole
+ model every time would let one failed field blank a good value.
+ """
+ save_credential_metadata(resource, {"description": "Good", "criteria": ["Good"]})
+ save_credential_metadata(resource, {"description": "Regenerated"})
+
+ stored = stored_credential_metadata(resource)
+ assert stored.description == "Regenerated"
+ assert stored.criteria == ["Good"]
+
+
+@pytest.mark.parametrize(
+ "fields", [{}, {"description": "", "criteria": []}, {"criteria": []}]
+)
+def test_save_credential_metadata_writes_nothing_when_empty(resource, fields):
+ """
+ A generation that produced nothing stores nothing.
+
+ """
+ assert save_credential_metadata(resource, fields) is None
+ assert not CredentialMetadata.objects.filter(learning_resource=resource).exists()
+
+
+def test_save_credential_metadata_ignores_an_unknown_field(resource):
+ """
+ A field with no column is dropped, not passed to the ORM.
+
+ The keys come from CredentialMetadataField; adding a member there without
+ a migration would otherwise raise FieldError on every resource in a sweep.
+ """
+ saved = save_credential_metadata(
+ resource, {"description": "A course.", "alignment": ["Some standard"]}
+ )
+
+ assert saved.description == "A course."
+ assert not hasattr(saved, "alignment")
+
+
+@pytest.fixture
+def configurations():
+ """
+ One active configuration per credential metadata field.
+
+ Created here rather than relying on migration 0124's seed, which a
+ transactional test elsewhere deletes without restoring.
+ """
+ CredentialMetadataConfiguration.objects.all().delete()
+ return [
+ CredentialMetadataConfigurationFactory.create(field=field.name)
+ for field in CredentialMetadataField
+ ]
+
+
+def test_active_credential_metadata_fields(configurations):
+ """Every active configuration's field is reported"""
+ assert active_credential_metadata_fields() == sorted(
+ field.name for field in CredentialMetadataField
+ )
+
+
+def test_active_credential_metadata_fields_skips_inactive(configurations):
+ """A field whose configuration is switched off is not reported"""
+ CredentialMetadataConfiguration.objects.filter(
+ field=CredentialMetadataField.criteria.name
+ ).update(is_active=False)
+
+ assert active_credential_metadata_fields() == [
+ CredentialMetadataField.description.name
+ ]
+
+
+def test_active_credential_metadata_fields_with_none_active(configurations):
+ """No active configuration means a generation would produce nothing"""
+ CredentialMetadataConfiguration.objects.update(is_active=False)
+
+ assert active_credential_metadata_fields() == []
+
+
+def test_active_credential_metadata_fields_skips_a_field_with_no_column(
+ configurations,
+):
+ """
+ A configured field with nowhere to store it is not reported.
+
+ Same whitelist as the writer: adding a CredentialMetadataField member
+ without a migration must not produce a filter on a column that does not
+ exist.
+ """
+ CredentialMetadataConfiguration.objects.filter(
+ field=CredentialMetadataField.criteria.name
+ ).update(field="alignment")
+
+ assert active_credential_metadata_fields() == [
+ CredentialMetadataField.description.name
+ ]
+
+
+@pytest.mark.parametrize(
+ ("stored", "fields", "expected"),
+ [
+ (None, ["description", "criteria"], True),
+ (
+ {"description": "A course", "criteria": ["Did a thing"]},
+ ["description", "criteria"],
+ False,
+ ),
+ (
+ {"description": "A course", "criteria": []},
+ ["description", "criteria"],
+ True,
+ ),
+ ({"description": "A course", "criteria": []}, ["description"], False),
+ ({"description": "A course", "criteria": []}, ["criteria"], True),
+ ({"description": "", "criteria": ["Did a thing"]}, ["description"], True),
+ ({"description": "", "criteria": ["Did a thing"]}, ["criteria"], False),
+ ],
+)
+def test_incomplete_credential_metadata_query(resource, stored, fields, expected):
+ """
+ The filter matches a resource missing any of the fields asked for.
+
+ `stored` is None for a resource with no metadata row at all, which always
+ matches: nothing has been generated for it.
+ """
+ if stored is not None:
+ CredentialMetadataFactory.create(learning_resource=resource, **stored)
+
+ matches = (
+ LearningResource.objects.filter(incomplete_credential_metadata_query(fields))
+ .filter(id=resource.id)
+ .exists()
+ )
+
+ assert matches is expected
+
+
+@pytest.mark.parametrize(
+ ("stored", "fields", "expected"),
+ [
+ (None, ["criteria", "description"], ["criteria", "description"]),
+ (
+ {"description": "A course", "criteria": ["Did a thing"]},
+ ["criteria", "description"],
+ [],
+ ),
+ (
+ {"description": "A course", "criteria": []},
+ ["criteria", "description"],
+ ["criteria"],
+ ),
+ (
+ {"description": "", "criteria": ["Did a thing"]},
+ ["criteria", "description"],
+ ["description"],
+ ),
+ # Only what was asked for: a field with no active configuration is
+ # nobody's to generate, however empty its column is.
+ ({"description": "", "criteria": []}, ["criteria"], ["criteria"]),
+ ],
+)
+def test_missing_credential_metadata_fields(resource, stored, fields, expected):
+ """
+ Only the fields still holding their column default come back.
+
+ This is what scopes a regeneration: the fields left out are already in
+ force, and generating them again would both cost a call and replace them.
+ """
+ if stored is not None:
+ CredentialMetadataFactory.create(learning_resource=resource, **stored)
+
+ assert missing_credential_metadata_fields(resource, fields) == expected
+
+
+def test_missing_credential_metadata_fields_without_active_configurations(resource):
+ """Nothing is configured, so nothing is missing -- there is nothing to ask for"""
+ CredentialMetadataFactory.create(learning_resource=resource, description="")
+
+ assert missing_credential_metadata_fields(resource, []) == []
diff --git a/learning_resources/credentials_test.py b/learning_resources/credentials_test.py
index 4c5cae13ec..c0a700f69e 100644
--- a/learning_resources/credentials_test.py
+++ b/learning_resources/credentials_test.py
@@ -2,6 +2,7 @@
import asyncio
import json
+import logging
import pytest
@@ -16,6 +17,7 @@
_get_llm,
_prepare_marketing_page,
build_credential_context,
+ generate_and_save_credential_metadata,
generate_credential_metadata,
)
from learning_resources.etl.constants import MARKETING_PAGE_FILE_TYPE
@@ -25,10 +27,12 @@
LearningResourceFactory,
)
from learning_resources.models import (
+ CredentialMetadata,
CredentialMetadataConfiguration,
CredentialMetadataGenerationLog,
)
from main.factories import UserFactory
+from main.utils import run_on_worker_loop
# Shaped like a real scraped program page: the program's own instructors come
# before every child course's content, and the site footer trails the last
@@ -637,3 +641,309 @@ def test_generate_credential_metadata_truncates_a_long_error(
# The record keeps what the response cannot carry.
log = CredentialMetadataGenerationLog.objects.get(learning_resource=resource)
assert detail in log.error
+
+
+@pytest.mark.django_db(transaction=True)
+def test_generate_credential_metadata_for_a_subset_of_fields(
+ resource, configurations, mock_llm, mock_retrieval
+):
+ """
+ `fields` narrows generation to what was asked for.
+
+ A resource whose description stored but whose criteria did not is
+ regenerated for criteria alone: the description already in force is
+ neither billed for again nor replaced.
+ """
+ generated = asyncio.run(generate_credential_metadata(resource, fields=["criteria"]))
+
+ assert set(generated.fields) == {"criteria"}
+ # Nothing is owed for a field nobody asked about.
+ assert generated.errors == {}
+ assert BadgeDescription not in mock_llm.prompts
+ assert BadgeCriteria in mock_llm.prompts
+
+
+@pytest.mark.django_db(transaction=True)
+def test_generating_only_a_description_skips_retrieval(
+ resource, configurations, mock_llm, mock_retrieval
+):
+ """
+ The retrieval query comes from the narrowed configurations.
+
+ Only criteria reads course content, so a description-only run has nothing
+ to retrieve for and does not pay Qdrant for chunks no prompt will see.
+ """
+ generated = asyncio.run(
+ generate_credential_metadata(resource, fields=["description"])
+ )
+
+ assert set(generated.fields) == {"description"}
+ mock_retrieval.assert_not_called()
+
+
+@pytest.mark.django_db(transaction=True)
+def test_generate_credential_metadata_with_no_fields(
+ resource, configurations, mock_llm, mock_retrieval, caplog
+):
+ """
+ An empty `fields` generates nothing, rather than falling back to all.
+
+ The caller asked for no field in particular -- a row that filled in
+ between being queued and being run -- which is not a licence to
+ regenerate the whole of it.
+ """
+ with caplog.at_level(logging.WARNING):
+ generated = asyncio.run(generate_credential_metadata(resource, fields=[]))
+
+ assert generated == ({}, {})
+ assert mock_llm.prompts == {}
+ assert "No active CredentialMetadataConfiguration" in caplog.text
+
+
+@pytest.mark.django_db(transaction=True)
+def test_generate_and_save_credential_metadata_keeps_the_fields_not_asked_for(
+ resource, configurations, mock_llm, mock_retrieval
+):
+ """
+ A scoped regeneration leaves the stored fields it did not generate alone.
+
+ Both fields are editable in the admin, so the description on a row whose
+ criteria never generated may have been corrected by hand. Regenerating
+ the row whole would replace it on the next sweep.
+ """
+ CredentialMetadata.objects.create(
+ learning_resource=resource,
+ description="ORIGINAL REVIEWED DESCRIPTION",
+ criteria=[],
+ )
+
+ run_on_worker_loop(
+ generate_and_save_credential_metadata(resource, fields=["criteria"])
+ )
+
+ stored = CredentialMetadata.objects.get(learning_resource=resource)
+ assert stored.description == "ORIGINAL REVIEWED DESCRIPTION"
+ assert stored.criteria == [
+ "Applied conservation laws",
+ "Modelled fluid flow",
+ ]
+
+
+@pytest.mark.django_db(transaction=True)
+def test_generate_and_save_credential_metadata(
+ resource, configurations, mock_llm, mock_retrieval
+):
+ """The generated fields are stored as well as returned"""
+ generated = run_on_worker_loop(generate_and_save_credential_metadata(resource))
+
+ stored = CredentialMetadata.objects.get(learning_resource=resource)
+ assert stored.description == generated.fields["description"]
+ assert stored.criteria == generated.fields["criteria"]
+
+
+@pytest.mark.django_db(transaction=True)
+def test_generate_and_save_stores_nothing_when_nothing_generated(
+ resource, no_configurations, mock_llm, mock_retrieval
+):
+ """A run with nothing to generate leaves no row behind"""
+ generated = run_on_worker_loop(generate_and_save_credential_metadata(resource))
+
+ assert generated.fields == {}
+ assert not CredentialMetadata.objects.filter(learning_resource=resource).exists()
+
+
+@pytest.mark.django_db(transaction=True)
+def test_generate_and_save_over_several_resources_in_one_process(
+ resource, configurations, mock_llm, mock_retrieval
+):
+ """
+ A sweep generates with full context for every resource, not just the first.
+
+ This is the regression test for the sync-to-async bridge. The clients
+ retrieval reaches are @cache'd and bound to the event loop that was alive
+ when they were built, so a loop per resource (asyncio.run, async_to_sync)
+ leaves resource two driving a cached channel onto a closed loop.
+ `_retrieve_chunks` catches that exception, after which generation is
+ skipped because required course content is missing. The assertions that
+ retrieval ran once per resource and that both rows were stored therefore
+ prove every resource used the live loop.
+ """
+ second = LearningResourceFactory.create(is_course=True)
+ ContentFileFactory.create(
+ learning_resource=second,
+ file_type=MARKETING_PAGE_FILE_TYPE,
+ content=MARKETING_PAGE,
+ published=True,
+ )
+
+ for each in (resource, second):
+ run_on_worker_loop(generate_and_save_credential_metadata(each))
+
+ assert mock_retrieval.call_count == 2
+ assert CredentialMetadata.objects.count() == 2
+
+
+@pytest.mark.django_db(transaction=True)
+def test_generation_stops_without_a_marketing_page(
+ configurations, mock_llm, mock_retrieval, caplog
+):
+ """
+ A resource with no marketing page is not generated for.
+
+ Its metadata alone would still produce plausible-looking output, which is
+ the problem: there is no way to tell it apart from a result with the
+ course behind it.
+ """
+ resource = LearningResourceFactory.create(is_course=True)
+
+ generated = asyncio.run(generate_credential_metadata(resource))
+
+ assert generated.fields == {}
+ assert set(generated.errors) == {field.name for field in CredentialMetadataField}
+ assert "missing its marketing page" in generated.errors["description"]
+ # No LLM call: an incomplete course costs nothing.
+ assert mock_llm.prompts == {}
+ assert "Not generating credential metadata" in caplog.text
+
+
+@pytest.mark.django_db(transaction=True)
+def test_generation_stops_without_course_content(
+ resource, configurations, mock_llm, mocker, caplog
+):
+ """A resource with nothing indexed is not generated for"""
+ mocker.patch(
+ "learning_resources.credentials.async_content_file_chunks_for_resource",
+ return_value=[],
+ )
+
+ generated = asyncio.run(generate_credential_metadata(resource))
+
+ assert generated.fields == {}
+ assert "missing its course content" in generated.errors["criteria"]
+ assert mock_llm.prompts == {}
+ assert "Not generating credential metadata" in caplog.text
+
+
+@pytest.mark.django_db(transaction=True)
+def test_missing_context_is_a_warning_without_a_traceback(
+ configurations, mock_llm, mock_retrieval, caplog
+):
+ """
+ An incomplete course logs one warning, not an ERROR traceback.
+
+ A course whose marketing page has not been scraped yet is an ordinary
+ state of the catalogue, and during a Qdrant outage the daily sweep would
+ otherwise print two contradictory tracebacks per affected course -- the
+ retrieval failure's, and one for a decision that has no traceback of its
+ own.
+ """
+ resource = LearningResourceFactory.create(is_course=True)
+
+ with caplog.at_level(logging.DEBUG, logger="learning_resources.credentials"):
+ asyncio.run(generate_credential_metadata(resource))
+
+ records = [
+ record
+ for record in caplog.records
+ if "Not generating credential metadata" in record.message
+ ]
+ assert len(records) == 1
+ assert records[0].levelno == logging.WARNING
+ assert records[0].exc_info is None
+
+
+@pytest.mark.django_db(transaction=True)
+def test_retrieval_failure_says_generation_is_skipped(
+ resource, configurations, mock_llm, mocker, caplog
+):
+ """
+ The retrieval failure log says what now actually happens.
+
+ It used to promise generation from the metadata and marketing page alone,
+ which contradicted the abort logged right after it.
+ """
+ mocker.patch(
+ "learning_resources.credentials.async_content_file_chunks_for_resource",
+ side_effect=ConnectionError("qdrant is down"),
+ )
+
+ asyncio.run(generate_credential_metadata(resource))
+
+ assert "skipping generation" in caplog.text
+ assert "marketing page alone" not in caplog.text
+
+
+@pytest.mark.django_db(transaction=True)
+def test_generation_stops_when_retrieval_fails(
+ resource, configurations, mock_llm, mocker
+):
+ """
+ A Qdrant failure stops generation rather than degrading it.
+
+ This is the case that used to succeed quietly, and is what made a broken
+ event loop in the sweep invisible: criteria generated from marketing copy
+ with none of the course's content read the same as criteria with it.
+ """
+ mocker.patch(
+ "learning_resources.credentials.async_content_file_chunks_for_resource",
+ side_effect=ConnectionError("qdrant is down"),
+ )
+
+ generated = asyncio.run(generate_credential_metadata(resource))
+
+ assert generated.fields == {}
+ assert "missing its course content" in generated.errors["criteria"]
+ assert mock_llm.prompts == {}
+
+
+@pytest.mark.django_db(transaction=True)
+def test_generation_names_every_missing_source(
+ configurations, mock_llm, mocker, caplog
+):
+ """Both missing sources are named, so one fix does not hide the other"""
+ resource = LearningResourceFactory.create(is_course=True)
+ mocker.patch(
+ "learning_resources.credentials.async_content_file_chunks_for_resource",
+ return_value=[],
+ )
+
+ generated = asyncio.run(generate_credential_metadata(resource))
+
+ assert (
+ "missing its marketing page and course content"
+ in generated.errors["description"]
+ )
+
+
+@pytest.mark.django_db(transaction=True)
+def test_generation_without_retrieval_needs_no_content(
+ resource, no_configurations, mock_llm, mock_retrieval
+):
+ """
+ A blank retrieval_query still generates from the marketing page alone.
+
+ Empty chunks are only a missing source when content was actually asked
+ for: a configuration with no query has said the marketing page is enough,
+ and demanding content would block that setup entirely.
+ """
+ CredentialMetadataConfigurationFactory.create(
+ field=CredentialMetadataField.criteria.name, retrieval_query=""
+ )
+
+ generated = asyncio.run(generate_credential_metadata(resource))
+
+ mock_retrieval.assert_not_called()
+ assert generated.fields["criteria"]
+ assert generated.errors == {}
+
+
+@pytest.mark.django_db(transaction=True)
+def test_generate_and_save_stores_nothing_for_an_incomplete_course(
+ configurations, mock_llm, mock_retrieval
+):
+ """An incomplete course is left with no stored metadata, so it is retried"""
+ resource = LearningResourceFactory.create(is_course=True)
+
+ run_on_worker_loop(generate_and_save_credential_metadata(resource))
+
+ assert not CredentialMetadata.objects.filter(learning_resource=resource).exists()
diff --git a/learning_resources/factories.py b/learning_resources/factories.py
index 60604a3170..f856edf953 100644
--- a/learning_resources/factories.py
+++ b/learning_resources/factories.py
@@ -984,3 +984,15 @@ class CredentialMetadataGenerationLogFactory(DjangoModelFactory):
class Meta:
model = models.CredentialMetadataGenerationLog
+
+
+class CredentialMetadataFactory(DjangoModelFactory):
+ """Factory for CredentialMetadata"""
+
+ learning_resource = factory.SubFactory(LearningResourceFactory, is_course=True)
+ description = factory.Faker("sentence")
+ criteria = factory.List([factory.Faker("sentence"), factory.Faker("sentence")])
+
+ class Meta:
+ model = models.CredentialMetadata
+ django_get_or_create = ("learning_resource",)
diff --git a/learning_resources/management/commands/generate_credential_metadata.py b/learning_resources/management/commands/generate_credential_metadata.py
new file mode 100644
index 0000000000..76020706ec
--- /dev/null
+++ b/learning_resources/management/commands/generate_credential_metadata.py
@@ -0,0 +1,57 @@
+"""Management command for pre-populating credential metadata"""
+
+from django.core.management import BaseCommand
+
+from learning_resources.tasks import (
+ credential_metadata_resource_ids,
+ generate_all_credential_metadata,
+)
+
+
+class Command(BaseCommand):
+ """Generate Open Badges credential metadata for MITx Online courses"""
+
+ help = "Generate Open Badges credential metadata for MITx Online courses"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--overwrite",
+ dest="overwrite",
+ action="store_true",
+ help="Regenerate metadata for resources that already have it",
+ )
+ parser.add_argument(
+ "--dry-run",
+ dest="dry_run",
+ action="store_true",
+ help=(
+ "Print how many resources would be generated for, and exit"
+ " without spending anything"
+ ),
+ )
+
+ def handle(self, *args, **options): # noqa: ARG002
+ """Run the credential metadata sweep"""
+ overwrite = options["overwrite"]
+
+ count = credential_metadata_resource_ids(overwrite=overwrite).count()
+ if options["dry_run"]:
+ self.stdout.write(
+ f"{count} resource(s) would have credential metadata generated"
+ )
+ return
+ if not count:
+ self.stdout.write("No resources need credential metadata generation")
+ return
+
+ task = generate_all_credential_metadata.delay(overwrite=overwrite)
+ self.stdout.write(
+ f"Started task {task} to generate credential metadata for"
+ f" {count} resource(s)"
+ )
+
+ self.stdout.write(
+ "Generation runs in the background, roughly a minute per resource."
+ " Follow the celery logs for progress and completion:"
+ )
+ self.stdout.write(" docker compose logs -f celery")
diff --git a/learning_resources/migrations/0126_credential_metadata_store.py b/learning_resources/migrations/0126_credential_metadata_store.py
new file mode 100644
index 0000000000..84d9cc914a
--- /dev/null
+++ b/learning_resources/migrations/0126_credential_metadata_store.py
@@ -0,0 +1,59 @@
+# Generated by Django 5.2.17 on 2026-09-14 20:03
+
+import django.contrib.postgres.fields
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("learning_resources", "0125_website_content_resource_unique"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="CredentialMetadata",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_on", models.DateTimeField(auto_now_add=True, db_index=True)),
+ ("updated_on", models.DateTimeField(auto_now=True)),
+ (
+ "description",
+ models.TextField(
+ blank=True,
+ default="",
+ help_text="The Open Badges 3.0 description, 1-2 sentences.",
+ ),
+ ),
+ (
+ "criteria",
+ django.contrib.postgres.fields.ArrayField(
+ base_field=models.TextField(),
+ blank=True,
+ default=list,
+ help_text="Open Badges 3.0 criteria, one skill per bullet.",
+ size=None,
+ ),
+ ),
+ (
+ "learning_resource",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="credential_metadata",
+ to="learning_resources.learningresource",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/learning_resources/models.py b/learning_resources/models.py
index aced4a80f5..ea9a39c23c 100644
--- a/learning_resources/models.py
+++ b/learning_resources/models.py
@@ -1790,3 +1790,38 @@ def __str__(self):
f"{self.field} generation for"
f" {self.learning_resource.readable_id} at {self.created_on}"
)
+
+
+class CredentialMetadata(TimestampedModel):
+ """
+ The credential metadata currently in force for a learning resource.
+
+ Pre-populated by a daily sweep so that a credential can be issued without
+ waiting on (or paying for) a generation, and replaced whenever the API is
+ asked to regenerate. Distinct from CredentialMetadataGenerationLog, which
+ is the append-only history of every attempt: this is the one current value.
+
+ """
+
+ learning_resource = models.OneToOneField(
+ LearningResource,
+ on_delete=models.CASCADE,
+ related_name="credential_metadata",
+ )
+ description = models.TextField(
+ blank=True,
+ default="",
+ help_text="The Open Badges 3.0 description, 1-2 sentences.",
+ )
+ # TextField, not the CharField(max_length=N) that every other ArrayField in
+ # this module wraps: nothing on the generation path truncates a bullet, and
+ # a varchar(N)[] would raise DataError mid-sweep on an unusually long one.
+ criteria = ArrayField(
+ models.TextField(),
+ default=list,
+ blank=True,
+ help_text="Open Badges 3.0 criteria, one skill per bullet.",
+ )
+
+ def __str__(self):
+ return f"Credential metadata for {self.learning_resource.readable_id}"
diff --git a/learning_resources/models_test.py b/learning_resources/models_test.py
index 19c1a71f61..e34da454a5 100644
--- a/learning_resources/models_test.py
+++ b/learning_resources/models_test.py
@@ -19,7 +19,11 @@
LearningResourceViewEventFactory,
ProgramFactory,
)
-from learning_resources.models import ContentFile, LearningResource
+from learning_resources.models import (
+ ContentFile,
+ CredentialMetadata,
+ LearningResource,
+)
pytestmark = [pytest.mark.django_db]
@@ -193,3 +197,48 @@ def test_content_file_null_key_unique():
with pytest.raises(IntegrityError), transaction.atomic():
ContentFile.objects.create(run=run, key=None)
+
+
+def test_credential_metadata_round_trip():
+ """A stored description and criteria list read back unchanged"""
+ resource = LearningResourceFactory.create(is_course=True)
+ CredentialMetadata.objects.create(
+ learning_resource=resource,
+ description="A course about modelling fluid flow.",
+ criteria=["Applied conservation laws", "Modelled fluid flow"],
+ )
+
+ stored = LearningResource.objects.get(id=resource.id).credential_metadata
+ assert stored.description == "A course about modelling fluid flow."
+ assert stored.criteria == ["Applied conservation laws", "Modelled fluid flow"]
+
+
+def test_credential_metadata_long_criteria():
+ """
+ A criteria bullet longer than any varchar is stored whole.
+
+ criteria is a text[] rather than the varchar(N)[] every other ArrayField
+ in the module uses: nothing on the generation path truncates a bullet, so
+ a length limit would surface as a DataError mid-sweep.
+ """
+ resource = LearningResourceFactory.create(is_course=True)
+ bullet = "Demonstrated " + ("a very specific skill " * 500)
+
+ stored = CredentialMetadata.objects.create(
+ learning_resource=resource, criteria=[bullet]
+ )
+ stored.refresh_from_db()
+
+ assert stored.criteria == [bullet]
+
+
+def test_credential_metadata_deleted_with_its_resource():
+ """Metadata does not outlive the resource it describes"""
+ resource = LearningResourceFactory.create(is_course=True)
+ CredentialMetadata.objects.create(
+ learning_resource=resource, description="A course"
+ )
+
+ resource.delete()
+
+ assert not CredentialMetadata.objects.exists()
diff --git a/learning_resources/permissions.py b/learning_resources/permissions.py
index 357b3cbc2d..00ff6098f5 100644
--- a/learning_resources/permissions.py
+++ b/learning_resources/permissions.py
@@ -139,8 +139,10 @@ class IsAdminOrCourseAuthor(BasePermission):
"""
Permission for endpoints only course authors and staff may reach.
- Used to gate credential metadata generation, which spends a frontier-model
- call on a large prompt per request.
+ Used to gate the credential metadata endpoint. Generating spends a
+ frontier-model call on a large prompt per request; reading the stored
+ values is cheap, but is gated the same way because it exposes unpublished
+ draft metadata that no learner-facing surface shows yet.
"""
def has_permission(self, request, view): # noqa: ARG002
diff --git a/learning_resources/serializers_test.py b/learning_resources/serializers_test.py
index 36f71aff95..49b7f9ae1d 100644
--- a/learning_resources/serializers_test.py
+++ b/learning_resources/serializers_test.py
@@ -1679,3 +1679,21 @@ def test_get_program_courses_includes_unpublished_test_mode_children_with_flag()
titles = [r["title"] for r in result]
assert "Test Mode Course" in titles
+
+
+def test_credential_metadata_is_not_serialized():
+ """
+ Stored credential metadata stays off the public catalogue.
+
+ It is draft, author-only content behind IsAdminOrCourseAuthor. Nothing
+ excludes it by name: LearningResourceBaseSerializer.Meta uses `exclude`,
+ and DRF skips reverse one-to-ones under it. That is implicit enough to be
+ worth pinning, since a switch to `fields` or a ModelSerializer elsewhere
+ would start leaking it silently.
+ """
+ resource = LearningResourceFactory.create(is_course=True)
+ factories.CredentialMetadataFactory.create(learning_resource=resource)
+
+ data = serializers.LearningResourceSerializer(instance=resource).data
+
+ assert "credential_metadata" not in data
diff --git a/learning_resources/tasks.py b/learning_resources/tasks.py
index 57f834a8fe..483fd674ef 100644
--- a/learning_resources/tasks.py
+++ b/learning_resources/tasks.py
@@ -18,7 +18,12 @@
sync_website_content_to_learning_resource,
unpublish_website_content_learning_resource,
)
-from learning_resources.constants import LearningResourceType
+from learning_resources.constants import LearningResourceType, PlatformType
+from learning_resources.credentials_store import (
+ active_credential_metadata_fields,
+ incomplete_credential_metadata_query,
+ missing_credential_metadata_fields,
+)
from learning_resources.etl import loaders, ovs, pipelines, podcast, youtube
from learning_resources.etl.canvas import (
sync_canvas_archive,
@@ -63,7 +68,7 @@
from main.celery import app
from main.constants import ISOFORMAT
from main.decorators import cooldown_task
-from main.utils import chunks, now_in_utc
+from main.utils import chunks, now_in_utc, run_on_worker_loop
log = logging.getLogger(__name__)
@@ -1111,3 +1116,132 @@ def unpublish_website_content_learning_resource_task(content_id: int) -> None:
return
unpublish_website_content_learning_resource(content_id)
+
+
+def credential_metadata_resources(*, overwrite: bool = False):
+ """
+ Resources the credential metadata sweep should generate for.
+
+ Args:
+ overwrite (bool): include resources that already have metadata
+
+ Returns:
+ QuerySet: the matching resources, empty when no configuration is
+ active
+ """
+ active_fields = active_credential_metadata_fields()
+ if not active_fields:
+ log.warning("No active CredentialMetadataConfiguration; nothing to generate")
+ return LearningResource.objects.none()
+
+ resources = (
+ LearningResource.objects.filter(Q(published=True) | Q(test_mode=True))
+ .filter(
+ resource_type=LearningResourceType.course.name,
+ etl_source=ETLSource.mitxonline.name,
+ platform=PlatformType.mitxonline.name,
+ )
+ .exclude(readable_id__in=load_course_blocklist())
+ )
+ if not overwrite:
+ resources = resources.filter(
+ incomplete_credential_metadata_query(active_fields)
+ )
+ return resources
+
+
+def credential_metadata_resource_ids(*, overwrite: bool = False):
+ """
+ Return the ids of the resources the sweep should generate for, newest first.
+
+ Args:
+ overwrite (bool): include resources that already have metadata
+
+ Returns:
+ QuerySet: the matching resource ids, newest first
+ """
+ return (
+ credential_metadata_resources(overwrite=overwrite)
+ .order_by("-id")
+ .values_list("id", flat=True)
+ )
+
+
+@app.task(acks_late=True, reject_on_worker_lost=True)
+def generate_credential_metadata_for_resource(
+ resource_id: int, *, overwrite: bool = False
+) -> bool:
+ """
+ Generate and store credential metadata for one resource.
+
+ Only the fields the resource is actually missing are generated, unless
+ `overwrite` asks for the row to be regenerated whole.
+
+ Args:
+ resource_id (int): the resource to generate for
+ overwrite (bool): regenerate even if the resource already has
+ complete metadata
+
+ Returns:
+ bool: whether anything was stored
+ """
+
+ from learning_resources.credentials import generate_and_save_credential_metadata
+
+ resource = (
+ credential_metadata_resources(overwrite=overwrite)
+ .filter(id=resource_id)
+ .first()
+ )
+ if not resource:
+ log.info(
+ "Skipping credential metadata for resource %s:"
+ " it no longer needs generating",
+ resource_id,
+ )
+ return False
+
+ fields = (
+ None
+ if overwrite
+ else missing_credential_metadata_fields(
+ resource, active_credential_metadata_fields()
+ )
+ )
+ metadata = run_on_worker_loop(
+ generate_and_save_credential_metadata(resource, fields=fields)
+ )
+ if metadata.errors:
+ log.warning(
+ "Credential metadata for %s is missing %s",
+ resource.readable_id,
+ ", ".join(sorted(metadata.errors)),
+ )
+ return bool(metadata.fields)
+
+
+@app.task
+def generate_all_credential_metadata(*, overwrite=False) -> int:
+ """
+ Queue credential metadata generation for MITx Online courses.
+
+ Args:
+ overwrite (bool): regenerate resources that already have metadata
+
+ Returns:
+ int: how many resources were queued. Zero is the normal case for the
+ daily non-overwriting sweep once the catalogue has been filled.
+ """
+ generation_tasks = [
+ generate_credential_metadata_for_resource.si(resource_id, overwrite=overwrite)
+ for resource_id in credential_metadata_resource_ids(overwrite=overwrite)
+ ]
+ if not generation_tasks:
+ log.info("No resources need credential metadata generation")
+ return 0
+ celery.group(generation_tasks).apply_async()
+ log.info(
+ "Queued credential metadata generation for %d resource(s)",
+ len(generation_tasks),
+ )
+ return len(generation_tasks)
diff --git a/learning_resources/tasks_test.py b/learning_resources/tasks_test.py
index 6a2203868a..b4c7d3a6bf 100644
--- a/learning_resources/tasks_test.py
+++ b/learning_resources/tasks_test.py
@@ -13,12 +13,20 @@
from learning_resources import factories, models, tasks
from learning_resources.conftest import OCW_TEST_PREFIX, setup_s3, setup_s3_ocw
-from learning_resources.constants import LearningResourceType, PlatformType
+from learning_resources.constants import (
+ CredentialMetadataField,
+ LearningResourceType,
+ PlatformType,
+)
+from learning_resources.credentials import CredentialMetadata
from learning_resources.etl.constants import MARKETING_PAGE_FILE_TYPE, ETLSource
from learning_resources.etl.exceptions import ExtractException
from learning_resources.factories import (
ContentFileFactory,
+ CredentialMetadataConfigurationFactory,
+ CredentialMetadataFactory,
LearningResourceFactory,
+ LearningResourcePlatformFactory,
LearningResourceRunFactory,
)
from learning_resources.models import ContentFile, LearningResource
@@ -36,6 +44,7 @@
update_next_start_date_and_prices,
update_ocw_learning_material_resources,
)
+from main.celery import app
from main.utils import now_in_utc
pytestmark = pytest.mark.django_db
@@ -1670,3 +1679,487 @@ def test_unpublish_website_content_task_skips_a_republished_item(
tasks.unpublish_website_content_learning_resource_task.delay(content.id)
assert mock_unpublish.called is expect_removal
+
+
+def credential_metadata_course(**kwargs):
+ """Create a course the credential metadata sweep should pick up"""
+ return LearningResourceFactory.create(
+ **{
+ "is_course": True,
+ "published": True,
+ "etl_source": ETLSource.mitxonline.name,
+ "platform": LearningResourcePlatformFactory.create(
+ code=PlatformType.mitxonline.name
+ ),
+ **kwargs,
+ }
+ )
+
+
+@pytest.fixture
+def credential_configurations():
+ """
+ One active configuration per credential metadata field.
+
+ Created here rather than relying on migration 0124's seed: a transactional
+ test elsewhere deletes those rows without restoring them, and with
+ --reuse-db they then stay missing for every later run. Since the sweep's
+ scope is now derived from the active configurations, a test that assumed
+ the seed would pass or fail on test-suite history.
+ """
+ models.CredentialMetadataConfiguration.objects.all().delete()
+ return [
+ CredentialMetadataConfigurationFactory.create(field=field.name)
+ for field in CredentialMetadataField
+ ]
+
+
+@pytest.fixture
+def mock_generate_and_save(mocker):
+ """
+ Stand in for credential metadata generation inside the leaf task.
+
+ Two things are patched because the leaf builds a coroutine and then hands
+ it to the bridge: the generator, so no real LLM coroutine is created, and
+ `run_on_worker_loop`, whose return value is what the task reads. Its
+ `side_effect` is what a test varies to make one resource fail.
+ """
+ # A MagicMock, not the AsyncMock `patch` would infer for an async def:
+ # the leaf builds the coroutine and hands it to the bridge, which is
+ # mocked too, so a real coroutine here would only go unawaited.
+ generator = mocker.patch(
+ "learning_resources.credentials.generate_and_save_credential_metadata",
+ new=mocker.MagicMock(),
+ )
+ bridge = mocker.patch(
+ "learning_resources.tasks.run_on_worker_loop",
+ return_value=CredentialMetadata(fields={"description": "A course"}, errors={}),
+ )
+
+ bridge.generator = generator
+ return bridge
+
+
+def test_generate_credential_metadata_for_resource(
+ credential_configurations, mock_blocklist, mock_generate_and_save
+):
+ """The resource is generated for, and the store reports it stored something"""
+ resource = credential_metadata_course()
+
+ assert tasks.generate_credential_metadata_for_resource(resource.id) is True
+ assert mock_generate_and_save.call_count == 1
+
+
+def test_generate_credential_metadata_for_resource_raises(
+ credential_configurations, mock_blocklist, mock_generate_and_save
+):
+ """
+ A failing resource fails its own task rather than being swallowed.
+
+ With a task per resource there is nothing to protect: celery marks this
+ one failed, where a chunked version had to swallow the error to keep the
+ successes beside it. A visibly failed task is the point.
+ """
+ resource = credential_metadata_course()
+ mock_generate_and_save.side_effect = ValueError("the provider refused")
+
+ with pytest.raises(ValueError, match="the provider refused"):
+ tasks.generate_credential_metadata_for_resource(resource.id)
+
+
+def test_generate_credential_metadata_for_resource_reports_nothing_stored(
+ credential_configurations, mock_blocklist, mock_generate_and_save
+):
+ """A generation that produced nothing reports False"""
+ resource = credential_metadata_course()
+ mock_generate_and_save.return_value = CredentialMetadata(
+ fields={}, errors={"description": "litellm.APIConnectionError"}
+ )
+
+ assert tasks.generate_credential_metadata_for_resource(resource.id) is False
+
+
+def test_generate_credential_metadata_for_a_vanished_resource(
+ credential_configurations, mock_blocklist, mock_generate_and_save
+):
+ """
+ A resource deleted between the sweep and its task is skipped, not a crash.
+
+ The fan-out is a snapshot of ids, and nothing holds a lock over the hours
+ a full sweep takes.
+ """
+ assert tasks.generate_credential_metadata_for_resource(-1) is False
+ mock_generate_and_save.assert_not_called()
+
+
+def test_generate_credential_metadata_rechecks_an_unpublished_resource(
+ credential_configurations, mock_blocklist, mock_generate_and_save
+):
+ """
+ A resource unpublished after the fan-out is not generated for.
+
+ Hours can pass between the sweep's queryset and this task being picked
+ up, and a course out of scope by then is pure spend.
+ """
+ resource = credential_metadata_course()
+ resource.published = False
+ resource.save()
+
+ assert tasks.generate_credential_metadata_for_resource(resource.id) is False
+ mock_generate_and_save.assert_not_called()
+
+
+def test_generate_credential_metadata_rechecks_a_blocklisted_resource(
+ credential_configurations, mocker, mock_generate_and_save
+):
+ """A resource blocklisted after the fan-out is not generated for"""
+ resource = credential_metadata_course()
+ mocker.patch(
+ "learning_resources.tasks.load_course_blocklist",
+ return_value=[resource.readable_id],
+ )
+
+ assert tasks.generate_credential_metadata_for_resource(resource.id) is False
+ mock_generate_and_save.assert_not_called()
+
+
+def test_generate_credential_metadata_skips_a_duplicate_task(
+ credential_configurations, mock_blocklist, mock_generate_and_save
+):
+ """
+ A redelivered or duplicated task does not pay for the same resource twice.
+
+ The first delivery's row now satisfies the sweep's own predicate, so the
+ second finds nothing to do -- which is why the recheck uses that
+ predicate rather than a bare existence check.
+ """
+ resource = credential_metadata_course()
+ CredentialMetadataFactory.create(
+ learning_resource=resource, description="A course", criteria=["Did a thing"]
+ )
+
+ assert tasks.generate_credential_metadata_for_resource(resource.id) is False
+ mock_generate_and_save.assert_not_called()
+
+
+def test_generate_credential_metadata_retries_a_partial_row(
+ credential_configurations, mock_blocklist, mock_generate_and_save
+):
+ """
+ A half-generated resource is generated for again, but only for what it lacks.
+ """
+ resource = credential_metadata_course()
+ CredentialMetadataFactory.create(
+ learning_resource=resource, description="A course", criteria=[]
+ )
+
+ assert tasks.generate_credential_metadata_for_resource(resource.id) is True
+ assert mock_generate_and_save.call_count == 1
+ assert mock_generate_and_save.generator.call_args.kwargs["fields"] == ["criteria"]
+
+
+def test_generate_credential_metadata_for_an_empty_row(
+ credential_configurations, mock_blocklist, mock_generate_and_save
+):
+ """A resource with nothing stored is generated for in full"""
+ resource = credential_metadata_course()
+
+ assert tasks.generate_credential_metadata_for_resource(resource.id) is True
+ assert mock_generate_and_save.generator.call_args.kwargs["fields"] == [
+ "criteria",
+ "description",
+ ]
+
+
+def test_generate_credential_metadata_overwrite_ignores_a_complete_row(
+ credential_configurations, mock_blocklist, mock_generate_and_save
+):
+ """
+ overwrite=True regenerates a resource that already has complete metadata.
+
+ The recheck still applies -- an unpublished or blocklisted resource is
+ skipped either way -- but a complete row stops being a reason to skip.
+ """
+ resource = credential_metadata_course()
+ CredentialMetadataFactory.create(
+ learning_resource=resource, description="A course", criteria=["Did a thing"]
+ )
+
+ assert (
+ tasks.generate_credential_metadata_for_resource(resource.id, overwrite=True)
+ is True
+ )
+ assert mock_generate_and_save.call_count == 1
+ assert mock_generate_and_save.generator.call_args.kwargs["fields"] is None
+
+
+def deactivate_credential_configuration(field):
+ """Turn off one field's configuration, as an admin would"""
+ models.CredentialMetadataConfiguration.objects.filter(field=field).update(
+ is_active=False
+ )
+
+
+def test_credential_metadata_scope_follows_the_active_configurations(
+ credential_configurations, mock_blocklist
+):
+ """
+ A field with no active configuration is not a reason to regenerate.
+
+ Generation only runs is_active configurations and leaves an unconfigured
+ field out of both its fields and its errors, so that column keeps its
+ default forever. A predicate demanding every column would requeue the
+ resource on every sweep and pay to regenerate the still-active field each
+ time.
+ """
+ resource = credential_metadata_course()
+ CredentialMetadataFactory.create(
+ learning_resource=resource, description="A course", criteria=[]
+ )
+ assert list(tasks.credential_metadata_resource_ids()) == [resource.id]
+
+ deactivate_credential_configuration(CredentialMetadataField.criteria.name)
+
+ assert list(tasks.credential_metadata_resource_ids()) == []
+
+
+def test_credential_metadata_scope_with_no_active_configurations(
+ credential_configurations, mock_blocklist
+):
+ """
+ Nothing needs generating when nothing is configured to generate.
+
+ Otherwise the sweep fans out a task per course that each generate and
+ store nothing.
+ """
+ credential_metadata_course()
+ models.CredentialMetadataConfiguration.objects.update(is_active=False)
+
+ assert list(tasks.credential_metadata_resource_ids()) == []
+ assert list(tasks.credential_metadata_resource_ids(overwrite=True)) == []
+
+
+def test_generate_all_credential_metadata_with_no_active_configurations(
+ credential_configurations, mocked_celery, mock_blocklist
+):
+ """The sweep queues nothing when no configuration is active"""
+ credential_metadata_course()
+ models.CredentialMetadataConfiguration.objects.update(is_active=False)
+
+ assert tasks.generate_all_credential_metadata.delay().get() == 0
+ mocked_celery.group.assert_not_called()
+
+
+def test_generate_credential_metadata_rechecks_the_active_configurations(
+ credential_configurations, mock_blocklist, mock_generate_and_save
+):
+ """
+ A resource complete for the active fields is skipped, not regenerated.
+
+ The recheck shares the sweep's predicate, so turning a configuration off
+ stops the spend at the task as well as at the fan-out.
+ """
+ resource = credential_metadata_course()
+ CredentialMetadataFactory.create(
+ learning_resource=resource, description="A course", criteria=[]
+ )
+ deactivate_credential_configuration(CredentialMetadataField.criteria.name)
+
+ assert tasks.generate_credential_metadata_for_resource(resource.id) is False
+ mock_generate_and_save.assert_not_called()
+
+
+def test_credential_metadata_resource_ids_skips_complete_rows(
+ credential_configurations, mock_blocklist
+):
+ """A resource with both fields stored is not regenerated"""
+ complete = credential_metadata_course()
+ CredentialMetadataFactory.create(
+ learning_resource=complete, description="A course", criteria=["Did a thing"]
+ )
+ missing = credential_metadata_course()
+
+ assert list(tasks.credential_metadata_resource_ids()) == [missing.id]
+
+
+@pytest.mark.parametrize(
+ ("description", "criteria"),
+ [("A course", []), ("", ["Did a thing"]), ("", [])],
+)
+def test_credential_metadata_resource_ids_retries_partial_rows(
+ credential_configurations, mock_blocklist, description, criteria
+):
+ """
+ A half-generated resource is retried.
+
+ One field failing writes the other, so `credential_metadata__isnull=True`
+ alone would leave that resource permanently half-generated.
+ """
+ resource = credential_metadata_course()
+ CredentialMetadataFactory.create(
+ learning_resource=resource, description=description, criteria=criteria
+ )
+
+ assert list(tasks.credential_metadata_resource_ids()) == [resource.id]
+
+
+def test_credential_metadata_resource_ids_overwrite(
+ credential_configurations, mock_blocklist
+):
+ """Overwrite includes resources that already have complete metadata"""
+ complete = credential_metadata_course()
+ CredentialMetadataFactory.create(
+ learning_resource=complete, description="A course", criteria=["Did a thing"]
+ )
+
+ assert list(tasks.credential_metadata_resource_ids(overwrite=True)) == [complete.id]
+
+
+def test_credential_metadata_resource_ids_excludes_other_resources(
+ credential_configurations, mock_blocklist
+):
+ """
+ Only published MITx Online courses are swept.
+
+ All four of published, resource_type, etl_source and platform are pinned
+ because the endpoint's resolver pins them: generating for a row the API
+ will never serve is pure spend.
+ """
+ wanted = credential_metadata_course()
+ LearningResourceFactory.create(
+ is_course=True, published=True, etl_source=ETLSource.mit_edx.name
+ )
+ credential_metadata_course(published=False)
+ credential_metadata_course(is_course=False, is_program=True)
+
+ assert list(tasks.credential_metadata_resource_ids()) == [wanted.id]
+
+
+def test_credential_metadata_resource_ids_respects_the_blocklist(
+ credential_configurations, mocker
+):
+ """A blocklisted course is not generated for"""
+ blocked = credential_metadata_course()
+ wanted = credential_metadata_course()
+ mocker.patch(
+ "learning_resources.tasks.load_course_blocklist",
+ return_value=[blocked.readable_id],
+ )
+
+ assert list(tasks.credential_metadata_resource_ids()) == [wanted.id]
+
+
+def test_generate_all_credential_metadata(
+ credential_configurations, mocked_celery, mock_blocklist
+):
+ """The sweep queues one task per resource and returns the count"""
+ resources = [credential_metadata_course() for _ in range(3)]
+ expected_ids = sorted((resource.id for resource in resources), reverse=True)
+
+ queued = tasks.generate_all_credential_metadata.delay().get()
+
+ assert queued == len(resources)
+ signatures = mocked_celery.group.call_args.args[0]
+ assert [signature.args[0] for signature in signatures] == expected_ids
+ # Passed through so each task re-applies the mode the sweep ran in.
+ assert {signature.kwargs["overwrite"] for signature in signatures} == {False}
+
+
+@pytest.mark.parametrize("overwrite", [True, False])
+def test_generate_all_credential_metadata_passes_overwrite(
+ credential_configurations, mocked_celery, mock_blocklist, overwrite
+):
+ """Each per-resource task is told which mode the sweep ran in"""
+ resource = credential_metadata_course()
+ CredentialMetadataFactory.create(
+ learning_resource=resource, description="A course", criteria=["Did a thing"]
+ )
+
+ tasks.generate_all_credential_metadata.delay(overwrite=overwrite).get()
+
+ if not overwrite:
+ # the complete row takes it out of scope entirely
+ mocked_celery.group.assert_not_called()
+ return
+ signatures = mocked_celery.group.call_args.args[0]
+ assert [signature.kwargs["overwrite"] for signature in signatures] == [True]
+
+
+def test_generate_all_credential_metadata_does_not_wait(
+ credential_configurations, mocked_celery, mock_blocklist
+):
+ """
+ The sweep publishes the group and returns rather than becoming it.
+
+ `self.replace` would make this task's result the whole group's, so any
+ caller -- and CELERY_RESULT_EXPIRES' worth of result keys -- would hang on
+ hours of per-resource LLM calls to learn what each resource's own task
+ already logs.
+ """
+ credential_metadata_course()
+
+ tasks.generate_all_credential_metadata.delay().get()
+
+ mocked_celery.group.return_value.apply_async.assert_called_once_with()
+ mocked_celery.replace.assert_not_called()
+
+
+def test_generate_all_credential_metadata_with_nothing_to_do(
+ credential_configurations, mocked_celery, mock_blocklist
+):
+ """
+ An empty sweep queues nothing.
+
+ In the non-overwriting steady state an empty set is the normal case, so
+ this is the day-two path, not an edge case.
+ """
+ assert tasks.generate_all_credential_metadata.delay().get() == 0
+ mocked_celery.group.assert_not_called()
+
+
+def test_credential_metadata_task_paths_resolve():
+ """
+ The dotted path the beat schedule names exists.
+
+ It is a string in settings, so a rename is otherwise only caught at run
+ time, as a scheduled task that silently never runs.
+ """
+ assert (
+ tasks.generate_all_credential_metadata.name
+ == "learning_resources.tasks.generate_all_credential_metadata"
+ )
+ assert (
+ tasks.generate_credential_metadata_for_resource.name
+ == "learning_resources.tasks.generate_credential_metadata_for_resource"
+ )
+
+
+def test_credential_metadata_tasks_are_unrouted():
+ """
+ Both tasks run on the default queue.
+
+ Left out of task_routes rather than routed to "default" by name:
+ task_default_queue already is "default", so an entry there would only be a
+ second place to keep in step.
+ """
+ for name in (
+ tasks.generate_all_credential_metadata.name,
+ tasks.generate_credential_metadata_for_resource.name,
+ ):
+ assert name not in app.conf.task_routes
+ assert app.conf.task_default_queue == "default"
+
+
+def test_credential_metadata_leaf_task_is_acknowledged_late():
+ """
+ The per-resource task survives a lost or recycled worker.
+
+ Early acking would tell the broker the task is done before the LLM call
+ returns, so a worker recycled mid-generation would leave that course
+ ungenerated until the next daily sweep. The eligibility recheck makes the
+ redelivery safe rather than a second frontier-model call.
+ """
+ task = tasks.generate_credential_metadata_for_resource
+
+ assert task.acks_late is True
+ assert task.reject_on_worker_lost is True
diff --git a/learning_resources/views.py b/learning_resources/views.py
index 9596b378c7..236ba2bed2 100644
--- a/learning_resources/views.py
+++ b/learning_resources/views.py
@@ -40,6 +40,7 @@
PlatformType,
PrivacyLevel,
)
+from learning_resources.credentials_store import stored_credential_metadata
from learning_resources.etl.constants import ETLSource
from learning_resources.etl.podcast import generate_aggregate_podcast_rss
from learning_resources.exceptions import WebhookException
@@ -50,6 +51,7 @@
)
from learning_resources.models import (
ContentFile,
+ CredentialMetadata,
LearningResource,
LearningResourceContentTag,
LearningResourceDepartment,
@@ -1769,7 +1771,95 @@ def problem_set_file_output(problem_set_file):
}
+async def credential_metadata_resource(readable_id: str) -> LearningResource:
+ """
+ Resolve the MITx Online course a credential metadata request names.
+
+
+ Args:
+ readable_id (str): the readable id the request asked for
+
+ Returns:
+ LearningResource: the matching MITx Online course
+
+ Raises:
+ NotFound: no resource anywhere has that readable_id
+ ValidationError: a resource has it, but is not an MITx Online course
+ """
+ resource = await db_sync_to_async(
+ lambda: LearningResource.objects.filter(
+ readable_id=readable_id,
+ platform=PlatformType.mitxonline.name,
+ resource_type=LearningResourceType.course.name,
+ etl_source=ETLSource.mitxonline.name,
+ ).first()
+ )()
+ if not resource:
+ exists = await db_sync_to_async(
+ LearningResource.objects.filter(readable_id=readable_id).exists
+ )()
+ if not exists:
+ msg = f"No learning resource with readable_id {readable_id}"
+ raise NotFound(msg)
+ msg = (
+ f"Credential metadata is only generated for"
+ f" {ETLSource.mitxonline.name} courses;"
+ f" {readable_id} is not one"
+ )
+ raise ValidationError(msg)
+ return resource
+
+
+def _stored_credential_metadata_body(
+ readable_id: str, stored: CredentialMetadata | None
+) -> dict:
+ """
+ Shape a stored credential metadata row into a response body.
+
+ Shared by both handlers so that a read straight after a write cannot
+ disagree with itself. A generation stores only the fields it produced and
+ leaves the rest of the row in force, so answering a POST with the
+ generated fields alone reports a field as absent when it is stored and a
+ GET a moment later will return it. A form prepopulated from that response
+ shows nothing for the field until it is reloaded.
+
+ Args:
+ readable_id (str): the resource the metadata belongs to
+ stored (CredentialMetadata | None): the stored row, or None when
+ nothing has ever been generated for the resource
+
+ Returns:
+ dict: the readable id, plus each stored field that has a value. An
+ empty field is left out rather than sent empty -- the same rule
+ the store applies when deciding what to write.
+ """
+ return {
+ "resource_readable_id": readable_id,
+ **(
+ {"description": stored.description} if stored and stored.description else {}
+ ),
+ **({"criteria": stored.criteria} if stored and stored.criteria else {}),
+ }
+
+
@extend_schema_view(
+ get=extend_schema(
+ # A parameter, not a request serializer: a serializer renders as a
+ # request body, which a GET does not have.
+ parameters=[
+ OpenApiParameter(
+ name="resource_readable_id",
+ type=str,
+ location=OpenApiParameter.QUERY,
+ required=True,
+ description=(
+ "The readable id of the learning resource to fetch"
+ " stored metadata for"
+ ),
+ )
+ ],
+ responses=CredentialMetadataSerializer(),
+ ),
post=extend_schema(
request=CredentialMetadataRequestSerializer(),
responses=CredentialMetadataSerializer(),
@@ -1777,56 +1867,55 @@ def problem_set_file_output(problem_set_file):
)
class CredentialMetadataView(AsyncAPIView):
"""
- Generate Open Badges credential metadata for a learning resource.
+ Read or generate Open Badges credential metadata for a learning resource.
Limited to MITx Online courses.
"""
permission_classes = (permissions.IsAdminOrCourseAuthor,)
+ @extend_schema(summary="Get stored credential metadata")
+ async def get(self, request):
+
+ readable_id = request.query_params.get("resource_readable_id")
+ if not readable_id:
+ msg = "resource_readable_id is required"
+ raise ValidationError(msg)
+
+ resource = await credential_metadata_resource(readable_id)
+ stored = await db_sync_to_async(stored_credential_metadata)(resource)
+ if not stored:
+ msg = f"No credential metadata has been generated for {readable_id}"
+ raise NotFound(msg)
+
+ return Response(
+ CredentialMetadataSerializer(
+ _stored_credential_metadata_body(readable_id, stored)
+ ).data
+ )
+
@extend_schema(summary="Generate credential metadata")
async def post(self, request):
- # Imported here, not at module scope: credentials pulls in litellm and
- # langchain, and the URLconf imports this module at boot. See
- # main/boot_imports_test.py.
- from learning_resources.credentials import generate_credential_metadata
+ from learning_resources.credentials import (
+ generate_and_save_credential_metadata,
+ )
request_data = CredentialMetadataRequestSerializer(data=request.data)
if not request_data.is_valid():
return Response(request_data.errors, status=400)
readable_id = request_data.data["resource_readable_id"]
+ resource = await credential_metadata_resource(readable_id)
- resource = await db_sync_to_async(
- lambda: LearningResource.objects.filter(
- readable_id=readable_id,
- platform=PlatformType.mitxonline.name,
- resource_type=LearningResourceType.course.name,
- etl_source=ETLSource.mitxonline.name,
- ).first()
- )()
- if not resource:
- exists = await db_sync_to_async(
- LearningResource.objects.filter(readable_id=readable_id).exists
- )()
- if not exists:
- msg = f"No learning resource with readable_id {readable_id}"
- raise NotFound(msg)
- msg = (
- f"Credential metadata is only generated for"
- f" {ETLSource.mitxonline.name} courses;"
- f" {readable_id} is not one"
- )
- raise ValidationError(msg)
+ generated = await generate_and_save_credential_metadata(
+ resource, user=request.user
+ )
- generated = await generate_credential_metadata(resource, user=request.user)
+ stored = await db_sync_to_async(stored_credential_metadata)(resource)
return Response(
CredentialMetadataSerializer(
{
- "resource_readable_id": readable_id,
- **generated.fields,
- # Omitted entirely when nothing failed, rather than sent as
- # an empty object: a caller checks for the key.
+ **_stored_credential_metadata_body(readable_id, stored),
**({"errors": generated.errors} if generated.errors else {}),
}
).data
diff --git a/learning_resources/views_credential_test.py b/learning_resources/views_credential_test.py
index 549dce2f14..2670b5990b 100644
--- a/learning_resources/views_credential_test.py
+++ b/learning_resources/views_credential_test.py
@@ -11,12 +11,15 @@
PlatformType,
)
from learning_resources.credentials import RESPONSE_SCHEMAS, CredentialMetadata
-from learning_resources.etl.constants import ETLSource
+from learning_resources.etl.constants import MARKETING_PAGE_FILE_TYPE, ETLSource
from learning_resources.factories import (
+ ContentFileFactory,
CredentialMetadataConfigurationFactory,
+ CredentialMetadataFactory,
LearningResourceFactory,
LearningResourcePlatformFactory,
)
+from learning_resources.models import CredentialMetadata as CredentialMetadataModel
from learning_resources.models import (
CredentialMetadataConfiguration,
CredentialMetadataGenerationLog,
@@ -66,6 +69,11 @@ def generate(client, readable_id=None):
return client.post(credential_url(), body)
+def fetch(client, readable_id):
+ """Get the stored metadata for a resource"""
+ return client.get(credential_url(), {"resource_readable_id": readable_id})
+
+
@pytest.mark.django_db(transaction=True)
def test_credential_metadata(client, django_user_model, resource, mocker):
CredentialMetadataConfiguration.objects.all().delete()
@@ -81,9 +89,24 @@ def test_credential_metadata(client, django_user_model, resource, mocker):
ainvoke=mocker.AsyncMock(return_value=canned[schema])
)
mocker.patch("learning_resources.credentials._get_llm", return_value=llm)
+
+ ContentFileFactory.create(
+ learning_resource=resource,
+ file_type=MARKETING_PAGE_FILE_TYPE,
+ content="## About this course\n\nLearn to model fluid flow.",
+ published=True,
+ )
mocker.patch(
"learning_resources.credentials.async_content_file_chunks_for_resource",
- return_value=[],
+ return_value=[
+ {
+ "point_id": "point-1",
+ "chunk_content": "A syllabus chunk long enough to be kept.",
+ "title": "Syllabus",
+ "file_extension": ".html",
+ "file_type": "text",
+ }
+ ],
)
user = django_user_model.objects.create(is_staff=True)
client.force_login(user)
@@ -256,22 +279,252 @@ def test_credential_metadata_omits_fields_it_could_not_generate(
@pytest.mark.django_db(transaction=True)
-def test_credential_metadata_rejects_get(client, django_user_model, mock_generate):
+def test_credential_metadata_post_answers_with_the_stored_row(
+ client, django_user_model, resource, mocker
+):
+ """
+ A POST reports what is now in force, not just what this run generated.
+
+ The store merges: a field that failed keeps the value an earlier run
+ stored. Answering with the generated fields alone made a POST and the GET
+ straight after it disagree -- the POST omitted the surviving criteria, so
+ a form prepopulated from it showed none until the page was reloaded.
+ """
+ CredentialMetadataFactory.create(
+ learning_resource=resource,
+ description="Stale",
+ criteria=["PRESERVED OLD CRITERION"],
+ )
+ mocker.patch(
+ "learning_resources.credentials.generate_credential_metadata",
+ return_value=CredentialMetadata(
+ fields={"description": "NEWLY GENERATED DESCRIPTION"},
+ errors={"criteria": "provider blew up"},
+ ),
+ )
+ client.force_login(django_user_model.objects.create(is_staff=True))
+
+ generated = generate(client, resource.readable_id)
+ fetched = fetch(client, resource.readable_id)
+
+ assert generated.status_code == 200
+ assert generated.json() == {
+ "resource_readable_id": resource.readable_id,
+ "description": "NEWLY GENERATED DESCRIPTION",
+ "criteria": ["PRESERVED OLD CRITERION"],
+ "errors": {"criteria": "provider blew up"},
+ }
+ # The two handlers agree on everything but the errors, which only the
+ # generating one has to report.
+ assert fetched.json() == {
+ key: value for key, value in generated.json().items() if key != "errors"
+ }
+
+
+@pytest.mark.django_db(transaction=True)
+def test_credential_metadata_post_with_nothing_generated_or_stored(
+ client, django_user_model, resource, mocker
+):
+ """
+ A total failure against a resource with no stored row reports only errors.
+
+ Re-reading the row must not invent fields: there is nothing stored and
+ nothing was generated, so the response carries the explanations alone.
+ """
+ errors = {field.name: "provider blew up" for field in CredentialMetadataField}
+ mocker.patch(
+ "learning_resources.credentials.generate_credential_metadata",
+ return_value=CredentialMetadata(fields={}, errors=errors),
+ )
+ client.force_login(django_user_model.objects.create(is_staff=True))
+
+ response = generate(client, resource.readable_id)
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "resource_readable_id": resource.readable_id,
+ "errors": errors,
+ }
+
+
+@pytest.mark.django_db(transaction=True)
+def test_credential_metadata_get_never_generates(
+ client, django_user_model, resource, mock_generate
+):
"""
- Generation is not reachable by GET.
+ A GET serves what is stored and never generates.
- It spends money per call and writes a generation log, so it must not be
- triggerable by a prefetch, a proxy retry, or a crafted link followed by a
- logged-in author -- none of which a CSRF check on GET would stop.
+ Generation spends money per call and writes a generation log, so it must
+ not be triggerable by a prefetch, a proxy retry, or a crafted link
+ followed by a logged-in author -- none of which a CSRF check on GET would
+ stop. GET used to answer 405 for that reason; now that it reads stored
+ values instead, the money assertion is what survives from that test.
"""
+ CredentialMetadataFactory.create(learning_resource=resource, **GENERATED)
client.force_login(django_user_model.objects.create(is_staff=True))
- response = client.get(credential_url(), {"resource_readable_id": "a-course"})
+ response = fetch(client, resource.readable_id)
- assert response.status_code == 405
+ assert response.status_code == 200
mock_generate.assert_not_called()
+@pytest.mark.django_db(transaction=True)
+def test_credential_metadata_get(client, django_user_model, resource):
+ """Stored metadata is returned in the same shape the generate path uses"""
+ CredentialMetadataFactory.create(learning_resource=resource, **GENERATED)
+ client.force_login(django_user_model.objects.create(is_staff=True))
+
+ response = fetch(client, resource.readable_id)
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "resource_readable_id": resource.readable_id,
+ **GENERATED,
+ }
+
+
+@pytest.mark.django_db(transaction=True)
+def test_credential_metadata_get_partial(client, django_user_model, resource):
+ """
+ Half a stored row returns only the half that exists.
+
+ A field the generator could not produce is omitted rather than sent as a
+ blank, matching the generate path: a caller prepopulating a form must not
+ overwrite a good value with an empty one.
+ """
+ CredentialMetadataFactory.create(
+ learning_resource=resource, description="Only this one worked.", criteria=[]
+ )
+ client.force_login(django_user_model.objects.create(is_staff=True))
+
+ response = fetch(client, resource.readable_id)
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "resource_readable_id": resource.readable_id,
+ "description": "Only this one worked.",
+ }
+
+
+@pytest.mark.django_db(transaction=True)
+def test_credential_metadata_get_not_yet_generated(client, django_user_model, resource):
+ """
+ A resource with no stored metadata is a 404 that says so.
+
+ Worded differently to the unknown-readable_id 404: one means come back
+ after the sweep has run, the other means the id is wrong.
+ """
+ client.force_login(django_user_model.objects.create(is_staff=True))
+
+ response = fetch(client, resource.readable_id)
+
+ assert response.status_code == 404
+ assert "has been generated" in response.json()["detail"]
+
+
+@pytest.mark.django_db(transaction=True)
+def test_credential_metadata_get_unknown_resource(client, django_user_model):
+ """An unknown readable_id is a 404 about the resource, not the metadata"""
+ client.force_login(django_user_model.objects.create(is_staff=True))
+
+ response = fetch(client, "course-v1:MITx+nope")
+
+ assert response.status_code == 404
+ assert "No learning resource" in response.json()["detail"]
+
+
+@pytest.mark.django_db(transaction=True)
+def test_credential_metadata_get_non_mitxonline(client, django_user_model):
+ """A non-MITx Online resource is rejected on read as it is on generate"""
+ other = LearningResourceFactory.create(
+ is_course=True,
+ etl_source=ETLSource.mit_edx.name,
+ platform=LearningResourcePlatformFactory.create(code=PlatformType.edx.name),
+ )
+ client.force_login(django_user_model.objects.create(is_staff=True))
+
+ response = fetch(client, other.readable_id)
+
+ assert response.status_code == 400
+
+
+@pytest.mark.django_db(transaction=True)
+def test_credential_metadata_get_requires_a_readable_id(client, django_user_model):
+ """A GET with no readable_id is a 400, not a 500"""
+ client.force_login(django_user_model.objects.create(is_staff=True))
+
+ assert client.get(credential_url()).status_code == 400
+
+
+@pytest.mark.django_db(transaction=True)
+def test_credential_metadata_get_anonymous(client, resource):
+ """Stored metadata is author-only draft content"""
+ CredentialMetadataFactory.create(learning_resource=resource, **GENERATED)
+
+ assert fetch(client, resource.readable_id).status_code == 403
+
+
+@pytest.mark.django_db(transaction=True)
+def test_credential_metadata_get_resolves_the_mitxonline_course(
+ client, django_user_model
+):
+ """
+ A readable id shared with another platform reads the MITx Online row.
+
+ readable_id is unique only per (platform, resource_type), so the read has
+ to pin the whole key. The edX row is created first and given different
+ metadata, so a lookup that took whichever row came back first would serve
+ it.
+ """
+ readable_id = "course-v1:MITx+18.01"
+ edx_course = LearningResourceFactory.create(
+ is_course=True,
+ readable_id=readable_id,
+ etl_source=ETLSource.mit_edx.name,
+ platform=LearningResourcePlatformFactory.create(code=PlatformType.edx.name),
+ )
+ CredentialMetadataFactory.create(
+ learning_resource=edx_course, description="The edX one."
+ )
+ CredentialMetadataFactory.create(
+ learning_resource=mitxonline_course(readable_id), **GENERATED
+ )
+ client.force_login(django_user_model.objects.create(is_staff=True))
+
+ response = fetch(client, readable_id)
+
+ assert response.status_code == 200
+ assert response.json()["description"] == GENERATED["description"]
+
+
+@pytest.mark.django_db(transaction=True)
+def test_credential_metadata_post_stores_what_it_generated(
+ client, django_user_model, resource, mock_generate
+):
+ """
+ A generate request replaces the stored metadata a GET will serve.
+
+ The response shape is unchanged: storing is a side effect, so that the
+ endpoint's regenerate path and the daily sweep leave the same state.
+ """
+ CredentialMetadataFactory.create(
+ learning_resource=resource, description="Stale", criteria=["Stale"]
+ )
+ client.force_login(django_user_model.objects.create(is_staff=True))
+
+ response = generate(client, resource.readable_id)
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "resource_readable_id": resource.readable_id,
+ **GENERATED,
+ }
+ stored = CredentialMetadataModel.objects.get(learning_resource=resource)
+ assert stored.description == GENERATED["description"]
+ assert stored.criteria == GENERATED["criteria"]
+
+
@pytest.mark.django_db(transaction=True)
def test_credential_metadata_resolves_the_mitxonline_course(
client, django_user_model, mock_generate
diff --git a/main/utils.py b/main/utils.py
index 1af9870e20..22b26eee14 100644
--- a/main/utils.py
+++ b/main/utils.py
@@ -1,9 +1,11 @@
"""main utilities"""
+import asyncio
import datetime
import json
import logging
import os
+import threading
from collections.abc import Callable
from enum import Flag, auto
from functools import wraps
@@ -394,6 +396,52 @@ def wrapper(*args, **kwargs):
return sync_to_async(wrapper, thread_sensitive=False)
+_worker_loop = None
+_worker_loop_lock = threading.Lock()
+_worker_loop_pid = None
+
+
+def run_on_worker_loop(coro):
+ """
+ Run a coroutine on this process's long-lived event loop.
+
+ Not `asyncio.run` (nor `async_to_sync`, which has the same defect): both
+ create a loop per call and close it on return, and the clients these
+ coroutines reach are `@cache`d and bind to whichever loop was alive when
+ they were built. The second call in a process would then drive a cached
+ gRPC channel onto a closed loop, causing content retrieval and credential
+ generation to be skipped. One loop per process instead, so every call
+ sees the loop its cached clients were built on.
+
+ The loop is created lazily rather than at import, so that a prefork Celery
+ child builds its own instead of inheriting the parent's -- an inherited
+ loop shares its epoll fd with every sibling and is unusable. It is never
+ closed: the process outliving it is the point, and
+ CELERY_WORKER_MAX_MEMORY_PER_CHILD recycles the child, dropping the loop
+ and the clients bound to it together.
+
+ Args:
+ coro (Coroutine): the coroutine to run
+
+ Returns:
+ Any: whatever the coroutine returns
+ """
+ global _worker_loop, _worker_loop_pid # noqa: PLW0603
+
+ pid = os.getpid()
+ with _worker_loop_lock:
+ # The pid check covers the fork itself: a child that inherited a
+ # parent's loop object must not use it.
+ if _worker_loop is None or _worker_loop_pid != pid:
+ _worker_loop = asyncio.new_event_loop()
+ _worker_loop_pid = pid
+ # Run inside the lock: one loop cannot be re-entered, so a second
+ # caller in this process has to wait rather than race. Celery's
+ # prefork worker runs one task per child at a time, so nothing
+ # contends in practice.
+ return _worker_loop.run_until_complete(coro)
+
+
def chunks(iterable, *, chunk_size=20):
"""
Yields chunks of an iterable as sub lists each of max size chunk_size.
diff --git a/main/utils_test.py b/main/utils_test.py
index 27a9399364..104705177d 100644
--- a/main/utils_test.py
+++ b/main/utils_test.py
@@ -17,6 +17,7 @@
from rest_framework.test import APIRequestFactory, force_authenticate
from rest_framework.views import APIView
+from main import utils as main_utils
from main.constants import (
ALLOWED_HTML_ATTRIBUTES_WITH_LINKS,
ALLOWED_HTML_TAGS_WITH_LINKS,
@@ -41,6 +42,7 @@
normalize_to_start_of_day,
now_in_utc,
prefetched_iterator,
+ run_on_worker_loop,
write_to_file,
)
@@ -692,3 +694,111 @@ def test_call_fastly_purge_api_soft_header(mocker, settings, soft):
headers = mock_request.call_args.kwargs["headers"]
assert headers.get("Fastly-Soft-Purge") == ("1" if soft else None)
+
+
+def test_run_on_worker_loop_returns_the_result():
+ """A coroutine's return value comes back to the sync caller"""
+
+ async def coro():
+ return "done"
+
+ assert run_on_worker_loop(coro()) == "done"
+
+
+def test_run_on_worker_loop_propagates_an_exception():
+ """A failing coroutine raises in the caller rather than being swallowed"""
+
+ async def coro():
+ msg = "nope"
+ raise ValueError(msg)
+
+ with pytest.raises(ValueError, match="nope"):
+ run_on_worker_loop(coro())
+
+
+def test_run_on_worker_loop_reuses_one_loop():
+ """
+ Every call in a process runs on the same loop.
+
+ This is the whole point of the helper. asyncio.run (and async_to_sync)
+ create a loop per call and close it on return, while the Qdrant clients
+ bind to the loop alive when they were built. Call two would drive a
+ cached gRPC channel onto a closed loop, so retrieval fails and generation
+ is skipped rather than producing metadata. The helper keeps subsequent
+ calls on the original live loop.
+ """
+
+ async def which_loop():
+ return asyncio.get_running_loop()
+
+ first = run_on_worker_loop(which_loop())
+ second = run_on_worker_loop(which_loop())
+
+ assert first is second
+ assert not first.is_closed()
+
+
+@pytest.fixture
+def isolated_worker_loop(monkeypatch):
+ """
+ Give a test the worker loop's module state to itself.
+
+ The helper holds its loop in a module global and deliberately never
+ closes it -- the process outliving it is the point -- so a test that
+ drives it has to reset that state going in and close what it created
+ coming out, rather than leaking an epoll fd into the rest of the suite.
+
+ Yields:
+ list: every loop the helper created during the test
+ """
+ monkeypatch.setattr(main_utils, "_worker_loop", None)
+ monkeypatch.setattr(main_utils, "_worker_loop_pid", None)
+ created = []
+ new_event_loop = asyncio.new_event_loop
+
+ def tracked_new_event_loop():
+ loop = new_event_loop()
+ created.append(loop)
+ return loop
+
+ monkeypatch.setattr(asyncio, "new_event_loop", tracked_new_event_loop)
+ yield created
+ for loop in created:
+ loop.close()
+
+
+def test_run_on_worker_loop_does_not_reuse_an_inherited_loop(
+ mocker, isolated_worker_loop
+):
+ """
+ A forked child builds its own loop instead of running on the parent's.
+
+ This is the prefork-safety branch, and the reason the loop is created
+ lazily: a Celery prefork child inherits the parent's loop object, whose
+ epoll fd it shares with every sibling. Running on it there is the same
+ unusable-loop failure the helper exists to prevent, and just as silent,
+ since content retrieval swallows it. A pid that stands still between
+ calls cannot exercise this, so the pid moves here instead of forking.
+ """
+
+ async def which_loop():
+ return asyncio.get_running_loop()
+
+ process = {"pid": 1111}
+ # A callable rather than a side_effect list: os.getpid is shared with
+ # everything else running during the test, so an extra call must not
+ # exhaust an iterator.
+ mocker.patch("main.utils.os.getpid", side_effect=lambda: process["pid"])
+
+ parent_loop = run_on_worker_loop(which_loop())
+ process["pid"] = 2222
+ child_loop = run_on_worker_loop(which_loop())
+ child_loop_again = run_on_worker_loop(which_loop())
+
+ assert child_loop is not parent_loop
+ # The child keeps its own loop across calls, exactly as the parent did.
+ assert child_loop_again is child_loop
+ assert len(isolated_worker_loop) == 2
+ # The inherited loop is dropped, never closed: its fd belongs to the
+ # parent too, and closing it in a child would break the parent's.
+ assert not parent_loop.is_closed()
diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml
index c1b7ac65c5..adfed4b995 100644
--- a/openapi/specs/v0.yaml
+++ b/openapi/specs/v0.yaml
@@ -161,10 +161,34 @@ paths:
'429':
description: Rate limit exceeded
/api/v0/credential_metadata/:
+ get:
+ operationId: credential_metadata_retrieve
+ description: |-
+ Read or generate Open Badges credential metadata for a learning resource.
+
+ Limited to MITx Online courses.
+ summary: Get stored credential metadata
+ parameters:
+ - in: query
+ name: resource_readable_id
+ schema:
+ type: string
+ description: The readable id of the learning resource to fetch stored metadata
+ for
+ required: true
+ tags:
+ - credential_metadata
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CredentialMetadata'
+ description: ''
post:
operationId: credential_metadata_create
description: |-
- Generate Open Badges credential metadata for a learning resource.
+ Read or generate Open Badges credential metadata for a learning resource.
Limited to MITx Online courses.
summary: Generate credential metadata
From 4f51124d6fca5800fd90bbcfc5dbacbf9f2c2e57 Mon Sep 17 00:00:00 2001
From: Doof
Date: Wed, 23 Sep 2026 14:54:09 +0000
Subject: [PATCH 4/4] Release 0.80.15
---
RELEASE.rst | 7 +++++++
main/settings.py | 2 +-
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/RELEASE.rst b/RELEASE.rst
index e05a6b42e7..da572a6b2b 100644
--- a/RELEASE.rst
+++ b/RELEASE.rst
@@ -1,6 +1,13 @@
Release Notes
=============
+Version 0.80.15
+---------------
+
+- credential metadata task (#3950)
+- Skip unreferenced static files when ingesting edX course archives (#3942)
+- Mark the Django session cookie Secure by default (#3968)
+
Version 0.80.14
---------------
diff --git a/main/settings.py b/main/settings.py
index b71254711e..731bce5ca2 100644
--- a/main/settings.py
+++ b/main/settings.py
@@ -36,7 +36,7 @@
from main.settings_pluggy import * # noqa: F403
from openapi.settings_spectacular import open_spectacular_settings
-VERSION = "0.80.14"
+VERSION = "0.80.15"
log = logging.getLogger()