From 3c06c362065d07fb05f4385fd7df47eaf671d709 Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Tue, 27 Aug 2024 02:27:19 -0400 Subject: [PATCH 01/28] Update PyPI deploy --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2178065e..4a9ecd94 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,7 +15,7 @@ jobs: uses: actions/setup-python@v2 - name: Create source distribution - run: python setup.py sdist + run: python -m build - name: Publish distribution to PyPI uses: pypa/gh-action-pypi-publish@release/v1 From d8714cae150cb84654b0f8fff97bdddad8259cde Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Tue, 27 Aug 2024 02:37:43 -0400 Subject: [PATCH 02/28] Update deploy.yml --- .github/workflows/deploy.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4a9ecd94..54466a09 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,10 +9,13 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 + + - name: Install build dependency + run: pip install build - name: Create source distribution run: python -m build From b4a7300547722a6f87016f9f0c4ec7da73dfc64d Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Tue, 27 Aug 2024 03:00:43 -0400 Subject: [PATCH 03/28] Update changelog - deploy GH action --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3529dcc0..24548a29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Backstage + +- Updated deploy Action to use more modern processes. + ## [3.3.0] - 2023-08-27 ### General From 5e4f2600247116a46701ef26ce046a213b3df519 Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Thu, 29 Aug 2024 11:19:19 -0400 Subject: [PATCH 04/28] Fix errors in changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24548a29..c0a7326f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,11 @@ ### General - Added documentation for PaginatedList -- Rework requester URLs to accomodate graphql and new quizzes endpoints (Thanks, [@bennettscience](https://github.com/bennettscience)) +- Rework requester URLs to accommodate graphql and new quizzes endpoints (Thanks, [@bennettscience](https://github.com/bennettscience)) ### Bugfixes -- Fixed PaginatedList not respecting new quizzes endpoints (Thanks, [@matthewf-ucsd](https://github.com/matthewf-ucsd)) +- Fixed PaginatedList not respecting new quizzes endpoints (Thanks, [@jonespm](https://github.com/jonespm)) ### Backstage From b4be7159cf968309d86ef88e27c1bdc9efa6fc2f Mon Sep 17 00:00:00 2001 From: Jasmine Hou Date: Tue, 3 Dec 2024 16:13:13 -0500 Subject: [PATCH 05/28] get_links, get_link (id), create_link initial implementation --- canvasapi/lti_resource_link.py | 104 +++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 canvasapi/lti_resource_link.py diff --git a/canvasapi/lti_resource_link.py b/canvasapi/lti_resource_link.py new file mode 100644 index 00000000..d57e04da --- /dev/null +++ b/canvasapi/lti_resource_link.py @@ -0,0 +1,104 @@ +import os +from canvasapi import Canvas +from canvasapi.canvas_object import CanvasObject +from canvasapi.paginated_list import PaginatedList +from canvasapi.util import combine_kwargs, obj_or_id +from canvasapi.course import Course +from canvasapi.exceptions import RequiredFieldMissing + +class LTIResourceLink(CanvasObject): + def __init__(self, requester, attributes): + # Initialize an LTIResourceLink object. + super(LTIResourceLink, self).__init__(requester, attributes) + +class ExtendedCourse(Course): + def get_lti_resource_links(self, **kwargs): + """ + Returns all LTI resource links for this course as a PaginatedList. + + :calls: `GET /api/v1/courses/:course_id/lti_resource_links \ + `_ + + :rtype: :class:`canvasapi.paginated_list.PaginatedList` + """ + + return PaginatedList( + LTIResourceLink, + self._requester, + "GET", + f"courses/{self.id}/lti_resource_links", + kwargs=combine_kwargs(**kwargs) + ) + + def get_lti_resource_link(self, lti_resource_link, **kwargs): + """ + Return details about the specified resource link. + + :calls: `GET /api/v1/courses/:course_id/lti_resource_links/:id \ + `_ + + :param lti_resource_link: The object or ID of the LTI resource link. + :type lti_resource_link: :class:`canvasapi.lti_resource_link.LTIResourceLink` or int + + :rtype: :class:`canvasapi.lti_resource_link.LTIResourceLink` + """ + lti_resource_link_id = obj_or_id(lti_resource_link, "lti_resource_link", (LTIResourceLink,)) + + response = self._requester.request( + "GET", + f"courses/{self.id}/lti_resource_links/{lti_resource_link_id}", + _kwargs=combine_kwargs(**kwargs) + ) + return LTIResourceLink(self._requester, response.json()) + + def create_lti_resource_link(self, url, title=None, custom=None, **kwargs): + """ + Create a new LTI resource link. + + :calls: `POST /api/v1/courses/:course_id/lti_resource_links \ + `_ + + :param course_id: The ID of the course. + :type course_id: `int` + :param url: The launch URL for the resource link. + :type url: `str` + :param title: The title of the resource link. + :type title: `str`, optional + :param custom: Custom parameters to send to the tool. + :type custom: `dict`, optional + + :rtype: :class:`canvasapi.lti_resource_link.LTIResourceLink` + """ + + if not url: + raise RequiredFieldMissing("The 'url' paramter is required.") + + kwargs["url"] = url + if title: + kwargs["title"] = title + if custom: + kwargs["custom"] = custom + + response = self._requester.request( + "POST", + f"courses/{self.id}/lti_resource_links", + _kwargs=combine_kwargs(**kwargs) + ) + return LTIResourceLink(self._requester, response.json()) + +# local testing +API_URL = os.getenv('CANVAS_API_URL') +API_KEY = os.getenv('CANVAS_API_KEY') + +if not API_URL or not API_KEY: + print("Error: Please set the CANVAS_API_URL and CANVAS_API_KEY environment variables.") + exit(1) + +canvas = Canvas(API_URL, API_KEY) + +course_id = 10791957 +course = ExtendedCourse(canvas._Canvas__requester, {'id': course_id}) + +lti_resource_links = course.get_lti_resource_links() +for link in lti_resource_links: + print(link) \ No newline at end of file From 92ea372e0a0d85f698e7fd7f93f446eb43445da0 Mon Sep 17 00:00:00 2001 From: Jasmine Hou Date: Tue, 3 Dec 2024 16:33:47 -0500 Subject: [PATCH 06/28] add unittests for LTI resource links --- canvasapi/lti_resource_link.py | 19 ------------- tests/test_lti_resource_link.py | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 19 deletions(-) create mode 100644 tests/test_lti_resource_link.py diff --git a/canvasapi/lti_resource_link.py b/canvasapi/lti_resource_link.py index d57e04da..9d708c32 100644 --- a/canvasapi/lti_resource_link.py +++ b/canvasapi/lti_resource_link.py @@ -1,5 +1,3 @@ -import os -from canvasapi import Canvas from canvasapi.canvas_object import CanvasObject from canvasapi.paginated_list import PaginatedList from canvasapi.util import combine_kwargs, obj_or_id @@ -85,20 +83,3 @@ def create_lti_resource_link(self, url, title=None, custom=None, **kwargs): _kwargs=combine_kwargs(**kwargs) ) return LTIResourceLink(self._requester, response.json()) - -# local testing -API_URL = os.getenv('CANVAS_API_URL') -API_KEY = os.getenv('CANVAS_API_KEY') - -if not API_URL or not API_KEY: - print("Error: Please set the CANVAS_API_URL and CANVAS_API_KEY environment variables.") - exit(1) - -canvas = Canvas(API_URL, API_KEY) - -course_id = 10791957 -course = ExtendedCourse(canvas._Canvas__requester, {'id': course_id}) - -lti_resource_links = course.get_lti_resource_links() -for link in lti_resource_links: - print(link) \ No newline at end of file diff --git a/tests/test_lti_resource_link.py b/tests/test_lti_resource_link.py new file mode 100644 index 00000000..afafd11b --- /dev/null +++ b/tests/test_lti_resource_link.py @@ -0,0 +1,48 @@ +import unittest + +import requests_mock + +from canvasapi import Canvas +from canvasapi.lti_resource_link import LTIResourceLink +from tests import settings +from tests.util import register_uris + + +@requests_mock.Mocker() +class TestLTIResourceLink(unittest.TestCase): + def setUp(self): + self.canvas = Canvas(settings.BASE_URL, settings.API_KEY) + + with requests_mock.Mocker() as m: + register_uris({"extended_course": ["get_by_id"]}, m) + self.extended_course = self.canvas.get_course(1) + + # create_lti_resource_link() + def test_create_lti_resource_link(self, m): + register_uris({"lti_resource_link": ["create_lti_resource_link"]}, m) + evnt = self.user.create_lti_resource_link( + name="Test LTI Resource Link", url="https://www.google.com" + ) + self.assertIsInstance(evnt, LTIResourceLink) + self.assertEqual(evnt.name, "Test LTI Resource Link") + self.assertEqual(evnt.url, "https://www.google.com") + + # get_lti_resource_links() + def test_get_lti_resource_links(self, m): + register_uris({"lti_resource_link": ["list_lti_resource_links"]}, m) + + lti_resource_links = self.extended_course.get_lti_resource_links() + lti_resource_link_list = [link for link in lti_resource_links] + self.assertEqual(len(lti_resource_link_list), 2) + self.assertIsInstance(lti_resource_link_list[0], LTIResourceLink) + + # get_lti_resource_link() + def test_get_lti_resource_link(self, m): + register_uris({"lti_resource_link": ["get_lti_resource_link"]}, m) + + lti_resource_link_by_id = self.extended_course.get_lti_resource_link(45) + self.assertIsInstance(lti_resource_link_by_id, LTIResourceLink) + self.assertEqual(lti_resource_link_by_id.name, "Test LTI Resource Link 3") + lti_resource_link_by_obj = self.extended_course.get_lti_resource_link(lti_resource_link_by_id) + self.assertIsInstance(lti_resource_link_by_obj, LTIResourceLink) + self.assertEqual(lti_resource_link_by_obj.name, "Test LTI Resource Link 3") From 2de108393817da647b9906d23db313aff8f382d3 Mon Sep 17 00:00:00 2001 From: Jasmine Hou Date: Tue, 3 Dec 2024 17:11:22 -0500 Subject: [PATCH 07/28] move under "course" class, add fixtures --- canvasapi/course.py | 79 +++++++++++++++++++++++++++ canvasapi/lti_resource_link.py | 79 +-------------------------- tests/fixtures/lti_resource_link.json | 48 ++++++++++++++++ tests/test_course.py | 32 ++++++++++- tests/test_lti_resource_link.py | 48 ---------------- 5 files changed, 161 insertions(+), 125 deletions(-) create mode 100644 tests/fixtures/lti_resource_link.json delete mode 100644 tests/test_lti_resource_link.py diff --git a/canvasapi/course.py b/canvasapi/course.py index 523a2713..df23845f 100644 --- a/canvasapi/course.py +++ b/canvasapi/course.py @@ -2761,6 +2761,85 @@ def upload(self, file: FileOrPathLike, **kwargs): return Uploader( self._requester, "courses/{}/files".format(self.id), file, **kwargs ).start() + + + def get_lti_resource_links(self, **kwargs): + """ + Returns all LTI resource links for this course as a PaginatedList. + + :calls: `GET /api/v1/courses/:course_id/lti_resource_links \ + `_ + + :rtype: :class:`canvasapi.paginated_list.PaginatedList` + """ + from canvasapi.lti_resource_link import LTIResourceLink + + return PaginatedList( + LTIResourceLink, + self._requester, + "GET", + f"courses/{self.id}/lti_resource_links", + kwargs=combine_kwargs(**kwargs) + ) + + def get_lti_resource_link(self, lti_resource_link, **kwargs): + """ + Return details about the specified resource link. + + :calls: `GET /api/v1/courses/:course_id/lti_resource_links/:id \ + `_ + + :param lti_resource_link: The object or ID of the LTI resource link. + :type lti_resource_link: :class:`canvasapi.lti_resource_link.LTIResourceLink` or int + + :rtype: :class:`canvasapi.lti_resource_link.LTIResourceLink` + """ + from canvasapi.lti_resource_link import LTIResourceLink + + lti_resource_link_id = obj_or_id(lti_resource_link, "lti_resource_link", (LTIResourceLink,)) + + response = self._requester.request( + "GET", + f"courses/{self.id}/lti_resource_links/{lti_resource_link_id}", + _kwargs=combine_kwargs(**kwargs) + ) + return LTIResourceLink(self._requester, response.json()) + + def create_lti_resource_link(self, url, title=None, custom=None, **kwargs): + """ + Create a new LTI resource link. + + :calls: `POST /api/v1/courses/:course_id/lti_resource_links \ + `_ + + :param course_id: The ID of the course. + :type course_id: `int` + :param url: The launch URL for the resource link. + :type url: `str` + :param title: The title of the resource link. + :type title: `str`, optional + :param custom: Custom parameters to send to the tool. + :type custom: `dict`, optional + + :rtype: :class:`canvasapi.lti_resource_link.LTIResourceLink` + """ + from canvasapi.lti_resource_link import LTIResourceLink + + if not url: + raise RequiredFieldMissing("The 'url' paramter is required.") + + kwargs["url"] = url + if title: + kwargs["title"] = title + if custom: + kwargs["custom"] = custom + + response = self._requester.request( + "POST", + f"courses/{self.id}/lti_resource_links", + _kwargs=combine_kwargs(**kwargs) + ) + return LTIResourceLink(self._requester, response.json()) class CourseNickname(CanvasObject): diff --git a/canvasapi/lti_resource_link.py b/canvasapi/lti_resource_link.py index 9d708c32..9fc0e894 100644 --- a/canvasapi/lti_resource_link.py +++ b/canvasapi/lti_resource_link.py @@ -6,80 +6,7 @@ class LTIResourceLink(CanvasObject): def __init__(self, requester, attributes): - # Initialize an LTIResourceLink object. super(LTIResourceLink, self).__init__(requester, attributes) - -class ExtendedCourse(Course): - def get_lti_resource_links(self, **kwargs): - """ - Returns all LTI resource links for this course as a PaginatedList. - - :calls: `GET /api/v1/courses/:course_id/lti_resource_links \ - `_ - - :rtype: :class:`canvasapi.paginated_list.PaginatedList` - """ - - return PaginatedList( - LTIResourceLink, - self._requester, - "GET", - f"courses/{self.id}/lti_resource_links", - kwargs=combine_kwargs(**kwargs) - ) - - def get_lti_resource_link(self, lti_resource_link, **kwargs): - """ - Return details about the specified resource link. - - :calls: `GET /api/v1/courses/:course_id/lti_resource_links/:id \ - `_ - - :param lti_resource_link: The object or ID of the LTI resource link. - :type lti_resource_link: :class:`canvasapi.lti_resource_link.LTIResourceLink` or int - - :rtype: :class:`canvasapi.lti_resource_link.LTIResourceLink` - """ - lti_resource_link_id = obj_or_id(lti_resource_link, "lti_resource_link", (LTIResourceLink,)) - - response = self._requester.request( - "GET", - f"courses/{self.id}/lti_resource_links/{lti_resource_link_id}", - _kwargs=combine_kwargs(**kwargs) - ) - return LTIResourceLink(self._requester, response.json()) - - def create_lti_resource_link(self, url, title=None, custom=None, **kwargs): - """ - Create a new LTI resource link. - - :calls: `POST /api/v1/courses/:course_id/lti_resource_links \ - `_ - - :param course_id: The ID of the course. - :type course_id: `int` - :param url: The launch URL for the resource link. - :type url: `str` - :param title: The title of the resource link. - :type title: `str`, optional - :param custom: Custom parameters to send to the tool. - :type custom: `dict`, optional - - :rtype: :class:`canvasapi.lti_resource_link.LTIResourceLink` - """ - - if not url: - raise RequiredFieldMissing("The 'url' paramter is required.") - - kwargs["url"] = url - if title: - kwargs["title"] = title - if custom: - kwargs["custom"] = custom - - response = self._requester.request( - "POST", - f"courses/{self.id}/lti_resource_links", - _kwargs=combine_kwargs(**kwargs) - ) - return LTIResourceLink(self._requester, response.json()) + + def __str__(self): + return "{} ({})".format(self.url, self.title) \ No newline at end of file diff --git a/tests/fixtures/lti_resource_link.json b/tests/fixtures/lti_resource_link.json new file mode 100644 index 00000000..0e72a6f4 --- /dev/null +++ b/tests/fixtures/lti_resource_link.json @@ -0,0 +1,48 @@ +{ + "create_lti_resource_link": { + "method": "POST", + "endpoint": "courses/1/lti_resource_links", + "data": { + "id": 45, + "context_id": 1, + "context_type": "Course", + "context_external_tool_id": 1, + "resource_type": "assignment", + "canvas_launch_url": "https://example.instructure.com/courses/1/external_tools/retrieve?resource_link_lookup_uuid=ae43ba23-d238-49bc-ab55-ba7f79f77896", + "resource_link_uuid": "ae43ba23-d238-49bc-ab55-ba7f79f77896", + "lookup_uuid": "c522554a-d4be-49ef-b163-9c87fdc6ad6f", + "title": "Test LTI Resource Link", + "url": "https://example.com/lti/launch/content_item/123" + }, + "status_code": 200 + }, + + "get_lti_resource_link": { + "method": "GET", + "endpoint": "courses/1/lti_resource_links/45", + "data": { + "id": 45, + "title": "Test LTI Resource Link" + }, + "status_code": 200 + }, + "list_lti_resource_links": { + "method": "GET", + "endpoint": "courses/1/lti_resource_links", + "data": [ + { + "id": 45, + "title": "Test LTI Resource Link" + }, + { + "id": 56, + "title": "Test LTI Resource Link 2" + }, + { + "id": 67, + "title": "Test LTI Resource Link 3" + } + ], + "status_code": 200 + } +} diff --git a/tests/test_course.py b/tests/test_course.py index 2e2669dd..061908ed 100644 --- a/tests/test_course.py +++ b/tests/test_course.py @@ -21,6 +21,7 @@ from canvasapi.exceptions import RequiredFieldMissing, ResourceDoesNotExist from canvasapi.external_feed import ExternalFeed from canvasapi.external_tool import ExternalTool +from canvasapi.lti_resource_link import LTIResourceLink from canvasapi.feature import Feature, FeatureFlag from canvasapi.file import File from canvasapi.folder import Folder @@ -1889,7 +1890,36 @@ def test_resolve_path_null(self, m): self.assertEqual(len(root_folder_list), 1) self.assertIsInstance(root_folder_list[0], Folder) self.assertEqual("course_files", root_folder_list[0].name) - + + # create_lti_resource_link() + def test_create_lti_resource_link(self, m): + register_uris({"lti_resource_link": ["create_lti_resource_link"]}, m) + evnt = self.course.create_lti_resource_link( + url="https://example.com/lti/launch/content_item/123", title="Test LTI Resource Link", + ) + self.assertIsInstance(evnt, LTIResourceLink) + self.assertEqual(evnt.title, "Test LTI Resource Link") + self.assertEqual(evnt.url, "https://example.com/lti/launch/content_item/123") + + # get_lti_resource_links() + def test_get_lti_resource_links(self, m): + register_uris({"lti_resource_link": ["list_lti_resource_links"]}, m) + + lti_resource_links = self.course.get_lti_resource_links() + lti_resource_link_list = [link for link in lti_resource_links] + self.assertEqual(len(lti_resource_link_list), 3) + self.assertIsInstance(lti_resource_link_list[0], LTIResourceLink) + + # get_lti_resource_link() + def test_get_lti_resource_link(self, m): + register_uris({"lti_resource_link": ["get_lti_resource_link"]}, m) + + lti_resource_link_by_id = self.course.get_lti_resource_link(45) + self.assertIsInstance(lti_resource_link_by_id, LTIResourceLink) + self.assertEqual(lti_resource_link_by_id.title, "Test LTI Resource Link") + lti_resource_link_by_obj = self.course.get_lti_resource_link(lti_resource_link_by_id) + self.assertIsInstance(lti_resource_link_by_obj, LTIResourceLink) + self.assertEqual(lti_resource_link_by_obj.title, "Test LTI Resource Link") @requests_mock.Mocker() class TestCourseNickname(unittest.TestCase): diff --git a/tests/test_lti_resource_link.py b/tests/test_lti_resource_link.py deleted file mode 100644 index afafd11b..00000000 --- a/tests/test_lti_resource_link.py +++ /dev/null @@ -1,48 +0,0 @@ -import unittest - -import requests_mock - -from canvasapi import Canvas -from canvasapi.lti_resource_link import LTIResourceLink -from tests import settings -from tests.util import register_uris - - -@requests_mock.Mocker() -class TestLTIResourceLink(unittest.TestCase): - def setUp(self): - self.canvas = Canvas(settings.BASE_URL, settings.API_KEY) - - with requests_mock.Mocker() as m: - register_uris({"extended_course": ["get_by_id"]}, m) - self.extended_course = self.canvas.get_course(1) - - # create_lti_resource_link() - def test_create_lti_resource_link(self, m): - register_uris({"lti_resource_link": ["create_lti_resource_link"]}, m) - evnt = self.user.create_lti_resource_link( - name="Test LTI Resource Link", url="https://www.google.com" - ) - self.assertIsInstance(evnt, LTIResourceLink) - self.assertEqual(evnt.name, "Test LTI Resource Link") - self.assertEqual(evnt.url, "https://www.google.com") - - # get_lti_resource_links() - def test_get_lti_resource_links(self, m): - register_uris({"lti_resource_link": ["list_lti_resource_links"]}, m) - - lti_resource_links = self.extended_course.get_lti_resource_links() - lti_resource_link_list = [link for link in lti_resource_links] - self.assertEqual(len(lti_resource_link_list), 2) - self.assertIsInstance(lti_resource_link_list[0], LTIResourceLink) - - # get_lti_resource_link() - def test_get_lti_resource_link(self, m): - register_uris({"lti_resource_link": ["get_lti_resource_link"]}, m) - - lti_resource_link_by_id = self.extended_course.get_lti_resource_link(45) - self.assertIsInstance(lti_resource_link_by_id, LTIResourceLink) - self.assertEqual(lti_resource_link_by_id.name, "Test LTI Resource Link 3") - lti_resource_link_by_obj = self.extended_course.get_lti_resource_link(lti_resource_link_by_id) - self.assertIsInstance(lti_resource_link_by_obj, LTIResourceLink) - self.assertEqual(lti_resource_link_by_obj.name, "Test LTI Resource Link 3") From 42876bd37e3772524f0a2b4a9177c1c668d25174 Mon Sep 17 00:00:00 2001 From: Jasmine Hou Date: Tue, 3 Dec 2024 17:16:54 -0500 Subject: [PATCH 08/28] styling fix --- canvasapi/course.py | 19 ++++++++++--------- canvasapi/lti_resource_link.py | 9 +++------ tests/test_course.py | 10 +++++++--- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/canvasapi/course.py b/canvasapi/course.py index df23845f..13467aa3 100644 --- a/canvasapi/course.py +++ b/canvasapi/course.py @@ -2761,7 +2761,6 @@ def upload(self, file: FileOrPathLike, **kwargs): return Uploader( self._requester, "courses/{}/files".format(self.id), file, **kwargs ).start() - def get_lti_resource_links(self, **kwargs): """ @@ -2773,15 +2772,15 @@ def get_lti_resource_links(self, **kwargs): :rtype: :class:`canvasapi.paginated_list.PaginatedList` """ from canvasapi.lti_resource_link import LTIResourceLink - + return PaginatedList( LTIResourceLink, self._requester, "GET", f"courses/{self.id}/lti_resource_links", - kwargs=combine_kwargs(**kwargs) + kwargs=combine_kwargs(**kwargs), ) - + def get_lti_resource_link(self, lti_resource_link, **kwargs): """ Return details about the specified resource link. @@ -2796,15 +2795,17 @@ def get_lti_resource_link(self, lti_resource_link, **kwargs): """ from canvasapi.lti_resource_link import LTIResourceLink - lti_resource_link_id = obj_or_id(lti_resource_link, "lti_resource_link", (LTIResourceLink,)) + lti_resource_link_id = obj_or_id( + lti_resource_link, "lti_resource_link", (LTIResourceLink,) + ) response = self._requester.request( "GET", f"courses/{self.id}/lti_resource_links/{lti_resource_link_id}", - _kwargs=combine_kwargs(**kwargs) + _kwargs=combine_kwargs(**kwargs), ) return LTIResourceLink(self._requester, response.json()) - + def create_lti_resource_link(self, url, title=None, custom=None, **kwargs): """ Create a new LTI resource link. @@ -2827,7 +2828,7 @@ def create_lti_resource_link(self, url, title=None, custom=None, **kwargs): if not url: raise RequiredFieldMissing("The 'url' paramter is required.") - + kwargs["url"] = url if title: kwargs["title"] = title @@ -2837,7 +2838,7 @@ def create_lti_resource_link(self, url, title=None, custom=None, **kwargs): response = self._requester.request( "POST", f"courses/{self.id}/lti_resource_links", - _kwargs=combine_kwargs(**kwargs) + _kwargs=combine_kwargs(**kwargs), ) return LTIResourceLink(self._requester, response.json()) diff --git a/canvasapi/lti_resource_link.py b/canvasapi/lti_resource_link.py index 9fc0e894..4d544cd2 100644 --- a/canvasapi/lti_resource_link.py +++ b/canvasapi/lti_resource_link.py @@ -1,12 +1,9 @@ from canvasapi.canvas_object import CanvasObject -from canvasapi.paginated_list import PaginatedList -from canvasapi.util import combine_kwargs, obj_or_id -from canvasapi.course import Course -from canvasapi.exceptions import RequiredFieldMissing + class LTIResourceLink(CanvasObject): def __init__(self, requester, attributes): super(LTIResourceLink, self).__init__(requester, attributes) - + def __str__(self): - return "{} ({})".format(self.url, self.title) \ No newline at end of file + return "{} ({})".format(self.url, self.title) diff --git a/tests/test_course.py b/tests/test_course.py index 061908ed..b993c221 100644 --- a/tests/test_course.py +++ b/tests/test_course.py @@ -1890,12 +1890,13 @@ def test_resolve_path_null(self, m): self.assertEqual(len(root_folder_list), 1) self.assertIsInstance(root_folder_list[0], Folder) self.assertEqual("course_files", root_folder_list[0].name) - + # create_lti_resource_link() def test_create_lti_resource_link(self, m): register_uris({"lti_resource_link": ["create_lti_resource_link"]}, m) evnt = self.course.create_lti_resource_link( - url="https://example.com/lti/launch/content_item/123", title="Test LTI Resource Link", + url="https://example.com/lti/launch/content_item/123", + title="Test LTI Resource Link", ) self.assertIsInstance(evnt, LTIResourceLink) self.assertEqual(evnt.title, "Test LTI Resource Link") @@ -1917,10 +1918,13 @@ def test_get_lti_resource_link(self, m): lti_resource_link_by_id = self.course.get_lti_resource_link(45) self.assertIsInstance(lti_resource_link_by_id, LTIResourceLink) self.assertEqual(lti_resource_link_by_id.title, "Test LTI Resource Link") - lti_resource_link_by_obj = self.course.get_lti_resource_link(lti_resource_link_by_id) + lti_resource_link_by_obj = self.course.get_lti_resource_link( + lti_resource_link_by_id + ) self.assertIsInstance(lti_resource_link_by_obj, LTIResourceLink) self.assertEqual(lti_resource_link_by_obj.title, "Test LTI Resource Link") + @requests_mock.Mocker() class TestCourseNickname(unittest.TestCase): def setUp(self): From 3e3d5ced8ef91ba0335288bf772f230471ffac97 Mon Sep 17 00:00:00 2001 From: Jasmine Hou Date: Sat, 7 Dec 2024 15:05:32 -0500 Subject: [PATCH 09/28] fix import ordering --- tests/test_course.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_course.py b/tests/test_course.py index b993c221..1d07d2bd 100644 --- a/tests/test_course.py +++ b/tests/test_course.py @@ -21,7 +21,6 @@ from canvasapi.exceptions import RequiredFieldMissing, ResourceDoesNotExist from canvasapi.external_feed import ExternalFeed from canvasapi.external_tool import ExternalTool -from canvasapi.lti_resource_link import LTIResourceLink from canvasapi.feature import Feature, FeatureFlag from canvasapi.file import File from canvasapi.folder import Folder @@ -36,6 +35,7 @@ from canvasapi.grading_standard import GradingStandard from canvasapi.group import Group, GroupCategory from canvasapi.license import License +from canvasapi.lti_resource_link import LTIResourceLink from canvasapi.module import Module from canvasapi.new_quiz import NewQuiz from canvasapi.outcome import OutcomeGroup, OutcomeLink, OutcomeResult From 317a45fdd70a980e0addc246c8c467c72e2754c6 Mon Sep 17 00:00:00 2001 From: Jasmine Hou Date: Mon, 9 Dec 2024 15:17:13 -0500 Subject: [PATCH 10/28] attempt module import fix --- canvasapi/course.py | 4 +--- canvasapi/lti_resource_link.py | 3 --- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/canvasapi/course.py b/canvasapi/course.py index 13467aa3..a8009e88 100644 --- a/canvasapi/course.py +++ b/canvasapi/course.py @@ -23,6 +23,7 @@ from canvasapi.grading_period import GradingPeriod from canvasapi.grading_standard import GradingStandard from canvasapi.license import License +from canvasapi.lti_resource_link import LTIResourceLink from canvasapi.module import Module from canvasapi.new_quiz import NewQuiz from canvasapi.outcome_import import OutcomeImport @@ -2771,7 +2772,6 @@ def get_lti_resource_links(self, **kwargs): :rtype: :class:`canvasapi.paginated_list.PaginatedList` """ - from canvasapi.lti_resource_link import LTIResourceLink return PaginatedList( LTIResourceLink, @@ -2793,7 +2793,6 @@ def get_lti_resource_link(self, lti_resource_link, **kwargs): :rtype: :class:`canvasapi.lti_resource_link.LTIResourceLink` """ - from canvasapi.lti_resource_link import LTIResourceLink lti_resource_link_id = obj_or_id( lti_resource_link, "lti_resource_link", (LTIResourceLink,) @@ -2824,7 +2823,6 @@ def create_lti_resource_link(self, url, title=None, custom=None, **kwargs): :rtype: :class:`canvasapi.lti_resource_link.LTIResourceLink` """ - from canvasapi.lti_resource_link import LTIResourceLink if not url: raise RequiredFieldMissing("The 'url' paramter is required.") diff --git a/canvasapi/lti_resource_link.py b/canvasapi/lti_resource_link.py index 4d544cd2..d951569e 100644 --- a/canvasapi/lti_resource_link.py +++ b/canvasapi/lti_resource_link.py @@ -2,8 +2,5 @@ class LTIResourceLink(CanvasObject): - def __init__(self, requester, attributes): - super(LTIResourceLink, self).__init__(requester, attributes) - def __str__(self): return "{} ({})".format(self.url, self.title) From 76f67ba080b4b53c3ce69374629566588b649332 Mon Sep 17 00:00:00 2001 From: Jasmine Hou Date: Mon, 9 Dec 2024 15:25:23 -0500 Subject: [PATCH 11/28] alphabetize function names --- canvasapi/course.py | 154 ++++++++++++++++++++++---------------------- 1 file changed, 77 insertions(+), 77 deletions(-) diff --git a/canvasapi/course.py b/canvasapi/course.py index a8009e88..4ae2a657 100644 --- a/canvasapi/course.py +++ b/canvasapi/course.py @@ -439,6 +439,41 @@ def create_late_policy(self, **kwargs): return LatePolicy(self._requester, late_policy_json["late_policy"]) + def create_lti_resource_link(self, url, title=None, custom=None, **kwargs): + """ + Create a new LTI resource link. + + :calls: `POST /api/v1/courses/:course_id/lti_resource_links \ + `_ + + :param course_id: The ID of the course. + :type course_id: `int` + :param url: The launch URL for the resource link. + :type url: `str` + :param title: The title of the resource link. + :type title: `str`, optional + :param custom: Custom parameters to send to the tool. + :type custom: `dict`, optional + + :rtype: :class:`canvasapi.lti_resource_link.LTIResourceLink` + """ + + if not url: + raise RequiredFieldMissing("The 'url' paramter is required.") + + kwargs["url"] = url + if title: + kwargs["title"] = title + if custom: + kwargs["custom"] = custom + + response = self._requester.request( + "POST", + f"courses/{self.id}/lti_resource_links", + _kwargs=combine_kwargs(**kwargs), + ) + return LTIResourceLink(self._requester, response.json()) + def create_module(self, module, **kwargs): """ Create a new module. @@ -1646,6 +1681,48 @@ def get_licenses(self, **kwargs): _kwargs=combine_kwargs(**kwargs), ) + def get_lti_resource_link(self, lti_resource_link, **kwargs): + """ + Return details about the specified resource link. + + :calls: `GET /api/v1/courses/:course_id/lti_resource_links/:id \ + `_ + + :param lti_resource_link: The object or ID of the LTI resource link. + :type lti_resource_link: :class:`canvasapi.lti_resource_link.LTIResourceLink` or int + + :rtype: :class:`canvasapi.lti_resource_link.LTIResourceLink` + """ + + lti_resource_link_id = obj_or_id( + lti_resource_link, "lti_resource_link", (LTIResourceLink,) + ) + + response = self._requester.request( + "GET", + f"courses/{self.id}/lti_resource_links/{lti_resource_link_id}", + _kwargs=combine_kwargs(**kwargs), + ) + return LTIResourceLink(self._requester, response.json()) + + def get_lti_resource_links(self, **kwargs): + """ + Returns all LTI resource links for this course as a PaginatedList. + + :calls: `GET /api/v1/courses/:course_id/lti_resource_links \ + `_ + + :rtype: :class:`canvasapi.paginated_list.PaginatedList` + """ + + return PaginatedList( + LTIResourceLink, + self._requester, + "GET", + f"courses/{self.id}/lti_resource_links", + kwargs=combine_kwargs(**kwargs), + ) + def get_migration_systems(self, **kwargs): """ Return a list of migration systems. @@ -2763,83 +2840,6 @@ def upload(self, file: FileOrPathLike, **kwargs): self._requester, "courses/{}/files".format(self.id), file, **kwargs ).start() - def get_lti_resource_links(self, **kwargs): - """ - Returns all LTI resource links for this course as a PaginatedList. - - :calls: `GET /api/v1/courses/:course_id/lti_resource_links \ - `_ - - :rtype: :class:`canvasapi.paginated_list.PaginatedList` - """ - - return PaginatedList( - LTIResourceLink, - self._requester, - "GET", - f"courses/{self.id}/lti_resource_links", - kwargs=combine_kwargs(**kwargs), - ) - - def get_lti_resource_link(self, lti_resource_link, **kwargs): - """ - Return details about the specified resource link. - - :calls: `GET /api/v1/courses/:course_id/lti_resource_links/:id \ - `_ - - :param lti_resource_link: The object or ID of the LTI resource link. - :type lti_resource_link: :class:`canvasapi.lti_resource_link.LTIResourceLink` or int - - :rtype: :class:`canvasapi.lti_resource_link.LTIResourceLink` - """ - - lti_resource_link_id = obj_or_id( - lti_resource_link, "lti_resource_link", (LTIResourceLink,) - ) - - response = self._requester.request( - "GET", - f"courses/{self.id}/lti_resource_links/{lti_resource_link_id}", - _kwargs=combine_kwargs(**kwargs), - ) - return LTIResourceLink(self._requester, response.json()) - - def create_lti_resource_link(self, url, title=None, custom=None, **kwargs): - """ - Create a new LTI resource link. - - :calls: `POST /api/v1/courses/:course_id/lti_resource_links \ - `_ - - :param course_id: The ID of the course. - :type course_id: `int` - :param url: The launch URL for the resource link. - :type url: `str` - :param title: The title of the resource link. - :type title: `str`, optional - :param custom: Custom parameters to send to the tool. - :type custom: `dict`, optional - - :rtype: :class:`canvasapi.lti_resource_link.LTIResourceLink` - """ - - if not url: - raise RequiredFieldMissing("The 'url' paramter is required.") - - kwargs["url"] = url - if title: - kwargs["title"] = title - if custom: - kwargs["custom"] = custom - - response = self._requester.request( - "POST", - f"courses/{self.id}/lti_resource_links", - _kwargs=combine_kwargs(**kwargs), - ) - return LTIResourceLink(self._requester, response.json()) - class CourseNickname(CanvasObject): def __str__(self): From a8118ec48b2131cddf2e6dc491eb8638d37f7b16 Mon Sep 17 00:00:00 2001 From: Jasmine Hou Date: Mon, 9 Dec 2024 16:09:37 -0500 Subject: [PATCH 12/28] additional test coverage --- canvasapi/course.py | 2 +- tests/fixtures/lti_resource_link.json | 3 ++- tests/test_course.py | 9 ++++++++ tests/test_lti_resource_link.py | 31 +++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 tests/test_lti_resource_link.py diff --git a/canvasapi/course.py b/canvasapi/course.py index 4ae2a657..e7c9b920 100644 --- a/canvasapi/course.py +++ b/canvasapi/course.py @@ -459,7 +459,7 @@ def create_lti_resource_link(self, url, title=None, custom=None, **kwargs): """ if not url: - raise RequiredFieldMissing("The 'url' paramter is required.") + raise RequiredFieldMissing("url is required as a str.") kwargs["url"] = url if title: diff --git a/tests/fixtures/lti_resource_link.json b/tests/fixtures/lti_resource_link.json index 0e72a6f4..2de13515 100644 --- a/tests/fixtures/lti_resource_link.json +++ b/tests/fixtures/lti_resource_link.json @@ -22,7 +22,8 @@ "endpoint": "courses/1/lti_resource_links/45", "data": { "id": 45, - "title": "Test LTI Resource Link" + "title": "Test LTI Resource Link", + "url": "https://example.com/lti/launch/content_item/123" }, "status_code": 200 }, diff --git a/tests/test_course.py b/tests/test_course.py index 1d07d2bd..f21eb9fe 100644 --- a/tests/test_course.py +++ b/tests/test_course.py @@ -1894,14 +1894,22 @@ def test_resolve_path_null(self, m): # create_lti_resource_link() def test_create_lti_resource_link(self, m): register_uris({"lti_resource_link": ["create_lti_resource_link"]}, m) + custom_dict = {"hello": "world"} + evnt = self.course.create_lti_resource_link( url="https://example.com/lti/launch/content_item/123", title="Test LTI Resource Link", + custom=custom_dict, ) self.assertIsInstance(evnt, LTIResourceLink) + self.assertEqual(evnt.title, "Test LTI Resource Link") self.assertEqual(evnt.url, "https://example.com/lti/launch/content_item/123") + def test_create_lti_resource_link_fail(self, m): + with self.assertRaises(RequiredFieldMissing): + self.course.create_lti_resource_link({}) + # get_lti_resource_links() def test_get_lti_resource_links(self, m): register_uris({"lti_resource_link": ["list_lti_resource_links"]}, m) @@ -1916,6 +1924,7 @@ def test_get_lti_resource_link(self, m): register_uris({"lti_resource_link": ["get_lti_resource_link"]}, m) lti_resource_link_by_id = self.course.get_lti_resource_link(45) + self.assertIsInstance(lti_resource_link_by_id, LTIResourceLink) self.assertEqual(lti_resource_link_by_id.title, "Test LTI Resource Link") lti_resource_link_by_obj = self.course.get_lti_resource_link( diff --git a/tests/test_lti_resource_link.py b/tests/test_lti_resource_link.py new file mode 100644 index 00000000..427a865f --- /dev/null +++ b/tests/test_lti_resource_link.py @@ -0,0 +1,31 @@ +import unittest + +import requests_mock + +from canvasapi import Canvas +from canvasapi.exceptions import RequiredFieldMissing +from tests import settings +from tests.util import register_uris + + +@requests_mock.Mocker() +class TestLTIResourceLink(unittest.TestCase): + def setUp(self): + self.canvas = Canvas(settings.BASE_URL, settings.API_KEY) + + with requests_mock.Mocker() as m: + register_uris( + { + "course": ["get_by_id"], + "lti_resource_link": ["get_lti_resource_link"], + }, + m, + ) + + self.course = self.canvas.get_course(1) + self.resource_link = self.course.get_lti_resource_link(45) + + # __str__() + def test__str__(self, m): + string = str(self.resource_link) + self.assertIsInstance(string, str) From dd38ca954d3b974c1bf8f3db221f71ada5c264b4 Mon Sep 17 00:00:00 2001 From: Jasmine Hou Date: Mon, 9 Dec 2024 16:17:33 -0500 Subject: [PATCH 13/28] bruh --- tests/test_lti_resource_link.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_lti_resource_link.py b/tests/test_lti_resource_link.py index 427a865f..f1b58901 100644 --- a/tests/test_lti_resource_link.py +++ b/tests/test_lti_resource_link.py @@ -3,7 +3,6 @@ import requests_mock from canvasapi import Canvas -from canvasapi.exceptions import RequiredFieldMissing from tests import settings from tests.util import register_uris From e00128621cd35fded509fc746c9b32eb6a4fd12b Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Mon, 9 Dec 2024 17:01:10 -0500 Subject: [PATCH 14/28] Update docs to include LTIResourceLink. Update authors and changelog --- AUTHORS.md | 1 + CHANGELOG.md | 4 ++++ canvasapi/course.py | 5 ++--- docs/class-reference.rst | 1 + docs/lti-resource-link-ref.rst | 6 ++++++ 5 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 docs/lti-resource-link-ref.rst diff --git a/AUTHORS.md b/AUTHORS.md index d2ac0bfa..67b1d405 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -53,6 +53,7 @@ - Ian Altgilbers [@altgilbers](https://github.com/altgilbers) - Ian Turgeon [@iturgeon](https://github.com/iturgeon) - [@jackrsteiner](https://github.com/jackrsteiner) +- Jasmine Hou [@jsmnhou](https://github.com/jsmnhou) - John Raible [@rebelaide](https://github.com/rebelaide) - Joon Ro [@joonro](https://github.com/joonro) - Jonah Majumder [@jonahmajumder](https://github.com/jonahmajumder) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0a7326f..9a2b16d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### New Endpoint Coverage + +- LTI Resource Links (Thanks, [@jsmnhou](https://github.com/jsmnhou)) + ### Backstage - Updated deploy Action to use more modern processes. diff --git a/canvasapi/course.py b/canvasapi/course.py index e7c9b920..2b28f810 100644 --- a/canvasapi/course.py +++ b/canvasapi/course.py @@ -446,8 +446,6 @@ def create_lti_resource_link(self, url, title=None, custom=None, **kwargs): :calls: `POST /api/v1/courses/:course_id/lti_resource_links \ `_ - :param course_id: The ID of the course. - :type course_id: `int` :param url: The launch URL for the resource link. :type url: `str` :param title: The title of the resource link. @@ -1712,7 +1710,8 @@ def get_lti_resource_links(self, **kwargs): :calls: `GET /api/v1/courses/:course_id/lti_resource_links \ `_ - :rtype: :class:`canvasapi.paginated_list.PaginatedList` + :rtype: :class:`canvasapi.paginated_list.PaginatedList` of + :class:`canvasapi.lti_resource_link.LTIResourceLink` """ return PaginatedList( diff --git a/docs/class-reference.rst b/docs/class-reference.rst index 7420abff..e73ade2c 100644 --- a/docs/class-reference.rst +++ b/docs/class-reference.rst @@ -41,6 +41,7 @@ Class Reference jwt-ref login-ref license-ref + lti-resource-link-ref module-ref outcome-ref outcome-import-ref diff --git a/docs/lti-resource-link-ref.rst b/docs/lti-resource-link-ref.rst new file mode 100644 index 00000000..b14ff90e --- /dev/null +++ b/docs/lti-resource-link-ref.rst @@ -0,0 +1,6 @@ +=============== +LTIResourceLink +=============== + +.. autoclass:: canvasapi.lti_resource_link.LTIResourceLink + :members: From 45ba5ac92eb5ced513f58fab180379ae02a3c0be Mon Sep 17 00:00:00 2001 From: Elias <38086802+HandcartCactus@users.noreply.github.com> Date: Thu, 26 Dec 2024 20:46:28 -0500 Subject: [PATCH 15/28] Merge pull request #677 from handcartcactus/issue/676-improve-developer-experience Issue #676 Improve developer experience --- AUTHORS.md | 1 + CHANGELOG.md | 1 + canvasapi/paginated_list.py | 13 +++++++++---- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/AUTHORS.md b/AUTHORS.md index 67b1d405..85081f77 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -42,6 +42,7 @@ - Deundre Williams [@deundrewilliams](https://github.com/deundrewilliams) - Devin Singh [@devints47](https://github.com/devints47) - Dmitry Savransky [@dsavransky](https://github.com/dsavransky) +- Elias [@HandcartCactus](https://github.com/HandcartCactus) - Elise Heron [@thedarkestknight](https://github.com/thedarkestknight) - Elli Howard [@qwertynerd97](https://github.com/qwertynerd97) - Erik Tews [@eriktews](https://github.com/eriktews) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a2b16d7..677052c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Backstage - Updated deploy Action to use more modern processes. +- Updated `PaginatedList` to be type-aware, showing which class is included in the response. (Thanks [@HandcartCactus](https://github.com/HandcartCactus)) ## [3.3.0] - 2023-08-27 diff --git a/canvasapi/paginated_list.py b/canvasapi/paginated_list.py index bf799874..96b91529 100644 --- a/canvasapi/paginated_list.py +++ b/canvasapi/paginated_list.py @@ -1,7 +1,12 @@ +from __future__ import annotations + import re +from typing import Iterable, Iterator, Type, TypeVar + +T = TypeVar("T") -class PaginatedList(object): +class PaginatedList(Iterable[T]): """ Abstracts `pagination of Canvas API \ `_. @@ -19,14 +24,14 @@ def __getitem__(self, index): def __init__( self, - content_class, + content_class: Type[T], requester, request_method, first_url, extra_attribs=None, _root=None, _url_override=None, - **kwargs + **kwargs, ): """ :param content_class: The expected type to return in the list. @@ -60,7 +65,7 @@ def __init__( self._root = _root self._url_override = _url_override - def __iter__(self): + def __iter__(self) -> Iterator[T]: for element in self._elements: yield element while self._has_next(): From 33461de534beec863de6206292127973170bf24e Mon Sep 17 00:00:00 2001 From: Alex Nunez-Carrasquillo Date: Fri, 25 Apr 2025 12:40:16 -0400 Subject: [PATCH 16/28] Added beta support for Canvas SmartSearch API --- canvasapi/course.py | 27 +++++++++ canvasapi/searchresult.py | 58 ++++++++++++++++++ tests/fixtures/course.json | 52 ++++++++++++++++ tests/test_course.py | 16 +++++ tests/test_searchresult.py | 120 +++++++++++++++++++++++++++++++++++++ 5 files changed, 273 insertions(+) create mode 100644 canvasapi/searchresult.py create mode 100644 tests/test_searchresult.py diff --git a/canvasapi/course.py b/canvasapi/course.py index 2b28f810..5650cecf 100644 --- a/canvasapi/course.py +++ b/canvasapi/course.py @@ -2744,6 +2744,33 @@ def show_front_page(self, **kwargs): return Page(self._requester, page_json) + def smartsearch(self, query, **kwargs): + """ + AI-powered course content search. + + :calls: `GET /api/v1/courses/:course_id/smartsearch \ + `_ + + :param query: The search query string. + :type query: str + :param kwargs: Optional query parameters (e.g., filter, per_page). + :type kwargs: dict + :rtype: :class:`canvasapi.paginated_list.PaginatedList` of + :class:`canvasapi.searchresult.SearchResult` + """ + from canvasapi.searchresult import SearchResult + + kwargs["q"] = query + + return PaginatedList( + SearchResult, + self._requester, + "GET", + f"courses/{self.id}/smartsearch", + {"course_id": self.id}, + _kwargs=combine_kwargs(**kwargs), + ) + def submissions_bulk_update(self, **kwargs): """ Update the grading and comments on multiple student's assignment diff --git a/canvasapi/searchresult.py b/canvasapi/searchresult.py new file mode 100644 index 00000000..aea53ebf --- /dev/null +++ b/canvasapi/searchresult.py @@ -0,0 +1,58 @@ +from canvasapi.canvas_object import CanvasObject + + +# NOTE - As of April 25th, 2025, the SmartSearch API is experimental, and may cause breaks +# on code changes. If you've landed here on an error, it could be the API was updated. +class SearchResult(CanvasObject): + """ + Represents a result (which can be of multiple types) return from the SmartSearch API. + `_ + """ + + REQUIRED_FIELDS = ["content_id", "content_type", "title", "html_url"] + + def __init__(self, requester, attributes): + super(SearchResult, self).__init__(requester, attributes) + + missing = [f for f in self.REQUIRED_FIELDS if not hasattr(self, f)] + if missing: + raise ValueError("SearchResult missing required fields: {}".format(missing)) + + def __str__(self): + # NOTE - Using Untitiled as a fallback in the event the API changes. + return "".format( + self.content_type, getattr(self, "title", "Untitled") + ) + + def resolve(self, course): + """ + Resolve this SearchResult into the corresponding Canvas object. + + :param course: The Course instance to resolve against. + :type course: :class:`canvasapi.course.Course` + :return: The full object (e.g., Page, Assignment, DiscussionTopic), or None if + resolution fails. + :rtype: :class:`canvasapi.page.Page`, :class:`canvasapi.assignment.Assignment`, + :class:`canvasapi.discussion_topic.DiscussionTopic` + """ + if not hasattr(self, "content_type") or not hasattr(self, "content_id"): + raise ValueError( + "SearchResult is missing 'content_type' or 'content_id' for resolution" + ) + + content_type = self.content_type.lower() + types = [ + ("page", course.get_page), + ("assignment", course.get_assignment), + ("discussion", course.get_discussion_topic), + ("announcement", course.get_discussion_topic), + ] + + for keyword, resolver in types: + if keyword in content_type: + return resolver(self.content_id) + + # See NOTE above. + raise ValueError( + "Resolution not supported for content_type: {}".format(self.content_type) + ) diff --git a/tests/fixtures/course.json b/tests/fixtures/course.json index 81b31308..722df6c6 100644 --- a/tests/fixtures/course.json +++ b/tests/fixtures/course.json @@ -2440,5 +2440,57 @@ } ], "status_code": 200 + }, + "smartsearch_basic": { + "method": "GET", + "endpoint": "courses/1/smartsearch", + "data": [ + { + "content_id": 2, + "content_type": "WikiPage", + "title": "Nicolaus Copernicus", + "body": "...", + "html_url": "https://canvas.example.com/courses/123/pages/nicolaus-copernicus", + "distance": 0.212 + } + ] + }, + "smartsearch_with_filter": { + "method": "GET", + "endpoint": "courses/1/smartsearch", + "data": [ + { + "content_id": 5, + "content_type": "Assignment", + "title": "Chain Rule Practice", + "html_url": "https://canvas.example.com/courses/123/assignments/5", + "distance": 0.112 + } + ] + }, + "get_assignment": { + "method": "GET", + "endpoint": "courses/1/assignments/5", + "data": { + "id": 5, + "title": "Derivatives HW" + } + }, + "get_announcement": { + "method": "GET", + "endpoint": "courses/1/discussion_topics/7", + "data": { + "id": 7, + "title": "Class Cancelled" + } + }, + "get_disc_topic": { + "method": "GET", + "endpoint": "courses/1/discussion_topics/6", + "data": { + "id": 7, + "title": "Class Cancelled" + } } } + diff --git a/tests/test_course.py b/tests/test_course.py index f21eb9fe..fbe3792a 100644 --- a/tests/test_course.py +++ b/tests/test_course.py @@ -50,6 +50,7 @@ from canvasapi.todo import Todo from canvasapi.usage_rights import UsageRights from canvasapi.user import User +from canvasapi.searchresult import SearchResult from tests import settings from tests.util import cleanup_file, register_uris @@ -1547,6 +1548,21 @@ def test_set_quiz_extensions(self, m): self.assertTrue(hasattr(extension[1], "extra_attempts")) self.assertEqual(extension[1].extra_attempts, 3) + def test_smartsearch(self, m): + register_uris({"course": ["smartsearch_basic"]}, m) + results = self.course.smartsearch("Copernicus") + self.assertTrue(results) + self.assertTrue(all(isinstance(r, SearchResult) for r in results)) + self.assertEqual(results[0].title, "Nicolaus Copernicus") + + def test_smartsearch_with_filter(self, m): + register_uris({"course": ["smartsearch_with_filter"]}, m) + results = self.course.smartsearch("derivatives", kwargs=["assignments"]) + results = list(results) + self.assertTrue(results) + self.assertTrue(all(isinstance(r, SearchResult) for r in results)) + self.assertEqual(results[0].title, "Chain Rule Practice") + def test_set_extensions_not_list(self, m): with self.assertRaises(ValueError): self.course.set_quiz_extensions({"user_id": 1, "extra_time": 60}) diff --git a/tests/test_searchresult.py b/tests/test_searchresult.py new file mode 100644 index 00000000..5fe61009 --- /dev/null +++ b/tests/test_searchresult.py @@ -0,0 +1,120 @@ +import unittest +import requests_mock +from canvasapi import Canvas +from canvasapi.searchresult import SearchResult +from tests import settings +from tests.util import register_uris + + +@requests_mock.Mocker() +class TestSearchResult(unittest.TestCase): + def setUp(self): + self.canvas = Canvas(settings.BASE_URL, settings.API_KEY) + with requests_mock.Mocker() as m: + register_uris( + { + "course": [ + "get_by_id", + "smartsearch_basic", + "smartsearch_with_filter", + "get_page", + "get_assignment", + "get_discussion_topic", + "get_announcement", + "get_disc_topic", + ] + }, + m, + ) + self.course = self.canvas.get_course(1) + self.basic_result = list(self.course.smartsearch("Copernicus"))[0] + self.assignment_result = list( + self.course.smartsearch("derivatives", filter=["assignments"]) + )[0] + + def test_str_representation(self, m): + register_uris({"course": ["smartsearch_basic"]}, m) + self.assertEqual( + str(self.basic_result), "" + ) + + def test_str_fallback(self, m): + register_uris({"course": ["smartsearch_basic"]}, m) + result = list(self.course.smartsearch("Copernicus"))[0] + delattr(result, "title") + self.assertIn("Untitled", str(result)) + + def test_missing_fields_raises(self, m): + with self.assertRaises(ValueError): + SearchResult(self.basic_result._requester, {"content_type": "WikiPage"}) + + def test_resolve_page(self, m): + register_uris({"course": ["get_assignment"]}, m) + resolved = self.basic_result.resolve(self.course) + self.assertEqual(resolved.title, "Derivatives HW") + + def test_resolve_assignment(self, m): + register_uris({"course": ["get_assignment"]}, m) + resolved = self.assignment_result.resolve(self.course) + self.assertEqual(resolved.title, "Derivatives HW") + + def test_resolve_discussion(self, m): + register_uris({"course": ["get_disc_topic"]}, m) + result = SearchResult( + self.basic_result._requester, + { + "content_id": 6, + "content_type": "DiscussionTopic", + "title": "Intro Discussion", + "html_url": "https://canvas.example.com/discussion", + }, + ) + resolved = result.resolve(self.course) + self.assertEqual(resolved.title, "Class Cancelled") + + def test_resolve_announcement(self, m): + register_uris({"course": ["get_discussion_topic", "get_announcement"]}, m) + result = SearchResult( + self.basic_result._requester, + { + "content_id": 7, + "content_type": "Announcement", + "title": "Class Cancelled", + "html_url": "https://canvas.example.com/announcements", + }, + ) + resolved = result.resolve(self.course) + self.assertEqual(resolved.title, "Class Cancelled") + + def test_resolve_unknown_type_raises(self, m): + result = SearchResult( + self.basic_result._requester, + { + "content_id": 999, + "content_type": "MysteryThing", + "title": "Mystery", + "html_url": "https://canvas.example.com/unknown", + }, + ) + with self.assertRaises(ValueError): + result.resolve(self.course) + + def test_resolve_missing_attrs_raises(self, m): + with self.assertRaises(ValueError): + result = SearchResult(self.basic_result._requester, {"title": "Incomplete"}) + result.resolve(self.course) + + def test_resolve_raises_if_missing_attrs(self, m): + result = SearchResult( + self.course._requester, + { + "content_id": 42, + "content_type": "Assignment", + "title": "Partial Result", + "html_url": "https://canvas.example.com", + }, + ) + delattr(result, "content_id") + with self.assertRaises(ValueError) as ctx: + result.resolve(self.course) + self.assertIn("content_type", str(ctx.exception)) From 906665cb23b8032be4cdc02576b1490531c639f8 Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Fri, 27 Jun 2025 13:38:52 -0400 Subject: [PATCH 17/28] Update sphinx. Fix doc build warnings --- CHANGELOG.md | 1 + canvasapi/paginated_list.py | 1 + dev_requirements.txt | 3 +-- docs/conf.py | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 677052c2..286a7cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Updated deploy Action to use more modern processes. - Updated `PaginatedList` to be type-aware, showing which class is included in the response. (Thanks [@HandcartCactus](https://github.com/HandcartCactus)) +- Updated Sphinx ## [3.3.0] - 2023-08-27 diff --git a/canvasapi/paginated_list.py b/canvasapi/paginated_list.py index 96b91529..904ceb9b 100644 --- a/canvasapi/paginated_list.py +++ b/canvasapi/paginated_list.py @@ -47,6 +47,7 @@ def __init__( :param _root: Specify a nested property from Canvas to use for the resulting list. :type _root: str :param _url_override: "new_quizzes" or "graphql" for specific Canvas endpoints. + Other URLs may be specified for third-party requests. :type _url_override: str :rtype: :class:`canvasapi.paginated_list.PaginatedList` of type content_class diff --git a/dev_requirements.txt b/dev_requirements.txt index cfc2056e..44efe0ce 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,7 +1,6 @@ -r tests_requirements.txt -docutils==0.15.2 pre-commit -Sphinx +Sphinx~=8.2.3 sphinx-rtd-theme sphinx-version-warning diff --git a/docs/conf.py b/docs/conf.py index a05eaed1..5b923f7f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -74,7 +74,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: From f9e5073e766028ce5b364d30a9bcc6a538658219 Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Fri, 27 Jun 2025 13:46:14 -0400 Subject: [PATCH 18/28] Set up GH Actions to run against current versions of Python --- .github/workflows/run-tests.yml | 6 +++--- CHANGELOG.md | 4 ++++ setup.py | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index c975729b..2ac3e2af 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -11,12 +11,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index 286a7cb9..0d92d965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - LTI Resource Links (Thanks, [@jsmnhou](https://github.com/jsmnhou)) +### General +- Added support for Python 3.12 and 3.13 +- Dropped support for Python 3.7 and 3.8 + ### Backstage - Updated deploy Action to use more modern processes. diff --git a/setup.py b/setup.py index a7248373..d0cedd3e 100644 --- a/setup.py +++ b/setup.py @@ -39,11 +39,11 @@ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries", ], ) From 531638b0359636b2b0d663430d45cdf5472f7f5e Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Fri, 27 Jun 2025 13:51:45 -0400 Subject: [PATCH 19/28] Update Black --- CHANGELOG.md | 1 + tests_requirements.txt | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d92d965..9de64453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - LTI Resource Links (Thanks, [@jsmnhou](https://github.com/jsmnhou)) ### General + - Added support for Python 3.12 and 3.13 - Dropped support for Python 3.7 and 3.8 diff --git a/tests_requirements.txt b/tests_requirements.txt index a3e5ec58..d83fdc09 100644 --- a/tests_requirements.txt +++ b/tests_requirements.txt @@ -1,8 +1,7 @@ -r requirements.txt -black>=23.3.0 +black~=25.1.0 coverage flake8 isort requests-mock -urllib3<2 From 50d91de3cfdecc7124c2cd270c6d0315e5d9ee9b Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Fri, 27 Jun 2025 14:16:42 -0400 Subject: [PATCH 20/28] Add kwargs to SearchResults.resolve(). Rework content_type lookup to use dict. --- canvasapi/course.py | 3 +-- canvasapi/searchresult.py | 39 +++++++++++++++++++------------------- tests/test_course.py | 2 +- tests/test_searchresult.py | 2 ++ 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/canvasapi/course.py b/canvasapi/course.py index 5650cecf..38d2e25c 100644 --- a/canvasapi/course.py +++ b/canvasapi/course.py @@ -32,6 +32,7 @@ from canvasapi.progress import Progress from canvasapi.quiz import QuizExtension from canvasapi.rubric import Rubric, RubricAssociation +from canvasapi.searchresult import SearchResult from canvasapi.submission import GroupedSubmission, Submission from canvasapi.tab import Tab from canvasapi.todo import Todo @@ -2758,8 +2759,6 @@ def smartsearch(self, query, **kwargs): :rtype: :class:`canvasapi.paginated_list.PaginatedList` of :class:`canvasapi.searchresult.SearchResult` """ - from canvasapi.searchresult import SearchResult - kwargs["q"] = query return PaginatedList( diff --git a/canvasapi/searchresult.py b/canvasapi/searchresult.py index aea53ebf..761b0ef4 100644 --- a/canvasapi/searchresult.py +++ b/canvasapi/searchresult.py @@ -1,8 +1,8 @@ from canvasapi.canvas_object import CanvasObject -# NOTE - As of April 25th, 2025, the SmartSearch API is experimental, and may cause breaks -# on code changes. If you've landed here on an error, it could be the API was updated. +# As of April 25th, 2025, the SmartSearch API is experimental, and may cause breaks +# on code changes. If you've landed here on an error, it could be the API was updated. class SearchResult(CanvasObject): """ Represents a result (which can be of multiple types) return from the SmartSearch API. @@ -19,12 +19,12 @@ def __init__(self, requester, attributes): raise ValueError("SearchResult missing required fields: {}".format(missing)) def __str__(self): - # NOTE - Using Untitiled as a fallback in the event the API changes. + # Using Untitled as a fallback in the event the API changes. return "".format( self.content_type, getattr(self, "title", "Untitled") ) - def resolve(self, course): + def resolve(self, course, **kwargs): """ Resolve this SearchResult into the corresponding Canvas object. @@ -41,18 +41,19 @@ def resolve(self, course): ) content_type = self.content_type.lower() - types = [ - ("page", course.get_page), - ("assignment", course.get_assignment), - ("discussion", course.get_discussion_topic), - ("announcement", course.get_discussion_topic), - ] - - for keyword, resolver in types: - if keyword in content_type: - return resolver(self.content_id) - - # See NOTE above. - raise ValueError( - "Resolution not supported for content_type: {}".format(self.content_type) - ) + types = { + "page": course.get_page, + "assignment": course.get_assignment, + "discussiontopic": course.get_discussion_topic, + "announcement": course.get_discussion_topic, + } + + resolver = types.get(content_type) + if not resolver: + raise ValueError( + "Resolution not supported for content_type: {}".format( + self.content_type + ) + ) + + return resolver(self.content_id, **kwargs) diff --git a/tests/test_course.py b/tests/test_course.py index fbe3792a..25249e9b 100644 --- a/tests/test_course.py +++ b/tests/test_course.py @@ -44,13 +44,13 @@ from canvasapi.progress import Progress from canvasapi.quiz import Quiz, QuizAssignmentOverrideSet, QuizExtension from canvasapi.rubric import Rubric, RubricAssociation +from canvasapi.searchresult import SearchResult from canvasapi.section import Section from canvasapi.submission import GroupedSubmission, Submission from canvasapi.tab import Tab from canvasapi.todo import Todo from canvasapi.usage_rights import UsageRights from canvasapi.user import User -from canvasapi.searchresult import SearchResult from tests import settings from tests.util import cleanup_file, register_uris diff --git a/tests/test_searchresult.py b/tests/test_searchresult.py index 5fe61009..213460dd 100644 --- a/tests/test_searchresult.py +++ b/tests/test_searchresult.py @@ -1,5 +1,7 @@ import unittest + import requests_mock + from canvasapi import Canvas from canvasapi.searchresult import SearchResult from tests import settings From feb6db10666d6348847d2a1015bb4a8163247f18 Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Fri, 27 Jun 2025 14:48:47 -0400 Subject: [PATCH 21/28] Update docs, add contrib to changelog. add alportoricensis to authors. --- AUTHORS.md | 1 + CHANGELOG.md | 1 + canvasapi/searchresult.py | 2 +- docs/class-reference.rst | 1 + docs/searchresult-ref.rst | 6 ++++++ 5 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 docs/searchresult-ref.rst diff --git a/AUTHORS.md b/AUTHORS.md index 85081f77..fb1924bd 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -17,6 +17,7 @@ - Adrian Goetz [@a-goetz](https://github.com/a-goetz) - Aileen Pongnon [@aileenpongnon](https://github.com/aileenpongnon) - Alyssa Davis [@allygator](https://github.com/allygator) +- Alex Gabriel Nunez-Carrasquillo [@alportoricensis](https://github.com/alportoricensis) - [@amorqiu](https://github.com/amorqiu) - Andrew Gardener [@andrew-gardener](https://github.com/andrew-gardener) - Anthony Rodriguez [@AnthonyRodriguez726](https://github.com/AnthonyRodriguez726) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9de64453..25a8984a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### New Endpoint Coverage - LTI Resource Links (Thanks, [@jsmnhou](https://github.com/jsmnhou)) +- Smart Search API [BETA] (Thanks, [@alportoricensis](https://github.com/alportoricensis)) ### General diff --git a/canvasapi/searchresult.py b/canvasapi/searchresult.py index 761b0ef4..c85c2641 100644 --- a/canvasapi/searchresult.py +++ b/canvasapi/searchresult.py @@ -5,7 +5,7 @@ # on code changes. If you've landed here on an error, it could be the API was updated. class SearchResult(CanvasObject): """ - Represents a result (which can be of multiple types) return from the SmartSearch API. + Represents a result (which can be of multiple types) return from the `SmartSearch API. \ `_ """ diff --git a/docs/class-reference.rst b/docs/class-reference.rst index e73ade2c..454314ec 100644 --- a/docs/class-reference.rst +++ b/docs/class-reference.rst @@ -58,6 +58,7 @@ Class Reference rubric-ref scope-ref section-ref + searchresult-ref sis-import-ref submission-ref tab-ref diff --git a/docs/searchresult-ref.rst b/docs/searchresult-ref.rst new file mode 100644 index 00000000..57a24cca --- /dev/null +++ b/docs/searchresult-ref.rst @@ -0,0 +1,6 @@ +============ +SearchResult +============ + +.. autoclass:: canvasapi.searchresult.SearchResult + :members: From efbca76e1090e9467974882d952be7a3ef90f304 Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Mon, 30 Jun 2025 13:36:37 -0400 Subject: [PATCH 22/28] Have resolver work directly on type dict. Rework test structure to match real-life use. Remove kwargs from SearchResult.resolve in favor of adding to kwargs not required list. --- canvasapi/course.py | 9 +-- canvasapi/searchresult.py | 15 +++-- scripts/find_missing_kwargs.py | 1 + tests/fixtures/course.json | 111 ++++++++++++++++++++++++--------- tests/test_course.py | 2 +- tests/test_searchresult.py | 62 +++++++++++++----- 6 files changed, 143 insertions(+), 57 deletions(-) diff --git a/canvasapi/course.py b/canvasapi/course.py index 38d2e25c..9fe704f5 100644 --- a/canvasapi/course.py +++ b/canvasapi/course.py @@ -2745,21 +2745,21 @@ def show_front_page(self, **kwargs): return Page(self._requester, page_json) - def smartsearch(self, query, **kwargs): + def smartsearch(self, q, **kwargs): """ AI-powered course content search. :calls: `GET /api/v1/courses/:course_id/smartsearch \ `_ - :param query: The search query string. - :type query: str + :param q: The search query string. + :type q: str :param kwargs: Optional query parameters (e.g., filter, per_page). :type kwargs: dict :rtype: :class:`canvasapi.paginated_list.PaginatedList` of :class:`canvasapi.searchresult.SearchResult` """ - kwargs["q"] = query + kwargs["q"] = q return PaginatedList( SearchResult, @@ -2767,6 +2767,7 @@ def smartsearch(self, query, **kwargs): "GET", f"courses/{self.id}/smartsearch", {"course_id": self.id}, + _root="results", _kwargs=combine_kwargs(**kwargs), ) diff --git a/canvasapi/searchresult.py b/canvasapi/searchresult.py index c85c2641..f1d3c6b2 100644 --- a/canvasapi/searchresult.py +++ b/canvasapi/searchresult.py @@ -24,7 +24,7 @@ def __str__(self): self.content_type, getattr(self, "title", "Untitled") ) - def resolve(self, course, **kwargs): + def resolve(self, course): """ Resolve this SearchResult into the corresponding Canvas object. @@ -40,15 +40,14 @@ def resolve(self, course, **kwargs): "SearchResult is missing 'content_type' or 'content_id' for resolution" ) - content_type = self.content_type.lower() types = { - "page": course.get_page, - "assignment": course.get_assignment, - "discussiontopic": course.get_discussion_topic, - "announcement": course.get_discussion_topic, + "WikiPage": course.get_page, + "Assignment": course.get_assignment, + "DiscussionTopic": course.get_discussion_topic, + "Announcement": course.get_discussion_topic, } - resolver = types.get(content_type) + resolver = types.get(self.content_type) if not resolver: raise ValueError( "Resolution not supported for content_type: {}".format( @@ -56,4 +55,4 @@ def resolve(self, course, **kwargs): ) ) - return resolver(self.content_id, **kwargs) + return resolver(self.content_id) diff --git a/scripts/find_missing_kwargs.py b/scripts/find_missing_kwargs.py index a40b3b76..daf01270 100644 --- a/scripts/find_missing_kwargs.py +++ b/scripts/find_missing_kwargs.py @@ -17,6 +17,7 @@ "Uploader.upload", "OutcomeGroup.context_ref", "OutcomeLink.context_ref", + "SearchResult.resolve", ) diff --git a/tests/fixtures/course.json b/tests/fixtures/course.json index 722df6c6..4680b125 100644 --- a/tests/fixtures/course.json +++ b/tests/fixtures/course.json @@ -2443,54 +2443,105 @@ }, "smartsearch_basic": { "method": "GET", - "endpoint": "courses/1/smartsearch", - "data": [ - { - "content_id": 2, - "content_type": "WikiPage", - "title": "Nicolaus Copernicus", - "body": "...", - "html_url": "https://canvas.example.com/courses/123/pages/nicolaus-copernicus", - "distance": 0.212 - } - ] + "endpoint": "courses/1/smartsearch?q=Copernicus", + "data": { + "results": [ + { + "content_id": 2, + "content_type": "WikiPage", + "title": "Nicolaus Copernicus", + "body": "...", + "html_url": "https://canvas.example.com/courses/1/pages/nicolaus-copernicus", + "distance": 0.212 + } + ] + } }, "smartsearch_with_filter": { "method": "GET", - "endpoint": "courses/1/smartsearch", - "data": [ - { - "content_id": 5, - "content_type": "Assignment", - "title": "Chain Rule Practice", - "html_url": "https://canvas.example.com/courses/123/assignments/5", - "distance": 0.112 - } - ] + "endpoint": "courses/1/smartsearch?q=derivatives&filter[]=assignments", + "data": { + "results": [ + { + "content_id": 5, + "content_type": "Assignment", + "title": "Chain Rule Practice", + "html_url": "https://canvas.example.com/courses/1/assignments/5", + "distance": 0.112 + } + ] + } }, - "get_assignment": { + "smartssearch_multiple_results": { + "method": "GET", + "endpoint": "courses/1/smartsearch?q=multiple", + "data": { + "results": [ + { + "content_id": 2, + "content_type": "WikiPage", + "title": "Nicolaus Copernicus", + "body": "...", + "html_url": "https://canvas.example.com/courses/1/pages/nicolaus-copernicus", + "distance": 0.212 + }, + { + "content_id": 5, + "content_type": "Assignment", + "title": "Chain Rule Practice", + "html_url": "https://canvas.example.com/courses/1/assignments/5", + "distance": 0.112 + }, + { + "content_id": 7, + "content_type": "DiscussionTopic", + "title": "Class Cancelled", + "html_url": "https://canvas.example.com/courses/1/discussion_topics/7", + "distance": 0.312 + }, + { + "content_id": 6, + "content_type": "Announcement", + "title": "Class Cancelled", + "html_url": "https://canvas.example.com/courses/1/discussion_topics/6", + "distance": 0.412 + } + ] + } + }, + "get_page_smartsearch_variant": { + "method": "GET", + "endpoint": "courses/1/pages/2", + "data": { + "page_id": 2, + "url": "nicolaus-copernicus", + "title": "Nicolaus Copernicus", + "body": "...", + "html_url": "https://canvas.example.com/courses/1/pages/nicolaus-copernicus" + } + }, + "get_assignment_smartsearch_variant": { "method": "GET", "endpoint": "courses/1/assignments/5", "data": { "id": 5, - "title": "Derivatives HW" + "name": "Derivatives HW" } }, - "get_announcement": { + "get_disc_topic_smartsearch_variant": { "method": "GET", - "endpoint": "courses/1/discussion_topics/7", + "endpoint": "courses/1/discussion_topics/6", "data": { - "id": 7, - "title": "Class Cancelled" + "id": 6, + "title": "Please Discuss" } }, - "get_disc_topic": { + "get_announcement_smartsearch_variant": { "method": "GET", - "endpoint": "courses/1/discussion_topics/6", + "endpoint": "courses/1/discussion_topics/7", "data": { "id": 7, "title": "Class Cancelled" } } } - diff --git a/tests/test_course.py b/tests/test_course.py index 25249e9b..f816b5ea 100644 --- a/tests/test_course.py +++ b/tests/test_course.py @@ -1557,7 +1557,7 @@ def test_smartsearch(self, m): def test_smartsearch_with_filter(self, m): register_uris({"course": ["smartsearch_with_filter"]}, m) - results = self.course.smartsearch("derivatives", kwargs=["assignments"]) + results = self.course.smartsearch("derivatives", filter=["assignments"]) results = list(results) self.assertTrue(results) self.assertTrue(all(isinstance(r, SearchResult) for r in results)) diff --git a/tests/test_searchresult.py b/tests/test_searchresult.py index 213460dd..08cfbe6e 100644 --- a/tests/test_searchresult.py +++ b/tests/test_searchresult.py @@ -3,6 +3,9 @@ import requests_mock from canvasapi import Canvas +from canvasapi.assignment import Assignment +from canvasapi.discussion_topic import DiscussionTopic +from canvasapi.page import Page from canvasapi.searchresult import SearchResult from tests import settings from tests.util import register_uris @@ -19,11 +22,6 @@ def setUp(self): "get_by_id", "smartsearch_basic", "smartsearch_with_filter", - "get_page", - "get_assignment", - "get_discussion_topic", - "get_announcement", - "get_disc_topic", ] }, m, @@ -35,9 +33,8 @@ def setUp(self): )[0] def test_str_representation(self, m): - register_uris({"course": ["smartsearch_basic"]}, m) self.assertEqual( - str(self.basic_result), "" + str(self.basic_result), "" ) def test_str_fallback(self, m): @@ -51,17 +48,18 @@ def test_missing_fields_raises(self, m): SearchResult(self.basic_result._requester, {"content_type": "WikiPage"}) def test_resolve_page(self, m): - register_uris({"course": ["get_assignment"]}, m) + register_uris({"course": ["get_page_smartsearch_variant"]}, m) resolved = self.basic_result.resolve(self.course) - self.assertEqual(resolved.title, "Derivatives HW") + self.assertEqual(resolved.title, "Nicolaus Copernicus") def test_resolve_assignment(self, m): - register_uris({"course": ["get_assignment"]}, m) + register_uris({"course": ["get_assignment_smartsearch_variant"]}, m) resolved = self.assignment_result.resolve(self.course) - self.assertEqual(resolved.title, "Derivatives HW") + self.assertIsInstance(resolved, Assignment) + self.assertEqual(resolved.name, "Derivatives HW") def test_resolve_discussion(self, m): - register_uris({"course": ["get_disc_topic"]}, m) + register_uris({"course": ["get_disc_topic_smartsearch_variant"]}, m) result = SearchResult( self.basic_result._requester, { @@ -72,10 +70,18 @@ def test_resolve_discussion(self, m): }, ) resolved = result.resolve(self.course) - self.assertEqual(resolved.title, "Class Cancelled") + self.assertEqual(resolved.title, "Please Discuss") def test_resolve_announcement(self, m): - register_uris({"course": ["get_discussion_topic", "get_announcement"]}, m) + register_uris( + { + "course": [ + "get_disc_topic_smartsearch_variant", + "get_announcement_smartsearch_variant", + ] + }, + m, + ) result = SearchResult( self.basic_result._requester, { @@ -120,3 +126,31 @@ def test_resolve_raises_if_missing_attrs(self, m): with self.assertRaises(ValueError) as ctx: result.resolve(self.course) self.assertIn("content_type", str(ctx.exception)) + + def test_resolve_multiple_types(self, m): + register_uris( + { + "course": [ + "smartssearch_multiple_results", + "get_page_smartsearch_variant", + "get_assignment_smartsearch_variant", + "get_disc_topic_smartsearch_variant", + "get_announcement_smartsearch_variant", + ] + }, + m, + ) + results = list(self.course.smartsearch("multiple")) + self.assertEqual(len(results), 4) + + # WikiPage + self.assertIsInstance(results[0].resolve(self.course), Page) + + # Assignment + self.assertIsInstance(results[1].resolve(self.course), Assignment) + + # DiscussionTopic + self.assertIsInstance(results[2].resolve(self.course), DiscussionTopic) + + # Announcement (uses DiscussionTopic) + self.assertIsInstance(results[3].resolve(self.course), DiscussionTopic) From aa40fd819cdbfa012a1ed2346fcd8504562658d9 Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Mon, 30 Jun 2025 13:49:25 -0400 Subject: [PATCH 23/28] Remove requirement for user to provide Course object. Since Course.smartsearch is the only way a user can really get a SmartResult object, and that function adds the course_id in, we can use the internal course_id value reliably. --- canvasapi/searchresult.py | 9 ++++++--- tests/test_searchresult.py | 26 +++++++++++++++----------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/canvasapi/searchresult.py b/canvasapi/searchresult.py index f1d3c6b2..044f179e 100644 --- a/canvasapi/searchresult.py +++ b/canvasapi/searchresult.py @@ -24,12 +24,10 @@ def __str__(self): self.content_type, getattr(self, "title", "Untitled") ) - def resolve(self, course): + def resolve(self): """ Resolve this SearchResult into the corresponding Canvas object. - :param course: The Course instance to resolve against. - :type course: :class:`canvasapi.course.Course` :return: The full object (e.g., Page, Assignment, DiscussionTopic), or None if resolution fails. :rtype: :class:`canvasapi.page.Page`, :class:`canvasapi.assignment.Assignment`, @@ -40,6 +38,11 @@ def resolve(self, course): "SearchResult is missing 'content_type' or 'content_id' for resolution" ) + # use course_id to create a "fake" Course object to work from + from canvasapi.course import Course + + course = Course(self._requester, {"id": self.course_id}) + types = { "WikiPage": course.get_page, "Assignment": course.get_assignment, diff --git a/tests/test_searchresult.py b/tests/test_searchresult.py index 08cfbe6e..e8249587 100644 --- a/tests/test_searchresult.py +++ b/tests/test_searchresult.py @@ -49,12 +49,12 @@ def test_missing_fields_raises(self, m): def test_resolve_page(self, m): register_uris({"course": ["get_page_smartsearch_variant"]}, m) - resolved = self.basic_result.resolve(self.course) + resolved = self.basic_result.resolve() self.assertEqual(resolved.title, "Nicolaus Copernicus") def test_resolve_assignment(self, m): register_uris({"course": ["get_assignment_smartsearch_variant"]}, m) - resolved = self.assignment_result.resolve(self.course) + resolved = self.assignment_result.resolve() self.assertIsInstance(resolved, Assignment) self.assertEqual(resolved.name, "Derivatives HW") @@ -67,9 +67,10 @@ def test_resolve_discussion(self, m): "content_type": "DiscussionTopic", "title": "Intro Discussion", "html_url": "https://canvas.example.com/discussion", + "course_id": 1, }, ) - resolved = result.resolve(self.course) + resolved = result.resolve() self.assertEqual(resolved.title, "Please Discuss") def test_resolve_announcement(self, m): @@ -89,9 +90,10 @@ def test_resolve_announcement(self, m): "content_type": "Announcement", "title": "Class Cancelled", "html_url": "https://canvas.example.com/announcements", + "course_id": 1, }, ) - resolved = result.resolve(self.course) + resolved = result.resolve() self.assertEqual(resolved.title, "Class Cancelled") def test_resolve_unknown_type_raises(self, m): @@ -102,15 +104,16 @@ def test_resolve_unknown_type_raises(self, m): "content_type": "MysteryThing", "title": "Mystery", "html_url": "https://canvas.example.com/unknown", + "course_id": 1, }, ) with self.assertRaises(ValueError): - result.resolve(self.course) + result.resolve() def test_resolve_missing_attrs_raises(self, m): with self.assertRaises(ValueError): result = SearchResult(self.basic_result._requester, {"title": "Incomplete"}) - result.resolve(self.course) + result.resolve() def test_resolve_raises_if_missing_attrs(self, m): result = SearchResult( @@ -120,11 +123,12 @@ def test_resolve_raises_if_missing_attrs(self, m): "content_type": "Assignment", "title": "Partial Result", "html_url": "https://canvas.example.com", + "course_id": 1, }, ) delattr(result, "content_id") with self.assertRaises(ValueError) as ctx: - result.resolve(self.course) + result.resolve() self.assertIn("content_type", str(ctx.exception)) def test_resolve_multiple_types(self, m): @@ -144,13 +148,13 @@ def test_resolve_multiple_types(self, m): self.assertEqual(len(results), 4) # WikiPage - self.assertIsInstance(results[0].resolve(self.course), Page) + self.assertIsInstance(results[0].resolve(), Page) # Assignment - self.assertIsInstance(results[1].resolve(self.course), Assignment) + self.assertIsInstance(results[1].resolve(), Assignment) # DiscussionTopic - self.assertIsInstance(results[2].resolve(self.course), DiscussionTopic) + self.assertIsInstance(results[2].resolve(), DiscussionTopic) # Announcement (uses DiscussionTopic) - self.assertIsInstance(results[3].resolve(self.course), DiscussionTopic) + self.assertIsInstance(results[3].resolve(), DiscussionTopic) From 83c4b5a9dadc3494cfb6fc54591e8f693fedeaeb Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Mon, 30 Jun 2025 13:56:51 -0400 Subject: [PATCH 24/28] minor comment tweak --- canvasapi/searchresult.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/canvasapi/searchresult.py b/canvasapi/searchresult.py index 044f179e..52a29457 100644 --- a/canvasapi/searchresult.py +++ b/canvasapi/searchresult.py @@ -1,7 +1,7 @@ from canvasapi.canvas_object import CanvasObject -# As of April 25th, 2025, the SmartSearch API is experimental, and may cause breaks +# As of June 30th, 2025, the SmartSearch API is experimental, and may cause breaks # on code changes. If you've landed here on an error, it could be the API was updated. class SearchResult(CanvasObject): """ @@ -38,7 +38,7 @@ def resolve(self): "SearchResult is missing 'content_type' or 'content_id' for resolution" ) - # use course_id to create a "fake" Course object to work from + # Use course_id set from Course.smartsearch to create a "fake" Course object to work from from canvasapi.course import Course course = Course(self._requester, {"id": self.course_id}) From cafac387c86a82ac8670402e1e3f15477eed7664 Mon Sep 17 00:00:00 2001 From: "Code Hugger (Matthew Jones)" Date: Tue, 1 Jul 2025 11:41:47 -0400 Subject: [PATCH 25/28] Merge pull request #680 from jonespm/issue_675 find_missing_modules should import rather than append to prioritize local --- scripts/find_missing_modules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/find_missing_modules.py b/scripts/find_missing_modules.py index 37c205aa..0e79c1cd 100644 --- a/scripts/find_missing_modules.py +++ b/scripts/find_missing_modules.py @@ -2,7 +2,7 @@ import os import sys -sys.path.append(os.path.join(sys.path[0], "..")) +sys.path.insert(0, (os.path.join(sys.path[0], ".."))) import canvasapi # noqa From 9aeb6e6486d646e02c5eaec29a1de91216e43658 Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Mon, 4 Aug 2025 18:21:24 -0400 Subject: [PATCH 26/28] Add missing QuizStatistic ref page to docs --- docs/quiz-ref.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/quiz-ref.rst b/docs/quiz-ref.rst index 32b779da..84ae13bc 100644 --- a/docs/quiz-ref.rst +++ b/docs/quiz-ref.rst @@ -33,6 +33,13 @@ QuizReport .. autoclass:: canvasapi.quiz.QuizReport :members: +============= +QuizStatistic +============= + +.. autoclass:: canvasapi.quiz.QuizStatistic + :members: + ============== QuizSubmission ============== From 1bb9920e1e35c1a589af296127fece1941977a04 Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Mon, 11 Aug 2025 10:11:39 -0400 Subject: [PATCH 27/28] New Quizzes Accommodations (#699) * New Quizzes Accommodations and backend tweak to JSON for POST requests (accommodations and graphql) * Spell accommodations correctly --- .github/CONTRIBUTING.md | 40 +++++++++++++++---------------- CHANGELOG.md | 2 ++ canvasapi/canvas.py | 8 ++++--- canvasapi/course.py | 32 +++++++++++++++++++++++-- canvasapi/new_quiz.py | 33 ++++++++++++++++++++++++++ canvasapi/requester.py | 11 +++++---- docs/class-reference.rst | 1 + docs/new-quiz-ref.rst | 13 ++++++++++ tests/fixtures/graphql.json | 14 +++++------ tests/fixtures/new_quiz.json | 28 ++++++++++++++++++++++ tests/test_course.py | 29 ++++++++++++++++++++++- tests/test_new_quiz.py | 46 +++++++++++++++++++++++++++++++++++- 12 files changed, 219 insertions(+), 38 deletions(-) create mode 100644 docs/new-quiz-ref.rst diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f4d34ceb..48fc916e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -7,26 +7,26 @@ Below you'll find guidelines for contributing that will keep our codebase clean ## Table of Contents * [Contributing to CanvasAPI](#contributing-to-canvasapi) - * [Table of Contents](#table-of-contents) - * [How can I contribute?](#how-can-i-contribute) - * [Bug reports](#bug-reports) - * [Resolving issues](#resolving-issues) - * [Making your first contribution](#making-your-first-contribution) - * [Setting up the environment](#setting-up-the-environment) - * [Writing tests](#writing-tests) - * [API coverage tests](#api-coverage-tests) - * [Engine tests](#engine-tests) - * [Running tests / coverage reports](#running-tests--coverage-reports) - * [Making a pull request](#making-a-pull-request) - * [Code style guidelines](#code-style-guidelines) - * [Running code style checks](#running-code-style-checks) - * [Foolish consistency](#foolish-consistency) - * [Method docstrings](#method-docstrings) - * [Descriptions](#descriptions) - * [Links to related API endpoints](#links-to-related-api-endpoints) - * [Parameters](#parameters) - * [Returns](#returns) - * [Docstring Examples](#docstring-examples) + * [Table of Contents](#table-of-contents) + * [How can I contribute?](#how-can-i-contribute) + * [Bug reports](#bug-reports) + * [Resolving issues](#resolving-issues) + * [Making your first contribution](#making-your-first-contribution) + * [Setting up the environment](#setting-up-the-environment) + * [Writing tests](#writing-tests) + * [API coverage tests](#api-coverage-tests) + * [Engine tests](#engine-tests) + * [Running tests / coverage reports](#running-tests--coverage-reports) + * [Making a pull request](#making-a-pull-request) + * [Code style guidelines](#code-style-guidelines) + * [Running code style checks](#running-code-style-checks) + * [Foolish consistency](#foolish-consistency) + * [Method docstrings](#method-docstrings) + * [Descriptions](#descriptions) + * [Links to related API endpoints](#links-to-related-api-endpoints) + * [Parameters](#parameters) + * [Returns](#returns) + * [Docstring Examples](#docstring-examples) ## How can I contribute? diff --git a/CHANGELOG.md b/CHANGELOG.md index 25a8984a..dccf14a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - LTI Resource Links (Thanks, [@jsmnhou](https://github.com/jsmnhou)) - Smart Search API [BETA] (Thanks, [@alportoricensis](https://github.com/alportoricensis)) +- New Quizzes Accommodations ### General @@ -17,6 +18,7 @@ - Updated deploy Action to use more modern processes. - Updated `PaginatedList` to be type-aware, showing which class is included in the response. (Thanks [@HandcartCactus](https://github.com/HandcartCactus)) - Updated Sphinx +- Reworked how `Requester` handles JSON-only POST requests (currently, only New Quizzes Accommodations and GraphQL) ## [3.3.0] - 2023-08-27 diff --git a/canvasapi/canvas.py b/canvasapi/canvas.py index bc6501af..7265335d 100644 --- a/canvasapi/canvas.py +++ b/canvasapi/canvas.py @@ -1276,11 +1276,13 @@ def graphql(self, query, variables=None, **kwargs): "POST", "graphql", headers={"Content-Type": "application/json"}, - _kwargs=combine_kwargs(**kwargs) - + [("query", query), ("variables", variables)], + _kwargs=combine_kwargs(**kwargs), # Needs to call special endpoint without api/v1 _url="graphql", - json=True, + json={ + "query": query, + "variables": variables if variables else {}, + }, ) return response.json() diff --git a/canvasapi/course.py b/canvasapi/course.py index 9fe704f5..9d0cacf7 100644 --- a/canvasapi/course.py +++ b/canvasapi/course.py @@ -25,7 +25,7 @@ from canvasapi.license import License from canvasapi.lti_resource_link import LTIResourceLink from canvasapi.module import Module -from canvasapi.new_quiz import NewQuiz +from canvasapi.new_quiz import AccommodationResponse, NewQuiz from canvasapi.outcome_import import OutcomeImport from canvasapi.page import Page from canvasapi.paginated_list import PaginatedList @@ -1846,7 +1846,7 @@ def get_new_quiz(self, assignment, **kwargs): def get_new_quizzes(self, **kwargs): """ - Get a list of new quizzes. + Get a list of new quizzes in this course. :calls: `GET /api/quiz/v1/courses/:course_id/quizzes \ `_ @@ -1861,6 +1861,7 @@ def get_new_quizzes(self, **kwargs): self._requester, "GET", endpoint, + {"course_id": self.id}, _url_override="new_quizzes", _kwargs=combine_kwargs(**kwargs), ) @@ -2654,6 +2655,33 @@ def resolve_path(self, full_path=None, **kwargs): _kwargs=combine_kwargs(**kwargs), ) + def set_new_quizzes_accommodations(self, accommodations, **kwargs): + """ + Apply accommodations to New Quizzes at the **course level** for + students enrolled in this course. + + :calls: `POST /api/quiz/v1/courses/:course_id/accommodations \ + `_ + + :param accommodations: A list of dictionaries containing accommodation details + for each user. Each dictionary must contain `user_id` and can optionally include + `extra_time`, `apply_to_in_progress_quiz_sessions`, and/or `reduce_choices_enabled`. + :type accommodations: list of dict + + :returns: AccommodationResponse object containing the status of the accommodation request. + :rtype: :class:`canvasapi.new_quiz.AccommodationResponse` + """ + endpoint = "courses/{}/accommodations".format(self.id) + + response = self._requester.request( + "POST", + endpoint, + _url="new_quizzes", + _kwargs=combine_kwargs(**kwargs), + json=accommodations, + ) + return AccommodationResponse(self._requester, response.json()) + def set_quiz_extensions(self, quiz_extensions, **kwargs): """ Set extensions for student all quiz submissions in a course. diff --git a/canvasapi/new_quiz.py b/canvasapi/new_quiz.py index 5b9b4e09..b7af7970 100644 --- a/canvasapi/new_quiz.py +++ b/canvasapi/new_quiz.py @@ -29,6 +29,34 @@ def delete(self, **kwargs): return NewQuiz(self._requester, response_json) + def set_accommodations(self, accommodations, **kwargs): + """ + Apply accommodations at the quiz level for students in a specific assignment. + + :calls: `POST /api/quiz/v1/courses/:course_id/quizzes/:assignment_id/accommodations \ + `_ + + :param accommodations: A list of dictionaries containing accommodation details + for each user. Each dictionary must contain `user_id` and can optionally include + `extra_time`, `extra_attempts`, and/or `reduce_choices_enabled`. + :type accommodations: list of dict + + :returns: AccommodationResponse object containing the status of the accommodation request. + :rtype: :class:`canvasapi.new_quiz.AccommodationResponse` + """ + endpoint = "courses/{}/quizzes/{}/accommodations".format( + self.course_id, self.id + ) + + response = self._requester.request( + "POST", + endpoint, + _url="new_quizzes", + _kwargs=combine_kwargs(**kwargs), + json=accommodations, + ) + return AccommodationResponse(self._requester, response.json()) + def update(self, **kwargs): """ Update a single New Quiz for the course. @@ -51,3 +79,8 @@ def update(self, **kwargs): response_json.update({"course_id": self.course_id}) return NewQuiz(self._requester, response_json) + + +class AccommodationResponse(CanvasObject): + def __str__(self): + return f"{self.message}" diff --git a/canvasapi/requester.py b/canvasapi/requester.py index b36f1685..81b9c4de 100644 --- a/canvasapi/requester.py +++ b/canvasapi/requester.py @@ -80,7 +80,7 @@ def _patch_request(self, url, headers, data=None, **kwargs): """ return self._session.patch(url, headers=headers, data=data) - def _post_request(self, url, headers, data=None, json=False): + def _post_request(self, url, headers, data=None, json=None): """ Issue a POST request to the specified endpoint with the data provided. @@ -90,11 +90,11 @@ def _post_request(self, url, headers, data=None, json=False): :type headers: dict :param data: The data to send with this request. :type data: dict - :param json: Whether or not to send the data as json - :type json: bool + :param json: JSON-encoded data to send in the body of the request. + :type json: dict """ if json: - return self._session.post(url, headers=headers, json=dict(data)) + return self._session.post(url, headers=headers, params=data, json=json) # Grab file from data. files = None @@ -222,6 +222,9 @@ def request( if _kwargs: logger.debug("Data: {data}".format(data=pformat(_kwargs))) + if json: + logger.debug("JSON: {json}".format(json=pformat(json))) + response = req_method(full_url, headers, _kwargs, json=json) logger.info( "Response: {method} {url} {status}".format( diff --git a/docs/class-reference.rst b/docs/class-reference.rst index 454314ec..142a3258 100644 --- a/docs/class-reference.rst +++ b/docs/class-reference.rst @@ -43,6 +43,7 @@ Class Reference license-ref lti-resource-link-ref module-ref + new-quiz-ref outcome-ref outcome-import-ref page-ref diff --git a/docs/new-quiz-ref.rst b/docs/new-quiz-ref.rst new file mode 100644 index 00000000..4a3c4e8c --- /dev/null +++ b/docs/new-quiz-ref.rst @@ -0,0 +1,13 @@ +======= +NewQuiz +======= + +.. autoclass:: canvasapi.new_quiz.NewQuiz + :members: + +===================== +AccommodationResponse +===================== + +.. autoclass:: canvasapi.new_quiz.AccommodationResponse + :members: diff --git a/tests/fixtures/graphql.json b/tests/fixtures/graphql.json index 5f19eaf1..2943aa98 100644 --- a/tests/fixtures/graphql.json +++ b/tests/fixtures/graphql.json @@ -5,17 +5,17 @@ "data": { "term": { "coursesConnection": { - "nodes": [ - { - "_id": "1", - "assignmentsConnection": { - "nodes": [] + "nodes": [ + { + "_id": "1", + "assignmentsConnection": { + "nodes": [] + } } - } ] } } }, "status_code": 200 } -} \ No newline at end of file +} diff --git a/tests/fixtures/new_quiz.json b/tests/fixtures/new_quiz.json index 5f6432e7..ef7606d5 100644 --- a/tests/fixtures/new_quiz.json +++ b/tests/fixtures/new_quiz.json @@ -55,5 +55,33 @@ "instructions": "

This is the updated New Quiz. You got this!

" }, "status_code": 200 + }, + "set_new_quizzes_accommodations_quiz_level": { + "method": "POST", + "endpoint": "courses/1/quizzes/1/accommodations", + "data": { + "message": "Accommodations processed", + "successful": [ + { + "user_id": 123 + } + ], + "failed": [] + }, + "status_code": 200 + }, + "set_new_quizzes_accommodations_course_level": { + "method": "POST", + "endpoint": "courses/1/accommodations", + "data": { + "message": "Accommodations processed", + "successful": [ + { + "user_id": 456 + } + ], + "failed": [] + }, + "status_code": 200 } } diff --git a/tests/test_course.py b/tests/test_course.py index f816b5ea..ec554c5a 100644 --- a/tests/test_course.py +++ b/tests/test_course.py @@ -37,7 +37,7 @@ from canvasapi.license import License from canvasapi.lti_resource_link import LTIResourceLink from canvasapi.module import Module -from canvasapi.new_quiz import NewQuiz +from canvasapi.new_quiz import AccommodationResponse, NewQuiz from canvasapi.outcome import OutcomeGroup, OutcomeLink, OutcomeResult from canvasapi.outcome_import import OutcomeImport from canvasapi.paginated_list import PaginatedList @@ -443,6 +443,33 @@ def test_get_new_quizzes(self, m): self.assertTrue(hasattr(new_quiz_list[0], "title")) self.assertEqual(new_quiz_list[0].title, "New Quiz One") + # set_new_quizzes_accommodations() + def test_set_new_quizzes_accommodations(self, m): + register_uris( + {"new_quiz": ["set_new_quizzes_accommodations_course_level"]}, + m, + base_url=settings.BASE_URL_NEW_QUIZZES, + ) + + accommodations = [ + { + "user_id": 456, + "extra_time": 240, + } + ] + response = self.course.set_new_quizzes_accommodations(accommodations) + + self.assertIsInstance(response, AccommodationResponse) + self.assertTrue(hasattr(response, "message")) + self.assertEqual(response.message, "Accommodations processed") + self.assertTrue(hasattr(response, "successful")) + self.assertIsInstance(response.successful, list) + self.assertEqual(len(response.successful), 1) + self.assertEqual(response.successful[0], {"user_id": 456}) + self.assertTrue(hasattr(response, "failed")) + self.assertIsInstance(response.failed, list) + self.assertEqual(len(response.failed), 0) + # get_modules() def test_get_modules(self, m): register_uris({"course": ["list_modules", "list_modules2"]}, m) diff --git a/tests/test_new_quiz.py b/tests/test_new_quiz.py index 020533fa..89a0c200 100644 --- a/tests/test_new_quiz.py +++ b/tests/test_new_quiz.py @@ -3,7 +3,7 @@ import requests_mock from canvasapi import Canvas -from canvasapi.new_quiz import NewQuiz +from canvasapi.new_quiz import AccommodationResponse, NewQuiz from tests import settings from tests.util import register_uris @@ -45,6 +45,36 @@ def test_delete(self, m): self.assertTrue(hasattr(deleted_quiz, "course_id")) self.assertEqual(deleted_quiz.course_id, self.course.id) + # set_accommodations() + def test_set_accommodations(self, m): + register_uris( + {"new_quiz": ["set_new_quizzes_accommodations_quiz_level"]}, + m, + base_url=settings.BASE_URL_NEW_QUIZZES, + ) + + accommodations = [ + { + "user_id": 123, + "extra_time": 30, + "extra_attempts": 1, + "reduce_choices_enabled": True, + } + ] + + response = self.new_quiz.set_accommodations(accommodations) + + self.assertIsInstance(response, AccommodationResponse) + self.assertTrue(hasattr(response, "message")) + self.assertEqual(response.message, "Accommodations processed") + self.assertTrue(hasattr(response, "successful")) + self.assertIsInstance(response.successful, list) + self.assertEqual(len(response.successful), 1) + self.assertEqual(response.successful[0], {"user_id": 123}) + self.assertTrue(hasattr(response, "failed")) + self.assertIsInstance(response.failed, list) + self.assertEqual(len(response.failed), 0) + # update() def test_update(self, m): register_uris( @@ -66,3 +96,17 @@ def test_update(self, m): self.assertEqual(new_quiz.instructions, new_instructions) self.assertTrue(hasattr(new_quiz, "course_id")) self.assertEqual(new_quiz.course_id, self.course.id) + + +class TestAccommodationResponse(unittest.TestCase): + def setUp(self): + self.response_data = { + "message": "Accommodations processed", + "successful": [{"user_id": 123}], + "failed": [], + } + self.accommodation_response = AccommodationResponse(None, self.response_data) + + def test_str(self): + expected_str = "Accommodations processed" + self.assertEqual(str(self.accommodation_response), expected_str) From 5104eb64e69191e784ec516408b90cc94abb0708 Mon Sep 17 00:00:00 2001 From: Matthew Emond Date: Mon, 10 Nov 2025 16:54:02 -0500 Subject: [PATCH 28/28] Update for 3.4.0 --- CHANGELOG.md | 5 ++++- canvasapi/__init__.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dccf14a0..fcd585b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [3.4.0] - 2025-11-10 + ### New Endpoint Coverage - LTI Resource Links (Thanks, [@jsmnhou](https://github.com/jsmnhou)) @@ -654,7 +656,8 @@ Huge thanks to [@liblit](https://github.com/liblit) for lots of issues, suggesti - Fixed some incorrectly defined parameters - Fixed an issue where tests would fail due to an improperly configured requires block -[Unreleased]: https://github.com/ucfopen/canvasapi/compare/v3.3.0...develop +[Unreleased]: https://github.com/ucfopen/canvasapi/compare/v3.4.0...develop +[3.4.0]: https://github.com/ucfopen/canvasapi/compare/v3.3.0...v3.4.0 [3.3.0]: https://github.com/ucfopen/canvasapi/compare/v3.2.0...v3.3.0 [3.2.0]: https://github.com/ucfopen/canvasapi/compare/v3.1.0...v3.2.0 [3.1.0]: https://github.com/ucfopen/canvasapi/compare/v3.0.0...v3.1.0 diff --git a/canvasapi/__init__.py b/canvasapi/__init__.py index d86ac5a5..7a50a6e5 100644 --- a/canvasapi/__init__.py +++ b/canvasapi/__init__.py @@ -4,4 +4,4 @@ __all__ = ["Canvas"] -__version__ = "3.3.0" +__version__ = "3.4.0"