Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Suppport for D2L files proxing using Via #4792

Merged
merged 4 commits into from
Jan 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lms/resources/_js_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from lms.models import Assignment, GroupInfo, Grouping, HUser
from lms.product.blackboard import Blackboard
from lms.product.canvas import Canvas
from lms.product.d2l import D2L
from lms.resources._js_config.file_picker_config import FilePickerConfig
from lms.services import HAPIError, JSTORService, VitalSourceService
from lms.validation.authentication import BearerTokenSchema
Expand Down Expand Up @@ -68,6 +69,16 @@ def add_document_url(self, document_url):
resource_link_id=self._request.lti_params["resource_link_id"],
),
}
elif document_url.startswith("d2l://"):
self._config["api"]["viaUrl"] = {
"authUrl": self._request.route_url(D2L.route.oauth2_authorize),
"path": self._request.route_path(
"d2l_api.courses.files.via_url",
course_id=self._request.lti_params["context_id"],
_query={"document_url": document_url},
),
}
Comment on lines +72 to +80
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok, this is the config telling the frontend to make the API call to get the Via URL? 👍


elif document_url.startswith("vitalsource://"):
svc: VitalSourceService = self._request.find_service(VitalSourceService)

Expand Down
3 changes: 3 additions & 0 deletions lms/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ def includeme(config): # pylint:disable=too-many-statements
"d2l_api.courses.group_sets.list", "/api/d2l/courses/{course_id}/group_sets"
)
config.add_route("d2l_api.courses.files.list", "/api/d2l/courses/{course_id}/files")
config.add_route(
"d2l_api.courses.files.via_url", "/api/d2l/courses/{course_id}/via_url"
)
Comment on lines +56 to +58
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And the new Via URL API for the frontend to call go get D2L files Via URLs 👍


config.add_route(
"blackboard_api.oauth.authorize",
Expand Down
34 changes: 30 additions & 4 deletions lms/services/d2l_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,33 @@ def list_files(self, org_unit) -> List[dict]:
"""Get a nested list of files and folders for the given `org_unit`."""

modules = self._get_course_modules(org_unit)
return list(self._find_files(modules))
return list(self._find_files(org_unit, modules))

def public_url(self, org_unit, file_id) -> str:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Service method for getting a public URL for a D2L file. So far this is all very familiar 😄

"""
Return the URL to download the given file.

As opposed to other LMS's that return a one time signed URL that we can then pass along to Via
D2L requires us to send the API authentication header to get the file contents.

To make sure the API authentication header is not expired we'll make a API
request here so if it needs to be refreshed we follow the standard token refresh procedure.

https://docs.valence.desire2learn.com/res/content.html#get--d2l-api-le-(version)-(orgUnitId)-content-topics-(topicId)
https://docs.valence.desire2learn.com/res/content.html#get--d2l-api-le-(version)-(orgUnitId)-content-topics-(topicId)-file
Comment on lines +129 to +133
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is probably better in code comments next to the code that makes these requests. Otherwise it seems likely that if the code changes this docstring will get out of date.

"""

# We don't need the data from this call.
# We are only interested on the potential side effect of needing
# a new access token and/or refreshing an existing one.
_ = self._api.request(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit of a hack but the whole setup is (sending the header etc).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this because we're going to be sending our access token to Via (in the new secret headers), and we therefore need to make sure that the access token is still valid first?

"GET",
self._api.api_url(f"/{org_unit}/content/topics/{file_id}", product="le"),
)

return self._api.api_url(
f"/{org_unit}/content/topics/{file_id}/file?stream=1", product="le"
)

@staticmethod
def get_api_user_id(user_id: str):
Expand Down Expand Up @@ -145,21 +171,21 @@ def _get_course_modules(self, org_unit) -> List[dict]:
)
return D2LTableOfContentsSchema(response).parse().get("modules", [])

def _find_files(self, modules):
def _find_files(self, course_id, modules):
"""Recursively find files in modules."""
for module in modules:
module_files = [
{
"type": "File",
"display_name": topic["display_name"],
"id": topic["id"],
"id": f"d2l://file/course/{course_id}/file_id/{topic['id']}/",
"updated_at": topic["updated_at"],
}
for topic in module.get("topics", [])
if topic.get("type") == "File"
]

module_children = self._find_files(module.get("modules", []))
module_children = self._find_files(course_id, module.get("modules", []))
yield {
"type": "Folder",
"display_name": module["display_name"],
Expand Down
2 changes: 1 addition & 1 deletion lms/views/api/d2l/authorize.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from lms.validation.authentication import OAuthCallbackSchema

GROUPS_SCOPES = ("groups:*:*",)
FILES_SCOPES = ("content:toc:read",)
FILES_SCOPES = ("content:toc:read", "content:topics:read")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder what will happen to people who've already granted us access tokens without the topics scope? Will they get prompted to re-authorize us? Maybe no real users are using this feature yet anyway?



@view_config(
Expand Down
34 changes: 31 additions & 3 deletions lms/views/api/d2l/files.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import re

from pyramid.view import view_config

from lms.security import Permissions
from lms.services.d2l_api import D2LAPIClient
from lms.views import helpers

DOCUMENT_URL_REGEX = re.compile(
r"d2l:\/\/file\/course\/(?P<course_id>[^\/]*)\/file_id\/(?P<file_id>[^\/]*)\/"
)


@view_config(
Expand All @@ -12,6 +19,27 @@
)
def list_files(_context, request):
"""Return the list of files in the given course."""
return request.find_service(D2LAPIClient).list_files(
org_unit=request.matchdict["course_id"]
)
return request.find_service(D2LAPIClient).list_files(request.matchdict["course_id"])


@view_config(
request_method="GET",
route_name="d2l_api.courses.files.via_url",
renderer="json",
permission=Permissions.API,
)
def via_url(_context, request):
course_id = request.matchdict["course_id"]
document_url = request.params["document_url"]
file_id = DOCUMENT_URL_REGEX.search(document_url)["file_id"]

public_url = request.find_service(D2LAPIClient).public_url(course_id, file_id)

access_token = request.find_service(name="oauth2_token").get().access_token
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought I might need to get this from a private method in the client but we can just use the oauth2 service 👍

headers = {"Authorization": f"Bearer {access_token}"}

return {
"via_url": helpers.via_url(
request, public_url, content_type="pdf", headers=headers
)
}
4 changes: 3 additions & 1 deletion lms/views/helpers/_via.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
__all__ = ["via_url"]


def via_url(request, document_url, content_type=None, options=None):
def via_url(request, document_url, content_type=None, options=None, headers=None):
"""
Return the Via URL for annotating the given ``document_url``.

Expand All @@ -17,6 +17,7 @@ def via_url(request, document_url, content_type=None, options=None):
:param document_url: Document URL to present in Via
:param content_type: Either "pdf" or "html" if known, None if not
:param options: Any extra options for the url
:param headers: Headers for the request to `document_url`
:return: A URL string
"""
if not options:
Expand All @@ -37,4 +38,5 @@ def via_url(request, document_url, content_type=None, options=None):
content_type,
blocked_for="lms",
options=options,
headers=headers,
)
40 changes: 24 additions & 16 deletions tests/unit/lms/resources/_js_config/__init___test.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,30 @@ def test_it_adds_the_viaUrl_api_config_for_Blackboard_documents(self, js_config)
"path": "/api/blackboard/courses/test_course_id/via_url?document_url=blackboard%3A%2F%2Fcontent-resource%2Fxyz123",
}

def test_it_adds_the_viaUrl_api_config_for_Canvas_documents(
self, js_config, pyramid_request
):
course_id, file_id = "125", "100"
pyramid_request.params["custom_canvas_course_id"] = course_id
pyramid_request.params["file_id"] = file_id

js_config.add_document_url(
f"canvas://file/course/{course_id}/file_id/{file_id}"
)

assert js_config.asdict()["api"]["viaUrl"] == {
"authUrl": "http://example.com/api/canvas/oauth/authorize",
"path": "/api/canvas/assignments/TEST_RESOURCE_LINK_ID/via_url",
}

def test_it_adds_the_viaUrl_api_config_for_D2L_documents(self, js_config):
js_config.add_document_url("d2l://file/course/125/file_id/100")

assert js_config.asdict()["api"]["viaUrl"] == {
"authUrl": "http://example.com/api/d2l/oauth/authorize",
"path": "/api/d2l/courses/test_course_id/via_url?document_url=d2l%3A%2F%2Ffile%2Fcourse%2F125%2Ffile_id%2F100",
}

def test_vitalsource_sets_config_with_sso(
self, js_config, pyramid_request, vitalsource_service
):
Expand Down Expand Up @@ -215,22 +239,6 @@ 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_it_adds_the_viaUrl_api_config_for_Canvas_documents(
self, js_config, pyramid_request
):
course_id, file_id = "125", "100"
pyramid_request.params["custom_canvas_course_id"] = course_id
pyramid_request.params["file_id"] = file_id

js_config.add_document_url(
f"canvas://file/course/{course_id}/file_id/{file_id}"
)

assert js_config.asdict()["api"]["viaUrl"] == {
"authUrl": "http://example.com/api/canvas/oauth/authorize",
"path": "/api/canvas/assignments/TEST_RESOURCE_LINK_ID/via_url",
}


class TestAddCanvasSpeedgraderSettings:
@pytest.mark.parametrize("group_set", (sentinel.group_set, None))
Expand Down
20 changes: 18 additions & 2 deletions tests/unit/lms/services/d2l_api/client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def test_group_set_groups_with_user_id(self, svc, d2l_groups_schema, groups):
"children": [
{
"display_name": "TITLE 1",
"id": "FILE 1",
"id": "d2l://file/course/COURSE_ID/file_id/FILE 1/",
"type": "File",
"updated_at": "DATE 1",
},
Expand All @@ -116,7 +116,7 @@ def test_group_set_groups_with_user_id(self, svc, d2l_groups_schema, groups):
"children": [
{
"display_name": "TITLE 2",
"id": "FILE 2",
"id": "d2l://file/course/COURSE_ID/file_id/FILE 2/",
"type": "File",
"updated_at": "DATE 2",
},
Expand Down Expand Up @@ -148,6 +148,22 @@ def test_list_files(

assert files == returned_files

def test_public_url(self, svc, basic_client):
public_url = svc.public_url("COURSE_ID", "FILE_ID")

basic_client.api_url.assert_any_call(
"/COURSE_ID/content/topics/FILE_ID", product="le"
)
basic_client.request.assert_called_once_with(
"GET", basic_client.api_url.return_value
)

basic_client.api_url.assert_called_with(
"/COURSE_ID/content/topics/FILE_ID/file?stream=1", product="le"
)

assert public_url == basic_client.api_url.return_value

@pytest.mark.parametrize(
"user_id,api_user_id",
[
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/lms/views/api/d2l/authorize_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ class TestAuthorize:
"groups,files,scopes",
[
(False, False, "core:*:*"),
(False, True, "core:*:* content:toc:read"),
(False, True, "core:*:* content:toc:read content:topics:read"),
(True, False, "core:*:* groups:*:*"),
(True, True, "core:*:* content:toc:read groups:*:*"),
(True, True, "core:*:* content:toc:read content:topics:read groups:*:*"),
],
)
def test_it(
Expand Down
27 changes: 26 additions & 1 deletion tests/unit/lms/views/api/d2l/files_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from unittest.mock import sentinel

from lms.views.api.d2l.files import list_files
import pytest

from lms.views.api.d2l.files import list_files, via_url


def test_course_group_sets(pyramid_request, d2l_api_client):
Expand All @@ -10,3 +12,26 @@ def test_course_group_sets(pyramid_request, d2l_api_client):

d2l_api_client.list_files.assert_called_once_with("test_course_id")
assert result == d2l_api_client.list_files.return_value


def test_via_url(helpers, pyramid_request, d2l_api_client, oauth2_token_service):
pyramid_request.matchdict = {"course_id": "COURSE_ID"}
pyramid_request.params[
"document_url"
] = "d2l://file/course/COURSE_ID/file_id/FILE_ID/"

response = via_url(sentinel.context, pyramid_request)

d2l_api_client.public_url.assert_called_once_with("COURSE_ID", "FILE_ID")
helpers.via_url.assert_called_once_with(
pyramid_request,
d2l_api_client.public_url.return_value,
content_type="pdf",
headers={"Authorization": f"Bearer {oauth2_token_service.get().access_token}"},
)
assert response == {"via_url": helpers.via_url.return_value}


@pytest.fixture
def helpers(patch):
return patch("lms.views.api.d2l.files.helpers")