diff --git a/lms/resources/_js_config/__init__.py b/lms/resources/_js_config/__init__.py index 7f8a276008..ba1f304971 100644 --- a/lms/resources/_js_config/__init__.py +++ b/lms/resources/_js_config/__init__.py @@ -3,6 +3,7 @@ from datetime import timedelta from enum import Enum, StrEnum from typing import Any +from urllib.parse import urlparse from lms.error_code import ErrorCode from lms.events import LTIEvent @@ -21,10 +22,31 @@ JSTORService, OrganizationService, VitalSourceService, + YouTubeService, ) from lms.validation.authentication import BearerTokenSchema from lms.views.helpers import via_url +# Regex to extract YouTube video ID (same URL patterns as frontend utils/youtube.ts) +_YOUTUBE_VIDEO_ID_RE = re.compile( + r"(?:youtu\.be/|v/|u/\w/|embed/|shorts/|live/|watch\?v=|&v=)([^#&?]*)", + re.IGNORECASE, +) + + +def _youtube_video_id_from_url(url: str) -> str | None: + """Return the YouTube video ID if url is a YouTube URL, else None.""" + try: + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return None + if parsed.netloc.lower() not in ("www.youtube.com", "youtube.com", "youtu.be"): + return None + match = _YOUTUBE_VIDEO_ID_RE.search(url) + return match.group(1) if match and match.group(1) else None + except (ValueError, AttributeError): + return None + class JSConfig: """The config for the app's JavaScript code.""" @@ -168,6 +190,9 @@ def add_document_url( # pylint: disable=too-complex,too-many-branches,useless-s } else: self._config["viaUrl"] = via_url(self._request, document_url) + youtube_service = self._request.find_service(iface=YouTubeService) + if youtube_service.enabled and _youtube_video_id_from_url(document_url): + self._hypothesis_client["youtubeAssignment"] = True def _update_focus_config(self, updates: dict): """ diff --git a/tests/unit/lms/resources/_js_config/__init___test.py b/tests/unit/lms/resources/_js_config/__init___test.py index ce9e82119b..c05b23b8eb 100644 --- a/tests/unit/lms/resources/_js_config/__init___test.py +++ b/tests/unit/lms/resources/_js_config/__init___test.py @@ -1,5 +1,5 @@ from datetime import timedelta -from unittest.mock import create_autospec, sentinel +from unittest.mock import create_autospec, patch, sentinel import pytest from h_matchers import Any @@ -7,7 +7,7 @@ from lms.models import Grouping, LTIParams from lms.product.product import Routes from lms.resources import LTILaunchResource, OAuth2RedirectResource -from lms.resources._js_config import JSConfig +from lms.resources._js_config import JSConfig, _youtube_video_id_from_url from lms.security import Identity, Permissions from lms.services import HAPIError from lms.views.api.sync import APISyncSchema @@ -22,6 +22,7 @@ "h_api", "vitalsource_service", "jstor_service", + "youtube_service", "misc_plugin", ) @@ -421,6 +422,94 @@ def test_jstor_sets_config(self, js_config, jstor_service, pyramid_request): } assert js_config.asdict()["viaUrl"] == jstor_service.via_url.return_value + def test_youtube_assignment_sets_client_flag( + self, js_config, youtube_service, course, assignment, via_url + ): + youtube_service.enabled = True + js_config.add_document_url("https://www.youtube.com/watch?v=abc123") + js_config.enable_lti_launch_mode(course, assignment) + config = js_config.asdict() + assert config["hypothesisClient"]["youtubeAssignment"] is True + via_url.assert_called_once() + + @pytest.mark.parametrize( + "url,sets_youtube_assignment", + [ + # Supported YouTube URL patterns (regex + host check) + ("https://www.youtube.com/watch?v=abc123", True), + ("https://youtube.com/watch?v=def456", True), + ("https://youtu.be/ghi789", True), + ("https://www.youtube.com/embed/jkl012", True), + ("https://www.youtube.com/shorts/mno345", True), + ("https://www.youtube.com/live/pqr678", True), + # Negative: wrong host + ("https://example.com/article", False), + ("https://vimeo.com/123456", False), + # Negative: wrong scheme (host would be ok but scheme fails) + ("ftp://www.youtube.com/watch?v=abc", False), + ], + ) + def test_youtube_assignment_detection_per_url_pattern( + self, + js_config, + youtube_service, + course, + assignment, + via_url, # noqa: ARG002 + url, + sets_youtube_assignment, + ): + """Lock down detection for each URL pattern and negative cases.""" + youtube_service.enabled = True + js_config.add_document_url(url) + js_config.enable_lti_launch_mode(course, assignment) + config = js_config.asdict() + if sets_youtube_assignment: + assert config["hypothesisClient"]["youtubeAssignment"] is True + else: + assert config["hypothesisClient"].get("youtubeAssignment") is not True + + def test_youtube_disabled_does_not_set_client_flag( + self, + js_config, + youtube_service, + course, + assignment, + via_url, # noqa: ARG002 + ): + youtube_service.enabled = False + js_config.add_document_url("https://www.youtube.com/watch?v=abc123") + js_config.enable_lti_launch_mode(course, assignment) + config = js_config.asdict() + assert config["hypothesisClient"].get("youtubeAssignment") is not True + + def test_non_youtube_url_does_not_set_client_flag( + self, + js_config, + youtube_service, + course, + assignment, + via_url, # noqa: ARG002 + ): + youtube_service.enabled = True + js_config.add_document_url("https://example.com/article") + js_config.enable_lti_launch_mode(course, assignment) + config = js_config.asdict() + assert config["hypothesisClient"].get("youtubeAssignment") is not True + + def test_youtube_video_id_from_url_returns_none_on_parse_error(self): + """Cover the except (ValueError, AttributeError) branch.""" + with patch("lms.resources._js_config.urlparse", side_effect=ValueError): + assert ( + _youtube_video_id_from_url("https://www.youtube.com/watch?v=abc") + is None + ) + + def test_youtube_video_id_from_url_is_case_insensitive_for_host(self): + """Host is normalized so YouTube.com / YOUTUBE.COM work like the frontend.""" + assert _youtube_video_id_from_url("https://YouTube.com/watch?v=xyz") == "xyz" + assert _youtube_video_id_from_url("https://YOUTU.BE/xyz") == "xyz" + class TestAddCanvasSpeedgraderSettings: @pytest.mark.parametrize("group_set", (sentinel.group_set, None))