From 7ce70508ab365de35ebc5c2ccc2006e621037bf8 Mon Sep 17 00:00:00 2001 From: Gabriel Morador Date: Wed, 4 Mar 2026 23:49:46 -0300 Subject: [PATCH 1/5] feat: detect yt assigment --- lms/resources/_js_config/__init__.py | 24 +++++++++++++++ .../lms/resources/_js_config/__init___test.py | 29 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/lms/resources/_js_config/__init__.py b/lms/resources/_js_config/__init__.py index 7f8a276008..104755f025 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,30 @@ 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=)([^#&?]*)" +) + + +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 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 +189,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..9fe7bbd622 100644 --- a/tests/unit/lms/resources/_js_config/__init___test.py +++ b/tests/unit/lms/resources/_js_config/__init___test.py @@ -22,6 +22,7 @@ "h_api", "vitalsource_service", "jstor_service", + "youtube_service", "misc_plugin", ) @@ -421,6 +422,34 @@ 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() + + def test_youtube_disabled_does_not_set_client_flag( + self, js_config, youtube_service, course, assignment, via_url + ): + 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 + ): + 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 + class TestAddCanvasSpeedgraderSettings: @pytest.mark.parametrize("group_set", (sentinel.group_set, None)) From cecaf36bbf183882e523f45ce8e126712d522bb5 Mon Sep 17 00:00:00 2001 From: Gabriel Morador Date: Fri, 6 Mar 2026 12:53:11 -0300 Subject: [PATCH 2/5] fix: test --- tests/unit/lms/resources/_js_config/__init___test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/lms/resources/_js_config/__init___test.py b/tests/unit/lms/resources/_js_config/__init___test.py index 9fe7bbd622..5c2092452a 100644 --- a/tests/unit/lms/resources/_js_config/__init___test.py +++ b/tests/unit/lms/resources/_js_config/__init___test.py @@ -433,7 +433,7 @@ def test_youtube_assignment_sets_client_flag( via_url.assert_called_once() def test_youtube_disabled_does_not_set_client_flag( - self, js_config, youtube_service, course, assignment, via_url + 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") @@ -442,7 +442,7 @@ def test_youtube_disabled_does_not_set_client_flag( 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 + self, js_config, youtube_service, course, assignment, via_url # noqa: ARG002 ): youtube_service.enabled = True js_config.add_document_url("https://example.com/article") From c99e0d53107ea00006fa86ab10ff9336494ab614 Mon Sep 17 00:00:00 2001 From: Gabriel Morador Date: Fri, 6 Mar 2026 13:10:28 -0300 Subject: [PATCH 3/5] fix: checkformatting --- .../unit/lms/resources/_js_config/__init___test.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/unit/lms/resources/_js_config/__init___test.py b/tests/unit/lms/resources/_js_config/__init___test.py index 5c2092452a..4557e9ab16 100644 --- a/tests/unit/lms/resources/_js_config/__init___test.py +++ b/tests/unit/lms/resources/_js_config/__init___test.py @@ -433,7 +433,12 @@ def test_youtube_assignment_sets_client_flag( via_url.assert_called_once() def test_youtube_disabled_does_not_set_client_flag( - self, js_config, youtube_service, course, assignment, via_url # noqa: ARG002 + 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") @@ -442,7 +447,12 @@ def test_youtube_disabled_does_not_set_client_flag( 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 + self, + js_config, + youtube_service, + course, + assignment, + via_url, # noqa: ARG002 ): youtube_service.enabled = True js_config.add_document_url("https://example.com/article") From 2ea131cba50519c8622d31b2b221c8fd86663632 Mon Sep 17 00:00:00 2001 From: Gabriel Morador Date: Fri, 6 Mar 2026 13:34:07 -0300 Subject: [PATCH 4/5] fix coverage --- tests/unit/lms/resources/_js_config/__init___test.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/unit/lms/resources/_js_config/__init___test.py b/tests/unit/lms/resources/_js_config/__init___test.py index 4557e9ab16..6ceeef0c4f 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 @@ -460,6 +460,14 @@ def test_non_youtube_url_does_not_set_client_flag( 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 + ) + class TestAddCanvasSpeedgraderSettings: @pytest.mark.parametrize("group_set", (sentinel.group_set, None)) From 498a11b26799e919077e2d4b854e3657c10273f4 Mon Sep 17 00:00:00 2001 From: Gabriel Morador Date: Fri, 6 Mar 2026 14:07:00 -0300 Subject: [PATCH 5/5] fix: copilot code review --- lms/resources/_js_config/__init__.py | 5 ++- .../lms/resources/_js_config/__init___test.py | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/lms/resources/_js_config/__init__.py b/lms/resources/_js_config/__init__.py index 104755f025..ba1f304971 100644 --- a/lms/resources/_js_config/__init__.py +++ b/lms/resources/_js_config/__init__.py @@ -29,7 +29,8 @@ # 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=)([^#&?]*)" + r"(?:youtu\.be/|v/|u/\w/|embed/|shorts/|live/|watch\?v=|&v=)([^#&?]*)", + re.IGNORECASE, ) @@ -39,7 +40,7 @@ def _youtube_video_id_from_url(url: str) -> str | None: parsed = urlparse(url) if parsed.scheme not in ("http", "https"): return None - if parsed.netloc not in ("www.youtube.com", "youtube.com", "youtu.be"): + 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 diff --git a/tests/unit/lms/resources/_js_config/__init___test.py b/tests/unit/lms/resources/_js_config/__init___test.py index 6ceeef0c4f..c05b23b8eb 100644 --- a/tests/unit/lms/resources/_js_config/__init___test.py +++ b/tests/unit/lms/resources/_js_config/__init___test.py @@ -432,6 +432,43 @@ def test_youtube_assignment_sets_client_flag( 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, @@ -468,6 +505,11 @@ def test_youtube_video_id_from_url_returns_none_on_parse_error(self): 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))