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/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2178065e..54466a09 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,13 +9,16 @@ 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 setup.py sdist + run: python -m build - name: Publish distribution to PyPI uses: pypa/gh-action-pypi-publish@release/v1 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/AUTHORS.md b/AUTHORS.md index d2ac0bfa..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) @@ -42,6 +43,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) @@ -53,6 +55,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 3529dcc0..fcd585b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,36 @@ ## [Unreleased] +## [3.4.0] - 2025-11-10 + +### New Endpoint Coverage + +- LTI Resource Links (Thanks, [@jsmnhou](https://github.com/jsmnhou)) +- Smart Search API [BETA] (Thanks, [@alportoricensis](https://github.com/alportoricensis)) +- New Quizzes Accommodations + +### 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. +- 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 ### 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 @@ -636,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" 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 523a2713..9d0cacf7 100644 --- a/canvasapi/course.py +++ b/canvasapi/course.py @@ -23,14 +23,16 @@ 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.new_quiz import AccommodationResponse, NewQuiz from canvasapi.outcome_import import OutcomeImport from canvasapi.page import Page from canvasapi.paginated_list import PaginatedList 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 @@ -438,6 +440,39 @@ 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 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("url is required as a str.") + + 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. @@ -1645,6 +1680,49 @@ 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` of + :class:`canvasapi.lti_resource_link.LTIResourceLink` + """ + + 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. @@ -1768,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 \ `_ @@ -1783,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), ) @@ -2576,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. @@ -2667,6 +2773,32 @@ def show_front_page(self, **kwargs): return Page(self._requester, page_json) + def smartsearch(self, q, **kwargs): + """ + AI-powered course content search. + + :calls: `GET /api/v1/courses/:course_id/smartsearch \ + `_ + + :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"] = q + + return PaginatedList( + SearchResult, + self._requester, + "GET", + f"courses/{self.id}/smartsearch", + {"course_id": self.id}, + _root="results", + _kwargs=combine_kwargs(**kwargs), + ) + def submissions_bulk_update(self, **kwargs): """ Update the grading and comments on multiple student's assignment diff --git a/canvasapi/lti_resource_link.py b/canvasapi/lti_resource_link.py new file mode 100644 index 00000000..d951569e --- /dev/null +++ b/canvasapi/lti_resource_link.py @@ -0,0 +1,6 @@ +from canvasapi.canvas_object import CanvasObject + + +class LTIResourceLink(CanvasObject): + def __str__(self): + return "{} ({})".format(self.url, self.title) 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/paginated_list.py b/canvasapi/paginated_list.py index bf799874..904ceb9b 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. @@ -42,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 @@ -60,7 +66,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(): 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/canvasapi/searchresult.py b/canvasapi/searchresult.py new file mode 100644 index 00000000..52a29457 --- /dev/null +++ b/canvasapi/searchresult.py @@ -0,0 +1,61 @@ +from canvasapi.canvas_object import CanvasObject + + +# 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): + """ + 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): + # Using Untitled as a fallback in the event the API changes. + return "".format( + self.content_type, getattr(self, "title", "Untitled") + ) + + def resolve(self): + """ + Resolve this SearchResult into the corresponding Canvas object. + + :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" + ) + + # 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}) + + types = { + "WikiPage": course.get_page, + "Assignment": course.get_assignment, + "DiscussionTopic": course.get_discussion_topic, + "Announcement": course.get_discussion_topic, + } + + resolver = types.get(self.content_type) + if not resolver: + raise ValueError( + "Resolution not supported for content_type: {}".format( + self.content_type + ) + ) + + return resolver(self.content_id) 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/class-reference.rst b/docs/class-reference.rst index 7420abff..142a3258 100644 --- a/docs/class-reference.rst +++ b/docs/class-reference.rst @@ -41,7 +41,9 @@ Class Reference jwt-ref login-ref license-ref + lti-resource-link-ref module-ref + new-quiz-ref outcome-ref outcome-import-ref page-ref @@ -57,6 +59,7 @@ Class Reference rubric-ref scope-ref section-ref + searchresult-ref sis-import-ref submission-ref tab-ref 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: 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: 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/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 ============== 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: 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/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 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", ], ) diff --git a/tests/fixtures/course.json b/tests/fixtures/course.json index 81b31308..4680b125 100644 --- a/tests/fixtures/course.json +++ b/tests/fixtures/course.json @@ -2440,5 +2440,108 @@ } ], "status_code": 200 + }, + "smartsearch_basic": { + "method": "GET", + "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?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 + } + ] + } + }, + "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, + "name": "Derivatives HW" + } + }, + "get_disc_topic_smartsearch_variant": { + "method": "GET", + "endpoint": "courses/1/discussion_topics/6", + "data": { + "id": 6, + "title": "Please Discuss" + } + }, + "get_announcement_smartsearch_variant": { + "method": "GET", + "endpoint": "courses/1/discussion_topics/7", + "data": { + "id": 7, + "title": "Class Cancelled" + } } } 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/lti_resource_link.json b/tests/fixtures/lti_resource_link.json new file mode 100644 index 00000000..2de13515 --- /dev/null +++ b/tests/fixtures/lti_resource_link.json @@ -0,0 +1,49 @@ +{ + "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", + "url": "https://example.com/lti/launch/content_item/123" + }, + "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/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 2e2669dd..ec554c5a 100644 --- a/tests/test_course.py +++ b/tests/test_course.py @@ -35,14 +35,16 @@ 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.new_quiz import AccommodationResponse, NewQuiz from canvasapi.outcome import OutcomeGroup, OutcomeLink, OutcomeResult from canvasapi.outcome_import import OutcomeImport from canvasapi.paginated_list import PaginatedList 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 @@ -441,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) @@ -1546,6 +1575,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", filter=["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}) @@ -1890,6 +1934,48 @@ def test_resolve_path_null(self, m): 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) + 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) + + 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 new file mode 100644 index 00000000..f1b58901 --- /dev/null +++ b/tests/test_lti_resource_link.py @@ -0,0 +1,30 @@ +import unittest + +import requests_mock + +from canvasapi import Canvas +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) 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) diff --git a/tests/test_searchresult.py b/tests/test_searchresult.py new file mode 100644 index 00000000..e8249587 --- /dev/null +++ b/tests/test_searchresult.py @@ -0,0 +1,160 @@ +import unittest + +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 + + +@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", + ] + }, + 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): + 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_page_smartsearch_variant"]}, m) + 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.assertIsInstance(resolved, Assignment) + self.assertEqual(resolved.name, "Derivatives HW") + + def test_resolve_discussion(self, m): + register_uris({"course": ["get_disc_topic_smartsearch_variant"]}, m) + result = SearchResult( + self.basic_result._requester, + { + "content_id": 6, + "content_type": "DiscussionTopic", + "title": "Intro Discussion", + "html_url": "https://canvas.example.com/discussion", + "course_id": 1, + }, + ) + resolved = result.resolve() + self.assertEqual(resolved.title, "Please Discuss") + + def test_resolve_announcement(self, m): + register_uris( + { + "course": [ + "get_disc_topic_smartsearch_variant", + "get_announcement_smartsearch_variant", + ] + }, + m, + ) + result = SearchResult( + self.basic_result._requester, + { + "content_id": 7, + "content_type": "Announcement", + "title": "Class Cancelled", + "html_url": "https://canvas.example.com/announcements", + "course_id": 1, + }, + ) + resolved = result.resolve() + 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", + "course_id": 1, + }, + ) + with self.assertRaises(ValueError): + result.resolve() + + def test_resolve_missing_attrs_raises(self, m): + with self.assertRaises(ValueError): + result = SearchResult(self.basic_result._requester, {"title": "Incomplete"}) + result.resolve() + + 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", + "course_id": 1, + }, + ) + delattr(result, "content_id") + with self.assertRaises(ValueError) as ctx: + result.resolve() + 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(), Page) + + # Assignment + self.assertIsInstance(results[1].resolve(), Assignment) + + # DiscussionTopic + self.assertIsInstance(results[2].resolve(), DiscussionTopic) + + # Announcement (uses DiscussionTopic) + self.assertIsInstance(results[3].resolve(), DiscussionTopic) 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