From a313f075710cb2e3f9c72888bcaa1058a09d2e82 Mon Sep 17 00:00:00 2001 From: Peter Pinch Date: Thu, 10 Sep 2026 08:25:36 -0400 Subject: [PATCH 1/9] Update certificate description in Product Page CertificateTrackCard (#3924) The Institute has strict guidelines around the use of the MIT name --- .../src/app-pages/ProductPages/CertificateTrackCard.test.tsx | 2 +- .../main/src/app-pages/ProductPages/CertificateTrackCard.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.test.tsx b/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.test.tsx index f5f91fe2a4..d336183032 100644 --- a/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.test.tsx @@ -31,7 +31,7 @@ describe("CertificateTrackCard", () => { ).toBeInTheDocument() expect(screen.getByText("Graded assignments & exams")).toBeInTheDocument() expect( - screen.getByText("MIT certificate on completion"), + screen.getByText("MIT Open Learning certificate of completion"), ).toBeInTheDocument() }) diff --git a/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.tsx b/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.tsx index f8e5529f56..85cee2b0ac 100644 --- a/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.tsx +++ b/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.tsx @@ -92,7 +92,7 @@ const CertificateTrackCard: React.FC = ({ ) From 743bb2a17cb7c7cf212981d649551476a086a6d3 Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Thu, 10 Sep 2026 09:14:58 -0400 Subject: [PATCH 2/9] Skip staff-only OLX content when ingesting edX course archives (#3909) --- learning_resources/etl/edx_shared.py | 70 +++++++- learning_resources/etl/edx_shared_test.py | 156 ++++++++++++++++++ learning_resources/etl/utils.py | 66 +++++++- learning_resources/etl/utils_test.py | 130 +++++++++++++++ .../commands/unpublish_staff_only_files.py | 69 ++++++++ learning_resources/tasks.py | 66 ++++++-- learning_resources/tasks_test.py | 37 +++++ main/celery.py | 2 + 8 files changed, 574 insertions(+), 22 deletions(-) create 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 b5666ff9d1..71fff4dbf3 100644 --- a/learning_resources/etl/edx_shared.py +++ b/learning_resources/etl/edx_shared.py @@ -1,11 +1,12 @@ """Shared functions for EdX sites""" import logging +import tarfile from itertools import chain from pathlib import Path -from tarfile import ReadError from tempfile import TemporaryDirectory +from defusedxml import ElementTree from django.conf import settings from django.core.cache import caches from django.db.models import Prefetch, Q @@ -15,10 +16,12 @@ from learning_resources.etl.utils import ( calc_checksum, 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 LearningResourceRun +from learning_resources.models import ContentFile, LearningResourceRun log = logging.getLogger(__name__) @@ -138,7 +141,7 @@ def process_course_archive( bucket.download_file(key, course_tarpath) try: checksum = calc_checksum(course_tarpath) - except ReadError: + except tarfile.ReadError: log.exception("Error reading tar file %s, skipping", course_tarpath) return True if run.checksum == checksum and not overwrite: @@ -364,3 +367,64 @@ def sync_edx_course_files( skipped, processed, ) + + +def unpublish_staff_only_content_files( + etl_source: str, ids: list[int], keys: list[str] +) -> int: + """ + Unpublish (and deindex) content files under staff-only OLX subtrees 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 + + Returns: + int: number of content files unpublished + """ + 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 + 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] + with TemporaryDirectory() as tempdir: + tarpath = Path(tempdir, key.rsplit("/", maxsplit=1)[-1]) + bucket.download_file(key, tarpath) + try: + with tarfile.open(tarpath) as tar: + tar.extractall(tempdir, filter="data") + except tarfile.ReadError: + log.exception("Error extracting %s, skipping", key) + continue + olx_path = next((p for p in Path(tempdir).iterdir() if p.is_dir()), None) + if olx_path is None: + continue + try: + hidden_paths = staff_only_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: + 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(): + search_tasks.deindex_run_content_files.delay(run.id, unpublished_only=True) + vector_tasks.remove_unpublished_run_content_files.delay(run.id) + return total diff --git a/learning_resources/etl/edx_shared_test.py b/learning_resources/etl/edx_shared_test.py index e0a8bc8a5c..1d9cc34aa6 100644 --- a/learning_resources/etl/edx_shared_test.py +++ b/learning_resources/etl/edx_shared_test.py @@ -4,9 +4,11 @@ import tarfile from datetime import datetime from pathlib import Path +from types import SimpleNamespace from zoneinfo import ZoneInfo import pytest +from defusedxml import ElementTree from learning_resources.constants import PlatformType from learning_resources.etl.constants import ETLSource @@ -17,6 +19,7 @@ normalize_run_id, process_course_archive, sync_edx_course_files, + unpublish_staff_only_content_files, ) from learning_resources.etl.utils import get_edx_module_id, get_s3_prefix_for_source from learning_resources.factories import ( @@ -1647,3 +1650,156 @@ def test_process_course_archive_all_failures_not_marked_empty(mocker, tmp_path): run.refresh_from_db() assert run.archive_key is None assert run.checksum is None + + +def _staff_only_archive(tmp_path) -> Path: + """Build a tar.gz OLX export with one visible and one staff-only chapter""" + olx = tmp_path / "course" + files = { + "course.xml": '', + "course/run.xml": '', + "chapter/ok.xml": '', + "sequential/seq_ok.xml": '', + "vertical/v_ok.xml": '', + "html/h_ok.xml": '', + "html/h_ok.html": "

ok

", + "chapter/staff.xml": ( + '' + ), + "sequential/seq_staff.xml": '', + "vertical/v_staff.xml": '', + "html/h_staff.xml": '', + "html/h_staff.html": "

staff

", + } + for rel, text in files.items(): + (olx / rel).parent.mkdir(parents=True, exist_ok=True) + (olx / rel).write_text(text) + tarpath = tmp_path / "course.tar.gz" + with tarfile.open(tarpath, "w:gz") as tar: + tar.add(olx, arcname="course") + return tarpath + + +@pytest.fixture +def staff_only_run(mock_course_archive_bucket, mocker, tmp_path): + """Create a mitxonline run whose mock-bucket archive has a staff-only chapter""" + mocker.patch( + "learning_resources.etl.edx_shared.get_bucket_by_name", + return_value=mock_course_archive_bucket.bucket, + ) + source = ETLSource.mitxonline.name + course = LearningResourceFactory.create( + etl_source=source, is_course=True, published=True, create_runs=False + ) + run = LearningResourceRunFactory.create(learning_resource=course, published=True) + key = f"{get_s3_prefix_for_source(source)}/{run.run_id}/foo.tar.gz" + mock_course_archive_bucket.bucket.put_object( + Key=key, Body=_staff_only_archive(tmp_path).read_bytes() + ) + return SimpleNamespace(source=source, course=course, run=run, key=key) + + +@pytest.fixture +def mock_deindex_tasks(mocker): + """Mock the OpenSearch and Qdrant deindex task dispatchers""" + return SimpleNamespace( + opensearch=mocker.patch( + "learning_resources_search.tasks.deindex_run_content_files.delay" + ), + qdrant=mocker.patch( + "vector_search.tasks.remove_unpublished_run_content_files.delay" + ), + ) + + +def test_unpublish_staff_only_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( + learning_resource=staff_only_run.course, published=True + ) + run_keys = { + name: get_edx_module_id(f"course/{name}", run) + for name in ("html/h_ok.xml", "html/h_staff.xml") + } + for cf_key in run_keys.values(): + ContentFileFactory.create(run=run, key=cf_key, published=True) + # same key strings on another run must not be touched + for cf_key in run_keys.values(): + ContentFileFactory.create(run=other_run, key=cf_key, published=True) + + unpublished = unpublish_staff_only_content_files( + staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key] + ) + + assert unpublished == 1 + assert not ContentFile.objects.filter( + run=run, key=run_keys["html/h_staff.xml"], published=True + ).exists() + assert ContentFile.objects.filter( + run=run, key=run_keys["html/h_ok.xml"], published=True + ).exists() + assert ContentFile.objects.filter(run=other_run, published=True).count() == 2 + 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_staff_only_content_files_nothing_hidden( + staff_only_run, mock_deindex_tasks +): + """No deindex tasks are queued when a run has no staff-only content files""" + run = staff_only_run.run + ContentFileFactory.create( + run=run, key=get_edx_module_id("course/html/h_ok.xml", run), published=True + ) + + assert ( + unpublish_staff_only_content_files( + staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key] + ) + == 0 + ) + mock_deindex_tasks.opensearch.assert_not_called() + + +def test_unpublish_staff_only_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", + side_effect=ElementTree.ParseError("bad"), + ) + ContentFileFactory.create( + run=staff_only_run.run, + key=get_edx_module_id("course/html/h_staff.xml", staff_only_run.run), + published=True, + ) + + assert ( + unpublish_staff_only_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( + staff_only_run, mock_deindex_tasks +): + """A re-run with already-unpublished hidden files still queues the deindex tasks""" + run = staff_only_run.run + ContentFileFactory.create( + run=run, key=get_edx_module_id("course/html/h_staff.xml", run), published=False + ) + + assert ( + unpublish_staff_only_content_files( + staff_only_run.source, [staff_only_run.course.id], [staff_only_run.key] + ) + == 0 + ) + mock_deindex_tasks.opensearch.assert_called_once_with(run.id, unpublished_only=True) + mock_deindex_tasks.qdrant.assert_called_once_with(run.id) diff --git a/learning_resources/etl/utils.py b/learning_resources/etl/utils.py index 2dd4a5be21..bd2a76eb41 100644 --- a/learning_resources/etl/utils.py +++ b/learning_resources/etl/utils.py @@ -319,11 +319,72 @@ def parse_dates(date_string, hour=12): return None +def _hidden_block_files(root: Path, tag: str, url_name: str, element) -> set[Path]: + """Files belonging to one hidden block: its own files, html body, transcripts""" + files = set(root.glob(f"{tag}/{url_name}.*")) + if tag == "html" and element.get("filename"): + files.update(root.glob(f"html/{element.get('filename')}.*")) + if tag == "video": + files.update( + root / "static" / transcript.get("src") + for transcript in element.iter("transcript") + if transcript.get("src") + ) + return files + + +def _parse_olx_block(root: Path, tag: str, url_name: str): + """ + Parse /.xml, returning None if missing. Malformed XML raises + so a course is never ingested with unverified staff-only status. + """ + try: + return ElementTree.parse(root / tag / f"{url_name}.xml").getroot() + except (FileNotFoundError, NotADirectoryError): + return None + + +def staff_only_olx_paths(olx_path: str | Path) -> set[Path]: + """ + Return the files under visible_to_staff_only="true" subtrees of an OLX + course tree, including transcripts of hidden videos. Empty when olx_path + is not an OLX export (no course.xml). Blocks may be pointers to + /.xml or hold their children inline; both are walked. + """ + root = Path(olx_path) + course = _parse_olx_block(root, "", "course") + if course is None: + return set() + hidden: set[Path] = set() + seen: set[tuple[str, str]] = 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: + element = pointer + staff_only = staff_only or "true" in ( + 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)) + stack.extend( + (child, child.tag, child.get("url_name"), staff_only) for child in element + ) + return hidden + + def documents_from_olx( olx_path: str, valid_file_types: list[str] = VALID_TEXT_FILE_TYPES ) -> Generator[tuple, None, None]: """ - Extract text from OLX directory + Extract text from OLX directory, skipping staff-only content Args: olx_path (str): The path to the directory with the OLX data @@ -331,11 +392,14 @@ def documents_from_olx( Yields: tuple: A list of (bytes of content, metadata) """ + staff_only = staff_only_olx_paths(olx_path) for root, _, files in os.walk(olx_path): path = "/".join(root.split("/")[3:]) for filename in files: extension_lower = Path(filename).suffix.lower() + if Path(root, filename) in staff_only: + continue if extension_lower in valid_file_types and "draft" not in root: with Path.open(Path(root, filename), "rb") as f: filebytes = f.read() diff --git a/learning_resources/etl/utils_test.py b/learning_resources/etl/utils_test.py index 805c714a56..3b68b15abf 100644 --- a/learning_resources/etl/utils_test.py +++ b/learning_resources/etl/utils_test.py @@ -8,6 +8,7 @@ import pypdf import pytest +from defusedxml import ElementTree from learning_resources.constants import ( CONTENT_TYPE_FILE, @@ -294,6 +295,135 @@ def test_documents_from_olx(): assert formula2do[1]["mime_type"].endswith("/xml") +def _write_olx(root, rel, text): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +def test_documents_from_olx_skips_staff_only_subtrees(tmp_path): + """Files under visible_to_staff_only subtrees (and their transcripts) are skipped""" + olx = tmp_path / "course" + _write_olx(olx, "course.xml", '') + _write_olx( + olx, + "course/run.xml", + '', + ) + _write_olx( + olx, "chapter/ch_ok.xml", '' + ) + _write_olx( + olx, + "sequential/seq_ok.xml", + '', + ) + _write_olx(olx, "vertical/v_ok.xml", '') + _write_olx(olx, "html/h_ok.xml", '') + _write_olx(olx, "html/h_ok.html", "

visible

") + _write_olx( + olx, + "vertical/v_staff.xml", + '', + ) + _write_olx(olx, "problem/p_staff.xml", "") + _write_olx( + olx, + "chapter/ch_staff.xml", + '', + ) + _write_olx( + olx, + "sequential/seq_staff.xml", + '', + ) + _write_olx( + olx, + "vertical/v_hidden.xml", + '', + ) + _write_olx(olx, "html/h_hidden.xml", '') + _write_olx(olx, "html/h_hidden_file.html", "

hidden

") + _write_olx( + olx, + "video/vid_hidden.xml", + '', + ) + _write_olx(olx, "static/hidden.srt", "1\n00:00:00,000 --> 00:00:01,000\nhidden\n") + _write_olx(olx, "static/visible.srt", "1\n00:00:00,000 --> 00:00:01,000\nvisible\n") + _write_olx(olx, "tabs/syllabus.html", "

tab

") + + prefix = "/".join(str(olx).split("/")[3:]) + "/" + paths = sorted( + meta["source_path"].removeprefix(prefix) + for _, meta in utils.documents_from_olx(str(olx)) + ) + assert paths == [ + "chapter/ch_ok.xml", + "course.xml", + "course/run.xml", + "html/h_ok.html", + "html/h_ok.xml", + "sequential/seq_ok.xml", + "static/visible.srt", + "tabs/syllabus.html", + "vertical/v_ok.xml", + ] + + +def test_documents_from_olx_skips_staff_only_inline_structure(tmp_path): + """Structure held inline in course.xml (no pointer files) is still filtered""" + olx = tmp_path / "course" + _write_olx( + olx, + "course.xml", + '' + '' + '' + '" + "", + ) + _write_olx(olx, "html/h_ok.xml", '') + _write_olx(olx, "html/h_ok.html", "

visible

") + _write_olx(olx, "html/h_staff.xml", '') + _write_olx(olx, "html/h_staff.html", "

hidden

") + _write_olx( + olx, + "video/vid_staff.xml", + '', + ) + _write_olx(olx, "static/hidden.srt", "1\n00:00:00,000 --> 00:00:01,000\nhidden\n") + + prefix = "/".join(str(olx).split("/")[3:]) + "/" + paths = sorted( + meta["source_path"].removeprefix(prefix) + for _, meta in utils.documents_from_olx(str(olx)) + ) + assert paths == ["course.xml", "html/h_ok.html", "html/h_ok.xml"] + + +@pytest.mark.parametrize("bad_file", ["course.xml", "chapter/a.xml"]) +def test_documents_from_olx_malformed_block_fails_closed(tmp_path, bad_file): + """A malformed block raises rather than ingesting files with unchecked visibility""" + olx = tmp_path / "course" + _write_olx(olx, "course.xml", '') + _write_olx(olx, "course/run.xml", '') + _write_olx(olx, "chapter/a.xml", '') + _write_olx(olx, bad_file, "') + _write_olx(olx, "web_resources/b.html", "

b

") + paths = [meta["source_path"] for _, meta in utils.documents_from_olx(str(olx))] + assert len(paths) == 2 + + @pytest.mark.parametrize( ("etl_source", "expected_setting"), [ diff --git a/learning_resources/management/commands/unpublish_staff_only_files.py b/learning_resources/management/commands/unpublish_staff_only_files.py new file mode 100644 index 0000000000..a03e57d2c8 --- /dev/null +++ b/learning_resources/management/commands/unpublish_staff_only_files.py @@ -0,0 +1,69 @@ +"""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 7f4f824b17..7cc3a6df4f 100644 --- a/learning_resources/tasks.py +++ b/learning_resources/tasks.py @@ -29,6 +29,7 @@ get_most_recent_course_archives, sync_edx_archive, sync_edx_course_files, + unpublish_staff_only_content_files, ) from learning_resources.etl.loaders import ( load_learning_materials, @@ -197,25 +198,8 @@ def get_content_tasks( if chunk_size is None: chunk_size = settings.LEARNING_COURSE_ITERATOR_CHUNK_SIZE - blocklisted_ids = load_course_blocklist() archive_keys = get_most_recent_course_archives(etl_source) - - if learning_resource_ids: - learning_resources = ( - LearningResource.objects.filter( - id__in=learning_resource_ids, etl_source=etl_source - ) - .order_by("-id") - .values_list("id", flat=True) - ) - else: - learning_resources = ( - LearningResource.objects.filter(Q(published=True) | Q(test_mode=True)) - .filter(course__isnull=False, etl_source=etl_source) - .exclude(readable_id__in=blocklisted_ids) - .order_by("-id") - .values_list("id", flat=True) - ) + learning_resources = _content_file_resource_ids(etl_source, learning_resource_ids) return celery.group( [ @@ -228,6 +212,52 @@ def get_content_tasks( ) +def _content_file_resource_ids(etl_source: str, learning_resource_ids): + """Course ids whose archives should be processed for an edX source""" + if learning_resource_ids: + return ( + LearningResource.objects.filter( + id__in=learning_resource_ids, etl_source=etl_source + ) + .order_by("-id") + .values_list("id", flat=True) + ) + return ( + LearningResource.objects.filter(Q(published=True) | Q(test_mode=True)) + .filter(course__isnull=False, etl_source=etl_source) + .exclude(readable_id__in=load_course_blocklist()) + .order_by("-id") + .values_list("id", flat=True) + ) + + +@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) + + +@app.task(bind=True) +def unpublish_all_staff_only_files( + self, *, etl_source, chunk_size=None, learning_resource_ids=None +): + """Fan out unpublish_staff_only_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) + 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, + ) + ] + ) + ) + + @app.task(bind=True) def import_all_mit_edx_files( self, *, chunk_size=None, overwrite=False, learning_resource_ids=None diff --git a/learning_resources/tasks_test.py b/learning_resources/tasks_test.py index 13a005f872..5ac6ca66ad 100644 --- a/learning_resources/tasks_test.py +++ b/learning_resources/tasks_test.py @@ -1551,3 +1551,40 @@ def test_get_podcast_transcripts(mocker): mock_etl_podcast.get_podcast_transcripts.assert_called_once_with( mock_etl_podcast.get_podcast_episodes_for_transcripts_job.return_value ) + + +@mock_aws +def test_unpublish_all_staff_only_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") + mocker.patch("learning_resources.tasks.load_course_blocklist", return_value=[]) + mocker.patch( + "learning_resources.tasks.get_most_recent_course_archives", + return_value=["foo.tar.gz"], + ) + setup_s3(settings) + etl_source = ETLSource.mitxonline.name + courses = factories.CourseFactory.create_batch( + 3, etl_source=etl_source, platform=PlatformType.mitxonline.name + ) + with pytest.raises(mocked_celery.replace_exception_class): + tasks.unpublish_all_staff_only_files.delay( + etl_source=etl_source, chunk_size=2, learning_resource_ids=None + ) + assert mock_task.call_count == 2 + 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"]) + + +def test_unpublish_staff_only_files_task(mocker): + """unpublish_staff_only_files task delegates to edx_shared""" + mock_fn = mocker.patch( + "learning_resources.tasks.unpublish_staff_only_content_files", return_value=3 + ) + assert tasks.unpublish_staff_only_files([1, 2], "mitxonline", ["k"]) == 3 + mock_fn.assert_called_once_with("mitxonline", [1, 2], ["k"]) diff --git a/main/celery.py b/main/celery.py index f1fb14c339..6a83648815 100644 --- a/main/celery.py +++ b/main/celery.py @@ -30,6 +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_search.tasks.index_run_content_files": {"queue": "edx_content"}, "learning_resources_search.tasks.deindex_run_content_files": { "queue": "edx_content" From 56fbdc75a0447bc50af30762f65c4b00b48a7bc0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:24:50 -0400 Subject: [PATCH 3/9] Update dependency sharp to v0.35.4 [SECURITY] (#3917) --- frontends/main/package.json | 2 +- yarn.lock | 253 ++++++++++++++++++------------------ 2 files changed, 129 insertions(+), 126 deletions(-) diff --git a/frontends/main/package.json b/frontends/main/package.json index cb33fd70b4..f98729719f 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -72,7 +72,7 @@ "react-hotkeys-hook": "^5.2.1", "react-markdown": "^10.0.0", "react-slick": "^0.31.0", - "sharp": "0.35.0", + "sharp": "0.35.4", "slick-carousel": "^1.8.1", "tiny-invariant": "^1.3.3", "video.js": "^8.23.7", diff --git a/yarn.lock b/yarn.lock index 9dfb70c47c..9b36ffbb49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2024,7 +2024,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.11.0": +"@emnapi/runtime@npm:^1.11.3": version: 1.11.3 resolution: "@emnapi/runtime@npm:1.11.3" dependencies: @@ -2646,11 +2646,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-darwin-arm64@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-darwin-arm64@npm:0.35.0" +"@img/sharp-darwin-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-darwin-arm64@npm:0.35.4" dependencies: - "@img/sharp-libvips-darwin-arm64": "npm:1.3.0" + "@img/sharp-libvips-darwin-arm64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-darwin-arm64": optional: true @@ -2670,11 +2670,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-darwin-x64@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-darwin-x64@npm:0.35.0" +"@img/sharp-darwin-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-darwin-x64@npm:0.35.4" dependencies: - "@img/sharp-libvips-darwin-x64": "npm:1.3.0" + "@img/sharp-libvips-darwin-x64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-darwin-x64": optional: true @@ -2682,11 +2682,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-freebsd-wasm32@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-freebsd-wasm32@npm:0.35.0" +"@img/sharp-freebsd-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-freebsd-wasm32@npm:0.35.4" dependencies: - "@img/sharp-wasm32": "npm:0.35.0" + "@img/sharp-wasm32": "npm:0.35.4" conditions: os=freebsd languageName: node linkType: hard @@ -2698,9 +2698,9 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-darwin-arm64@npm:1.3.0": - version: 1.3.0 - resolution: "@img/sharp-libvips-darwin-arm64@npm:1.3.0" +"@img/sharp-libvips-darwin-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-darwin-arm64@npm:1.3.3" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -2712,9 +2712,9 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-darwin-x64@npm:1.3.0": - version: 1.3.0 - resolution: "@img/sharp-libvips-darwin-x64@npm:1.3.0" +"@img/sharp-libvips-darwin-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-darwin-x64@npm:1.3.3" conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -2726,9 +2726,9 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linux-arm64@npm:1.3.0": - version: 1.3.0 - resolution: "@img/sharp-libvips-linux-arm64@npm:1.3.0" +"@img/sharp-libvips-linux-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-arm64@npm:1.3.3" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard @@ -2740,9 +2740,9 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linux-arm@npm:1.3.0": - version: 1.3.0 - resolution: "@img/sharp-libvips-linux-arm@npm:1.3.0" +"@img/sharp-libvips-linux-arm@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-arm@npm:1.3.3" conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard @@ -2754,9 +2754,9 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linux-ppc64@npm:1.3.0": - version: 1.3.0 - resolution: "@img/sharp-libvips-linux-ppc64@npm:1.3.0" +"@img/sharp-libvips-linux-ppc64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-ppc64@npm:1.3.3" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard @@ -2768,9 +2768,9 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linux-riscv64@npm:1.3.0": - version: 1.3.0 - resolution: "@img/sharp-libvips-linux-riscv64@npm:1.3.0" +"@img/sharp-libvips-linux-riscv64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-riscv64@npm:1.3.3" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard @@ -2782,9 +2782,9 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linux-s390x@npm:1.3.0": - version: 1.3.0 - resolution: "@img/sharp-libvips-linux-s390x@npm:1.3.0" +"@img/sharp-libvips-linux-s390x@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-s390x@npm:1.3.3" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard @@ -2796,9 +2796,9 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linux-x64@npm:1.3.0": - version: 1.3.0 - resolution: "@img/sharp-libvips-linux-x64@npm:1.3.0" +"@img/sharp-libvips-linux-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-x64@npm:1.3.3" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard @@ -2810,9 +2810,9 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linuxmusl-arm64@npm:1.3.0": - version: 1.3.0 - resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.3.0" +"@img/sharp-libvips-linuxmusl-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.3.3" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard @@ -2824,9 +2824,9 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linuxmusl-x64@npm:1.3.0": - version: 1.3.0 - resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.3.0" +"@img/sharp-libvips-linuxmusl-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.3.3" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard @@ -2843,11 +2843,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-arm64@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-linux-arm64@npm:0.35.0" +"@img/sharp-linux-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-arm64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-arm64": "npm:1.3.0" + "@img/sharp-libvips-linux-arm64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-arm64": optional: true @@ -2867,11 +2867,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-arm@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-linux-arm@npm:0.35.0" +"@img/sharp-linux-arm@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-arm@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-arm": "npm:1.3.0" + "@img/sharp-libvips-linux-arm": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-arm": optional: true @@ -2891,11 +2891,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-ppc64@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-linux-ppc64@npm:0.35.0" +"@img/sharp-linux-ppc64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-ppc64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-ppc64": "npm:1.3.0" + "@img/sharp-libvips-linux-ppc64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-ppc64": optional: true @@ -2915,11 +2915,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-riscv64@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-linux-riscv64@npm:0.35.0" +"@img/sharp-linux-riscv64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-riscv64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-riscv64": "npm:1.3.0" + "@img/sharp-libvips-linux-riscv64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-riscv64": optional: true @@ -2939,11 +2939,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-s390x@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-linux-s390x@npm:0.35.0" +"@img/sharp-linux-s390x@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-s390x@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-s390x": "npm:1.3.0" + "@img/sharp-libvips-linux-s390x": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-s390x": optional: true @@ -2963,11 +2963,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-x64@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-linux-x64@npm:0.35.0" +"@img/sharp-linux-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-x64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-x64": "npm:1.3.0" + "@img/sharp-libvips-linux-x64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-x64": optional: true @@ -2987,11 +2987,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linuxmusl-arm64@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-linuxmusl-arm64@npm:0.35.0" +"@img/sharp-linuxmusl-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linuxmusl-arm64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.0" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linuxmusl-arm64": optional: true @@ -3011,11 +3011,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linuxmusl-x64@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-linuxmusl-x64@npm:0.35.0" +"@img/sharp-linuxmusl-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linuxmusl-x64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.0" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linuxmusl-x64": optional: true @@ -3032,20 +3032,20 @@ __metadata: languageName: node linkType: hard -"@img/sharp-wasm32@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-wasm32@npm:0.35.0" +"@img/sharp-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-wasm32@npm:0.35.4" dependencies: - "@emnapi/runtime": "npm:^1.11.0" - checksum: 10/58173a1b3a596b4a450cc23561413aca76741ea9e03534daaea907c9d1438a3f765273216876fe3e03372cfd8a5b72506228f3a20d5eccb6aec1f3b7cdb0c710 + "@emnapi/runtime": "npm:^1.11.3" + checksum: 10/24250d2a5c1e681577c97a1774fd8785fc237066146badb9d25f8512dfd09771838b6d76fb0912baa0b29a0d7a564282dfc562062e747486aee832cc98941210 languageName: node linkType: hard -"@img/sharp-webcontainers-wasm32@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-webcontainers-wasm32@npm:0.35.0" +"@img/sharp-webcontainers-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-webcontainers-wasm32@npm:0.35.4" dependencies: - "@img/sharp-wasm32": "npm:0.35.0" + "@img/sharp-wasm32": "npm:0.35.4" conditions: cpu=wasm32 languageName: node linkType: hard @@ -3057,9 +3057,9 @@ __metadata: languageName: node linkType: hard -"@img/sharp-win32-arm64@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-win32-arm64@npm:0.35.0" +"@img/sharp-win32-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-arm64@npm:0.35.4" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -3071,9 +3071,9 @@ __metadata: languageName: node linkType: hard -"@img/sharp-win32-ia32@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-win32-ia32@npm:0.35.0" +"@img/sharp-win32-ia32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-ia32@npm:0.35.4" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard @@ -3085,9 +3085,9 @@ __metadata: languageName: node linkType: hard -"@img/sharp-win32-x64@npm:0.35.0": - version: 0.35.0 - resolution: "@img/sharp-win32-x64@npm:0.35.0" +"@img/sharp-win32-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-x64@npm:0.35.4" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -16982,7 +16982,7 @@ __metadata: react-hotkeys-hook: "npm:^5.2.1" react-markdown: "npm:^10.0.0" react-slick: "npm:^0.31.0" - sharp: "npm:0.35.0" + sharp: "npm:0.35.4" slick-carousel: "npm:^1.8.1" tiny-invariant: "npm:^1.3.3" ts-jest: "npm:^29.2.4" @@ -21206,7 +21206,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.8.4": +"semver@npm:^7.8.5": version: 7.8.5 resolution: "semver@npm:7.8.5" bin: @@ -21281,38 +21281,38 @@ __metadata: languageName: node linkType: hard -"sharp@npm:0.35.0": - version: 0.35.0 - resolution: "sharp@npm:0.35.0" +"sharp@npm:0.35.4": + version: 0.35.4 + resolution: "sharp@npm:0.35.4" dependencies: "@img/colour": "npm:^1.1.0" - "@img/sharp-darwin-arm64": "npm:0.35.0" - "@img/sharp-darwin-x64": "npm:0.35.0" - "@img/sharp-freebsd-wasm32": "npm:0.35.0" - "@img/sharp-libvips-darwin-arm64": "npm:1.3.0" - "@img/sharp-libvips-darwin-x64": "npm:1.3.0" - "@img/sharp-libvips-linux-arm": "npm:1.3.0" - "@img/sharp-libvips-linux-arm64": "npm:1.3.0" - "@img/sharp-libvips-linux-ppc64": "npm:1.3.0" - "@img/sharp-libvips-linux-riscv64": "npm:1.3.0" - "@img/sharp-libvips-linux-s390x": "npm:1.3.0" - "@img/sharp-libvips-linux-x64": "npm:1.3.0" - "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.0" - "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.0" - "@img/sharp-linux-arm": "npm:0.35.0" - "@img/sharp-linux-arm64": "npm:0.35.0" - "@img/sharp-linux-ppc64": "npm:0.35.0" - "@img/sharp-linux-riscv64": "npm:0.35.0" - "@img/sharp-linux-s390x": "npm:0.35.0" - "@img/sharp-linux-x64": "npm:0.35.0" - "@img/sharp-linuxmusl-arm64": "npm:0.35.0" - "@img/sharp-linuxmusl-x64": "npm:0.35.0" - "@img/sharp-webcontainers-wasm32": "npm:0.35.0" - "@img/sharp-win32-arm64": "npm:0.35.0" - "@img/sharp-win32-ia32": "npm:0.35.0" - "@img/sharp-win32-x64": "npm:0.35.0" + "@img/sharp-darwin-arm64": "npm:0.35.4" + "@img/sharp-darwin-x64": "npm:0.35.4" + "@img/sharp-freebsd-wasm32": "npm:0.35.4" + "@img/sharp-libvips-darwin-arm64": "npm:1.3.3" + "@img/sharp-libvips-darwin-x64": "npm:1.3.3" + "@img/sharp-libvips-linux-arm": "npm:1.3.3" + "@img/sharp-libvips-linux-arm64": "npm:1.3.3" + "@img/sharp-libvips-linux-ppc64": "npm:1.3.3" + "@img/sharp-libvips-linux-riscv64": "npm:1.3.3" + "@img/sharp-libvips-linux-s390x": "npm:1.3.3" + "@img/sharp-libvips-linux-x64": "npm:1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.3" + "@img/sharp-linux-arm": "npm:0.35.4" + "@img/sharp-linux-arm64": "npm:0.35.4" + "@img/sharp-linux-ppc64": "npm:0.35.4" + "@img/sharp-linux-riscv64": "npm:0.35.4" + "@img/sharp-linux-s390x": "npm:0.35.4" + "@img/sharp-linux-x64": "npm:0.35.4" + "@img/sharp-linuxmusl-arm64": "npm:0.35.4" + "@img/sharp-linuxmusl-x64": "npm:0.35.4" + "@img/sharp-webcontainers-wasm32": "npm:0.35.4" + "@img/sharp-win32-arm64": "npm:0.35.4" + "@img/sharp-win32-ia32": "npm:0.35.4" + "@img/sharp-win32-x64": "npm:0.35.4" detect-libc: "npm:^2.1.2" - semver: "npm:^7.8.4" + semver: "npm:^7.8.5" dependenciesMeta: "@img/sharp-darwin-arm64": optional: true @@ -21364,7 +21364,10 @@ __metadata: optional: true "@img/sharp-win32-x64": optional: true - checksum: 10/391d1212df0a8ac61c4bdf3b745a8530b050ff71c198038ece5eccfa013cbb5bc0735dba71d47aeeb4f82a86d2bc4dc212e44b397cbb6afa285624a6bd069516 + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10/f3130f6f126e532d67560b808b95d7c235e669b7f4dd68e0d464feed9a70d52c356ac53867eb44ec877788176a15a3bdffc8ac777a9ed777fc4b63cc99582cb2 languageName: node linkType: hard From 2e275f4f265cc4287eedd90840789ac5a6b278e8 Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Fri, 11 Sep 2026 14:16:17 +0500 Subject: [PATCH 4/9] Build internal resource links from learn_url (#3885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Build internal resource links from learn_url Eleven components built links to related resources from (id, parent, title), slugifying each title locally: the podcast and video listings, the "more from this playlist" and "up next" rails, the series navigation, and the drawer's call-to-action. They now read the resource's learn_url, so the backend is the only thing that decides how a URL is spelled. Where a link is scoped by context rather than by the resource — a video in the playlist you are browsing, an episode under the podcast you arrived from — only the slug comes from learn_url and the parent segment stays as given. Podcasts and playlists have no such parent and use learn_url whole. Share URLs use it whole too, so sharing hands out the URL that owns the content rather than the context it was found in. The call-to-action keeps its membership guards: a video outside any playlist, or an episode with no podcast, still falls through to the source URL rather than to a Learn page. `PodcastEpisodeParentSerializer` gains learn_url. The episode page names its series in a breadcrumb and in its PodcastEpisode JSON-LD, and the embedded parent carried only an id, so `partOfSeries.url` would otherwise have to name a URL that redirects. `parent` is already select_related by the `_podcasts` prefetch, so this costs no extra query. With those callers converted, pathSlug and the four title-based path builders are unused and removed. slugify remains only for the drawer's cosmetic `resource_title`, which the backend cannot supply per-resource: a resource with a dedicated page has no resource_title in its learn_url. Co-Authored-By: Claude Opus 5 (1M context) * Document the slug contract these path helpers actually have The block described a `title` argument that no helper takes, and attributed the "resource" fallback to the frontend when the backend owns it. State what the helpers do: take an already-derived slug, emit the bare redirecting path when it is undefined, and in generateVideoPlaylistPath's case return that bare form unconditionally. Co-Authored-By: Claude Opus 5 (1M context) * Assert the episode row hrefs on the podcast page The fixture set each episode's parent ids but left its own learn_url drawer-shaped, so learnUrlSlug yielded "search" and every row rendered /podcast//podcast_episode//search. Nothing asserted the href, so the malformed link was invisible. Give episodes a dedicated-page learn_url, and pin the hrefs in the list test so a drawer-shaped fixture cannot quietly return. Add a case where the episode's canonical learn_url names a different parent — an episode in several podcasts is viewable under any of them — proving the row keeps the podcast being viewed as context and borrows only the slug. Co-Authored-By: Claude Opus 5 (1M context) * Assert the "More from" row hrefs on the episode page The section's tests only checked that titles rendered, and the factory left each episode's learn_url drawer-shaped, so learnUrlSlug yielded "search" and every row rendered /podcast//podcast_episode//search undetected. Give factory episodes a dedicated-page learn_url scoped to a deliberately different canonical parent, so every row exercises the case an episode in several podcasts presents. Assert the hrefs by role: the context segment is the podcast being viewed, the slug comes from learn_url. Co-Authored-By: Claude Opus 5 (1M context) * Assert the remaining context-plus-slug hrefs Two more rails read their slug from learn_url while keeping their own context, and neither had an href pinned: the latest-episodes list on /podcasts, and the "more from this playlist" rows on the video page. Both fixtures kept the factory's drawer-shaped learn_url, so each row rendered a "/search" slug with no test failing. Scope both fixtures' learn_url to a deliberately different canonical parent, so every row exercises the multi-parent case, and assert the hrefs by role. In VideoDetailPage the fixture default is applied only when a caller has not overridden learn_url, so the share-URL test still names its own value. Co-Authored-By: Claude Opus 5 (1M context) * Stop justifying learn_url by naming the UI that reads it A fixture that explains a field by pointing at a breadcrumb and a JSON-LD block goes stale the moment either changes, and nobody returns to a fixture comment to notice. Drop it from both factories. The serializer docstring loses the same sentence but keeps what a reader cannot infer from the code: why the parent list is empty, and that `parent` is already select_related so the field costs no query. Co-Authored-By: Claude Opus 5 (1M context) * Return a resource's URL slug as a top-level API field Consumers that build a resource page path need the slug segment, and the only way to get it today is to parse it back out of learn_url. Serve it directly as url_slug. * Consume url_slug; keep context for UI and share, canonical for crawlers Replaces the fifteen `learnUrlSlug(learn_url)` call sites with the resource's `url_slug` and drops both `learnUrlSlug` and `learnUrlPath`: Next treats an absolute same-origin href the same as a path, and the redirect pages now build their canonical from the slug directly. Splits the three uses of a resource URL the way the review asks: - HTML UI (breadcrumbs, carousels, "view all") keeps parent context - Share keeps parent context too, as `main` did — a video shared from the xTalk playlist should not land on a different series - JSON-LD is canonical throughout. `buildPodcastEpisodeStructuredData` now takes the canonical parent from the episode itself, so `url` and `partOfSeries` can no longer name two different podcasts. * Pass a playlist id as the string the API declares `VideoResource.playlists` is Array, unlike an episode's `podcasts`, which is Array. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Build the episode share URL with absoluteUrl Matches the two video pages and drops the file's only use of env(). * Let the podcast fixture keep the factory's learn_url The href assertion compares against the fixture's own value, so a hand-written URL asserted nothing. The factory's drawer-shaped default makes it fail if the component builds the path instead of using learn_url. * Compare both sides of a canonical redirect with the same builder The incoming path was written out by hand at all four [slug] pages, so it repeated what the builder already knows and could drift from it. It also escaped the id segment differently: the builder runs ids through generatePath, the hand-written copy did not. videoDetailPath now takes a string id and playlist so the video page can pass its route params straight in. * Drop a String() on a value already typed string podcastEpisodePath takes both ids as strings, unlike the builders that accept number | string and need the conversion. --------- Co-authored-by: Ahtesham Quraish Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Ahtesham Quraish Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- frontends/api/src/generated/v0/api.ts | 4 + frontends/api/src/generated/v1/api.ts | 4 + .../test-utils/factories/learningResources.ts | 1 + .../PodcastPage/PodcastDetailPage.test.tsx | 35 +++++- .../PodcastPage/PodcastDetailPage.tsx | 6 +- .../PodcastEpisodeDetailPage.test.tsx | 95 +++++++++++++-- .../PodcastPage/PodcastEpisodeDetailPage.tsx | 31 ++--- .../LatestEpisodesSection.test.tsx | 38 +++++- .../LatestEpisodesSection.tsx | 6 +- .../PodcastSection.test.tsx | 4 +- .../PodcastsListingPage/PodcastSection.tsx | 12 +- .../PodcastsListingPage/helpers.test.ts | 36 +++++- .../podcastEpisodeStructuredData.test.ts | 60 +++++----- .../podcastEpisodeStructuredData.ts | 26 ++--- .../MoreFromPlaylist.tsx | 11 +- .../RelatedPlaylist.tsx | 9 +- .../VideoDetailPage.test.tsx | 49 +++++--- .../VideoDetailPage.tsx | 31 ++--- .../VideoPlaylistCollectionPage.test.tsx | 11 +- .../VideoPlaylistCollectionPage.tsx | 4 +- .../VideoSeriesDetailPage.test.tsx | 16 +-- .../VideoSeriesDetailPage.tsx | 30 ++--- .../useSeriesNavigation.ts | 4 +- .../podcast/[podcastId]/[slug]/page.test.tsx | 5 +- .../podcast/[podcastId]/[slug]/page.tsx | 10 +- .../(site)/podcast/[podcastId]/page.test.tsx | 7 +- .../app/(site)/podcast/[podcastId]/page.tsx | 4 +- .../[episodeId]/[slug]/page.test.tsx | 5 +- .../[episodeId]/[slug]/page.tsx | 12 +- .../podcast_episode/[episodeId]/page.test.tsx | 3 +- .../podcast_episode/[episodeId]/page.tsx | 8 +- .../video-playlist/[id]/[slug]/page.test.tsx | 2 +- .../video-playlist/[id]/[slug]/page.tsx | 6 +- .../(site)/video-playlist/[id]/page.test.tsx | 3 +- .../app/(site)/video-playlist/[id]/page.tsx | 4 +- .../(site)/video/[id]/[slug]/page.test.tsx | 21 +++- .../src/app/(site)/video/[id]/[slug]/page.tsx | 9 +- .../src/app/(site)/video/[id]/page.test.tsx | 4 +- .../main/src/app/(site)/video/[id]/page.tsx | 8 +- frontends/main/src/common/urls.test.ts | 100 +++++++--------- frontends/main/src/common/urls.ts | 109 +++++------------- .../CallToActionSection.test.tsx | 85 ++++++++++++++ .../CallToActionSection.tsx | 29 +++-- .../LearningResourceExpanded.test.tsx | 11 +- .../LearningResourcePaste.test.ts | 25 ++-- learning_resources/serializers.py | 13 +++ learning_resources/serializers_test.py | 3 +- learning_resources/utils.py | 7 +- openapi/specs/v0.yaml | 5 + openapi/specs/v1.yaml | 5 + 50 files changed, 613 insertions(+), 413 deletions(-) diff --git a/frontends/api/src/generated/v0/api.ts b/frontends/api/src/generated/v0/api.ts index 886e79590a..a39b59a902 100644 --- a/frontends/api/src/generated/v0/api.ts +++ b/frontends/api/src/generated/v0/api.ts @@ -1579,6 +1579,10 @@ export interface PodcastEpisodeParent { id: number title: string readable_id: string + /** + * Where this podcast lives within Learn + */ + learn_url: string } /** * Serializer for podcast episode resources diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index b851ec7b93..fa2b4a73ed 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -2540,6 +2540,10 @@ export interface PodcastEpisodeParent { id: number title: string readable_id: string + /** + * Where this podcast lives within Learn + */ + learn_url: string } /** * Serializer for PodcastEpisode diff --git a/frontends/api/src/test-utils/factories/learningResources.ts b/frontends/api/src/test-utils/factories/learningResources.ts index 5d9174f0f3..53305df828 100644 --- a/frontends/api/src/test-utils/factories/learningResources.ts +++ b/frontends/api/src/test-utils/factories/learningResources.ts @@ -655,6 +655,7 @@ const podcastEpisode: LearningResourceFactory = ( id: parentPodcastId, title: faker.lorem.words(3), readable_id: faker.string.uuid(), + learn_url: `${TEST_APP_ORIGIN}/podcast/${parentPodcastId}/podcast`, }, ], duration: faker.helpers.arrayElement(["PT1H13M44S", "PT2H30M", "PT1M"]), diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx index 2a43638a36..12e48bb365 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx @@ -3,6 +3,7 @@ import { factories, setMockResponse, urls } from "api/test-utils" import { ResourceTypeEnum } from "api/v1" import type { LearningResource, PodcastEpisodeResource } from "api/v1" import { renderWithProviders, screen, user } from "@/test-utils" +import { podcastEpisodePath } from "@/common/urls" import { PodcastDetailPage } from "./PodcastDetailPage" jest.mock( @@ -67,6 +68,7 @@ const setupApis = ({ id: podcast.id, title: podcast.title!, readable_id: podcast.readable_id, + learn_url: podcast.learn_url, }, ] } @@ -98,6 +100,28 @@ const setupApis = ({ } describe("PodcastDetailPage", () => { + test("episode rows keep this podcast as context and take only the backend slug", async () => { + // The episode's canonical parent is a *different* podcast — an episode in + // several podcasts is viewable under any of them. The row href must keep the + // podcast being viewed and borrow only the slug. + const episodes = makePodcastEpisodes(1) + const { podcast } = setupApis({ episodesPage1: episodes }) + const episode = episodes[0] + episode.podcast_episode!.podcasts = [podcast.id + 1, podcast.id] + + renderWithProviders() + + const title = await screen.findByText(episode.title!) + expect(title.closest("a")).toHaveAttribute( + "href", + podcastEpisodePath( + String(episode.id), + String(podcast.id), + episode.url_slug, + ), + ) + }) + test("renders initial episode list", async () => { const episodes = makePodcastEpisodes(3) const { podcast } = setupApis({ episodesPage1: episodes }) @@ -106,7 +130,16 @@ describe("PodcastDetailPage", () => { await screen.findByText(episodes[0].title!) for (const episode of episodes) { - expect(screen.getByText(episode.title!)).toBeInTheDocument() + const title = screen.getByText(episode.title!) + expect(title).toBeInTheDocument() + expect(title.closest("a")).toHaveAttribute( + "href", + podcastEpisodePath( + String(episode.id), + String(podcast.id), + episode.url_slug, + ), + ) } }) diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx index d4d0e9c2ee..026ca3438f 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx @@ -11,7 +11,7 @@ import { import { ResourceTypeEnum } from "api/v1" import type { LearningResource } from "api/v1" import { formatDate } from "ol-utilities" -import { HOME, podcastEpisodePageView } from "@/common/urls" +import { HOME, podcastEpisodePath } from "@/common/urls" import { addExternalLinkTargets } from "@/common/utils" import PodcastContainer from "./PodcastContainer" import PodcastBreadcrumbs from "./PodcastBreadcrumbs" @@ -406,10 +406,10 @@ export const PodcastDetailPage: React.FC = ({ key={episode.id} isMobile={isMobile} episode={episode} - href={podcastEpisodePageView( + href={podcastEpisodePath( String(episode.id), String(id), - episode.title, + episode.url_slug, )} onPlayClick={handlePlayClick} onPauseClick={pause} diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.test.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.test.tsx index 5f25f8c595..8b0d74b244 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.test.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.test.tsx @@ -1,5 +1,6 @@ import React from "react" import { factories, setMockResponse, urls } from "api/test-utils" +import { absoluteUrl, podcastEpisodePath } from "@/common/urls" import { ResourceTypeEnum } from "api/v1" import type { LearningResource, PodcastEpisodeResource } from "api/v1" import { renderWithProviders, screen, user, waitFor } from "@/test-utils" @@ -77,6 +78,7 @@ const setupApis = ({ id: podcast.id, title: podcast.title!, readable_id: podcast.readable_id, + learn_url: podcast.learn_url, }, ], has_transcript: @@ -171,6 +173,18 @@ describe("PodcastEpisodeDetailPage", () => { await screen.findByText(moreEpisodes[0].title!) expect(screen.getByText(moreEpisodes[1].title!)).toBeInTheDocument() + + // Each row keeps the podcast being viewed as the context segment and takes + // only the slug from the episode. + for (const more of moreEpisodes) { + const link = screen.getByRole("link", { + name: new RegExp(more.title!, "i"), + }) + expect(link).toHaveAttribute( + "href", + podcastEpisodePath(String(more.id), String(podcast.id), more.url_slug), + ) + } }) test("play button is present and enabled when episode has an audio URL", async () => { @@ -247,6 +261,7 @@ describe("PodcastEpisodeDetailPage", () => { id: podcast.id, title: podcast.title!, readable_id: podcast.readable_id, + learn_url: podcast.learn_url, }, ] @@ -316,7 +331,63 @@ describe("PodcastEpisodeDetailPage", () => { expect(internalLink).not.toHaveAttribute("target") }) - test("names the URL's podcast (not the first parent) for a multi-parent episode", async () => { + test("Share link keeps the podcast the episode is viewed under", async () => { + // Sharing hands out the page in front of the user, parent podcast included, + // even when that is not the canonical parent: the recommendation is usually + // about the series it was found in. + const episode = makePodcastEpisode() + const canonical = makePodcast({ title: "Canonical Podcast" }) + const viewed = makePodcast({ title: "Viewed Podcast" }) + episode.podcast_episode.podcasts = [canonical.id, viewed.id] + episode.podcast_episode.parent_podcasts = [ + { + id: canonical.id, + title: canonical.title!, + readable_id: canonical.readable_id, + learn_url: canonical.learn_url, + }, + { + id: viewed.id, + title: viewed.title!, + readable_id: viewed.readable_id, + learn_url: viewed.learn_url, + }, + ] + + setMockResponse.get( + urls.learningResources.details({ id: episode.id }), + episode, + ) + setMockResponse.get( + urls.learningResources.details({ id: viewed.id }), + viewed, + ) + setMockResponse.get( + `${urls.learningResources.items({ id: viewed.id })}?limit=${EPISODES_PAGE_SIZE}`, + makeItemsResponse([episode]), + ) + + renderWithProviders( + , + ) + + await user.click(await screen.findByRole("button", { name: /share/i })) + + expect(screen.getByRole("textbox")).toHaveValue( + absoluteUrl( + podcastEpisodePath( + String(episode.id), + String(viewed.id), + episode.url_slug, + ), + ), + ) + }) + + test("shows the viewed podcast but publishes the canonical one", async () => { const episode = makePodcastEpisode() episode.podcast_episode.audio_url = "https://example.com/ep.mp3" // The resource factory leaves last_modified unset, and the JSON-LD is @@ -331,11 +402,13 @@ describe("PodcastEpisodeDetailPage", () => { id: podcastA.id, title: "Podcast A", readable_id: podcastA.readable_id, + learn_url: podcastA.learn_url, }, { id: podcastB.id, title: "Podcast B", readable_id: podcastB.readable_id, + learn_url: podcastB.learn_url, }, ] @@ -363,24 +436,22 @@ describe("PodcastEpisodeDetailPage", () => { await screen.findByRole("button", { name: /play episode/i }), ) - // The header/breadcrumb and the player bar must agree on Podcast B. + // The header/breadcrumb and the player bar follow the route: Podcast B. expect(screen.getByTestId("player-podcast-name")).toHaveTextContent( "Podcast B", ) - // So must the JSON-LD: partOfSeries takes its url from the podcast in the - // current route, so taking the name from parent_podcasts[0] instead would - // publish Podcast A's name against Podcast B's url. + // The JSON-LD does not. It is read by crawlers, so both its url and its + // series name the canonical parent — A — and never the route's. const jsonLd = JSON.parse( document.querySelector('script[type="application/ld+json"]')!.innerHTML, ) - expect(jsonLd.partOfSeries).toEqual( - expect.objectContaining({ - "@type": "PodcastSeries", - name: "Podcast B", - url: expect.stringContaining(`/podcast/${podcastB.id}/`), - }), - ) + expect(jsonLd.url).toBe(episode.learn_url) + expect(jsonLd.partOfSeries).toEqual({ + "@type": "PodcastSeries", + name: "Podcast A", + url: podcastA.learn_url, + }) }) test("escapes every < in the JSON-LD, not just { diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.tsx index bc93c78c5a..74a8a06c7c 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.tsx @@ -13,7 +13,7 @@ import { useQuery } from "@tanstack/react-query" import { ResourceTypeEnum } from "api/v1" import type { PodcastEpisodeResource } from "api/v1" import { formatDate } from "ol-utilities" -import { HOME, podcastPageView, podcastEpisodePageView } from "@/common/urls" +import { HOME, absoluteUrl, podcastEpisodePath } from "@/common/urls" import { addExternalLinkTargets } from "@/common/utils" import { EpisodeItem } from "./PodcastsListingPage/EpisodeItem" import PodcastContainer from "./PodcastContainer" @@ -37,9 +37,6 @@ import PodcastShareButton from "./PodcastShareButton" import EpisodeContentTabs from "./EpisodeContentTabs" import type { TranscriptState } from "./EpisodeContentTabs" import { buildPodcastEpisodeStructuredData } from "./podcastEpisodeStructuredData" -import { env } from "@/env" - -const NEXT_PUBLIC_ORIGIN = env("NEXT_PUBLIC_ORIGIN") /* ── Layout ── */ @@ -259,13 +256,17 @@ export const PodcastEpisodeDetailPage: React.FC< toggle(episode, Number(podcastId)) } - const podcastHref = podcastId - ? podcastPageView(podcastId, parentPodcast?.title) - : "/" + // A podcast has a single page, so its `learn_url` is that page. + const podcastHref = parentPodcast?.learn_url ?? "/" + // Shares the page in front of the user, parent podcast included: an episode + // in several podcasts is viewable under any of them, and a recommendation is + // usually about the series it was found in. const sharePageUrl = episode && podcastId - ? `${NEXT_PUBLIC_ORIGIN}${podcastEpisodePageView(String(episode.id), podcastId, episode.title)}` + ? absoluteUrl( + podcastEpisodePath(String(episode.id), podcastId, episode.url_slug), + ) : "" // Episode descriptions are sanitized on the backend with nh3 during ETL @@ -285,15 +286,7 @@ export const PodcastEpisodeDetailPage: React.FC< // tag so crawlers can read it without executing any additional JS. // See: https://schema.org/PodcastEpisode const structuredData = !episodeLoading - ? buildPodcastEpisodeStructuredData(episode as PodcastEpisodeResource, { - url: sharePageUrl || undefined, - // The same parent the breadcrumb and podcastHref use, so partOfSeries' - // name and url always describe one series. - series: parentPodcast, - seriesUrl: podcastId - ? `${NEXT_PUBLIC_ORIGIN}${podcastHref}` - : undefined, - }) + ? buildPodcastEpisodeStructuredData(episode as PodcastEpisodeResource) : null return ( @@ -398,10 +391,10 @@ export const PodcastEpisodeDetailPage: React.FC< episode={episode} href={ podcastId - ? podcastEpisodePageView( + ? podcastEpisodePath( String(episode.id), podcastId, - episode.title, + episode.url_slug, ) : "" } diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/LatestEpisodesSection.test.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/LatestEpisodesSection.test.tsx index 7243dd776c..773413b2b6 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/LatestEpisodesSection.test.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/LatestEpisodesSection.test.tsx @@ -1,17 +1,21 @@ import React from "react" +import { faker } from "@faker-js/faker/locale/en" import { factories } from "api/test-utils" import type { LearningResource } from "api/v1" -import { SEARCH_PODCAST_EPISODES } from "@/common/urls" +import { SEARCH_PODCAST_EPISODES, podcastEpisodePath } from "@/common/urls" import { renderWithProviders, screen, user } from "@/test-utils" import LatestEpisodesSection from "./LatestEpisodesSection" +/** The podcast the rows are scoped to, taken from the episode's `podcasts[0]`. */ +const CONTEXT_PODCAST_ID = faker.number.int({ min: 1, max: 1e6 }) + const makeEpisodes = (count: number): LearningResource[] => Array.from({ length: count }, (_, i) => factories.learningResources.podcastEpisode({ title: `Episode ${i + 1}`, podcast_episode: { id: i + 1, - podcasts: [1], + podcasts: [CONTEXT_PODCAST_ID], duration: "PT1M", audio_url: "https://example.com/audio.mp3", episode_link: "https://example.com/link", @@ -36,6 +40,36 @@ describe("LatestEpisodesSection", () => { expect(screen.getByText("All episodes")).toBeInTheDocument() }) + it("keeps each episode's podcast context and takes only the backend slug", () => { + const episodes = makeEpisodes(2) + renderWithProviders( + true} + />, + ) + + // Each row is an anchor given role="listitem", so query by that role. + const rows = screen.getAllByRole("listitem") + expect(rows).toHaveLength(episodes.length) + episodes.forEach((episode, i) => { + expect(rows[i]).toHaveTextContent(episode.title!) + expect(rows[i]).toHaveAttribute( + "href", + podcastEpisodePath( + String(episode.id), + String(CONTEXT_PODCAST_ID), + episode.url_slug, + ), + ) + }) + }) + it("renders all provided episodes", () => { const episodes = makeEpisodes(3) renderWithProviders( diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/LatestEpisodesSection.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/LatestEpisodesSection.tsx index b36633c8e4..4817ee59e1 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/LatestEpisodesSection.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/LatestEpisodesSection.tsx @@ -2,7 +2,7 @@ import React from "react" import { Link, Skeleton, styled } from "ol-components" import { ButtonLink } from "@mitodl/smoot-design" import type { LearningResource, PodcastEpisodeResource } from "api/v1" -import { SEARCH_PODCAST_EPISODES, podcastEpisodePageView } from "@/common/urls" +import { SEARCH_PODCAST_EPISODES, podcastEpisodePath } from "@/common/urls" import { Section, SectionHeader, @@ -130,10 +130,10 @@ const LatestEpisodesSection: React.FC = ({ overline={overline} href={ parentPodcastId - ? podcastEpisodePageView( + ? podcastEpisodePath( String(episode.id), String(parentPodcastId), - episode.title, + episode.url_slug, ) : SEARCH_PODCAST_EPISODES } diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.test.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.test.tsx index 51317c7f6f..7c175b8228 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.test.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.test.tsx @@ -1,7 +1,7 @@ import React from "react" import { factories } from "api/test-utils" import type { LearningResource } from "api/v1" -import { SEARCH_PODCASTS, podcastPageView } from "@/common/urls" +import { SEARCH_PODCASTS } from "@/common/urls" import { renderWithProviders, screen } from "@/test-utils" import PodcastSection from "./PodcastSection" @@ -67,7 +67,7 @@ describe("PodcastSection", () => { expect(screen.getByText(/Updated May 3/)).toBeInTheDocument() expect(screen.getByRole("link", { name: /Chalk Radio/ })).toHaveAttribute( "href", - podcastPageView("1", "Chalk Radio"), + series.learn_url, ) }) diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.tsx index c5c3d5e4a9..1723d5b687 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.tsx @@ -7,7 +7,7 @@ import { RiArrowRightLine, RiArrowRightSLine } from "@remixicon/react" import DOMPurify from "isomorphic-dompurify" import { formatDate } from "ol-utilities" import type { LearningResource } from "api/v1" -import { SEARCH_PODCASTS, podcastPageView } from "@/common/urls" +import { SEARCH_PODCASTS } from "@/common/urls" import { stripAnchorTags } from "@/common/utils" import { Section, @@ -316,10 +316,7 @@ const PodcastSection: React.FC = ({ ? formatDate(item.last_modified, "MMM D") : null return ( - + {item.image?.url && ( = ({ ? formatDate(item.last_modified, "MMM D") : null return ( - + { id: 1, podcasts: [42], parent_podcasts: [ - { id: 42, title: "The Show Name", readable_id: "the-show" }, + { + id: 42, + title: "The Show Name", + readable_id: "the-show", + learn_url: "http://test.learn.odl.local:8062/podcast/42/the-show", + }, ], duration: "PT1M", audio_url: "https://example.com/audio.mp3", @@ -230,8 +235,18 @@ describe("getEpisodeParentPodcastName", () => { id: 1, podcasts: [1, 2], parent_podcasts: [ - { id: 1, title: "Podcast A", readable_id: "podcast-a" }, - { id: 2, title: "Podcast B", readable_id: "podcast-b" }, + { + id: 1, + title: "Podcast A", + readable_id: "podcast-a", + learn_url: "http://test.learn.odl.local:8062/podcast/1/podcast-a", + }, + { + id: 2, + title: "Podcast B", + readable_id: "podcast-b", + learn_url: "http://test.learn.odl.local:8062/podcast/2/podcast-b", + }, ], duration: "PT1M", audio_url: "https://example.com/audio.mp3", @@ -263,8 +278,18 @@ describe("getEpisodeParentPodcast", () => { id: 1, podcasts: [1, 2], parent_podcasts: [ - { id: 1, title: "Podcast A", readable_id: "podcast-a" }, - { id: 2, title: "Podcast B", readable_id: "podcast-b" }, + { + id: 1, + title: "Podcast A", + readable_id: "podcast-a", + learn_url: "http://test.learn.odl.local:8062/podcast/1/podcast-a", + }, + { + id: 2, + title: "Podcast B", + readable_id: "podcast-b", + learn_url: "http://test.learn.odl.local:8062/podcast/2/podcast-b", + }, ], duration: "PT1M", audio_url: "https://example.com/audio.mp3", @@ -284,6 +309,7 @@ describe("getEpisodeParentPodcast", () => { id: 2, title: "Podcast B", readable_id: "podcast-b", + learn_url: "http://test.learn.odl.local:8062/podcast/2/podcast-b", }) }) diff --git a/frontends/main/src/app-pages/PodcastPage/podcastEpisodeStructuredData.test.ts b/frontends/main/src/app-pages/PodcastPage/podcastEpisodeStructuredData.test.ts index cb7653b442..a87b574c32 100644 --- a/frontends/main/src/app-pages/PodcastPage/podcastEpisodeStructuredData.test.ts +++ b/frontends/main/src/app-pages/PodcastPage/podcastEpisodeStructuredData.test.ts @@ -1,5 +1,6 @@ +import { faker } from "@faker-js/faker/locale/en" import { factories } from "api/test-utils" -import type { PodcastEpisodeResource } from "api/v1" +import type { PodcastEpisodeParent, PodcastEpisodeResource } from "api/v1" import { buildPodcastEpisodeStructuredData } from "./podcastEpisodeStructuredData" const makeEpisode = ( @@ -12,24 +13,29 @@ const makeEpisode = ( podcast_episode: podcastEpisode, }) +const makeParent = (): PodcastEpisodeParent => { + const id = faker.number.int({ min: 1, max: 1e6 }) + const slug = faker.lorem.slug() + return { + id, + title: faker.lorem.words(3), + readable_id: faker.lorem.slug(), + learn_url: `http://test.learn.odl.local:8062/podcast/${id}/${slug}`, + } +} + test("omits the payload entirely without a last_modified date", () => { const episode = factories.learningResources.podcastEpisode({ last_modified: null, }) - expect( - buildPodcastEpisodeStructuredData(episode, { series: null }), - ).toBeNull() - expect( - buildPodcastEpisodeStructuredData(undefined, { series: null }), - ).toBeNull() + expect(buildPodcastEpisodeStructuredData(episode)).toBeNull() + expect(buildPodcastEpisodeStructuredData(undefined)).toBeNull() }) test.each(["PT17M16S", "PT0S", "PT1H13M44S", "P1D", "P1Y2M3DT4H5M6.7S"])( "keeps the valid ISO-8601 duration %s", (duration) => { - const built = buildPodcastEpisodeStructuredData(makeEpisode({ duration }), { - series: null, - }) + const built = buildPodcastEpisodeStructuredData(makeEpisode({ duration })) expect(built).toHaveProperty("duration", duration) }, ) @@ -39,27 +45,29 @@ test.each(["PT17M16S", "PT0S", "PT1H13M44S", "P1D", "P1Y2M3DT4H5M6.7S"])( test.each(["P", "PT", "P1DT", "17 minutes", "1:13:44", ""])( "drops the invalid duration %p rather than publishing it", (duration) => { - const built = buildPodcastEpisodeStructuredData(makeEpisode({ duration }), { - series: null, - }) + const built = buildPodcastEpisodeStructuredData(makeEpisode({ duration })) expect(built).not.toHaveProperty("duration") }, ) -test("names the series it is given, not one it picks itself", () => { - const episode = makeEpisode({ - parent_podcasts: [ - { id: 1, title: "Podcast A", readable_id: "a" }, - { id: 2, title: "Podcast B", readable_id: "b" }, - ], - }) - const built = buildPodcastEpisodeStructuredData(episode, { - series: { id: 2, title: "Podcast B", readable_id: "b" }, - seriesUrl: "https://learn.mit.edu/podcast/2/podcast-b", - }) +test("names the canonical series, not a later parent", () => { + const canonical = makeParent() + const other = makeParent() + const episode = makeEpisode({ parent_podcasts: [canonical, other] }) + + const built = buildPodcastEpisodeStructuredData(episode) + + expect(built).toHaveProperty("url", episode.learn_url) expect(built).toHaveProperty("partOfSeries", { "@type": "PodcastSeries", - name: "Podcast B", - url: "https://learn.mit.edu/podcast/2/podcast-b", + name: canonical.title, + url: canonical.learn_url, }) }) + +test("omits partOfSeries for an episode with no parent podcast", () => { + const built = buildPodcastEpisodeStructuredData( + makeEpisode({ parent_podcasts: [] }), + ) + expect(built).not.toHaveProperty("partOfSeries") +}) diff --git a/frontends/main/src/app-pages/PodcastPage/podcastEpisodeStructuredData.ts b/frontends/main/src/app-pages/PodcastPage/podcastEpisodeStructuredData.ts index f7481b0f87..3252ac3e9f 100644 --- a/frontends/main/src/app-pages/PodcastPage/podcastEpisodeStructuredData.ts +++ b/frontends/main/src/app-pages/PodcastPage/podcastEpisodeStructuredData.ts @@ -7,24 +7,14 @@ import type { PodcastEpisodeParent, PodcastEpisodeResource } from "api/v1" const ISO_8601_DURATION_RE = /^P(?!$)(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?!$)(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/ -type BuildOptions = { - /** Absolute canonical url of the episode page */ - url?: string - /** - * The parent podcast series the episode is being viewed under, as resolved - * by `getEpisodeParentPodcast`. Required rather than picked from - * `parent_podcasts[0]` here: an episode can belong to several series, and - * choosing one independently of the caller would pair that series' name with - * `seriesUrl`, which the caller builds from the parent in the current url. - */ - series: PodcastEpisodeParent | null - /** Absolute canonical url of the parent podcast page */ - seriesUrl?: string -} - /** * Builds a schema.org PodcastEpisode structured-data payload. * + * Every URL here is canonical, taken from the episode rather than from the page + * rendering it. An episode in several podcasts is viewable under any of them, so + * a page that passed in its own parent would pair one series' name with another + * series' `url`. `parent_podcasts[0]` is the parent `learn_url` was built from. + * * The transcript text itself is deliberately not included. `schema.org`'s * `transcript` property has a `domainIncludes` of `AudioObject` and * `VideoObject` only -- it is not a `PodcastEpisode` property -- and no Google @@ -38,11 +28,11 @@ type BuildOptions = { */ export function buildPodcastEpisodeStructuredData( episode: PodcastEpisodeResource | undefined, - { url, series, seriesUrl }: BuildOptions, ): Record | null { if (!episode || !episode.last_modified) return null const details = episode.podcast_episode + const series: PodcastEpisodeParent | undefined = details?.parent_podcasts?.[0] const durationIso = details?.duration && ISO_8601_DURATION_RE.test(details.duration) @@ -54,7 +44,7 @@ export function buildPodcastEpisodeStructuredData( "@type": "PodcastEpisode", name: episode.title, ...(episode.description ? { description: episode.description } : {}), - ...(url ? { url } : {}), + url: episode.learn_url, datePublished: episode.last_modified, ...(episode.image?.url ? { image: episode.image.url } : {}), ...(durationIso ? { duration: durationIso } : {}), @@ -71,7 +61,7 @@ export function buildPodcastEpisodeStructuredData( partOfSeries: { "@type": "PodcastSeries", name: series.title, - ...(seriesUrl ? { url: seriesUrl } : {}), + url: series.learn_url, }, } : {}), diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/MoreFromPlaylist.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/MoreFromPlaylist.tsx index 8a51b0dfc8..6c90c54e5f 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/MoreFromPlaylist.tsx +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/MoreFromPlaylist.tsx @@ -3,14 +3,15 @@ import Image from "next/image" import { Skeleton } from "ol-components" import { formatDurationClockTime } from "ol-utilities" import type { VideoResource } from "api/v1" -import { videoDetailPageView, videoPlaylistPageView } from "@/common/urls" +import { videoDetailPath } from "@/common/urls" import * as Styled from "./VideoDetailPage.styled" type MoreFromPlaylistProps = { playlistId: number /** Display name of the playlist, used in headings and labels. */ playlistLabel: string - playlistTitle?: string + /** The playlist's own page, for the "see all" link. */ + playlistHref: string /** Sibling videos to list, already filtered and capped by the caller. */ videos: VideoResource[] /** Total videos in the playlist, used to decide whether to link to the rest. */ @@ -34,7 +35,7 @@ const MoreFromPlaylistItem: React.FC<{ return ( @@ -75,7 +76,7 @@ const MoreFromPlaylistItem: React.FC<{ const MoreFromPlaylist: React.FC = ({ playlistId, playlistLabel, - playlistTitle, + playlistHref, videos, totalVideos, isLoading, @@ -121,7 +122,7 @@ const MoreFromPlaylist: React.FC = ({ {hasMore && ( View all in {playlistLabel} → diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/RelatedPlaylist.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/RelatedPlaylist.tsx index 8d8602effa..51dce2304a 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/RelatedPlaylist.tsx +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/RelatedPlaylist.tsx @@ -4,7 +4,6 @@ import { Skeleton, Typography, styled, theme } from "ol-components" import VideoContainer from "./VideoContainer" import type { VideoPlaylistResource } from "api/v1" import { formatDurationHuman } from "ol-utilities" -import { videoPlaylistPageView } from "@/common/urls" const Section = styled.section(({ theme }) => ({ padding: "80px 0", @@ -140,13 +139,7 @@ const RelatedPlaylist: React.FC = ({ )) : collections.map((collection) => ( - + {collectionTypeLabel(collection)} diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.test.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.test.tsx index c82af78d12..6733edf605 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.test.tsx +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.test.tsx @@ -1,6 +1,7 @@ import React from "react" import user from "@testing-library/user-event" import { setMockResponse, urls, factories } from "api/test-utils" +import { absoluteUrl, videoDetailPath } from "@/common/urls" import { renderWithProviders, screen } from "@/test-utils" import { useFeatureFlagEnabled } from "posthog-js/react" import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" @@ -103,6 +104,24 @@ describe("VideoDetailPage", () => { mockedUseFeatureFlagsLoaded.mockReturnValue(true) }) + test("'more from this playlist' rows keep the browsed playlist and take only the backend slug", async () => { + const current = makeVideo({ title: "Current Video" }) + const sibling = makeVideo({ title: "Sibling Video" }) + renderPage({ + video: current, + playlistId: 99, + playlistItems: [current, sibling], + }) + + const row = await screen.findByRole("link", { + name: `Open video ${sibling.title}`, + }) + expect(row).toHaveAttribute( + "href", + videoDetailPath(sibling.id, 99, sibling.url_slug), + ) + }) + test("renders the video title once data is loaded", async () => { const video = makeVideo({ title: "Introduction to Machine Learning" }) renderPage({ video }) @@ -112,29 +131,23 @@ describe("VideoDetailPage", () => { }) }) - // Share URL is the slugged canonical form, and carries the playlist only - // when present (no `?playlist=null` when the video is viewed without one). - test.each([ - { - playlistId: 99, - expected: - "http://test.learn.odl.local:8062/video/720/intro-to-machine-learning?playlist=99", - }, - { - playlistId: null, - expected: - "http://test.learn.odl.local:8062/video/720/intro-to-machine-learning", - }, - ])( - "Share link is the slugged canonical URL (playlistId=$playlistId)", - async ({ playlistId, expected }) => { - const video = makeVideo({ id: 720, title: "Intro to Machine Learning" }) + // Sharing keeps the playlist the video is being watched in, even when that + // is not the canonical one: the recommendation is usually about the series. + // A video watched outside any playlist gets the bare form, not `playlist=null`. + test.each([{ playlistId: 99 }, { playlistId: null }])( + "Share link keeps the browsed playlist (playlistId=$playlistId)", + async ({ playlistId }) => { + const video = makeVideo({ title: "Intro to Machine Learning" }) renderPage({ video, playlistId }) await screen.findByRole("heading", { name: video.title }) await user.click(screen.getByRole("button", { name: /share/i })) - expect(screen.getByRole("textbox")).toHaveValue(expected) + expect(screen.getByRole("textbox")).toHaveValue( + absoluteUrl( + videoDetailPath(video.id, playlistId ?? undefined, video.url_slug), + ), + ) }, ) diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.tsx index 2be1d42088..2547e2edf0 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.tsx +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.tsx @@ -1,6 +1,5 @@ "use client" -import { env } from "@/env" import React, { useEffect, useRef } from "react" import { Skeleton, SkipLink } from "ol-components" import VideoContainer from "./VideoContainer" @@ -14,13 +13,11 @@ import { import type { VideoResource, VideoPlaylistResource } from "api/v1" import { VideoResourceResourceTypeEnum } from "api/v1" import { formatDurationClockTime } from "ol-utilities" -import { videoDetailPageView, videoPlaylistPageView } from "@/common/urls" +import { absoluteUrl, videoDetailPath, videoPlaylistPath } from "@/common/urls" import { buildVideoStructuredData } from "./videoStructuredData" import type { VideoPlayerHandle } from "@/page-components/VideoPlayer/VideoResourcePlayer" import * as Styled from "./VideoDetailPage.styled" -const NEXT_PUBLIC_ORIGIN = env("NEXT_PUBLIC_ORIGIN") - /** How many sibling videos the "More from" list shows at most. */ const MORE_FROM_LIMIT = 5 @@ -144,10 +141,7 @@ const VideoDetailPage: React.FC = ({ ...(playlist ? [ { - href: videoPlaylistPageView( - String(playlist.id), - playlist.title, - ), + href: playlist.learn_url, label: playlistLabel, }, ] @@ -163,9 +157,7 @@ const VideoDetailPage: React.FC = ({ {isLoading ? ( ) : playlist ? ( - + {playlistLabel} ) : null} @@ -197,7 +189,16 @@ const VideoDetailPage: React.FC = ({ )} @@ -232,7 +233,11 @@ const VideoDetailPage: React.FC = ({ offered_by: { code: "ocw", name: "OCW", channel_url: null }, }) -const makeVideo = (overrides = {}) => +const makeVideo = (overrides: { title?: string } = {}) => factories.learningResources.resource({ resource_type: ResourceTypeEnum.Video, ...overrides, @@ -212,7 +213,7 @@ describe("VideoPage", () => { const titleEl = await screen.findByText(collection.title) expect(titleEl.closest("a")).toHaveAttribute( "href", - `/video/${collection.id}/collection-video?playlist=${playlist.id}`, + videoDetailPath(collection.id, playlist.id, collection.url_slug), ) }) @@ -230,7 +231,7 @@ describe("VideoPage", () => { const titleEls = await screen.findAllByText(featured.title) expect(titleEls[0].closest("a")).toHaveAttribute( "href", - `/video/${featured.id}/quantum-computing-and-the-future?playlist=${playlist.id}`, + videoDetailPath(featured.id, playlist.id, featured.url_slug), ) }) }) @@ -304,13 +305,13 @@ describe("VideoPage", () => { const ep1Title = await screen.findByText(ep1.title) expect(ep1Title.closest("a")).toHaveAttribute( "href", - `/video/${ep1.id}/episode-alpha?playlist=${playlist.id}`, + videoDetailPath(ep1.id, playlist.id, ep1.url_slug), ) const ep2Title = screen.getByText(ep2.title) expect(ep2Title.closest("a")).toHaveAttribute( "href", - `/video/${ep2.id}/episode-beta?playlist=${playlist.id}`, + videoDetailPath(ep2.id, playlist.id, ep2.url_slug), ) }) }) diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoPlaylistCollectionPage.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoPlaylistCollectionPage.tsx index 69871a12df..cea8f4ba34 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoPlaylistCollectionPage.tsx +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoPlaylistCollectionPage.tsx @@ -12,7 +12,7 @@ import { } from "api/hooks/learningResources" import { formatDurationHuman } from "ol-utilities" import { isOcwPlaylist } from "@/common/utils" -import { videoDetailPageView } from "@/common/urls" +import { videoDetailPath } from "@/common/urls" import type { VideoResource, VideoPlaylistResource } from "api/v1" import { ResourceTypeEnum, VideoResourceResourceTypeEnum } from "api/v1" import { EpisodeItem } from "./SeriesVideoList" @@ -65,7 +65,7 @@ const VideoPlaylistCollectionPage: React.FC< VideoPlaylistCollectionPageProps > = ({ playlistId }) => { const getVideoHref = (resource: VideoResource) => - videoDetailPageView(resource.id, playlistId, resource.title) + videoDetailPath(resource.id, playlistId, resource.url_slug) const { data: playlist, diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.test.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.test.tsx index ad4adeb3ee..4e4253edee 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.test.tsx +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.test.tsx @@ -1,5 +1,6 @@ import React from "react" import { setMockResponse, urls, factories } from "api/test-utils" +import { absoluteUrl, videoDetailPath } from "@/common/urls" import { renderWithProviders, screen, user } from "@/test-utils" import VideoSeriesDetailPage from "./VideoSeriesDetailPage" import { ResourceTypeEnum } from "api/v1" @@ -130,10 +131,7 @@ describe("VideoSeriesDetailPage", () => { name: "Neural Networks Series", }) expect(playlistLinks.length).toBeGreaterThanOrEqual(1) - expect(playlistLinks[0]).toHaveAttribute( - "href", - `/video-playlist/${playlist.id}/neural-networks-series`, - ) + expect(playlistLinks[0]).toHaveAttribute("href", playlist.learn_url) }) test("does not include a playlist breadcrumb when no playlistId", async () => { @@ -206,7 +204,7 @@ describe("VideoSeriesDetailPage", () => { }) expect(prevLink).toHaveAttribute( "href", - `/video/${prev.id}/part-1?playlist=${playlist.id}`, + videoDetailPath(prev.id, playlist.id, prev.url_slug), ) }) @@ -226,7 +224,7 @@ describe("VideoSeriesDetailPage", () => { }) expect(nextLink).toHaveAttribute( "href", - `/video/${next.id}/part-2?playlist=${playlist.id}`, + videoDetailPath(next.id, playlist.id, next.url_slug), ) }) @@ -291,7 +289,7 @@ describe("VideoSeriesDetailPage", () => { ).not.toBeInTheDocument() }) - test("share URL uses the slugged canonical form with playlist param", async () => { + test("share URL keeps the playlist the video is watched in", async () => { const playlist = makePlaylist({ id: 99 }) const current = makeVideo({ id: 720, title: "Intro to Machine Learning" }) const next = makeVideo({ title: "Next Lecture" }) @@ -308,8 +306,10 @@ describe("VideoSeriesDetailPage", () => { name: /share intro to machine learning/i, }), ) + // Sharing hands out the page in front of the user, playlist included, + // even when that is not the canonical playlist. expect(screen.getByRole("textbox")).toHaveValue( - "http://test.learn.odl.local:8062/video/720/intro-to-machine-learning?playlist=99", + absoluteUrl(videoDetailPath(current.id, playlist.id, current.url_slug)), ) }) diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.tsx index 3d14cef071..8df08dff2b 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.tsx +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.tsx @@ -7,19 +7,16 @@ import { useLearningResourcesDetail } from "api/hooks/learningResources" import type { VideoResource, VideoPlaylistResource } from "api/v1" import { formatDurationClockTime } from "ol-utilities" import { useSeriesNavigation } from "./useSeriesNavigation" -import { videoDetailPageView, videoPlaylistPageView } from "@/common/urls" +import { absoluteUrl, videoDetailPath, videoPlaylistPath } from "@/common/urls" import SeriesNavBar from "./SeriesNavBar" import UpNextSection from "./UpNextSection" import * as Styled from "./VideoSeriesDetailPage.styled" -import { env } from "@/env" import { buildVideoStructuredData } from "./videoStructuredData" import VideoResourcePlayer from "@/page-components/VideoPlayer/VideoResourcePlayer" import type { VideoPlayerHandle } from "@/page-components/VideoPlayer/VideoResourcePlayer" import VideoShareButton from "./VideoShareButton" -const NEXT_PUBLIC_ORIGIN = env("NEXT_PUBLIC_ORIGIN") - const StyledVideoResourcePlayer = styled(VideoResourcePlayer)(({ theme }) => ({ borderBottom: `3px solid ${theme.custom.colors.darkGray2}`, })) @@ -122,10 +119,7 @@ const VideoSeriesDetailPage: React.FC = ({ ...(playlist && playlistId ? [ { - href: videoPlaylistPageView( - String(playlist.id), - playlist.title, - ), + href: playlist.learn_url, label: playlistLabel, }, ] @@ -139,10 +133,11 @@ const VideoSeriesDetailPage: React.FC = ({ {/* Series navigation bar */} {playlistId && ( = ({ )} diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/useSeriesNavigation.ts b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/useSeriesNavigation.ts index 02448816a7..b953858b37 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/useSeriesNavigation.ts +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/useSeriesNavigation.ts @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query" import { learningResourceQueries } from "api/hooks/learningResources" import type { VideoResource } from "api/v1" import { VideoResourceResourceTypeEnum } from "api/v1" -import { videoDetailPageView } from "@/common/urls" +import { videoDetailPath } from "@/common/urls" export type SeriesNavigation = { videoItems: VideoResource[] @@ -44,7 +44,7 @@ export function useSeriesNavigation( const videoPosition = currentIndex >= 0 ? currentIndex + 1 : null const getVideoHref = (v: VideoResource) => - videoDetailPageView(v.id, playlistId ?? undefined, v.title) + videoDetailPath(v.id, playlistId ?? undefined, v.url_slug) return { videoItems, diff --git a/frontends/main/src/app/(site)/podcast/[podcastId]/[slug]/page.test.tsx b/frontends/main/src/app/(site)/podcast/[podcastId]/[slug]/page.test.tsx index f912c0545f..b961423e4d 100644 --- a/frontends/main/src/app/(site)/podcast/[podcastId]/[slug]/page.test.tsx +++ b/frontends/main/src/app/(site)/podcast/[podcastId]/[slug]/page.test.tsx @@ -27,15 +27,12 @@ beforeEach(() => { const ORIGIN = "http://test.learn.odl.local:8062" -/** - * The backend names the canonical URL, so a test asserting on it must say what - * the backend returned. `slug` is the segment learn_url carries. - */ const mockPodcast = (slug = "beyond-biology") => { const id = 1234 const podcast = factories.learningResources.podcast({ id, title: "Beyond Biology", + url_slug: slug, learn_url: `${ORIGIN}/podcast/${id}/${slug}`, }) setMockResponse.get( diff --git a/frontends/main/src/app/(site)/podcast/[podcastId]/[slug]/page.tsx b/frontends/main/src/app/(site)/podcast/[podcastId]/[slug]/page.tsx index ad19747cd1..7b39e0cd9d 100644 --- a/frontends/main/src/app/(site)/podcast/[podcastId]/[slug]/page.tsx +++ b/frontends/main/src/app/(site)/podcast/[podcastId]/[slug]/page.tsx @@ -12,7 +12,7 @@ import { import { learningResourceQueries } from "api/hooks/learningResources" import { notFound, redirect } from "next/navigation" import { parseResourceId } from "@/common/slugs" -import { carrySearchParams, learnUrlPath } from "@/common/urls" +import { carrySearchParams, podcastPath } from "@/common/urls" type Props = AppPageProps<"/podcast/[podcastId]/[slug]"> @@ -57,10 +57,10 @@ const Page: React.FC = async (props) => { notFound() } - // The backend names the canonical URL; redirect if we're not on it (stale or - // uppercase slug, or a non-normalized id segment). - const canonical = learnUrlPath(resource.learn_url) - if (`/podcast/${podcastId}/${slug}` !== canonical) { + // The backend names the slug; redirect if we're not on the canonical form + // (stale or uppercase slug, or a non-normalized id segment). + const canonical = podcastPath(id, resource.url_slug) + if (podcastPath(podcastId, slug) !== canonical) { redirect(carrySearchParams(canonical, await props.searchParams)) } diff --git a/frontends/main/src/app/(site)/podcast/[podcastId]/page.test.tsx b/frontends/main/src/app/(site)/podcast/[podcastId]/page.test.tsx index 1d43ee65ff..d3825de307 100644 --- a/frontends/main/src/app/(site)/podcast/[podcastId]/page.test.tsx +++ b/frontends/main/src/app/(site)/podcast/[podcastId]/page.test.tsx @@ -26,16 +26,11 @@ const pageProps = ( searchParams: Promise.resolve(searchParams), }) -/** - * The backend names the canonical URL, so a test that asserts on it must say - * what the backend returned. The factory default is a drawer URL, which is what - * a resource with no page of its own gets. - */ const podcastWithPage = (id: number, slug: string) => factories.learningResources.podcast({ id, title: "Beyond Biology", - learn_url: `http://test.learn.odl.local:8062/podcast/${id}/${slug}`, + url_slug: slug, }) test("bare podcast id redirects to the slugged canonical", async () => { diff --git a/frontends/main/src/app/(site)/podcast/[podcastId]/page.tsx b/frontends/main/src/app/(site)/podcast/[podcastId]/page.tsx index 9c6cedc079..66548b37ae 100644 --- a/frontends/main/src/app/(site)/podcast/[podcastId]/page.tsx +++ b/frontends/main/src/app/(site)/podcast/[podcastId]/page.tsx @@ -4,7 +4,7 @@ import { ResourceTypeEnum } from "api" import { learningResourceQueries } from "api/hooks/learningResources" import { notFound, redirect } from "next/navigation" import { parseResourceId } from "@/common/slugs" -import { carrySearchParams, learnUrlPath } from "@/common/urls" +import { carrySearchParams, podcastPath } from "@/common/urls" /** Bare /podcast/{id} is never canonical → 307-redirect to the slugged form. */ const Page = async (props: AppPageProps<"/podcast/[podcastId]">) => { @@ -22,7 +22,7 @@ const Page = async (props: AppPageProps<"/podcast/[podcastId]">) => { } redirect( carrySearchParams( - learnUrlPath(resource.learn_url), + podcastPath(id, resource.url_slug), await props.searchParams, ), ) diff --git a/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/[slug]/page.test.tsx b/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/[slug]/page.test.tsx index f90a76a2d7..63cf822e8d 100644 --- a/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/[slug]/page.test.tsx +++ b/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/[slug]/page.test.tsx @@ -38,9 +38,10 @@ const mockEpisode = ( const episode = factories.learningResources.podcastEpisode({ id, title: "Episode One", + url_slug: slug, podcast_episode: { podcasts: parentIds, has_transcript: hasTranscript }, - // The backend names the slug, always under the canonical parent; the page - // resolves the parent segment against the request. + // learn_url is always scoped to the canonical parent; the page resolves the + // parent segment against the request instead. learn_url: `http://test.learn.odl.local:8062/podcast/${parentIds[0]}/podcast_episode/${id}/${slug}`, }) setMockResponse.get( diff --git a/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/[slug]/page.tsx b/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/[slug]/page.tsx index b68f9be800..fc912478c4 100644 --- a/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/[slug]/page.tsx +++ b/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/[slug]/page.tsx @@ -19,11 +19,7 @@ import { parseResourceId, resolveEpisodeParent, } from "@/common/slugs" -import { - carrySearchParams, - learnUrlSlug, - podcastEpisodePath, -} from "@/common/urls" +import { carrySearchParams, podcastEpisodePath } from "@/common/urls" type Props = AppPageProps<"/podcast/[podcastId]/podcast_episode/[episodeId]/[slug]"> @@ -88,11 +84,9 @@ const Page: React.FC = async (props) => { const canonical = podcastEpisodePath( String(epId), String(canonicalPodcastId), - learnUrlSlug(episode.learn_url), + episode.url_slug, ) - if ( - `/podcast/${podcastId}/podcast_episode/${episodeId}/${slug}` !== canonical - ) { + if (podcastEpisodePath(episodeId, podcastId, slug) !== canonical) { redirect(carrySearchParams(canonical, await props.searchParams)) } diff --git a/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/page.test.tsx b/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/page.test.tsx index 5a0d3986a2..3a81ed2e6a 100644 --- a/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/page.test.tsx +++ b/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/page.test.tsx @@ -19,9 +19,8 @@ test("bare episode URL redirects to the slugged canonical with corrected parent" const episode = factories.learningResources.podcastEpisode({ id, title: "Episode One", + url_slug: "episode-one", podcast_episode: { podcasts: [10] }, - // The backend names the slug; the factory default is a drawer URL. - learn_url: `http://test.learn.odl.local:8062/podcast/10/podcast_episode/${id}/episode-one`, }) setMockResponse.get( urls.learningResources.details({ id: episode.id }), diff --git a/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/page.tsx b/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/page.tsx index 7b0f410c5b..b75db277a6 100644 --- a/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/page.tsx +++ b/frontends/main/src/app/(site)/podcast/[podcastId]/podcast_episode/[episodeId]/page.tsx @@ -8,11 +8,7 @@ import { parseResourceId, resolveEpisodeParent, } from "@/common/slugs" -import { - carrySearchParams, - learnUrlSlug, - podcastEpisodePath, -} from "@/common/urls" +import { carrySearchParams, podcastEpisodePath } from "@/common/urls" /** * Bare /podcast/{podcastId}/podcast_episode/{episodeId} is never canonical → @@ -46,7 +42,7 @@ const Page = async ( podcastEpisodePath( String(epId), String(canonicalPodcastId), - learnUrlSlug(episode.learn_url), + episode.url_slug, ), await props.searchParams, ), diff --git a/frontends/main/src/app/(site)/video-playlist/[id]/[slug]/page.test.tsx b/frontends/main/src/app/(site)/video-playlist/[id]/[slug]/page.test.tsx index 88b290056c..ae5b45e625 100644 --- a/frontends/main/src/app/(site)/video-playlist/[id]/[slug]/page.test.tsx +++ b/frontends/main/src/app/(site)/video-playlist/[id]/[slug]/page.test.tsx @@ -26,12 +26,12 @@ beforeEach(() => { }) }) -/** The backend names the canonical URL; `slug` is the segment it carries. */ const mockPlaylist = (slug = "great-talks") => { const id = 4242 const playlist = factories.learningResources.videoPlaylist({ id, title: "Great Talks", + url_slug: slug, learn_url: `http://test.learn.odl.local:8062/video-playlist/${id}/${slug}`, }) // Playlist detail is a different endpoint from learningResources.details. diff --git a/frontends/main/src/app/(site)/video-playlist/[id]/[slug]/page.tsx b/frontends/main/src/app/(site)/video-playlist/[id]/[slug]/page.tsx index 98b4b7ff20..8416be32d9 100644 --- a/frontends/main/src/app/(site)/video-playlist/[id]/[slug]/page.tsx +++ b/frontends/main/src/app/(site)/video-playlist/[id]/[slug]/page.tsx @@ -10,7 +10,7 @@ import { getQueryClient } from "@/app/getQueryClient" import VideoPlaylistCollectionPage from "@/app-pages/VideoPlaylistCollectionPage/VideoPlaylistCollectionPage" import { notFound, redirect } from "next/navigation" import { parseResourceId } from "@/common/slugs" -import { carrySearchParams, learnUrlPath } from "@/common/urls" +import { carrySearchParams, videoPlaylistPath } from "@/common/urls" type Props = AppPageProps<"/video-playlist/[id]/[slug]"> @@ -63,8 +63,8 @@ const Page: React.FC = async ({ params, searchParams }) => { videoPlaylistQueries.detail(playlistId), ) - const canonical = learnUrlPath(playlist.learn_url) - if (`/video-playlist/${id}/${slug}` !== canonical) { + const canonical = videoPlaylistPath(playlistId, playlist.url_slug) + if (videoPlaylistPath(id, slug) !== canonical) { redirect(carrySearchParams(canonical, await searchParams)) } diff --git a/frontends/main/src/app/(site)/video-playlist/[id]/page.test.tsx b/frontends/main/src/app/(site)/video-playlist/[id]/page.test.tsx index 4821edbf36..a4c5b82ea2 100644 --- a/frontends/main/src/app/(site)/video-playlist/[id]/page.test.tsx +++ b/frontends/main/src/app/(site)/video-playlist/[id]/page.test.tsx @@ -19,8 +19,7 @@ test("bare /video-playlist/{id} redirects to the slugged canonical", async () => const playlist = factories.learningResources.videoPlaylist({ id, title: "Great Talks", - // The backend names the canonical URL; the factory default is a drawer URL. - learn_url: `http://test.learn.odl.local:8062/video-playlist/${id}/great-talks`, + url_slug: "great-talks", }) setMockResponse.get( urls.videoPlaylists.details({ id: playlist.id }), diff --git a/frontends/main/src/app/(site)/video-playlist/[id]/page.tsx b/frontends/main/src/app/(site)/video-playlist/[id]/page.tsx index f305baa27a..5992c3d95c 100644 --- a/frontends/main/src/app/(site)/video-playlist/[id]/page.tsx +++ b/frontends/main/src/app/(site)/video-playlist/[id]/page.tsx @@ -3,7 +3,7 @@ import { videoPlaylistQueries } from "api/hooks/learningResources" import { getQueryClient } from "@/app/getQueryClient" import { notFound, redirect } from "next/navigation" import { parseResourceId } from "@/common/slugs" -import { carrySearchParams, learnUrlPath } from "@/common/urls" +import { carrySearchParams, videoPlaylistPath } from "@/common/urls" /** Bare /video-playlist/{id} is never canonical → 307-redirect to slugged form. */ const Page = async (props: AppPageProps<"/video-playlist/[id]">) => { @@ -18,7 +18,7 @@ const Page = async (props: AppPageProps<"/video-playlist/[id]">) => { ) redirect( carrySearchParams( - learnUrlPath(playlist.learn_url), + videoPlaylistPath(playlistId, playlist.url_slug), await props.searchParams, ), ) diff --git a/frontends/main/src/app/(site)/video/[id]/[slug]/page.test.tsx b/frontends/main/src/app/(site)/video/[id]/[slug]/page.test.tsx index 522489a6de..0bac1efa86 100644 --- a/frontends/main/src/app/(site)/video/[id]/[slug]/page.test.tsx +++ b/frontends/main/src/app/(site)/video/[id]/[slug]/page.test.tsx @@ -33,8 +33,9 @@ const mockVideo = (playlists: string[], slug = "beyond-biology") => { const id = 777 const video = factories.learningResources.video({ id, - // The backend names the slug, always under the canonical playlist; the page - // resolves ?playlist against the request. + url_slug: slug, + // learn_url is always scoped to the canonical playlist; the page resolves + // ?playlist against the request instead. learn_url: `http://test.learn.odl.local:8062/video/${id}/${slug}?playlist=${playlists[0]}`, title: "Beyond Biology", playlists, @@ -90,6 +91,22 @@ test("generateMetadata canonical is the same URL in every playlist context", asy ) }) +test("redirects a non-normalized ?playlist to its canonical spelling", async () => { + // Both sides of the compare come from videoDetailPath, so the incoming value + // has to survive verbatim: "007" resolves to playlist 7, whose canonical form + // is ?playlist=7. + const video = mockVideo(["7", "66"]) + await expect( + Page({ + params: Promise.resolve({ id: String(video.id), slug: "beyond-biology" }), + searchParams: Promise.resolve({ playlist: "007" }), + }), + ).rejects.toThrow("NEXT_REDIRECT") + expect(mockRedirect).toHaveBeenCalledWith( + `/video/${video.id}/beyond-biology?playlist=7`, + ) +}) + test("notFound for a resource that is not a video", async () => { const course = factories.learningResources.course() setMockResponse.get(urls.learningResources.details({ id: course.id }), course) diff --git a/frontends/main/src/app/(site)/video/[id]/[slug]/page.tsx b/frontends/main/src/app/(site)/video/[id]/[slug]/page.tsx index 56bdab3bfc..432663162a 100644 --- a/frontends/main/src/app/(site)/video/[id]/[slug]/page.tsx +++ b/frontends/main/src/app/(site)/video/[id]/[slug]/page.tsx @@ -19,7 +19,7 @@ import { resolveVideoPlaylist, videoPlaylistIds, } from "@/common/slugs" -import { carrySearchParams, learnUrlSlug, videoDetailPath } from "@/common/urls" +import { carrySearchParams, videoDetailPath } from "@/common/urls" type Props = AppPageProps<"/video/[id]/[slug]"> @@ -75,16 +75,13 @@ const Page: React.FC = async ({ params, searchParams }) => { const canonical = videoDetailPath( videoId, playlistId ?? undefined, - learnUrlSlug(video.learn_url), + video.url_slug, ) - const incomingBase = `/video/${id}/${slug}` // A repeated ?playlist (array) resolves as no-playlist but is never the // canonical form, so it always redirects (which strips it). const incoming = Array.isArray(rawPlaylist) ? null - : typeof rawPlaylist === "string" - ? `${incomingBase}?playlist=${rawPlaylist}` - : incomingBase + : videoDetailPath(id, rawPlaylist, slug) if (incoming !== canonical) { redirect(carrySearchParams(canonical, resolvedSearchParams, ["playlist"])) } diff --git a/frontends/main/src/app/(site)/video/[id]/page.test.tsx b/frontends/main/src/app/(site)/video/[id]/page.test.tsx index 3fb2aeed42..4e762878e2 100644 --- a/frontends/main/src/app/(site)/video/[id]/page.test.tsx +++ b/frontends/main/src/app/(site)/video/[id]/page.test.tsx @@ -18,9 +18,7 @@ test("bare /video/{id} redirects to the slug + first playlist", async () => { const id = 777 const video = factories.learningResources.video({ id, - // The backend names the slug; the page resolves ?playlist against the - // request, so learn_url carries the canonical playlist. - learn_url: `http://test.learn.odl.local:8062/video/${id}/beyond-biology?playlist=55`, + url_slug: "beyond-biology", title: "Beyond Biology", playlists: ["55", "66"], }) diff --git a/frontends/main/src/app/(site)/video/[id]/page.tsx b/frontends/main/src/app/(site)/video/[id]/page.tsx index d097173a75..b97deb73ea 100644 --- a/frontends/main/src/app/(site)/video/[id]/page.tsx +++ b/frontends/main/src/app/(site)/video/[id]/page.tsx @@ -8,7 +8,7 @@ import { resolveVideoPlaylist, videoPlaylistIds, } from "@/common/slugs" -import { carrySearchParams, learnUrlSlug, videoDetailPath } from "@/common/urls" +import { carrySearchParams, videoDetailPath } from "@/common/urls" /** Bare /video/{id} is never canonical → 307-redirect to slug + resolved playlist. */ const Page = async ({ params, searchParams }: AppPageProps<"/video/[id]">) => { @@ -31,11 +31,7 @@ const Page = async ({ params, searchParams }: AppPageProps<"/video/[id]">) => { ) redirect( carrySearchParams( - videoDetailPath( - videoId, - playlistId ?? undefined, - learnUrlSlug(video.learn_url), - ), + videoDetailPath(videoId, playlistId ?? undefined, video.url_slug), resolvedSearchParams, ["playlist"], ), diff --git a/frontends/main/src/common/urls.test.ts b/frontends/main/src/common/urls.test.ts index d9966a6f91..82228af4c2 100644 --- a/frontends/main/src/common/urls.test.ts +++ b/frontends/main/src/common/urls.test.ts @@ -6,10 +6,10 @@ import { coursePageView, ocwLearnPageView, programPageView, - podcastPageView, - podcastEpisodePageView, - videoDetailPageView, - videoPlaylistPageView, + podcastEpisodePath, + videoDetailPath, + podcastPath, + videoPlaylistPath, canonicalResourceDrawerUrl, carrySearchParams, resourceDrawerSearch, @@ -179,50 +179,6 @@ test.each([ expect(ocwLearnPageView(input)).toBe(expected) }) -describe("slug-aware path builders", () => { - test("podcastPageView appends a slug segment; blank → 'resource'; no title → bare", () => { - expect(podcastPageView("123", "Beyond Biology")).toBe( - "/podcast/123/beyond-biology", - ) - expect(podcastPageView("123", "2024")).toBe("/podcast/123/resource") // blank slug - // explicit undefined title → bare (redirects) - expect(podcastPageView("123", undefined)).toBe("/podcast/123") - }) - - test("podcastEpisodePageView slugs the episode, keeps podcast id bare", () => { - expect(podcastEpisodePageView("55", "123", "Episode One")).toBe( - "/podcast/123/podcast_episode/55/episode-one", - ) - expect(podcastEpisodePageView("55", "123", "你好")).toBe( - "/podcast/123/podcast_episode/55/resource", - ) - }) - - test("videoDetailPageView slugs the video and keeps ?playlist", () => { - expect(videoDetailPageView(16765, 13798, "Beyond Biology")).toBe( - "/video/16765/beyond-biology?playlist=13798", - ) - expect(videoDetailPageView(16765, undefined, "Beyond Biology")).toBe( - "/video/16765/beyond-biology", - ) - expect(videoDetailPageView(16765, 13798, undefined)).toBe( - "/video/16765?playlist=13798", - ) - }) - - test("videoDetailPageView allows a slug equal to 'embed' (embed route moved out)", () => { - expect(videoDetailPageView(16765, undefined, "Embed")).toBe( - "/video/16765/embed", - ) - }) - - test("videoPlaylistPageView appends a slug segment", () => { - expect(videoPlaylistPageView("13798", "Great Talks")).toBe( - "/video-playlist/13798/great-talks", - ) - }) -}) - describe("separate-param drawer builders", () => { test("resourceDrawerSearch emits resource + optional resource_title (relative)", () => { expect(resourceDrawerSearch(114927, "Beyond Biology")).toBe( @@ -244,17 +200,43 @@ describe("separate-param drawer builders", () => { }) }) -test("INVARIANT: canonical paths round-trip URL decoding byte-identically", () => { - // The [slug] pages compare Next's *decoded* route params against builder - // output; if a builder ever emits a percent-encodable character, a URL - // could redirect to a spelling of itself and loop. See pathSlug in urls.ts. - const paths = [ - podcastPageView("123", "Beyond Biology!"), - podcastEpisodePageView("55", "123", "Épisode #1"), - videoDetailPageView(16765, 13798, "你好"), - videoPlaylistPageView("9", "a".repeat(70)), - ] - paths.forEach((p) => expect(decodeURIComponent(p)).toBe(p)) +describe("resource page paths", () => { + test("podcastEpisodePath places the episode under the given podcast", () => { + expect(podcastEpisodePath("55", "123", "episode-one")).toBe( + "/podcast/123/podcast_episode/55/episode-one", + ) + // No slug → bare, which redirects to the canonical form. + expect(podcastEpisodePath("55", "123", undefined)).toBe( + "/podcast/123/podcast_episode/55", + ) + }) + + test("videoDetailPath keeps ?playlist alongside the slug", () => { + expect(videoDetailPath(16765, 13798, "beyond-biology")).toBe( + "/video/16765/beyond-biology?playlist=13798", + ) + expect(videoDetailPath(16765, undefined, "beyond-biology")).toBe( + "/video/16765/beyond-biology", + ) + expect(videoDetailPath(16765, 13798, undefined)).toBe( + "/video/16765?playlist=13798", + ) + }) + + test("videoPlaylistPath appends the slug when there is one", () => { + expect(videoPlaylistPath(13798, "xtalks")).toBe( + "/video-playlist/13798/xtalks", + ) + // No slug → bare, which redirects to the canonical form. + expect(videoPlaylistPath(13798, undefined)).toBe("/video-playlist/13798") + }) + + test("podcastPath appends the slug when there is one", () => { + expect(podcastPath(123, "beyond-biology")).toBe( + "/podcast/123/beyond-biology", + ) + expect(podcastPath(123, undefined)).toBe("/podcast/123") + }) }) describe("carrySearchParams", () => { diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index dca3cb939b..17b4219f9a 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -160,19 +160,6 @@ export const RESOURCE_DRAWER_PARAMS = { syllabusOnly: "syllabus_only", } as const -/** - * Path slug segment from a title: the slug, or the literal "resource" when the - * slug is blank (the canonical path's slug segment is mandatory — see the - * readable-URLs spec, mitodl/hq#11210). The slug is cosmetic and ignored on - * lookup. - * - * INVARIANT: canonical paths must round-trip Next's URL decoding - * byte-identically — keep the slug charset to [a-z0-9-] and ids numeric, or - * the [slug] pages' incoming-vs-canonical string compares could redirect a - * URL to a spelling of itself and loop. - */ -const pathSlug = (title: string): string => slugify(title) || "resource" - /** Prefix a same-origin path with the public origin (for canonical tags). */ export const absoluteUrl = (path: string): string => `${requiredEnv("NEXT_PUBLIC_ORIGIN")}${path}` @@ -371,34 +358,37 @@ export const LINKEDIN_ADD_TO_PROFILE_BASE_URL = export const COURSE_PAGE_VIEW = "/courses/[readableId]" export const coursePageView = (readableId: string) => generatePath(COURSE_PAGE_VIEW, { readableId }) -// Each page-view builder appends a mandatory slug segment when a title is given -// (the slug, or the literal "resource" when blank). With an undefined title it -// emits the bare path, which still resolves and 307-redirects to canonical. -// `title` is required-but-undefinable so a call site can't silently omit it — -// passing undefined (e.g. a title still in flight) is a visible opt-in to the -// redirecting bare form. Id and slug are separate segments; the slug is -// cosmetic and ignored on lookup. +// The resource page builders below take the resource's `url_slug`. `slug` is +// required-but-undefinable so a call site can't silently omit it; passing +// undefined emits the bare path, which resolves and 307-redirects to the +// slugged canonical. export const VIDEO_PLAYLIST_PAGE_VIEW = "/video-playlist/[id]" -export const videoPlaylistPageView = ( - id: string, - title: string | undefined, +export const videoPlaylistPath = ( + id: number | string, + slug: string | undefined, ) => { - const base = generatePath(VIDEO_PLAYLIST_PAGE_VIEW, { id }) - return title === undefined ? base : `${base}/${pathSlug(title)}` + const base = generatePath(VIDEO_PLAYLIST_PAGE_VIEW, { id: String(id) }) + return slug === undefined ? base : `${base}/${slug}` } export const PODCASTS_PAGE_VIEW = "/podcasts" export const PODCAST_PAGE_VIEW = "/podcast/[podcastId]" -export const podcastPageView = (id: string, title: string | undefined) => { - const base = generatePath(PODCAST_PAGE_VIEW, { podcastId: id }) - return title === undefined ? base : `${base}/${pathSlug(title)}` +export const podcastPath = ( + podcastId: number | string, + slug: string | undefined, +) => { + const base = generatePath(PODCAST_PAGE_VIEW, { + podcastId: String(podcastId), + }) + return slug === undefined ? base : `${base}/${slug}` } + export const PODCAST_EPISODE_PAGE_VIEW = "/podcast/[podcastId]/podcast_episode/[episodeId]" /** - * An episode's path from an already-known slug. The episode pages read the slug - * from the resource's `learn_url` but resolve the parent podcast against the - * request, since an episode in several podcasts is viewable under any of them. + * An episode's path. The parent podcast is the caller's to choose: an episode in + * several podcasts is viewable under any of them, so a page passes the podcast + * it is being viewed under rather than the canonical one. */ export const podcastEpisodePath = ( id: string, @@ -406,31 +396,21 @@ export const podcastEpisodePath = ( slug: string | undefined, ) => { const base = generatePath(PODCAST_EPISODE_PAGE_VIEW, { - podcastId: String(podcastId), // bare context id - episodeId: String(id), + podcastId, // bare context id + episodeId: id, }) return slug === undefined ? base : `${base}/${slug}` } -export const podcastEpisodePageView = ( - id: string, - podcastId: string, - title: string | undefined, -) => - podcastEpisodePath( - id, - podcastId, - title === undefined ? undefined : pathSlug(title), - ) export const VIDEO_DETAIL_PAGE_VIEW = "/video/[videoId]" /** - * A video's path from an already-known slug. The video pages read the slug from - * the resource's `learn_url` but resolve `?playlist` against the request, since - * a video in several playlists is legitimately viewable in any of them. + * A video's path. `?playlist` is the caller's to choose: a video in several + * playlists is viewable in any of them, so a page passes the playlist it is + * being viewed in rather than the canonical one. */ export const videoDetailPath = ( - videoId: number, - playlistId: number | undefined, + videoId: number | string, + playlistId: number | string | undefined, slug: string | undefined, ) => { const path = generatePath(VIDEO_DETAIL_PAGE_VIEW, { @@ -444,39 +424,6 @@ export const videoDetailPath = ( return base } -export const videoDetailPageView = ( - videoId: number, - playlistId: number | undefined, - title: string | undefined, -) => { - return videoDetailPath( - videoId, - playlistId, - title === undefined ? undefined : pathSlug(title), - ) -} -/** - * The path and query of a resource's `learn_url`. - * - * `learn_url` is absolute; redirect targets and the [slug] pages' canonical - * comparisons are same-origin paths. Parsing rather than string-slicing keeps - * the readable-id characters that are legal unescaped in a path — an MITx - * Online id such as `course-v1:MITxT+14.100x` survives intact. - */ -export const learnUrlPath = (learnUrl: string): string => { - const { pathname, search } = new URL(learnUrl) - return `${pathname}${search}` -} - -/** - * The slug segment of a dedicated-page `learn_url`, i.e. its final path - * segment. Used where a page owns part of the URL the backend does not — a - * video's `?playlist`, which is resolved against the incoming request rather - * than fixed to the canonical parent. - */ -export const learnUrlSlug = (learnUrl: string): string => - new URL(learnUrl).pathname.split("/").filter(Boolean).at(-1) ?? "" - /** * Append a request's incoming search params to a canonical URL so redirects * preserve tracking params (e.g. utm_*). Params the canonical already sets diff --git a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx index 05ffadb86c..54eea7a44f 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx @@ -1,7 +1,9 @@ import React from "react" import { screen, fireEvent } from "@testing-library/react" import { renderWithProviders } from "@/test-utils" +import { faker } from "@faker-js/faker/locale/en" import { factories } from "api/test-utils" +import type { LearningResource } from "api" import { DEFAULT_RESOURCE_IMG } from "ol-utilities" import { getByImageSrc } from "ol-test-utilities" import { PlatformEnum, ResourceTypeEnum } from "api" @@ -123,6 +125,89 @@ describe("CallToActionSection", () => { ) }) + describe("resources whose canonical home is on Learn", () => { + const render = (resource: LearningResource) => + renderWithProviders( + , + ) + + it.each([ + { + label: "a video playlist", + resource: () => + factories.learningResources.videoPlaylist({ + resource_category: "Video Playlist", + }), + cta: "Learn More", + }, + { + label: "a video in a playlist", + resource: () => + factories.learningResources.video({ + resource_category: "Video", + playlists: [String(faker.number.int({ min: 1, max: 1e6 }))], + }), + cta: "Watch Video", + }, + { + label: "a podcast", + resource: () => + factories.learningResources.podcast({ + resource_category: "Podcast", + }), + cta: "Listen to Podcast", + }, + { + label: "an episode with a parent podcast", + resource: () => + factories.learningResources.podcastEpisode({ + resource_category: "Podcast Episode", + podcast_episode: { + podcasts: [faker.number.int({ min: 1, max: 1e6 })], + }, + }), + cta: "Listen to Podcast", + }, + ])("sends $label to its learn_url", ({ resource: make, cta }) => { + const resource = make() as LearningResource + render(resource) + expect(screen.getByRole("link", { name: cta })).toHaveAttribute( + "href", + resource.learn_url, + ) + }) + + it("sends an episode with no parent podcast to the source instead", () => { + // Its learn_url is this very drawer, so linking there would be circular. + const episode = factories.learningResources.podcastEpisode({ + resource_category: "Podcast Episode", + url: "https://example.com/episode.mp3", + podcast_episode: { podcasts: [] }, + }) as LearningResource + render(episode) + expect( + screen.getByRole("link", { name: "Listen to Podcast" }), + ).toHaveAttribute("href", expect.stringContaining("example.com/episode")) + }) + + it("sends a video outside every playlist to the source instead", () => { + const video = factories.learningResources.video({ + resource_category: "Video", + url: "https://youtube.com/watch?v=abc", + playlists: [], + }) as LearningResource + render(video) + expect(screen.getByRole("link", { name: "Watch Video" })).toHaveAttribute( + "href", + expect.stringContaining("youtube.com/watch"), + ) + }) + }) + describe("UTM parameters", () => { it("adds UTM params to external URLs", () => { const resource = factories.learningResources.resource({ diff --git a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx index aaeac27c1d..73843aa1f3 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx @@ -43,10 +43,6 @@ import { FACEBOOK_SHARE_BASE_URL, TWITTER_SHARE_BASE_URL, LINKEDIN_SHARE_BASE_URL, - videoDetailPageView, - videoPlaylistPageView, - podcastPageView, - podcastEpisodePageView, ocwLearnPageView, } from "@/common/urls" import { parentPodcastIds, videoPlaylistIds } from "@/common/slugs" @@ -321,27 +317,30 @@ const getResourceUrl = ( ocwProductPages?: boolean }, ) => { - if (resource.resource_type === ResourceTypeEnum.VideoPlaylist) { - return videoPlaylistPageView(resource.id.toString(), resource.title) + if ( + resource.resource_type === ResourceTypeEnum.VideoPlaylist || + resource.resource_type === ResourceTypeEnum.Podcast + ) { + return resource.learn_url } + + // A video outside every playlist does have a Learn page, but a context-free + // one. The fallthrough below reaches the OCW product page, which frames the + // video in its course. if (resource.resource_type === ResourceTypeEnum.Video) { const [firstPlaylist] = videoPlaylistIds(resource) if (firstPlaylist !== undefined) { - return videoDetailPageView(resource.id, firstPlaylist, resource.title) + return resource.learn_url } } - if (resource.resource_type === ResourceTypeEnum.Podcast) { - return podcastPageView(resource.id.toString(), resource.title) - } + // An episode with no parent podcast has no page of its own, so its + // `learn_url` is the drawer this button sits in. The fallthrough sends those + // to the source rather than linking the drawer to itself. if (resource.resource_type === ResourceTypeEnum.PodcastEpisode) { const [parentPodcastId] = parentPodcastIds(resource) if (parentPodcastId !== undefined) { - return podcastEpisodePageView( - resource.id.toString(), - String(parentPodcastId), - resource.title, - ) + return resource.learn_url } } diff --git a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.test.tsx index d2e892de8c..1f41c330a0 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.test.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.test.tsx @@ -12,7 +12,6 @@ import { renderWithProviders } from "@/test-utils" import { useFeatureFlagEnabled } from "posthog-js/react" import { kebabCase } from "lodash" import { faker } from "@faker-js/faker/locale/en" -import { podcastEpisodePageView } from "@/common/urls" import { parentPodcastIds } from "@/common/slugs" jest.mock("posthog-js/react") @@ -177,14 +176,10 @@ describe("Learning Resource Expanded", () => { const [parentPodcastId] = parentPodcastIds( resource as Parameters[0], ) + // With a parent podcast the CTA points at the episode's own page, which + // the backend names; without one it falls through to the source URL. const expectedHref = - parentPodcastId !== undefined - ? podcastEpisodePageView( - String(resource.id), - String(parentPodcastId), - resource.title, - ) - : resource.url + parentPodcastId !== undefined ? resource.learn_url : resource.url expect(link.href).toContain(expectedHref || "") }, ) diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourcePaste.test.ts b/frontends/main/src/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourcePaste.test.ts index c9fafcc362..41f2e8497e 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourcePaste.test.ts +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourcePaste.test.ts @@ -1,35 +1,34 @@ import { - videoDetailPageView, - podcastPageView, - podcastEpisodePageView, + videoDetailPath, + podcastEpisodePath, + podcastPath, resourceDrawerSearch, } from "@/common/urls" import { extractResourceId } from "./LearningResourcePaste" -// The paste handler must recover the resource id from the *canonical* URLs our -// builders emit — including the cosmetic slug segment they now append. Building -// inputs from the real constructors (rather than hand-written strings) keeps -// this contract pinned if the URL shape ever changes. +// The paste handler must recover the resource id from the URLs our path +// builders emit, cosmetic slug segment included. Every input here comes from a +// real builder, so a change to a URL shape reaches this test on its own. describe("extractResourceId", () => { test("recovers the video id from a canonical video URL", () => { - const url = videoDetailPageView(135366, 128974, "Intro to Machine Learning") + const url = videoDetailPath(135366, 128974, "intro-to-machine-learning") expect(extractResourceId(url)).toBe(135366) }) test("recovers the episode id (not the podcast id) from an episode URL", () => { - const url = podcastEpisodePageView("137277", "136068", "Episode One") + const url = podcastEpisodePath("137277", "136068", "episode-one") expect(extractResourceId(url)).toBe(137277) }) test("recovers the podcast id from a canonical podcast URL", () => { - const url = podcastPageView("136068", "Beyond Biology") + const url = podcastPath(136068, "beyond-biology") expect(extractResourceId(url)).toBe(136068) }) test("recovers the id from a bare (no-slug) canonical URL", () => { - // Builders emit the bare path when title is undefined; the id sits at the - // end of the string, exercising the `$` arm of the `(?:[/?#]|$)` boundary. - const url = podcastPageView("136068", undefined) + // The id sits at the end of the string, exercising the `$` arm of the + // `(?:[/?#]|$)` boundary. + const url = podcastPath(136068, undefined) expect(extractResourceId(url)).toBe(136068) }) diff --git a/learning_resources/serializers.py b/learning_resources/serializers.py index 5610e6ce29..685339788f 100644 --- a/learning_resources/serializers.py +++ b/learning_resources/serializers.py @@ -458,6 +458,19 @@ class PodcastEpisodeParentSerializer(serializers.Serializer): id = serializers.IntegerField(source="parent_id") title = serializers.CharField(source="parent.title") readable_id = serializers.CharField(source="parent.readable_id") + learn_url = serializers.SerializerMethodField( + help_text="Where this podcast lives within Learn" + ) + + def get_learn_url(self, instance) -> str: + """ + Return the parent podcast's own page on Learn. + + A podcast has no URL-forming parent of its own, hence the empty parent + list. `parent` is select_related by the `_podcasts` prefetch, so this + costs no extra query. + """ + return learn_url_for_resource(instance.parent, []) class PodcastEpisodeSerializer(serializers.ModelSerializer): diff --git a/learning_resources/serializers_test.py b/learning_resources/serializers_test.py index cc9691ffea..36f71aff95 100644 --- a/learning_resources/serializers_test.py +++ b/learning_resources/serializers_test.py @@ -40,7 +40,7 @@ LearningResource, LearningResourceRelationship, ) -from learning_resources.utils import path_slug +from learning_resources.utils import learn_url_for_resource, path_slug from main.test_utils import assert_json_equal, drf_datetime from main.utils import frontend_absolute_url @@ -261,6 +261,7 @@ def test_serialize_podcast_episode_playlists_to_json(): "id": podcast.learning_resource.id, "title": podcast.learning_resource.title, "readable_id": podcast.learning_resource.readable_id, + "learn_url": learn_url_for_resource(podcast.learning_resource, []), } ] diff --git a/learning_resources/utils.py b/learning_resources/utils.py index b68c077649..1795f9783b 100644 --- a/learning_resources/utils.py +++ b/learning_resources/utils.py @@ -1007,7 +1007,7 @@ def build_program_children_content_bulk(program_resources): SLUG_MAX_LENGTH = 60 # Path segments are mandatory, so a title that slugifies to nothing still needs -# a segment. Matches the frontend's `pathSlug`. +# a segment. BLANK_SLUG_PATH_SEGMENT = "resource" # Characters that are legal, unescaped, in a path segment: ! $ & ' ( ) * + , ; = : @ ~ @@ -1027,6 +1027,11 @@ def slugify_title(title: str) -> str: path segments substitute BLANK_SLUG_PATH_SEGMENT, the drawer omits its `resource_title` param. + The output charset is [a-z0-9-], and the frontend's `[slug]` pages depend on + that: they compare a path built from this slug against Next's + already-decoded route params, so a slug carrying a percent-encodable + character would redirect to a different spelling of itself and loop. + NOT interchangeable with django.utils.text.slugify, which deletes punctuation instead of converting it to "-", applies no length limit, and has no "no ascii letters" rule. Those diverge on ~30% of current titles. diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index f180879934..dd35fdab4f 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -4544,8 +4544,13 @@ components: type: string readable_id: type: string + learn_url: + type: string + description: Where this podcast lives within Learn + readOnly: true required: - id + - learn_url - readable_id - title PodcastEpisodeResource: diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 3e69979ff0..38049eb87a 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -15116,8 +15116,13 @@ components: type: string readable_id: type: string + learn_url: + type: string + description: Where this podcast lives within Learn + readOnly: true required: - id + - learn_url - readable_id - title PodcastEpisodeRequest: From 3416120a79eb53bca0a2a98fdc3323a72352ede5 Mon Sep 17 00:00:00 2001 From: Ahtisham Shahid Date: Fri, 11 Sep 2026 17:25:43 +0500 Subject: [PATCH 5/9] Sanitize and render rich-text descriptions from OVS (#3879) * feat(ovs): sanitize and render rich-text descriptions from OVS OVS is gaining a rich-text editor for video and collection descriptions (mitodl/hq#13064, mitodl/odl-video-service#1585). Two things follow for Learn. First, and independent of that work: the OVS ETL copied `description` straight through, and six components render it with dangerouslySetInnerHTML. Descriptions are plain text today so nothing is exploitable, but the moment OVS allows formatting this is live HTML from a source we do not control. `clean_description` runs both the video and the collection description through the same allowlist the podcast ETL uses for show notes - links kept, because links are the point of the feature. Second, the display side assumed plain text in places: - the series header interpolated `playlist.description`, so an author's formatting would have reached the learner as visible tags. - VideoCard, SeriesVideoList and MoreFromPlaylist inject the description inside a Link, where an author's is a nested anchor - invalid HTML that browsers resolve by splitting the row's own link. They now use the existing stripAnchorTags helper, keeping the words. - descriptions had no styling for lists, links or emphasis. A shared richTextDescription rule set covers the full-width surfaces; the clamped previews flatten lists inline so a list cannot blow the box out. - the schema.org VideoObject description carried raw markup. It is stripped locally rather than with common/htmlToPlainText, which is documented server-only (it pulls in isomorphic-dompurify) and this module is imported by a "use client" component. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * chore: prettier formatting * fix(ovs): strip tags to a fixed point in the JSON-LD description CodeQL, correctly: a single-pass replace(/<[^>]*>/g, "") is not idempotent - removing one match can join its neighbours into a new tag, so ipt> survives as /script>"], + ["entity-encoded tags", "<script>alert(1)</script>"], + ])("leaves no angle bracket for %s", (_name, html) => { + const result = descriptionOf(html) + expect(result).not.toContain("<") + expect(result).not.toContain(">") + }) + + test("decodes the entities the sanitizer emits", () => { + expect(descriptionOf("

Sessions 1 & 2

")).toBe("Sessions 1 & 2") + }) + + test("omits the description entirely when there is none", () => { + const data = buildVideoStructuredData(videoWith("")) + expect(data).not.toHaveProperty("description") + }) +}) diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/videoStructuredData.ts b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/videoStructuredData.ts index a500f5ba53..65c31ddd82 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/videoStructuredData.ts +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/videoStructuredData.ts @@ -12,6 +12,67 @@ const ISO_8601_DURATION_RE = * * See: https://developers.google.com/search/docs/appearance/structured-data/video */ +/* + * schema.org values are plain text, so the description has to be stripped of + * the markup OVS now sends. Deliberately not common/htmlToPlainText: that is + * documented server-only because it pulls in isomorphic-dompurify (jsdom), and + * this module is imported by a "use client" component. The input is already + * sanitized to a small tag set during ETL, so a strip plus the handful of + * entities nh3 emits is sufficient here. + */ +const HTML_ENTITIES: Record = { + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", + " ": " ", +} + +/* + * Strip tags to a fixed point rather than in one pass. + * + * A single `replace(/<[^>]*>/g, "")` is not idempotent: removing one match can + * join its neighbours into a new tag, so `ipt>` survives as `", "script"), + ('', "onerror"), + ('

hover

', "onmouseover"), + ('

styled

', "style="), + ('
x', "javascript"), + ], + ) + def test_dangerous_markup_is_stripped(self, raw, forbidden): + """A forged or compromised payload must not become live markup""" + assert forbidden not in clean_description(raw) + + @pytest.mark.parametrize("value", [None, "", 42, {"nope": True}]) + def test_non_string_payloads(self, value): + """The payload is untrusted and not necessarily a string""" + assert clean_description(value) == "" + + def test_transform_video_sanitizes(self, ovs_video_with_subtitles, settings): + """The sanitizing is wired into the transform, not just available""" + settings.OVS_API_BASE_URL = "https://ovs.example.com" + video = dict(ovs_video_with_subtitles) + video["description"] = "

ok

" + result = transform_video(video) + assert "script" not in result["description"] + assert "

ok

" in result["description"] + + def test_transform_collection_sanitizes(self, settings): + """Series descriptions get the same treatment as video descriptions""" + settings.OVS_API_BASE_URL = "https://ovs.example.com" + result = transform_collection( + { + "key": "abc123", + "title": "A series", + "description": "

ok

", + } + ) + assert "script" not in result["description"] + assert "

ok

" in result["description"] From f9da62b1709e02a288dfa1ab8c702d9acdef63b6 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Fri, 11 Sep 2026 10:23:56 -0400 Subject: [PATCH 6/9] fix(sentry): set max_request_body_size to small and scrub Postgres DETAIL rows (#3915) * fix(sentry): cap request bodies at 1KB and scrub Postgres DETAIL rows Two ways learner data reaches Sentry, neither gated by send_default_pii. Request bodies. The SDK sets request.data unconditionally at sentry_sdk/integrations/_wsgi_common.py:123; max_request_body_size, checked at :61, is the only control, and left unset it defaults to "medium" -- 10,000-byte bodies. Nobody chose that. Measured over the last 30 days, the write endpoints that actually raise are the sensitive ones: SCIM user PATCH, /api/v1/enrollments/, /api/checkout/result/, /api/checkout/redeem_discount/, /api/profile/details/, and CMS page edits. Set to "small" explicitly, so the choice is findable at the call site instead of in a dependency's defaults. Postgres DETAIL lines. A constraint violation carries a DETAIL line that echoes the whole offending row, and psycopg puts it in str(exc) -- so it ships inside the exception value, which no SDK privacy option covers. Measured on mitxonline MITXONLINE-6PK: a SCIM PATCH IntegrityError reproducing a learner email address three times per event, 46,764 occurrences since 2026-05-27. before_send now truncates at the DETAIL marker across exception values, logentry, and the legacy top-level message, keeping the primary error that names the failure. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RVJKTGk9KHUTN2xfU59ujX * fix(sentry): scrub DETAIL rows from the whole event, not three named fields Copilot review, verified against sentry-sdk 2.55.0 source. The first pass enumerated three paths -- exception values, logentry.message/.formatted, and the legacy top-level message -- and missed every other field that can carry the same string: breadcrumbs[].message LoggingIntegration records each log record as a breadcrumb (integrations/logging.py:311). This is exactly MITXONLINE-6PK's shape: mechanism=logging, logger=django_scim.views. logentry.params record.args verbatim (:274), so logger.error("...: %s", exc) carries it. frames[].vars include_local_variables defaults to True (consts.py:1028, utils.py:616), so a catch block holding the exception in a local carries it. Confirmed the old implementation leaked on all three shapes before changing it; the new tests fail against it and pass against the walk. Replaced with a recursive walk of the event instead of a longer path list -- it covers these without enumerating them and does not go stale when the SDK grows another such field. The walk only rewrites str leaves and preserves everything else, with a test pinning that. Copilot also suggested normalizing exception-valued params. Not needed: client._prepare_event serializes the event before calling before_send (client.py:650 vs :658), so every leaf is already a JSON primitive by then and there are no live exception objects left to coerce. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RVJKTGk9KHUTN2xfU59ujX * fix(sentry): scrub repr'd DETAIL lines and narrow the body-size comment The SDK repr()s frame locals and logging params before before_send, so the DETAIL line there carries a literal backslash-n and the old find() missed it. Match both forms, and test through the real SDK. The body-size comment was copied from mitxonline; it now describes what this app receives. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MYc2F3cFvSCfVzqeRMshGy --------- Co-authored-by: Claude Opus 5 --- main/sentry.py | 76 ++++++++++++++++- main/sentry_test.py | 198 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 main/sentry_test.py diff --git a/main/sentry.py b/main/sentry.py index 9a2d5fd754..4304b3b98e 100644 --- a/main/sentry.py +++ b/main/sentry.py @@ -1,6 +1,7 @@ """Sentry setup and configuration""" import logging +import re import sentry_sdk from celery.exceptions import WorkerLostError @@ -18,6 +19,62 @@ log = logging.getLogger() +# Postgres appends a DETAIL line to constraint violations that echoes the whole +# offending row -- on a users table that is the learner's name, email and +# external UUID. psycopg puts it in str(exc), so it ships inside the exception +# value, where no SDK privacy setting reaches it: send_default_pii governs +# user/cookie/header capture and max_request_body_size governs request bodies, +# and neither touches exception text. +# +# The newline is matched both raw and as a literal backslash-n: the SDK repr()s +# frame locals and non-string logging params during serialization, so there the +# DETAIL line arrives as "...constraint\\nDETAIL: ..." inside a repr string. +PG_DETAIL_RE = re.compile(r"(\n|\\n)DETAIL:.*", re.DOTALL) + + +def scrub_pg_detail(text): + """Truncate a Postgres error string at its DETAIL line. + + Keeps the primary message, which is what identifies the failure, and drops + the row echo plus any HINT/CONTEXT Postgres appends after it. + """ + return PG_DETAIL_RE.sub( + lambda match: match.group(1) + "DETAIL: [scrubbed]", text, count=1 + ) + + +def scrub_pg_details(event): + """Truncate Postgres DETAIL lines everywhere in a Sentry event. + + The row echo reaches Sentry through more fields than the exception value: + LoggingIntegration puts the log message in a breadcrumb + (BreadcrumbHandler._breadcrumb_from_record), logger.error("...: %s", exc) + puts it in logentry.params (EventHandler._emit), and captured stack-frame + locals carry it in frame vars because include_local_variables defaults to + True (serialize_frame). Walking the whole event covers those without + enumerating them, and does not go stale when the SDK adds another. + + Safe to walk naively because Client._prepare_event serializes the event + before calling before_send, so every leaf here is already a JSON + primitive -- no live exception objects to coerce. + """ + return _scrub_node(event) + + +def _scrub_node(node): + """Recurse through the serialized event, rewriting strings in place.""" + if isinstance(node, str): + return scrub_pg_detail(node) + if isinstance(node, dict): + for key, value in node.items(): + node[key] = _scrub_node(value) + return node + if isinstance(node, list): + node[:] = [_scrub_node(item) for item in node] + return node + return node + + def before_send(event, hint): """ Filter or transform events before they're sent to Sentry @@ -34,7 +91,7 @@ def before_send(event, hint): if isinstance(exc_value, SHUTDOWN_ERRORS): # so we don't want to report expected shutdown errors to sentry return None - return event + return scrub_pg_details(event) def init_sentry( # noqa: PLR0913 @@ -74,6 +131,23 @@ def init_sentry( # noqa: PLR0913 environment=environment, release=version, before_send=before_send, + # Request bodies are NOT gated on send_default_pii: the SDK sets + # request.data unconditionally (RequestExtractor.extract_into_event) + # and this is the only control (request_body_within_bounds). Left + # unset it defaults to "medium", i.e. 10,000-byte bodies. Set + # explicitly so the choice is findable here rather than in a + # dependency's defaults. + # + # This applies to every Django view, the webhooks included. The + # content_files webhook posts a few short fields (content_path, source, + # course ids), so its bodies still fit under the bound. + # + # It is not a hard 1KB cap: the bound is checked against the declared + # Content-Length, and a missing one counts as 0. Under ASGI (granian + # serving main.asgi), Django takes CONTENT_LENGTH from the request + # header, so a chunked request without one has its parsed body + # captured in full. + max_request_body_size="small", traces_sample_rate=traces_sample_rate, profiles_sample_rate=profiles_sample_rate, # Sentry's auto-enabling integrations (langchain, openai, etc.) import diff --git a/main/sentry_test.py b/main/sentry_test.py new file mode 100644 index 0000000000..96cbe47e5c --- /dev/null +++ b/main/sentry_test.py @@ -0,0 +1,198 @@ +"""Tests for Sentry event scrubbing.""" + +import json +import logging + +import pytest +import sentry_sdk +from sentry_sdk.integrations.logging import LoggingIntegration +from sentry_sdk.transport import Transport + +from main.sentry import ( + before_send, + scrub_pg_detail, + scrub_pg_details, +) + +# A real MITXONLINE-6PK exception value, with the learner identifiers replaced. +PG_INTEGRITY_ERROR = ( + 'null value in column "name" of relation "users_user" violates not-null ' + "constraint\n" + "DETAIL: Failing row contains (1863408, , 2026-08-07 18:38:14.503726+00, f, " + "learner@example.invalid, learner@example.invalid, null, f, t, " + "12d7dfc5-6f84-46db-9383-2d7079434173, 1863408, learner@example.invalid, f)." +) +PG_PRIMARY_MESSAGE = ( + 'null value in column "name" of relation "users_user" violates not-null constraint' +) + + +class FakeTransport(Transport): + """Collect outgoing events instead of sending them.""" + + def __init__(self): + super().__init__() + self.events = [] + + def capture_envelope(self, envelope): + self.events.extend( + item.payload.json for item in envelope.items if item.type == "event" + ) + + +@pytest.fixture +def sentry_transport(): + """Initialize the real SDK with before_send, and detach it afterwards.""" + transport = FakeTransport() + sentry_sdk.init( + dsn="https://k@o0.ingest.sentry.io/0", + transport=transport, + before_send=before_send, + default_integrations=False, + integrations=[ + LoggingIntegration(level=logging.INFO, event_level=logging.ERROR) + ], + ) + yield transport + sentry_sdk.get_global_scope().set_client(None) + + +def test_detail_line_is_truncated(): + """The row echo goes; the primary error that names the failure stays.""" + scrubbed = scrub_pg_detail(PG_INTEGRITY_ERROR) + assert scrubbed.startswith(PG_PRIMARY_MESSAGE) + assert "learner@example.invalid" not in scrubbed + assert "12d7dfc5-6f84-46db-9383-2d7079434173" not in scrubbed + + +def test_escaped_detail_line_is_truncated(): + """repr() turns the newline into a literal backslash-n; that form goes too.""" + scrubbed = scrub_pg_detail(repr(Exception(PG_INTEGRITY_ERROR))) + assert PG_PRIMARY_MESSAGE in scrubbed + assert "learner@example.invalid" not in scrubbed + + +def test_message_without_detail_is_unchanged(): + """A message with no DETAIL line passes through untouched.""" + message = "connection to server failed" + assert scrub_pg_detail(message) == message + + +def test_hint_and_context_after_detail_are_dropped(): + """HINT and CONTEXT follow DETAIL and can quote row data too.""" + text = "boom\nDETAIL: row data\nHINT: try again\nCONTEXT: SQL statement" + scrubbed = scrub_pg_detail(text) + assert "row data" not in scrubbed + assert "try again" not in scrubbed + assert "SQL statement" not in scrubbed + + +def test_scrubs_exception_values_logentry_and_message(): + """Every place the SDK can put an error string is covered.""" + event = { + "exception": {"values": [{"value": PG_INTEGRITY_ERROR}]}, + "logentry": { + "message": PG_INTEGRITY_ERROR, + "formatted": PG_INTEGRITY_ERROR, + }, + "message": PG_INTEGRITY_ERROR, + } + scrub_pg_details(event) + assert "learner@example.invalid" not in repr(event) + + +def test_before_send_scrubs_the_event(): + """The scrub is wired into the before_send hook, not just callable.""" + event = {"exception": {"values": [{"value": PG_INTEGRITY_ERROR}]}} + assert "learner@example.invalid" not in repr(before_send(event, {})) + + +def test_scrubs_breadcrumb_messages(): + """LoggingIntegration records the log message as a breadcrumb.""" + event = { + "breadcrumbs": { + "values": [ + { + "type": "log", + "category": "django_scim.views", + "message": PG_INTEGRITY_ERROR, + } + ] + } + } + scrub_pg_details(event) + assert "learner@example.invalid" not in repr(event) + + +def test_scrubs_logentry_params(): + """logger.error("...: %s", exc) puts the repr'd exception in logentry.params.""" + event = { + "logentry": { + "message": "Unable to complete SCIM call: %s", + "formatted": "Unable to complete SCIM call: " + PG_INTEGRITY_ERROR, + "params": [repr(Exception(PG_INTEGRITY_ERROR))], + } + } + scrub_pg_details(event) + assert "learner@example.invalid" not in repr(event) + + +def test_scrubs_captured_frame_locals(): + """include_local_variables defaults to True, so repr'd frame vars carry it.""" + event = { + "exception": { + "values": [ + { + "value": "boom", + "stacktrace": { + "frames": [ + { + "function": "save", + "vars": { + "exc": repr(Exception(PG_INTEGRITY_ERROR)), + "retries": 3, + }, + } + ] + }, + } + ] + } + } + scrub_pg_details(event) + assert "learner@example.invalid" not in repr(event) + frame = event["exception"]["values"][0]["stacktrace"]["frames"][0] + assert frame["vars"]["retries"] == 3 + + +def test_walk_preserves_non_string_leaves(): + """The walk must not coerce timestamps, ints or None into strings.""" + event = { + "timestamp": 1757345533.179, + "level": "error", + "extra": {"count": 42, "missing": None, "flag": True}, + "message": PG_INTEGRITY_ERROR, + } + scrub_pg_details(event) + assert event["timestamp"] == 1757345533.179 + assert event["extra"] == {"count": 42, "missing": None, "flag": True} + assert "learner@example.invalid" not in event["message"] + + +def test_real_sdk_scrubs_params_and_local_variables(sentry_transport): + """Go through the real SDK, which repr()s params and locals before before_send.""" + + def save(): + exc = Exception(PG_INTEGRITY_ERROR) + raise exc + + try: + save() + except Exception as e: # noqa: BLE001 + logging.getLogger("x").error("Unable to save: %s", e) # noqa: TRY400 + sentry_sdk.capture_exception(e) + sentry_sdk.flush() + + assert len(sentry_transport.events) == 2 + for event in sentry_transport.events: + assert "learner@example.invalid" not in json.dumps(event) From b09a428b31429c57bd139fe9c73ad9500cf0c6a1 Mon Sep 17 00:00:00 2001 From: Ahtisham Shahid Date: Sat, 12 Sep 2026 01:06:24 +0500 Subject: [PATCH 7/9] chore: refresh drf-lint baseline for ORM003-ORM006 (#3928) mitol-drf-lint 2026.8.28 added cross-file analysis and rules ORM003-ORM009; the hook pins no version, so pre-commit.ci picked the new release up when its cached env rebuilt. The checked-in baseline only covered ORM002, leaving 41 pre-existing N+1 risks unsuppressed and every build in the repo red. Regenerate the baseline over all tracked serializers.py so existing debt is grandfathered and recorded, while new violations still fail the hook. Existing ORM002 entries are preserved; nothing is dropped. Also ignore .drf_lint_cache.json, the cross-file index cache the new version writes at the repo root. --- .gitignore | 3 +++ drf_lint_baseline.json | 43 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e78ecff6ad..1e8fc16c8b 100644 --- a/.gitignore +++ b/.gitignore @@ -152,3 +152,6 @@ load_testing/data/ # Hacksnack game assets (copied by postinstall) frontends/main/public/games/hacksnack + +# drf-lint cross-file index cache +.drf_lint_cache.json diff --git a/drf_lint_baseline.json b/drf_lint_baseline.json index 2222d8bddd..2a50349ca7 100644 --- a/drf_lint_baseline.json +++ b/drf_lint_baseline.json @@ -2,5 +2,46 @@ "channels/serializers.py:132:24:ORM002", "channels/serializers.py:134:24:ORM002", "channels/serializers.py:136:24:ORM002", - "profiles/serializers.py:205:16:ORM002" + "channels/serializers.py:97:32:ORM003", + "learning_resources/serializers.py:103:4:ORM005", + "learning_resources/serializers.py:1281:44:ORM005", + "learning_resources/serializers.py:1315:61:ORM003", + "learning_resources/serializers.py:1320:59:ORM003", + "learning_resources/serializers.py:1328:12:ORM004", + "learning_resources/serializers.py:1344:21:ORM004", + "learning_resources/serializers.py:1357:11:ORM006", + "learning_resources/serializers.py:1358:60:ORM006", + "learning_resources/serializers.py:1362:24:ORM004", + "learning_resources/serializers.py:1525:11:ORM006", + "learning_resources/serializers.py:1526:19:ORM006", + "learning_resources/serializers.py:1527:11:ORM006", + "learning_resources/serializers.py:1528:19:ORM006", + "learning_resources/serializers.py:1529:15:ORM006", + "learning_resources/serializers.py:153:4:ORM005", + "learning_resources/serializers.py:163:4:ORM005", + "learning_resources/serializers.py:1822:4:ORM005", + "learning_resources/serializers.py:1830:20:ORM004", + "learning_resources/serializers.py:213:4:ORM005", + "learning_resources/serializers.py:428:4:ORM005", + "learning_resources/serializers.py:429:4:ORM005", + "learning_resources/serializers.py:487:49:ORM006", + "learning_resources/serializers.py:502:12:ORM006", + "learning_resources/serializers.py:541:4:ORM005", + "learning_resources/serializers.py:590:4:ORM005", + "learning_resources/serializers.py:605:17:ORM006", + "learning_resources/serializers.py:610:17:ORM006", + "learning_resources/serializers.py:620:17:ORM006", + "learning_resources/serializers.py:877:18:ORM004", + "learning_resources/serializers.py:926:16:ORM004", + "learning_resources/serializers.py:943:20:ORM004", + "learning_resources/serializers.py:965:20:ORM004", + "learning_resources_search/serializers.py:661:4:ORM005", + "learning_resources_search/serializers.py:663:4:ORM005", + "profiles/serializers.py:108:39:ORM006", + "profiles/serializers.py:108:60:ORM006", + "profiles/serializers.py:113:19:ORM006", + "profiles/serializers.py:205:16:ORM002", + "profiles/serializers.py:353:45:ORM004", + "profiles/serializers.py:359:45:ORM004", + "profiles/serializers.py:502:8:ORM006" ] From fd9a67b1fada36d96e3e99e571e3a5c8dab5eab9 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Fri, 11 Sep 2026 16:40:58 -0400 Subject: [PATCH 8/9] Only offer a run's courseware once it has started (#3925) * pass start date into enrollment handlers and make sure a course has actually started before automatically redirecting to it * titles should also not be clickable by non-admins if the course hasn't started yet * allow Toaster to also display success messages and show one when you successfully enroll in a course but are not redirected * revert the enroll success toast Adding a success Snackbar belongs in smoot-design with a design that every surface can reuse, not bolted onto an unrelated bug fix. Reverted here and tracked separately. The start-date gate is unchanged: enrolling in a run that has not started still enrolls without redirecting, and the card re-renders as enrolled showing when the run starts. Toaster, toastStore and the test harness are back to their state on main. The comment rework and the SiblingRunsAccordion wording that rode along in the same commit are kept, which is why this is a manual revert rather than a git revert. --- .../EnrolledCourseCard.test.tsx | 49 +++ .../CoursewareDisplay/EnrolledCourseCard.tsx | 15 +- .../SiblingRunsAccordion.test.tsx | 54 +++ .../SiblingRunsAccordion.tsx | 14 +- .../UnenrolledCourseCard.test.tsx | 320 +++++++++++++++++- .../UnenrolledCourseCard.tsx | 2 + .../CoursewareDisplay/courseDateUtils.ts | 11 + .../hooks/useEnrollmentHandler.ts | 26 +- 8 files changed, 470 insertions(+), 21 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx index 1fde5e8ae5..e654b974fc 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx @@ -98,6 +98,55 @@ describe.each([ }, ) + test.each([ + { runDates: currentRunDates, expectLink: true, case: "started" }, + { runDates: futureRunDates, expectLink: false, case: "not started" }, + ])( + "Title links to courseware only once the run has started ($case)", + async ({ runDates, expectLink }) => { + setupUserApis() + const coursewareUrl = faker.internet.url() + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + grades: [], + certificate: null, + run: { ...runDates, courseware_url: coursewareUrl }, + }) + renderWithProviders() + const card = getCard() + const title = enrollment.run.course.title + + if (expectLink) { + expect(within(card).getByRole("link", { name: title })).toHaveAttribute( + "href", + coursewareUrl, + ) + } else { + // The heading still names the course, it just isn't a way in. + await waitFor(() => { + expect( + within(card).queryByRole("link", { name: title }), + ).not.toBeInTheDocument() + }) + expect(within(card).getByText(title)).toBeInTheDocument() + } + }, + ) + + test("Title links to courseware for staff before the run starts", async () => { + setupUserApis({ is_staff: true }) + const coursewareUrl = faker.internet.url() + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + grades: [], + certificate: null, + run: { ...futureRunDates, courseware_url: coursewareUrl }, + }) + renderWithProviders() + const link = await within(getCard()).findByRole("link", { + name: enrollment.run.course.title, + }) + expect(link).toHaveAttribute("href", coursewareUrl) + }) + test("Courseware button is a navigable link for staff even when course has not started", async () => { setupUserApis({ is_staff: true }) const coursewareUrl = faker.internet.url() diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.tsx index 0654379ef3..e6c4746bf8 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.tsx @@ -24,7 +24,7 @@ import { getDashboardEnrollmentStatus, pickCertificateEnrollment, } from "./model/dashboardViewModel" -import { getCourseDateText } from "./courseDateUtils" +import { canOpenCourseware, getCourseDateText } from "./courseDateUtils" import { isVerifiedEnrollmentMode } from "@/common/mitxonline" import { RiArrowUpCircleLine, RiAwardLine, RiMore2Line } from "@remixicon/react" import { useReplaceBasketItem } from "@/common/mitxonline/useReplaceBasketItem" @@ -287,7 +287,7 @@ export const EnrolledCourseCard = ({ const enrollmentMode = enrollment?.enrollment_mode const offerUpgrade = !enrollment?.b2b_contract_id const startDate = run?.start_date - const hasStarted = startDate ? isInPast(startDate) : true + const coursewareOpen = canOpenCourseware(startDate, { isStaff }) const endDate = run?.end_date const hasEnded = endDate ? isInPast(endDate) : false const hasCourseDateText = getCourseDateText(startDate, endDate) !== null @@ -335,7 +335,7 @@ export const EnrolledCourseCard = ({ productId={run?.upgrade_product_id} isVerifiedProgramEnrollment={isVerifiedProgramEnrollment} readableId={run?.courseware_id} - coursewareUrl={coursewareUrl ?? undefined} + coursewareUrl={coursewareOpen ? (coursewareUrl ?? undefined) : undefined} programReadableIds={ancestorContext?.parentProgramReadableIds} programCoursewareId={ ancestorContext?.programEnrollment?.program.readable_id @@ -364,7 +364,7 @@ export const EnrolledCourseCard = ({ ) : null const titleSection = ( - {coursewareUrl ? ( + {coursewareUrl && coursewareOpen ? ( ) - // Determine if button should be disabled - // Staff can access courseware even before the course has started const courseHasEnded = run?.end_date ? isInPast(run.end_date) : false - const isDisabled = Boolean( - !coursewareUrl || // Enrolled but no action available - (!!startDate && !hasStarted && !isStaff), // Enrolled but course hasn't started yet - ) + const isDisabled = Boolean(!coursewareUrl || !coursewareOpen) const isCompleted = enrollmentStatus === EnrollmentStatus.Completed || courseHasEnded const buttonText = isCompleted ? "View" : "Continue" diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/SiblingRunsAccordion.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/SiblingRunsAccordion.test.tsx index 55f9a3e099..c891670844 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/SiblingRunsAccordion.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/SiblingRunsAccordion.test.tsx @@ -4,6 +4,7 @@ import { screen, setMockResponse, user, + waitFor, within, } from "@/test-utils" import * as mitxonline from "api/mitxonline-test-utils" @@ -29,6 +30,12 @@ beforeEach(() => { // Each row resolves its own Receipt item from the order history; default to // none, tests override. setupOrderHistory() + // Rows read is_staff to decide whether pre-start courseware is reachable. + // The factory randomises it, so pin it off; the staff test overrides. + setMockResponse.get( + mitxonline.urls.userMe.get(), + mitxonline.factories.user.user({ is_staff: false }), + ) setPerRunMenus(true) }) @@ -358,6 +365,53 @@ describe("SiblingRunsToggle + SiblingRunsPanel", () => { ).not.toBeInTheDocument() }) + // A run that hasn't started cannot be completed, so drop the factory's + // default certificate; completion outranks the dates for the row label. + const makeUpcomingEnrollment = () => + mitxonline.factories.enrollment.courseEnrollment({ + certificate: null, + grades: [], + run: { + start_date: moment().add(30, "days").toISOString(), + end_date: moment().add(90, "days").toISOString(), + courseware_url: faker.internet.url(), + }, + }) + + test.each([ + { isStaff: false, expectLink: false }, + { isStaff: true, expectLink: true }, + ])( + "upcoming sibling run offers 'View content' only to staff (isStaff=$isStaff)", + async ({ isStaff, expectLink }) => { + setMockResponse.get( + mitxonline.urls.userMe.get(), + mitxonline.factories.user.user({ is_staff: isStaff }), + ) + renderWithProviders( + , + ) + await expandAccordion() + expect(await screen.findByText(/^Upcoming:/)).toBeInTheDocument() + + const link = screen.queryByRole("link", { + name: /View content for Upcoming/, + }) + if (expectLink) { + await waitFor(() => { + expect( + screen.getByRole("link", { name: /View content for Upcoming/ }), + ).toBeInTheDocument() + }) + } else { + expect(link).not.toBeInTheDocument() + } + }, + ) + test("upcoming sibling run label starts with 'Upcoming:'", async () => { // A run that hasn't started cannot be completed, so drop the factory's // default certificate; completion outranks the dates. diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/SiblingRunsAccordion.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/SiblingRunsAccordion.tsx index 626736652d..ad0baae01e 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/SiblingRunsAccordion.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/SiblingRunsAccordion.tsx @@ -12,7 +12,11 @@ import { RiSubtractLine, RiTimeLine, } from "@remixicon/react" -import { formatRunIdentifier, getRunTimeState } from "./courseDateUtils" +import { + canOpenCourseware, + formatRunIdentifier, + getRunTimeState, +} from "./courseDateUtils" import type { RunTimeState } from "./courseDateUtils" import { ActionButton, VisuallyHidden } from "@mitodl/smoot-design" import { EnrollmentStatusIcon } from "./EnrollmentStatus" @@ -22,6 +26,8 @@ import { useOrderIdForRun } from "@/common/mitxonline/useOrderIdForResource" import { getRunMenuItems } from "./runMenuItems" import { useFeatureFlagEnabled } from "posthog-js/react" import { FeatureFlags } from "@/common/feature_flags" +import { useQuery } from "@tanstack/react-query" +import { mitxUserQueries } from "api/mitxonline-hooks/user" const UpcomingRunIcon = styled(RiTimeLine)(({ theme }) => ({ width: "16px", @@ -194,7 +200,11 @@ const RunListRow: React.FC = ({ enrollment, isFirst, }) => { + const mitxOnlineUser = useQuery(mitxUserQueries.me()) const coursewareUrl = enrollment.run?.courseware_url + const coursewareOpen = canOpenCourseware(enrollment.run?.start_date, { + isStaff: mitxOnlineUser.data?.is_staff, + }) /** * Resolved per row so each run's Receipt item reflects that run's own order. * Every row shares the one `orders/history` query, so N rows still cost a @@ -241,7 +251,7 @@ const RunListRow: React.FC = ({ - {coursewareUrl && ( + {coursewareUrl && coursewareOpen && ( <> ({ const mitxOnlineCourse = mitxonline.factories.courses.course -const mitxUser = mitxonline.factories.user.user +// The factory randomises is_staff, and staff bypass the start-date gate, which +// would make these tests flaky. Staff tests pass it explicitly. +const mitxUser: typeof mitxonline.factories.user.user = (overrides = {}) => + mitxonline.factories.user.user({ is_staff: false, ...overrides }) const setupUserApis = (overrides?: Parameters[0]) => { const userData = mitxonline.factories.user.user({ @@ -301,6 +304,8 @@ describe.each([ user: ReturnType course: ReturnType run?: ReturnType + /** Defer the POST to control when the mutation settles. */ + enrollResponse?: unknown }) => { setMockResponse.get(mitxonline.urls.userMe.get(), opts.user) setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) @@ -308,10 +313,13 @@ describe.each([ const runId = opts.run?.courseware_id ?? opts.course.readable_id ?? undefined const enrollmentUrl = mitxonline.urls.b2b.courseEnrollment(runId) - setMockResponse.post(enrollmentUrl, { - result: "b2b-enroll-success", - order: 1, - }) + setMockResponse.post( + enrollmentUrl, + opts.enrollResponse ?? { + result: "b2b-enroll-success", + order: 1, + }, + ) const countries = [ { code: "US", name: "United States" }, @@ -332,6 +340,62 @@ describe.each([ { trigger: "title-link" as const }, ] + /** + * A response the test resolves by hand, giving `enrollAndSettle` a settle + * point to wait on. The B2B and verified redirects fire in `onSuccess` and + * leave no other trace, so asserting once the POST is merely issued can run + * before the redirect would have, and pass either way. + */ + const deferredResponse = () => { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } + } + + /** + * Checks both kinds of leaving: the hard `window.location` redirect this + * guards, and a router navigation, so neither can creep back in. + */ + const expectStayedPut = ( + location: ReturnType["location"], + pathnameBefore: string, + hrefBefore: string, + ) => { + expect(window.location.href).toBe(hrefBefore) + expect(location.current.pathname).toBe(pathnameBefore) + expect(location.current.search).toBe("") + } + + const enrollAndSettle = async ( + card: HTMLElement, + release: () => void, + ): Promise => { + const button = within(card).getByTestId("courseware-button") + await user.click(button) + await waitFor(() => { + expect(button).toHaveAttribute("aria-busy", "true") + }) + release() + await waitFor(() => { + expect(button).toHaveAttribute("aria-busy", "false") + }) + } + + const START_DATE_CASES = [ + { + case: "redirects to courseware when the run has started", + startDate: moment().subtract(7, "days").toISOString(), + expectRedirect: true, + }, + { + case: "does not redirect when the run has not started", + startDate: moment().add(30, "days").toISOString(), + expectRedirect: false, + }, + ] + test.each(ENROLLMENT_TRIGGERS)( "B2B enrollment for complete profile bypasses just-in-time dialog ($trigger)", async ({ trigger }) => { @@ -414,6 +478,93 @@ describe.each([ }, ) + test("B2B enrollment redirects staff to courseware even before the run starts", async () => { + const userData = mitxUser({ + is_staff: true, + legal_address: { country: "US" }, + user_profile: { year_of_birth: 1988 }, + }) + const b2bContractId = faker.number.int() + const coursewareUrl = faker.internet.url() + const run = mitxonline.factories.courses.courseRun({ + b2b_contract: b2bContractId, + is_enrollable: true, + start_date: moment().add(30, "days").toISOString(), + courseware_url: coursewareUrl, + }) + const course = mitxOnlineCourse({ courseruns: [run], next_run_id: run.id }) + const { enrollmentUrl } = setupEnrollmentApis({ + user: userData, + course, + run, + }) + + renderWithProviders( + , + ) + + await user.click(within(getCard()).getByTestId("courseware-button")) + + await waitFor(() => { + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: "post", url: enrollmentUrl }), + ) + }) + // Staff keep pre-start courseware access, so the redirect still fires. + await waitFor(() => { + expect(window.location.href).toBe(coursewareUrl) + }) + }) + + test.each(START_DATE_CASES)( + "B2B enrollment $case", + async ({ startDate, expectRedirect }) => { + const userData = mitxUser({ + legal_address: { country: "US" }, + user_profile: { year_of_birth: 1988 }, + }) + const b2bContractId = faker.number.int() + const coursewareUrl = faker.internet.url() + const run = mitxonline.factories.courses.courseRun({ + b2b_contract: b2bContractId, + is_enrollable: true, + start_date: startDate, + end_date: moment(startDate).add(60, "days").toISOString(), + courseware_url: coursewareUrl, + }) + const course = mitxOnlineCourse({ + courseruns: [run], + next_run_id: run.id, + }) + const enroll = deferredResponse() + const { enrollmentUrl } = setupEnrollmentApis({ + user: userData, + course, + run, + enrollResponse: enroll.promise, + }) + + const { location } = renderWithProviders( + , + ) + const pathnameBefore = location.current.pathname + const hrefBefore = window.location.href + + await enrollAndSettle(getCard(), () => + enroll.resolve({ result: "b2b-enroll-success", order: 1 }), + ) + + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: "post", url: enrollmentUrl }), + ) + if (expectRedirect) { + expect(window.location.href).toBe(coursewareUrl) + } else { + expectStayedPut(location, pathnameBefore, hrefBefore) + } + }, + ) + test("B2B enrollment targets the displayed (variant) run, not getBestRun's default pick", async () => { const userData = mitxUser({ legal_address: { country: "US" }, @@ -482,6 +633,65 @@ describe.each([ // --------------------------------------------------------------------------- describe("B2C (non-B2B) Enrollment", () => { + // The dialog is its own redirect path: the start date comes from the run + // picked there, not the one the card displayed. + test.each(START_DATE_CASES)( + "CourseEnrollmentDialog submission $case", + async ({ startDate, expectRedirect }) => { + setMockResponse.get(mitxonline.urls.userMe.get(), mitxUser()) + + const coursewareUrl = faker.internet.url() + // Both modes opens the dialog; a single run makes it preselect. + const run = mitxonline.factories.courses.courseRun({ + b2b_contract: null, + is_enrollable: true, + start_date: startDate, + end_date: moment(startDate).add(60, "days").toISOString(), + courseware_url: coursewareUrl, + enrollment_modes: [ + mitxonline.factories.courses.enrollmentMode({ + requires_payment: false, + }), + mitxonline.factories.courses.enrollmentMode({ + requires_payment: true, + }), + ], + }) + const course = mitxOnlineCourse({ + courseruns: [run], + next_run_id: run.id, + }) + setMockResponse.post(mitxonline.urls.enrollment.enrollmentsListV1(), {}) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) + + const { location } = renderWithProviders( + , + ) + const pathnameBefore = location.current.pathname + const hrefBefore = window.location.href + + await user.click(within(getCard()).getByTestId("courseware-button")) + const dialog = await screen.findByRole("dialog", { + name: course.title, + }) + await user.click( + within(dialog).getByRole("button", { + name: /Enroll for Free without a certificate/, + }), + ) + + // Fires inside the same onSuccess that decides where to go. + await waitFor(() => { + expect(trackCourseEnrolled).toHaveBeenCalledWith(course.title) + }) + if (expectRedirect) { + expect(window.location.href).toBe(coursewareUrl) + } else { + expectStayedPut(location, pathnameBefore, hrefBefore) + } + }, + ) + test.each(ENROLLMENT_TRIGGERS)( "Clicking $trigger opens CourseEnrollmentDialog for both-mode enrollment", async ({ trigger }) => { @@ -570,6 +780,54 @@ describe.each([ }, ) + test.each(START_DATE_CASES)( + "Free single-run enrollment $case", + async ({ startDate, expectRedirect }) => { + setMockResponse.get(mitxonline.urls.userMe.get(), mitxUser()) + + const coursewareUrl = faker.internet.url() + const run = mitxonline.factories.courses.courseRun({ + b2b_contract: null, + is_enrollable: true, + start_date: startDate, + end_date: moment(startDate).add(60, "days").toISOString(), + courseware_url: coursewareUrl, + enrollment_modes: [ + mitxonline.factories.courses.enrollmentMode({ + requires_payment: false, + }), + ], + }) + const course = mitxOnlineCourse({ + courseruns: [run], + next_run_id: run.id, + }) + + setMockResponse.post(mitxonline.urls.enrollment.enrollmentsListV1(), {}) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) + + const { location } = renderWithProviders( + , + ) + const pathnameBefore = location.current.pathname + const hrefBefore = window.location.href + + await user.click(within(getCard()).getByTestId("courseware-button")) + + await waitFor(() => { + expect(trackCourseEnrolled).toHaveBeenCalledWith(course.title) + }) + + if (expectRedirect) { + await waitFor(() => { + expect(window.location.href).toBe(coursewareUrl) + }) + } else { + expectStayedPut(location, pathnameBefore, hrefBefore) + } + }, + ) + test.each(ENROLLMENT_TRIGGERS)( "Clicking $trigger bypasses dialog for paid-only single-run enrollment", async ({ trigger }) => { @@ -634,6 +892,7 @@ describe.each([ const run = mitxonline.factories.courses.courseRun({ b2b_contract: null, is_enrollable: true, + start_date: moment().subtract(7, "days").toISOString(), courseware_url: faker.internet.url(), }) const course = mitxOnlineCourse({ @@ -687,6 +946,57 @@ describe.each([ }, ) + test.each(START_DATE_CASES)( + "Verified program enrollment $case", + async ({ startDate, expectRedirect }) => { + setMockResponse.get(mitxonline.urls.userMe.get(), mitxUser()) + + const coursewareUrl = faker.internet.url() + const run = mitxonline.factories.courses.courseRun({ + b2b_contract: null, + is_enrollable: true, + start_date: startDate, + end_date: moment(startDate).add(60, "days").toISOString(), + courseware_url: coursewareUrl, + }) + const course = mitxOnlineCourse({ + courseruns: [run], + next_run_id: run.id, + }) + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + enrollment_mode: "verified", + }) + const programEnrollmentEndpoint = + mitxonline.urls.verifiedProgramEnrollments.create(run.courseware_id) + const enroll = deferredResponse() + setMockResponse.post(programEnrollmentEndpoint, enroll.promise) + + const { location } = renderWithProviders( + , + ) + const pathnameBefore = location.current.pathname + const hrefBefore = window.location.href + + await enrollAndSettle(getCard(), () => enroll.resolve({})) + + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "post", + url: programEnrollmentEndpoint, + }), + ) + if (expectRedirect) { + expect(window.location.href).toBe(coursewareUrl) + } else { + expectStayedPut(location, pathnameBefore, hrefBefore) + } + }, + ) + test("Audit program enrollment opens CourseEnrollmentDialog when both enrollment modes are available", async () => { const userData = mitxUser() setMockResponse.get(mitxonline.urls.userMe.get(), userData) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.tsx index b92c88cdd6..bc672642b3 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.tsx @@ -85,12 +85,14 @@ export const UnenrolledCourseCard = ({ b2bProgramId: ancestorContext?.parentProgramReadableIds?.[0] ?? ancestorContext?.programEnrollment?.program.readable_id, + startDate: courseRun?.start_date, }) }, [ course, ancestorContext, readableId, coursewareUrl, + courseRun?.start_date, isContractPageResource, enrollment, ]) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/courseDateUtils.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/courseDateUtils.ts index ffae5f224a..9637f0b823 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/courseDateUtils.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/courseDateUtils.ts @@ -22,6 +22,17 @@ export const getRunTimeState = ( return "underway" } +/** + * Whether this run's courseware can be opened yet; staff may preview early. + * + * Shared by every route in (card button, card title, sibling-run rows, upgrade + * and post-enrollment redirects) so they cannot disagree. + */ +export const canOpenCourseware = ( + startDate?: string | null, + { isStaff = false }: { isStaff?: boolean } = {}, +): boolean => isStaff || getRunTimeState(startDate) !== "upcoming" + /** * A run's date range. Returns "" when the run has neither date; prefer * `formatRunIdentifier` for anything a learner reads, since that case is not diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useEnrollmentHandler.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useEnrollmentHandler.ts index 4728e13f36..57779b752b 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useEnrollmentHandler.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useEnrollmentHandler.ts @@ -14,6 +14,9 @@ import { getCourseEnrollmentAction } from "@/common/mitxonline" import { useComplianceGate } from "@/common/mitxonline/useComplianceGate" import CourseEnrollmentDialog from "@/page-components/EnrollmentDialogs/CourseEnrollmentDialog" import { trackCourseEnrolled } from "@/common/analytics/gtm" +import { canOpenCourseware } from "../courseDateUtils" +import { mitxUserQueries } from "api/mitxonline-hooks/user" +import { useQuery } from "@tanstack/react-query" const ENROLL_COURSE_ERROR = "Something went wrong enrolling you in this course. Please try again." @@ -32,6 +35,8 @@ export const useEnrollmentHandler = () => { }) const replaceBasketItem = useReplaceBasketItem() const { ensureCompliance } = useComplianceGate() + const mitxOnlineUser = useQuery(mitxUserQueries.me()) + const isStaff = mitxOnlineUser.data?.is_staff const enroll = React.useCallback( async ({ @@ -44,6 +49,7 @@ export const useEnrollmentHandler = () => { programCoursewareId, programReadableIds, b2bProgramId, + startDate, }: { course: CourseWithCourseRunsSerializerV2 readableId?: string @@ -54,7 +60,18 @@ export const useEnrollmentHandler = () => { programCoursewareId?: string programReadableIds?: string[] b2bProgramId?: string + startDate?: string | null }) => { + /** + * Enrolling early is allowed, so only redirect once the courseware is + * open. Otherwise the learner stays put and the card they clicked + * re-renders as enrolled, showing when the run starts. + */ + const finishEnrollment = (url: string, runStartDate?: string | null) => { + if (!canOpenCourseware(runStartDate, { isStaff })) return + window.location.href = url + } + if (isB2B) { if (!readableId) { console.warn("Cannot enroll in B2B course: missing required data", { @@ -90,7 +107,7 @@ export const useEnrollmentHandler = () => { }, { onSuccess: () => { - window.location.href = destinationUrl + finishEnrollment(destinationUrl, startDate) }, }, ) @@ -125,7 +142,7 @@ export const useEnrollmentHandler = () => { { courserun_id: readableId, request_body: requestBody }, { onSuccess: () => { - window.location.href = verifiedDestination ?? href + finishEnrollment(verifiedDestination ?? href, startDate) }, }, ) @@ -144,7 +161,7 @@ export const useEnrollmentHandler = () => { enrollmentAction.run.courseware_url ?? href if (destination) { - window.location.href = destination + finishEnrollment(destination, enrollmentAction.run.start_date) } }, }, @@ -158,7 +175,7 @@ export const useEnrollmentHandler = () => { } const onCourseEnroll = (run: CourseRunV2) => { - window.location.href = run.courseware_url! + finishEnrollment(run.courseware_url!, run.start_date) } NiceModal.show(CourseEnrollmentDialog, { course, onCourseEnroll }) } @@ -169,6 +186,7 @@ export const useEnrollmentHandler = () => { createEnrollment, createVerifiedProgramEnrollment, replaceBasketItem, + isStaff, ], ) From 78d678e8793a59b4c087194ebc8e4ff03fc89215 Mon Sep 17 00:00:00 2001 From: Doof Date: Mon, 14 Sep 2026 07:05:13 +0000 Subject: [PATCH 9/9] Release 0.80.2 --- RELEASE.rst | 12 ++++++++++++ main/settings.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 9bfa45f5bc..453d9a25d4 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,18 @@ Release Notes ============= +Version 0.80.2 +-------------- + +- Only offer a run's courseware once it has started (#3925) +- chore: refresh drf-lint baseline for ORM003-ORM006 (#3928) +- fix(sentry): set max_request_body_size to small and scrub Postgres DETAIL rows (#3915) +- Sanitize and render rich-text descriptions from OVS (#3879) +- Build internal resource links from learn_url (#3885) +- Update dependency sharp to v0.35.4 [SECURITY] (#3917) +- Skip staff-only OLX content when ingesting edX course archives (#3909) +- Update certificate description in Product Page CertificateTrackCard (#3924) + Version 0.80.1 -------------- diff --git a/main/settings.py b/main/settings.py index 4938527cf8..a0f3353dd4 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.1" +VERSION = "0.80.2" log = logging.getLogger()