Skip to content

Commit 261a630

Browse files
Feature/lms youtube gdpr banner (#7333)
* feat: detect yt assigment * fix: test * fix: checkformatting * fix coverage * fix: copilot code review
1 parent ee1e3a9 commit 261a630

2 files changed

Lines changed: 116 additions & 2 deletions

File tree

lms/resources/_js_config/__init__.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from datetime import timedelta
44
from enum import Enum, StrEnum
55
from typing import Any
6+
from urllib.parse import urlparse
67

78
from lms.error_code import ErrorCode
89
from lms.events import LTIEvent
@@ -21,10 +22,31 @@
2122
JSTORService,
2223
OrganizationService,
2324
VitalSourceService,
25+
YouTubeService,
2426
)
2527
from lms.validation.authentication import BearerTokenSchema
2628
from lms.views.helpers import via_url
2729

30+
# Regex to extract YouTube video ID (same URL patterns as frontend utils/youtube.ts)
31+
_YOUTUBE_VIDEO_ID_RE = re.compile(
32+
r"(?:youtu\.be/|v/|u/\w/|embed/|shorts/|live/|watch\?v=|&v=)([^#&?]*)",
33+
re.IGNORECASE,
34+
)
35+
36+
37+
def _youtube_video_id_from_url(url: str) -> str | None:
38+
"""Return the YouTube video ID if url is a YouTube URL, else None."""
39+
try:
40+
parsed = urlparse(url)
41+
if parsed.scheme not in ("http", "https"):
42+
return None
43+
if parsed.netloc.lower() not in ("www.youtube.com", "youtube.com", "youtu.be"):
44+
return None
45+
match = _YOUTUBE_VIDEO_ID_RE.search(url)
46+
return match.group(1) if match and match.group(1) else None
47+
except (ValueError, AttributeError):
48+
return None
49+
2850

2951
class JSConfig:
3052
"""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
168190
}
169191
else:
170192
self._config["viaUrl"] = via_url(self._request, document_url)
193+
youtube_service = self._request.find_service(iface=YouTubeService)
194+
if youtube_service.enabled and _youtube_video_id_from_url(document_url):
195+
self._hypothesis_client["youtubeAssignment"] = True
171196

172197
def _update_focus_config(self, updates: dict):
173198
"""

tests/unit/lms/resources/_js_config/__init___test.py

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
from datetime import timedelta
2-
from unittest.mock import create_autospec, sentinel
2+
from unittest.mock import create_autospec, patch, sentinel
33

44
import pytest
55
from h_matchers import Any
66

77
from lms.models import Grouping, LTIParams
88
from lms.product.product import Routes
99
from lms.resources import LTILaunchResource, OAuth2RedirectResource
10-
from lms.resources._js_config import JSConfig
10+
from lms.resources._js_config import JSConfig, _youtube_video_id_from_url
1111
from lms.security import Identity, Permissions
1212
from lms.services import HAPIError
1313
from lms.views.api.sync import APISyncSchema
@@ -22,6 +22,7 @@
2222
"h_api",
2323
"vitalsource_service",
2424
"jstor_service",
25+
"youtube_service",
2526
"misc_plugin",
2627
)
2728

@@ -421,6 +422,94 @@ def test_jstor_sets_config(self, js_config, jstor_service, pyramid_request):
421422
}
422423
assert js_config.asdict()["viaUrl"] == jstor_service.via_url.return_value
423424

425+
def test_youtube_assignment_sets_client_flag(
426+
self, js_config, youtube_service, course, assignment, via_url
427+
):
428+
youtube_service.enabled = True
429+
js_config.add_document_url("https://www.youtube.com/watch?v=abc123")
430+
js_config.enable_lti_launch_mode(course, assignment)
431+
config = js_config.asdict()
432+
assert config["hypothesisClient"]["youtubeAssignment"] is True
433+
via_url.assert_called_once()
434+
435+
@pytest.mark.parametrize(
436+
"url,sets_youtube_assignment",
437+
[
438+
# Supported YouTube URL patterns (regex + host check)
439+
("https://www.youtube.com/watch?v=abc123", True),
440+
("https://youtube.com/watch?v=def456", True),
441+
("https://youtu.be/ghi789", True),
442+
("https://www.youtube.com/embed/jkl012", True),
443+
("https://www.youtube.com/shorts/mno345", True),
444+
("https://www.youtube.com/live/pqr678", True),
445+
# Negative: wrong host
446+
("https://example.com/article", False),
447+
("https://vimeo.com/123456", False),
448+
# Negative: wrong scheme (host would be ok but scheme fails)
449+
("ftp://www.youtube.com/watch?v=abc", False),
450+
],
451+
)
452+
def test_youtube_assignment_detection_per_url_pattern(
453+
self,
454+
js_config,
455+
youtube_service,
456+
course,
457+
assignment,
458+
via_url, # noqa: ARG002
459+
url,
460+
sets_youtube_assignment,
461+
):
462+
"""Lock down detection for each URL pattern and negative cases."""
463+
youtube_service.enabled = True
464+
js_config.add_document_url(url)
465+
js_config.enable_lti_launch_mode(course, assignment)
466+
config = js_config.asdict()
467+
if sets_youtube_assignment:
468+
assert config["hypothesisClient"]["youtubeAssignment"] is True
469+
else:
470+
assert config["hypothesisClient"].get("youtubeAssignment") is not True
471+
472+
def test_youtube_disabled_does_not_set_client_flag(
473+
self,
474+
js_config,
475+
youtube_service,
476+
course,
477+
assignment,
478+
via_url, # noqa: ARG002
479+
):
480+
youtube_service.enabled = False
481+
js_config.add_document_url("https://www.youtube.com/watch?v=abc123")
482+
js_config.enable_lti_launch_mode(course, assignment)
483+
config = js_config.asdict()
484+
assert config["hypothesisClient"].get("youtubeAssignment") is not True
485+
486+
def test_non_youtube_url_does_not_set_client_flag(
487+
self,
488+
js_config,
489+
youtube_service,
490+
course,
491+
assignment,
492+
via_url, # noqa: ARG002
493+
):
494+
youtube_service.enabled = True
495+
js_config.add_document_url("https://example.com/article")
496+
js_config.enable_lti_launch_mode(course, assignment)
497+
config = js_config.asdict()
498+
assert config["hypothesisClient"].get("youtubeAssignment") is not True
499+
500+
def test_youtube_video_id_from_url_returns_none_on_parse_error(self):
501+
"""Cover the except (ValueError, AttributeError) branch."""
502+
with patch("lms.resources._js_config.urlparse", side_effect=ValueError):
503+
assert (
504+
_youtube_video_id_from_url("https://www.youtube.com/watch?v=abc")
505+
is None
506+
)
507+
508+
def test_youtube_video_id_from_url_is_case_insensitive_for_host(self):
509+
"""Host is normalized so YouTube.com / YOUTUBE.COM work like the frontend."""
510+
assert _youtube_video_id_from_url("https://YouTube.com/watch?v=xyz") == "xyz"
511+
assert _youtube_video_id_from_url("https://YOUTU.BE/xyz") == "xyz"
512+
424513

425514
class TestAddCanvasSpeedgraderSettings:
426515
@pytest.mark.parametrize("group_set", (sentinel.group_set, None))

0 commit comments

Comments
 (0)