diff --git a/lms/services/canvas_studio.py b/lms/services/canvas_studio.py index 87cd0434f3..439e3e9a3d 100644 --- a/lms/services/canvas_studio.py +++ b/lms/services/canvas_studio.py @@ -280,19 +280,29 @@ def get_canonical_video_url(self, media_id: str) -> str: # Example: "https://hypothesis.instructuremedia.com/api/public/v1/media/4" return self._api_url(f"v1/media/{media_id}") - def get_video_download_url(self, media_id: str) -> str: + def get_video_download_url(self, media_id: str) -> str | None: """ Return temporary download URL for a video. + This may return `None` if the video is not available for download. + This can happen for videos imported into Canvas Studio from YouTube + or Vimeo. + Security: This method does not check whether the current user should have access to this video. See `_admin_api_request`. """ - download_rsp = self._bare_api_request( - f"v1/media/{media_id}/download", as_admin=True, allow_redirects=False - ) - download_redirect = download_rsp.headers.get("Location") + try: + download_rsp = self._bare_api_request( + f"v1/media/{media_id}/download", as_admin=True, allow_redirects=False + ) + except ExternalRequestError as err: + # Canvas Studio returns 422 if the video is not available for download. + if err.status_code == 422: + return None + raise + download_redirect = download_rsp.headers.get("Location") if download_rsp.status_code != 302 or not download_redirect: raise ExternalRequestError( message="Media download did not return valid redirect", diff --git a/lms/static/scripts/frontend_apps/components/LaunchErrorDialog.tsx b/lms/static/scripts/frontend_apps/components/LaunchErrorDialog.tsx index ce21c56858..385dc57754 100644 --- a/lms/static/scripts/frontend_apps/components/LaunchErrorDialog.tsx +++ b/lms/static/scripts/frontend_apps/components/LaunchErrorDialog.tsx @@ -320,6 +320,42 @@ export default function LaunchErrorDialog({ ); + case 'canvas_studio_download_unavailable': + return ( + +

+ Only videos uploaded directly to Canvas Studio can be used. Videos + hosted on YouTube or Vimeo cannot be used. +

+
+ ); + + case 'canvas_studio_media_not_found': + return ( + + ); + + case 'canvas_studio_transcript_unavailable': + return ( + +

+ To use a video with Hypothesis, you must upload or generate captions + in Canvas Studio and publish them. +

+
+ ); case 'blackboard_group_set_not_found': return ( { hasRetry: true, withError: true, }, + { + errorState: 'canvas_studio_download_unavailable', + expectedText: + 'Only videos uploaded directly to Canvas Studio can be used. Videos hosted on YouTube or Vimeo cannot be used.', + expectedTitle: 'Unable to fetch Canvas Studio video', + hasRetry: false, + withError: true, + }, + { + errorState: 'canvas_studio_media_not_found', + expectedText: '', + expectedTitle: 'Canvas Studio media not found', + hasRetry: false, + withError: true, + }, + { + errorState: 'canvas_studio_transcript_unavailable', + expectedText: + 'To use a video with Hypothesis, you must upload or generate captions in Canvas Studio and publish them.', + expectedTitle: 'Video does not have a published transcript', + hasRetry: false, + withError: true, + }, { errorState: 'd2l_file_not_found_in_course_instructor', expectedText: diff --git a/lms/static/scripts/frontend_apps/errors.ts b/lms/static/scripts/frontend_apps/errors.ts index c94442f82c..fdcf625364 100644 --- a/lms/static/scripts/frontend_apps/errors.ts +++ b/lms/static/scripts/frontend_apps/errors.ts @@ -18,6 +18,9 @@ export type LTILaunchServerErrorCode = | 'canvas_group_set_not_found' | 'canvas_page_not_found_in_course' | 'canvas_student_not_in_group' + | 'canvas_studio_download_unavailable' + | 'canvas_studio_transcript_unavailable' + | 'canvas_studio_media_not_found' | 'd2l_file_not_found_in_course_instructor' | 'd2l_file_not_found_in_course_student' | 'd2l_group_set_empty' @@ -162,6 +165,9 @@ export function isLTILaunchServerError(error: ErrorLike): error is APIError { 'canvas_group_set_not_found', 'canvas_group_set_empty', 'canvas_student_not_in_group', + 'canvas_studio_download_unavailable', + 'canvas_studio_transcript_unavailable', + 'canvas_studio_media_not_found', 'vitalsource_user_not_found', 'vitalsource_no_book_license', 'moodle_page_not_found_in_course', diff --git a/lms/views/api/canvas_studio.py b/lms/views/api/canvas_studio.py index fed348bb7c..91c6d055fd 100644 --- a/lms/views/api/canvas_studio.py +++ b/lms/views/api/canvas_studio.py @@ -4,16 +4,29 @@ See `CanvasStudioService` for more details. """ -from pyramid.httpexceptions import HTTPBadRequest, HTTPFound +from pyramid.httpexceptions import HTTPFound from pyramid.view import view_config from lms.security import Permissions from lms.services import CanvasStudioService from lms.services.canvas_studio import replace_localhost_in_url +from lms.services.exceptions import SerializableError from lms.validation.authentication import OAuthCallbackSchema from lms.views.helpers import via_video_url +class CanvasStudioLaunchError(SerializableError): + """ + An error occurred while launching a Canvas Studio assignment. + + This exception is used for non-authorization errors that prevent a + Canvas Studio assignment from being launched. + """ + + def __init__(self, error_code: str, message: str): + super().__init__(error_code=error_code, message=message) + + # View for authorization popup which redirects to Canvas Studio's OAuth # authorization endpoint. # @@ -135,15 +148,31 @@ def via_url(request): document_url = assignment.document_url media_id = CanvasStudioService.media_id_from_url(document_url) if not media_id: - raise HTTPBadRequest("Unable to get Canvas Studio media ID") + raise CanvasStudioLaunchError( + "canvas_studio_media_not_found", "Unable to get Canvas Studio media ID" + ) svc = request.find_service(CanvasStudioService) canonical_url = svc.get_canonical_video_url(media_id) + + # Get the video download URL, then the transcript. We do things in this + # order because if the video cannot be used (eg. because it is a Vimeo + # upload), there is no point in the user uploading a transcript, if that is + # also missing. + download_url = svc.get_video_download_url(media_id) - transcript_url = svc.get_transcript_url(media_id) + if not download_url: + raise CanvasStudioLaunchError( + "canvas_studio_download_unavailable", + "Hypothesis was unable to fetch the video", + ) + transcript_url = svc.get_transcript_url(media_id) if not transcript_url: - raise HTTPBadRequest("This video does not have a published transcript") + raise CanvasStudioLaunchError( + "canvas_studio_transcript_unavailable", + "This video does not have a published transcript", + ) return { "via_url": via_video_url(request, canonical_url, download_url, transcript_url) diff --git a/tests/unit/lms/services/canvas_studio_test.py b/tests/unit/lms/services/canvas_studio_test.py index 7223abe10c..4a10b4182d 100644 --- a/tests/unit/lms/services/canvas_studio_test.py +++ b/tests/unit/lms/services/canvas_studio_test.py @@ -191,6 +191,9 @@ def test_get_video_download_error(self, svc): svc.get_video_download_url("456") assert Any.instance_of(exc_info.value.response).with_attrs({"status_code": 404}) + def test_get_video_download_url_returns_None_if_video_not_available(self, svc): + assert svc.get_video_download_url("800") is None + def test_get_video_download_url_fails_if_admin_email_not_set( self, svc, pyramid_request ): @@ -412,6 +415,11 @@ def handler(url, allow_redirects=True): case "media/456/download": status_code = 404 json_data = {} + case "media/800/download": + # Simulate response in case where video is hosted on + # YouTube or Vimeo, so not available for download. + status_code = 422 + json_data = {} case _: # pragma: nocover raise ValueError(f"Unexpected URL {url}") diff --git a/tests/unit/lms/views/api/canvas_studio_test.py b/tests/unit/lms/views/api/canvas_studio_test.py index 4b561b4be8..30998321e7 100644 --- a/tests/unit/lms/views/api/canvas_studio_test.py +++ b/tests/unit/lms/views/api/canvas_studio_test.py @@ -2,7 +2,7 @@ import pytest from h_matchers import Any -from pyramid.httpexceptions import HTTPBadRequest, HTTPFound +from pyramid.httpexceptions import HTTPFound import lms.views.api.canvas_studio as views @@ -104,7 +104,8 @@ def test_it_raises_if_transcript_not_available( canvas_studio_service.get_transcript_url.return_value = None with pytest.raises( - HTTPBadRequest, match="This video does not have a published transcript" + views.CanvasStudioLaunchError, + match="This video does not have a published transcript", ): views.via_url(pyramid_request) @@ -115,7 +116,18 @@ def test_it_raises_if_document_url_not_valid( "https://not-a-canvas-studio-url.com" ) with pytest.raises( - HTTPBadRequest, match="Unable to get Canvas Studio media ID" + views.CanvasStudioLaunchError, match="Unable to get Canvas Studio media ID" + ): + views.via_url(pyramid_request) + + def test_it_raises_if_download_not_available( + self, canvas_studio_service, pyramid_request + ): + canvas_studio_service.get_video_download_url.return_value = None + + with pytest.raises( + views.CanvasStudioLaunchError, + match="Hypothesis was unable to fetch the video", ): views.via_url(pyramid_request)