diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f68e4ddd..d781c64c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @Thetwam @jessemcbride +* @Thetwam @bennettscience @jonespm diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 6a22947c..2dc96c3c 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v2 diff --git a/AUTHORS.md b/AUTHORS.md index 3b38cd8d..d2ac0bfa 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -1,14 +1,17 @@ -Authors -======= +# Authors -Development Lead ----------------- +## Original Authors - Matthew Emond [@Thetwam](https://github.com/Thetwam) - Jesse McBride [@jessemcbride](https://github.com/jessemcbride) -Patches and Suggestions ------------------------ +## Maintainers + +- Matthew Emond [@Thetwam](https://github.com/Thetwam) (Lead) +- Brian Bennett [@bennettscience](https://github.com/bennettscience) +- Matthew Jones [@jonespm](https://github.com/jonespm) + +## Patches and Suggestions - Abrahan Nevarez [@zenith110](https://github.com/zenith110) - Adrian Goetz [@a-goetz](https://github.com/a-goetz) @@ -24,10 +27,10 @@ Patches and Suggestions - [@Birdmaaan4](https://github.com/Birdmaaan4) - [@blepabyte](https://github.com/blepabyte) - Bradford Lynch [@bradfordlynch](https://github.com/bradfordlynch) -- Brian Bennett [@bennettscience](https://github.com/bennettscience) - Bruce Spang [@brucespang](https://github.com/brucespang) -- Catherine Abbruzzese [@cat0698](https://github.com/cat0698)) +- Caitlin Fabian [@Caitlin-Fabian](https://github.com/Caitlin-Fabian) - Cameron Cuff [@ctcuff](https://github.com/ctcuff) +- Catherine Abbruzzese [@cat0698](https://github.com/cat0698) - Craig Thompson [@craigdsthompson](https://github.com/craigdsthompson) - Dalton Durst [@UniversalSuperBox](https://github.com/UniversalSuperBox) - Damian Sweeney [@damianfs](https://github.com/damianfs) @@ -67,7 +70,6 @@ Patches and Suggestions - Mark Lalor [@MarkLalor](https://github.com/MarkLalor) - Markus [@elec3647](https://github.com/elec3647) - Matthew Fedder [@matthewf-ucsd](https://github.com/matthewf-ucsd) -- Matthew Jones [@jonespm](https://github.com/jonespm) - Michael Phelps [@nottheswimmer](https://github.com/nottheswimmer) - Mike Nahmias [@Mike-Nahmias](https://github.com/Mike-Nahmias) - Mike Suhan [@mikesuhan](https://github.com/mikesuhan) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39d8e444..9abbb42f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,35 +2,51 @@ ## [Unreleased] +## [3.2.0] - 2023-05-25 + +### New Endpoint Coverage + +- New Quizzes +- Delete Page in Groups (Thanks, [@Caitlin-Fabian](https://github.com/Caitlin-Fabian)) + +### General + +- Added support for pagination with metadata when headers are missing (Thanks, [@bennettscience](https://github.com/bennettscience)) +- Added support for Python 3.11 + +### Bugfixes + +- Fixed an issue where `Course.create_discussion_topic` wouldn't accept attachment files. + ## [3.1.0] - 2023-04-21 ### New Endpoint Coverage - Account Calendars (Thanks, [@dmols](https://github.com/dmols)) - - List available account calendars - - Get a single account calendar - - Update a calendar's visibility - - Update many calendars' visibility - - List all account calendars + - List available account calendars + - Get a single account calendar + - Update a calendar's visibility + - Update many calendars' visibility + - List all account calendars - Enrollments (Thanks, [@svanderwulp](https://github.com/svanderwulp)) - - Accept Course Invitation - - Reject Course Invitation + - Accept Course Invitation + - Reject Course Invitation - File (Thanks, [@bennettscience](https://github.com/bennettscience)) - - Update File + - Update File - JWTs (Thanks [@dmols](https://github.com/dmols)) - - Create JWTs - - Refresh JWTs + - Create JWTs + - Refresh JWTs - Moderation Grading (Moderation Set) - - List students selected for moderation - - Select students for moderation + - List students selected for moderation + - Select students for moderation - Query Course Events (Thanks, [@dmols](https://github.com/dmols)) - - Query by course - - Query by account + - Query by course + - Query by account - Rubrics (Thanks, [@bennettscience](https://github.com/bennettscience)) - - Create, Update, and Delete Rubric Assessments - - Create a Rubric Association + - Create, Update, and Delete Rubric Assessments + - Create a Rubric Association - Users - - Terminate all user sessions (Thanks, [@lucas-salas](https://github.com/lucas-salas)) + - Terminate all user sessions (Thanks, [@lucas-salas](https://github.com/lucas-salas)) ### General @@ -130,11 +146,11 @@ - Custom Gradebook Columns (Thanks,[@aileenpongnon](https://github.com/aileenpongnon)) - Files - - Resolve Path (Thanks,[@dsavransky](https://github.com/dsavransky)) + - Resolve Path (Thanks,[@dsavransky](https://github.com/dsavransky)) ### Bugfixes -- Fixed an issue where `Quiz.get_quiz_group` incorrectly set `course_id` to the quiz ID. (Thanks,[@hcolclou](https://github.com/hcolclou)) +- Fixed an issue where `Quiz.get_quiz_group` incorrectly set `course_id` to the quiz ID. (Thanks,[@hcolclou](https://github.com/hcolclou)) - Fixed an issue where `Course.create_external_tool` didn't accept `client_id` (LTI 1.3 support). - Fixed an issue where `Module.create_module_item` didn't (Thanks,[@aileenpongnon](https://github.com/aileenpongnon) and [@onomou](https://github.com/onomou)) - Fixed an issue where `Page.revert_to_revision` would incorrectly always set `group_id` to the page ID. Now correctly sets `group_id` or `course_id` appropriately. @@ -170,9 +186,9 @@ ### New Endpoint Coverage - Enrollment Terms - - Get a Single Enrollment Term (Thanks, [@lcamacho](https://github.com/lcamacho)) + - Get a Single Enrollment Term (Thanks, [@lcamacho](https://github.com/lcamacho)) - Files - - Resolve Path for Course (Thanks,[@dsavransky](https://github.com/dsavransky)) + - Resolve Path for Course (Thanks,[@dsavransky](https://github.com/dsavransky)) - GraphQL (Thanks,[@jonespm](https://github.com/jonespm)) - Late Policy (Thanks, [@kennygperez](https://github.com/kennygperez)) - Quiz Assignment Overrides (Thanks, [@kennygperez](https://github.com/kennygperez)) @@ -185,11 +201,11 @@ ### Deprecation Warnings - :warning: **_This is the final release with support for Python 2.7_** :warning: - - [Python 2.7 is end-of-life as of January 2020](https://www.python.org/doc/sunset-python-2/) - - Future releases of CanvasAPI will *NOT* support any version of Python 2 + - [Python 2.7 is end-of-life as of January 2020](https://www.python.org/doc/sunset-python-2/) + - Future releases of CanvasAPI will _NOT_ support any version of Python 2 - :warning: **_This is the final release with support for Python 3.4_** :warning: - - [Python 3.4 is end-of-life as of March 2019](https://www.python.org/downloads/release/python-3410/) - - Future releases of CanvasAPI will *NOT* support Python 3.4 or below + - [Python 3.4 is end-of-life as of March 2019](https://www.python.org/downloads/release/python-3410/) + - Future releases of CanvasAPI will _NOT_ support Python 3.4 or below - This is the final deprecation warning for all methods marked as deprecated in this changelog or in our documentation. They will be removed in the next release. ### Bugfixes @@ -208,7 +224,7 @@ - Assignment Extensions (Thanks, [@ljoks](https://github.com/ljoks)) - AssignmentGroup (Thanks, [@ctcuff](https://github.com/ctcuff)) - - List Assignments + - List Assignments - Authentications Log (Thanks, [@weining-li](https://github.com/weining-li)) - Brand Configs (Thanks, [@bennettscience](https://github.com/bennettscience)) - Comm Messages (Thanks, [@ljoks](https://github.com/ljoks)) @@ -242,30 +258,30 @@ ### New Endpoint Coverage - API Token scopes (Thanks, [@jrsilveti](https://github.com/jrsilveti)) - - List scopes + - List scopes - Account Notifications (Thanks, [@jrsilveti](https://github.com/jrsilveti)) - - Show a global notification - - Update a global notification + - Show a global notification + - Update a global notification - Account Reports (Thanks, [@jrsilveti](https://github.com/jrsilveti)) - - Start a report - - Status of a report - - Delete a report + - Start a report + - Status of a report + - Delete a report - Collaborations (Thanks, [@jrsilveti](https://github.com/jrsilveti)) - - List collaborations - - List members of a collaboration + - List collaborations + - List members of a collaboration - Feature Flags (Thanks, [@cat0698](https://github.com/cat0698)) - - List features - - List enabled features - - Get feature flag - - Set feature flag - - Remove feature flag + - List features + - List enabled features + - Get feature flag + - Set feature flag + - Remove feature flag - Rubric (Thanks, [@cat0698](https://github.com/cat0698)) - - Create a single rubric + - Create a single rubric ### General - Removed overzealous global enabling of `DeprecationWarning`. (Thanks, [@Screeeech](https://github.com/Screeeech)) - - *Note:* `DeprecationWarnings` are disabled by default, so you may need to run your code with `python -Wd` to see them. + - _Note:_ `DeprecationWarnings` are disabled by default, so you may need to run your code with `python -Wd` to see them. ## [0.13.0] - 2019-07-08 @@ -278,14 +294,14 @@ - Outcome Import (Thanks, [@jrsilveti](https://github.com/jrsilveti)) - Peer Reviews (Thanks, [@vutoan1245](https://github.com/vutoan1245)) - Planner (Thanks, [@weining-li](https://github.com/weining-li)) - - Planner - - Planner Notes - - Planner Overrides + - Planner + - Planner Notes + - Planner Overrides - Polls (Thanks, [@Goff-Davis](https://github.com/Goff-Davis)) - - Poll - - PollChoice - - PollSession - - PollSubmission + - Poll + - PollChoice + - PollSession + - PollSubmission - Quiz Submission Questions (Thanks, [@bradfordlynch](https://github.com/bradfordlynch)) ### General @@ -391,12 +407,12 @@ ### Deprecation Warnings - :warning: **_Dropped support for Python 3.3_** :warning: - - [Python 3.3 is end-of-life as of September 2017](https://www.python.org/dev/peps/pep-0398/#lifespan) - - Should continue to function in 3.3, but compatibility cannot be guaranteed going forward. + - [Python 3.3 is end-of-life as of September 2017](https://www.python.org/dev/peps/pep-0398/#lifespan) + - Should continue to function in 3.3, but compatibility cannot be guaranteed going forward. - Several methods in the `Course` and `Section` classes relating to assignments and submissions have been deprecated. - - Comparable methods have been implemented in the `Assignment` and `Submission` classes, as appropriate. - - The deprecated methods now include a warning in the documentation with reference to the replacement. Additionally, the deprecated methods will raise a `DeprecationWarning`. - - These methods will be removed in a future release. + - Comparable methods have been implemented in the `Assignment` and `Submission` classes, as appropriate. + - The deprecated methods now include a warning in the documentation with reference to the replacement. Additionally, the deprecated methods will raise a `DeprecationWarning`. + - These methods will be removed in a future release. - `Course.list_sections()` has been deprecated. Use `Course.get_sections()` instead. ### Bugfixes @@ -427,12 +443,12 @@ ### New Endpoint Coverage - Account - - Delete a sub account + - Delete a sub account - Grading Standards (Thanks, [@JonGuilbe](https://github.com/JonGuilbe)) - Notification Preferences (Thanks, [@a-goetz](https://github.com/a-goetz)) - - Update a preference - - Update preferences by category - - Update multiple preferences + - Update a preference + - Update preferences by category + - Update multiple preferences - Outcomes (Thanks, [@a-goetz](https://github.com/a-goetz)) - Quiz Question Groups (Thanks, [@JonGuilbe](https://github.com/JonGuilbe)) - Rubric (Thanks, [@sigurdurb](https://github.com/sigurdurb)) @@ -443,8 +459,8 @@ - For many endpoints that accept an "object id", either a CanvasAPI Object or integer ID can now be passed. (Thanks, [@a-goetz](https://github.com/a-goetz)) - Added a requester cache that remembers the last 5 requests to Canvas. It can be accessed as the attribute `_cache` of the `requester object`. (e.g. `course._requester._cache`) - Files can now be downloaded directly from the `File` object in one of two ways: (Thanks, [@DanBrink91](https://github.com/DanBrink91)) - 1. `get_contents` will directly return the contents of the file. (e.g. `file.get_contents()`) - 2. `download` will download the file and save it to the provided path. (e.g. `file.download('example.txt')`) + 1. `get_contents` will directly return the contents of the file. (e.g. `file.get_contents()`) + 2. `download` will download the file and save it to the provided path. (e.g. `file.download('example.txt')`) - Moved several methods exclusive to the API Key owner's user from the `User` class to a new class called `CurrentUser`. There is a new method in the `Canvas` class called `get_current_user` to access this object. (e.g. `canvas.get_current_user()`) (Thanks, [@DanBrink91](https://github.com/DanBrink91)) ### Bugfixes @@ -595,9 +611,9 @@ Huge thanks to [@liblit](https://github.com/liblit) for lots of issues, suggesti - Added contribution guide - Added Docker container for testing (e.g. with Jenkins) - Split requirements files into three: - - dev_requirements.txt - - tests_requirements.txt - - requirements.txt + - dev_requirements.txt + - tests_requirements.txt + - requirements.txt ### Bugfixes @@ -605,7 +621,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.1.0...develop +[Unreleased]: https://github.com/ucfopen/canvasapi/compare/v3.2.0...develop +[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 [3.0.0]: https://github.com/ucfopen/canvasapi/compare/v2.2.0...v3.0.0 [2.2.0]: https://github.com/ucfopen/canvasapi/compare/v2.1.0...v2.2.0 diff --git a/README.md b/README.md index df6d18e5..48555ded 100644 --- a/README.md +++ b/README.md @@ -13,19 +13,19 @@ CanvasAPI is a Python library for accessing Instructure’s [Canvas LMS API](htt ## Table of Contents -* [CanvasAPI](#canvasapi) - * [Table of Contents](#table-of-contents) - * [Installation](#installation) - * [Documentation](#documentation) - * [Contributing](#contributing) - * [Quickstart](#quickstart) - * [Working with Canvas Objects](#working-with-canvas-objects) - * [Course objects](#course-objects) - * [User objects](#user-objects) - * [Paginated Lists](#paginated-lists) - * [Keyword arguments](#keyword-arguments) - * [CanvasAPI Projects](#canvasapi-projects) - * [Contact Us](#contact-us) +- [CanvasAPI](#canvasapi) + - [Table of Contents](#table-of-contents) + - [Installation](#installation) + - [Documentation](#documentation) + - [Contributing](#contributing) + - [Quickstart](#quickstart) + - [Working with Canvas Objects](#working-with-canvas-objects) + - [Course objects](#course-objects) + - [User objects](#user-objects) + - [Paginated Lists](#paginated-lists) + - [Keyword arguments](#keyword-arguments) + - [CanvasAPI Projects](#canvasapi-projects) + - [Contact Us](#contact-us) ## Installation @@ -156,12 +156,12 @@ For a more detailed description of how CanvasAPI handles more complex keyword ar Since its initial release in June 2016, CanvasAPI has amassed over 100 [dependent repositories](https://github.com/ucfopen/canvasapi/network/dependents). Many of these include various tools used to enhance the Canvas experience for both instructors and students. Here are a few popular repositories that use CanvasAPI: -* [Canvas Grab](https://github.com/skyzh/canvas_grab) - * Canvas Grab is the most popular project using CanvasAPI. This tool, with one click, copies all files from Canvas LMS to local directory. CanvasAPI is used in this project to connect to a course and grab its files. -* [Clanvas](https://github.com/marklalor/clanvas) - * Clanvas is a command-line client for Canvas. It uses the already available bash commands plus some additional ones to interact with various features of Canvas from the commmand line. -* [CS221Bot](https://github.com/Person314159/cs221bot) - * CS221Bot is a Discord bot for the CPCS 221 course at University of British Columbia. CanvasAPI is used in this project to connect to and synchronize with a course and get its data, such as announcements, new assignments, and more. +- [Canvas Grab](https://github.com/skyzh/canvas_grab) + - Canvas Grab is the most popular project using CanvasAPI. This tool, with one click, copies all files from Canvas LMS to local directory. CanvasAPI is used in this project to connect to a course and grab its files. +- [Clanvas](https://github.com/marklalor/clanvas) + - Clanvas is a command-line client for Canvas. It uses the already available bash commands plus some additional ones to interact with various features of Canvas from the commmand line. +- [CS221Bot](https://github.com/Person314159/cs221bot) + - CS221Bot is a Discord bot for the CPCS 221 course at University of British Columbia. CanvasAPI is used in this project to connect to and synchronize with a course and get its data, such as announcements, new assignments, and more. If you have a project that uses CanvasAPI that you'd like to promote, please contact us! diff --git a/canvasapi/__init__.py b/canvasapi/__init__.py index c3f2ed26..9f66fc6c 100644 --- a/canvasapi/__init__.py +++ b/canvasapi/__init__.py @@ -4,4 +4,4 @@ __all__ = ["Canvas"] -__version__ = "3.1.0" +__version__ = "3.2.0" diff --git a/canvasapi/course.py b/canvasapi/course.py index 0c3e227c..21bde43f 100644 --- a/canvasapi/course.py +++ b/canvasapi/course.py @@ -24,6 +24,7 @@ from canvasapi.grading_standard import GradingStandard from canvasapi.license import License from canvasapi.module import Module +from canvasapi.new_quiz import NewQuiz from canvasapi.outcome_import import OutcomeImport from canvasapi.page import Page from canvasapi.paginated_list import PaginatedList @@ -278,25 +279,37 @@ def create_custom_column(self, column, **kwargs): return CustomGradebookColumn(self._requester, column_json) - def create_discussion_topic(self, **kwargs): + def create_discussion_topic(self, attachment=None, **kwargs): """ Creates a new discussion topic for the course or group. :calls: `POST /api/v1/courses/:course_id/discussion_topics \ `_ + :param attachment: (Optional) A file handler or path of the file to import. + :type attachment: file or str + :rtype: :class:`canvasapi.discussion_topic.DiscussionTopic` """ - response = self._requester.request( - "POST", - "courses/{}/discussion_topics".format(self.id), - _kwargs=combine_kwargs(**kwargs), - ) + if attachment is not None: + attachment_file, is_path = file_or_path(attachment) + attachment = {"attachment": attachment_file} - response_json = response.json() - response_json.update({"course_id": self.id}) + try: + response = self._requester.request( + "POST", + "courses/{}/discussion_topics".format(self.id), + file=attachment, + _kwargs=combine_kwargs(**kwargs), + ) - return DiscussionTopic(self._requester, response_json) + response_json = response.json() + response_json.update({"course_id": self.id}) + + return DiscussionTopic(self._requester, response_json) + finally: + if attachment is not None and is_path: + attachment_file.close() def create_epub_export(self, **kwargs): """ @@ -452,6 +465,29 @@ def create_module(self, module, **kwargs): return Module(self._requester, module_json) + def create_new_quiz(self, **kwargs): + """ + Create a new quiz for the course. + + :calls: `POST /api/quiz/v1/courses/:course_id/quizzes \ + `_ + + :returns: The newly-created New Quiz object + :rtype: :class:`canvasapi.new_quiz.NewQuiz` + """ + endpoint = "courses/{}/quizzes".format(self.id) + + response = self._requester.request( + "POST", + endpoint, + _url=self._requester.original_url + "/api/quiz/v1/" + endpoint, + _kwargs=combine_kwargs(**kwargs), + ) + response_json = response.json() + response_json.update({"course_id": self.id}) + + return NewQuiz(self._requester, response_json) + def create_page(self, wiki_page, **kwargs): """ Create a new wiki page. @@ -1701,6 +1737,56 @@ def get_multiple_submissions(self, **kwargs): _kwargs=combine_kwargs(**kwargs), ) + def get_new_quiz(self, assignment, **kwargs): + """ + Get details about a single new quiz. + + :calls: `GET /api/quiz/v1/courses/:course_id/quizzes/:assignment_id \ + `_ + + :param assignment: The id of the assignment associated with the quiz. + :type assignment: :class:`canvasapi.assignment.Assignment` + or :class:`canvasapi.new_quiz.NewQuiz` or int + + :returns: A New Quiz object. + :rtype: :class:`canvasapi.new_quiz.NewQuiz` + """ + + assignment_id = obj_or_id(assignment, "assignment", (Assignment, NewQuiz)) + endpoint = "courses/{}/quizzes/{}".format(self.id, assignment_id) + + response = self._requester.request( + "GET", + endpoint, + _url=self._requester.original_url + "/api/quiz/v1/" + endpoint, + _kwargs=combine_kwargs(**kwargs), + ) + response_json = response.json() + response_json.update({"course_id": self.id}) + + return NewQuiz(self._requester, response_json) + + def get_new_quizzes(self, **kwargs): + """ + Get a list of new quizzes. + + :calls: `GET /api/quiz/v1/courses/:course_id/quizzes \ + `_ + + :returns: A paginated list of New Quiz objects. + :rtype: :class:`canvasapi.paginated_list.PaginatedList` + of :class:`canvasapi.new_quiz.NewQuiz` + """ + endpoint = "courses/{}/quizzes".format(self.id) + return PaginatedList( + NewQuiz, + self._requester, + "GET", + endpoint, + _url_override=self._requester.original_url + "/api/quiz/v1/" + endpoint, + _kwargs=combine_kwargs(**kwargs), + ) + def get_outcome_group(self, group, **kwargs): """ Returns the details of the Outcome Group with the given id. @@ -1719,7 +1805,9 @@ def get_outcome_group(self, group, **kwargs): outcome_group_id = obj_or_id(group, "group", (OutcomeGroup,)) response = self._requester.request( - "GET", "courses/{}/outcome_groups/{}".format(self.id, outcome_group_id) + "GET", + "courses/{}/outcome_groups/{}".format(self.id, outcome_group_id), + _kwargs=combine_kwargs(**kwargs), ) return OutcomeGroup(self._requester, response.json()) diff --git a/canvasapi/new_quiz.py b/canvasapi/new_quiz.py new file mode 100644 index 00000000..69200154 --- /dev/null +++ b/canvasapi/new_quiz.py @@ -0,0 +1,53 @@ +from canvasapi.canvas_object import CanvasObject +from canvasapi.util import combine_kwargs + + +class NewQuiz(CanvasObject): + def __str__(self): + return "{} ({})".format(self.title, self.id) + + def delete(self, **kwargs): + """ + Delete a single new quiz. + + :calls: `DELETE /api/quiz/v1/courses/:course_id/quizzes/:assignment_id \ + `_ + + :returns: The deleted New Quiz object + :rtype: :class:`canvasapi.new_quiz.NewQuiz` + """ + endpoint = "courses/{}/quizzes/{}".format(self.course_id, self.id) + + response = self._requester.request( + "DELETE", + endpoint, + _url=self._requester.original_url + "/api/quiz/v1/" + endpoint, + _kwargs=combine_kwargs(**kwargs), + ) + response_json = response.json() + response_json.update({"course_id": self.course_id}) + + return NewQuiz(self._requester, response_json) + + def update(self, **kwargs): + """ + Update a single New Quiz for the course. + + :calls: `PATCH /api/quiz/v1/courses/:course_id/quizzes/:assignment_id \ + `_ + + :returns: The updated New Quiz object + :rtype: :class:`canvasapi.new_quiz.NewQuiz` + """ + endpoint = "courses/{}/quizzes/{}".format(self.course_id, self.id) + + response = self._requester.request( + "PATCH", + endpoint, + _url=self._requester.original_url + "/api/quiz/v1/" + endpoint, + _kwargs=combine_kwargs(**kwargs), + ) + response_json = response.json() + response_json.update({"course_id": self.course_id}) + + return NewQuiz(self._requester, response_json) diff --git a/canvasapi/page.py b/canvasapi/page.py index e0840b88..207cc182 100644 --- a/canvasapi/page.py +++ b/canvasapi/page.py @@ -12,13 +12,17 @@ def delete(self, **kwargs): Delete this page. :calls: `DELETE /api/v1/courses/:course_id/pages/:url \ - `_ + `_ + or + `DELETE /api/v1/groups/:group_id/pages/:url \ + `_ :rtype: :class:`canvasapi.page.Page` """ + response = self._requester.request( "DELETE", - "courses/{}/pages/{}".format(self.course_id, self.url), + "{}s/{}/pages/{}".format(self.parent_type, self.parent_id, self.url), _kwargs=combine_kwargs(**kwargs), ) return Page(self._requester, response.json()) @@ -51,7 +55,8 @@ def get_parent(self, **kwargs): :calls: `GET /api/v1/groups/:group_id \ `_ - or :calls: `GET /api/v1/courses/:id \ + or + `GET /api/v1/courses/:id \ `_ :rtype: :class:`canvasapi.group.Group` or :class:`canvasapi.course.Course` diff --git a/canvasapi/paginated_list.py b/canvasapi/paginated_list.py index ce14420d..9decf747 100644 --- a/canvasapi/paginated_list.py +++ b/canvasapi/paginated_list.py @@ -25,6 +25,7 @@ def __init__( first_url, extra_attribs=None, _root=None, + _url_override=None, **kwargs ): self._elements = list() @@ -39,6 +40,7 @@ def __init__( self._extra_attribs = extra_attribs or {} self._request_method = request_method self._root = _root + self._url_override = _url_override def __iter__(self): for element in self._elements: @@ -53,12 +55,29 @@ def __repr__(self): def _get_next_page(self): response = self._requester.request( - self._request_method, self._next_url, **self._next_params + self._request_method, + self._next_url, + _url=self._url_override, + **self._next_params, ) data = response.json() self._next_url = None + # Check the response headers first. This is the normal Canvas convention + # for pagination, but there are endpoints which return a `meta` property + # for pagination instead. + # See https://github.com/ucfopen/canvasapi/discussions/605 + if response.links: + next_link = response.links.get("next") + elif isinstance(data, dict) and "meta" in data: + # requests parses headers into dicts, this returns the same + # structure so the regex will still work. + try: + next_link = {"url": data["meta"]["pagination"]["next"], "rel": "next"} + except KeyError: + next_link = None + else: + next_link = None - next_link = response.links.get("next") regex = r"{}(.*)".format(re.escape(self._requester.base_url)) self._next_url = ( @@ -73,8 +92,9 @@ def _get_next_page(self): try: data = data[self._root] except KeyError: - # TODO: Fix this message to make more sense to an end user. - raise ValueError("Invalid root value specified.") + raise ValueError( + "The key <{}> does not exist in the response.".format(self._root) + ) for element in data: if element is not None: diff --git a/canvasapi/requester.py b/canvasapi/requester.py index b1b87450..bda2c991 100644 --- a/canvasapi/requester.py +++ b/canvasapi/requester.py @@ -98,7 +98,7 @@ def _post_request(self, url, headers, data=None, json=False): files = None for field, value in data: if field == "file": - if isinstance(value, dict): + if isinstance(value, dict) or value is None: files = value else: files = {"file": value} diff --git a/markdown-style.rb b/markdown-style.rb index defe4ef3..d232996b 100644 --- a/markdown-style.rb +++ b/markdown-style.rb @@ -6,4 +6,4 @@ rule 'no-trailing-punctuation', :punctuation => '.,;:!' rule 'ol-prefix', :style => 'ordered' -rule 'ul-indent', :indent => 4 +rule 'ul-indent', :indent => 2 diff --git a/setup.py b/setup.py index 654f0a7a..a7248373 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,7 @@ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Topic :: Software Development :: Libraries", ], ) diff --git a/tests/fixtures/generic_file.txt b/tests/fixtures/generic_file.txt new file mode 100644 index 00000000..d333441d --- /dev/null +++ b/tests/fixtures/generic_file.txt @@ -0,0 +1 @@ +This is a generic file for testing file uploads. diff --git a/tests/fixtures/new_quiz.json b/tests/fixtures/new_quiz.json new file mode 100644 index 00000000..5f6432e7 --- /dev/null +++ b/tests/fixtures/new_quiz.json @@ -0,0 +1,59 @@ +{ + "create_new_quiz": { + "method": "POST", + "endpoint": "courses/1/quizzes", + "data": { + "id": 1, + "title": "New Quiz One", + "instructions": "

This is the first New Quiz. Good luck!

" + }, + "status_code": 200 + }, + "delete_new_quiz": { + "method": "DELETE", + "endpoint": "courses/1/quizzes/1", + "data": { + "id": 1, + "title": "New Quiz One", + "instructions": "

This is the first New Quiz. Good luck!

" + }, + "status_code": 200 + }, + "get_new_quiz": { + "method": "GET", + "endpoint": "courses/1/quizzes/1", + "data": { + "id": 1, + "title": "New Quiz One", + "instructions": "

This is the first New Quiz. Good luck!

" + }, + "status_code": 200 + }, + "get_new_quizzes": { + "method": "GET", + "endpoint": "courses/1/quizzes", + "data": [ + { + "id": 1, + "title": "New Quiz One", + "instructions": "

This is the first New Quiz. Good luck!

" + }, + { + "id": 2, + "title": "New Quiz Two", + "instructions": "

This is the second New Quiz. Good luck!

" + } + ], + "status_code": 200 + }, + "update_new_quiz": { + "method": "PATCH", + "endpoint": "courses/1/quizzes/1", + "data": { + "id": 1, + "title": "New Quiz One - Updated!", + "instructions": "

This is the updated New Quiz. You got this!

" + }, + "status_code": 200 + } +} diff --git a/tests/fixtures/page.json b/tests/fixtures/page.json index dac500fb..d98e7120 100644 --- a/tests/fixtures/page.json +++ b/tests/fixtures/page.json @@ -1,116 +1,126 @@ { - "get_page": { - "method": "GET", - "endpoint": "courses/1/pages/my-url", - "data": { - "id": 1, - "url": "my-url", - "title": "Awesome Page" - }, - "status_code": 200 - }, - "edit": { - "method": "PUT", - "endpoint": "courses/1/pages/my-url", - "data": { - "id": 1, - "title": "New Page", - "url": "my-url" - }, - "status_code": 200 - }, - "delete_page": { - "method": "DELETE", - "endpoint": "courses/1/pages/my-url", - "data": { - "id": 1, - "title": "Page To Be Deleted", - "url": "my-url" - }, - "status_code": 200 - }, - "list_revisions": { - "method": "GET", - "endpoint": "courses/1/pages/my-url/revisions", - "data": [ - { - "id": 1, - "title": "Revision 1" - }, - { - "id": 2, - "title": "Revision 2" - } - ], - "status_code": 200, - "headers": { - "Link": "; rel=\"next\"" - } - }, - "list_revisions2": { - "method": "GET", - "endpoint": "courses/1/pages/my-url/revisions?page=2&per_page=2", - "data": [ - { - "id": 3, - "title": "Revision 3" - }, - { - "id": 4, - "title": "Revision 4" - } - ], - "status_code": 200 - }, - "latest_revision": { - "method": "GET", - "endpoint": "courses/1/pages/my-url/revisions/latest", - "data": { - "id": 1, - "title": "Latest Revision", - "url": "my-url" - }, - "status_code": 200 - }, - "get_latest_rev_by_id": { - "method": "GET", - "endpoint": "courses/1/pages/my-url/revisions/2", - "data": { - "id": 2, - "updated_at": "2012-08-07T11:23:58-06:00", - "revision_id": 2, - "url": "my-url" - }, - "status_code": 200 - }, - "get_latest_rev_by_id_group": { - "method": "GET", - "endpoint": "groups/1/pages/my-url/revisions/2", - "data": { - "id": 2, - "revision_id": 2, - "url": "my-url" - }, - "status_code": 200 - }, - "revert_to_revision": { - "method": "POST", - "endpoint": "courses/1/pages/my-url/revisions/3", - "data": { - "id": 1, - "revision_id": 3, - "url": "my-url" - }, - "status_code": 200 - }, - "revert_to_revision_group": { - "method": "POST", - "endpoint": "groups/1/pages/my-url/revisions/3", - "data": { - "id": 1, - "revision_id": 3, - "url": "my-url" - }, - "status_code": 200 - } -} \ No newline at end of file + "get_page": { + "method": "GET", + "endpoint": "courses/1/pages/my-url", + "data": { + "id": 1, + "url": "my-url", + "title": "Awesome Page" + }, + "status_code": 200 + }, + "edit": { + "method": "PUT", + "endpoint": "courses/1/pages/my-url", + "data": { + "id": 1, + "title": "New Page", + "url": "my-url" + }, + "status_code": 200 + }, + "delete_page_course": { + "method": "DELETE", + "endpoint": "courses/1/pages/my-url", + "data": { + "id": 1, + "title": "Page To Be Deleted", + "url": "my-url" + }, + "status_code": 200 + }, + "delete_page_group": { + "method": "DELETE", + "endpoint": "groups/1/pages/my-url", + "data": { + "id": 1, + "title": "Page To Be Deleted", + "url": "my-url" + }, + "status_code": 200 + }, + "list_revisions": { + "method": "GET", + "endpoint": "courses/1/pages/my-url/revisions", + "data": [ + { + "id": 1, + "title": "Revision 1" + }, + { + "id": 2, + "title": "Revision 2" + } + ], + "status_code": 200, + "headers": { + "Link": "; rel=\"next\"" + } + }, + "list_revisions2": { + "method": "GET", + "endpoint": "courses/1/pages/my-url/revisions?page=2&per_page=2", + "data": [ + { + "id": 3, + "title": "Revision 3" + }, + { + "id": 4, + "title": "Revision 4" + } + ], + "status_code": 200 + }, + "latest_revision": { + "method": "GET", + "endpoint": "courses/1/pages/my-url/revisions/latest", + "data": { + "id": 1, + "title": "Latest Revision", + "url": "my-url" + }, + "status_code": 200 + }, + "get_latest_rev_by_id": { + "method": "GET", + "endpoint": "courses/1/pages/my-url/revisions/2", + "data": { + "id": 2, + "updated_at": "2012-08-07T11:23:58-06:00", + "revision_id": 2, + "url": "my-url" + }, + "status_code": 200 + }, + "get_latest_rev_by_id_group": { + "method": "GET", + "endpoint": "groups/1/pages/my-url/revisions/2", + "data": { + "id": 2, + "revision_id": 2, + "url": "my-url" + }, + "status_code": 200 + }, + "revert_to_revision": { + "method": "POST", + "endpoint": "courses/1/pages/my-url/revisions/3", + "data": { + "id": 1, + "revision_id": 3, + "url": "my-url" + }, + "status_code": 200 + }, + "revert_to_revision_group": { + "method": "POST", + "endpoint": "groups/1/pages/my-url/revisions/3", + "data": { + "id": 1, + "revision_id": 3, + "url": "my-url" + }, + "status_code": 200 + } +} diff --git a/tests/fixtures/paginated_list.json b/tests/fixtures/paginated_list.json index e37006fb..db7a3d47 100644 --- a/tests/fixtures/paginated_list.json +++ b/tests/fixtures/paginated_list.json @@ -114,5 +114,66 @@ } ], "status_code": 200 - } -} \ No newline at end of file + }, + "no_header_4_2_pages_p1": { + "method": "ANY", + "endpoint": "no_header_four_objects_two_pages", + "data": { + "assessments": [ + { + "id": "1", + "name": "object 1" + }, + { + "id": "2", + "name": "object 2" + } + ], + "meta": { + "pagination": { + "next": "https://example.com/api/v1/no_header_four_objects_two_pages?page=2" + } + } + }, + "status_code": 200 + }, + "no_header_4_2_pages_p2": { + "method": "ANY", + "endpoint": "no_header_four_objects_two_pages?page=2", + "data": { + "assessments": [ + { + "id": "3", + "name": "object 3" + }, + { + "id": "4", + "name": "object 4" + } + ] + }, + "status_code": 200 + }, + "no_header_no_next_key": { + "method": "ANY", + "endpoint": "no_header_no_next_key", + "data": { + "assessments": [ + { + "id": "1", + "name": "object 1" + }, + { + "id": "2", + "name": "object 2" + } + ], + "meta": { + "pagination": { + "prev": "https://example.com/api/v1/previous" + } + } + } + }, + "status_code": 200 +} diff --git a/tests/settings.py b/tests/settings.py index a817f9f1..7a017a9c 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,5 +1,6 @@ BASE_URL = "https://example.com" BASE_URL_GRAPHQL = "https://example.com/api/" +BASE_URL_NEW_QUIZZES = "https://example.com/api/quiz/v1/" BASE_URL_WITH_VERSION = "https://example.com/api/v1/" BASE_URL_AS_HTTP = "http://example.com" BASE_URL_AS_BLANK = "" diff --git a/tests/test_course.py b/tests/test_course.py index 21b483bb..2e2669dd 100644 --- a/tests/test_course.py +++ b/tests/test_course.py @@ -1,3 +1,4 @@ +import os import unittest import uuid import warnings @@ -35,6 +36,7 @@ from canvasapi.group import Group, GroupCategory from canvasapi.license import License from canvasapi.module import Module +from canvasapi.new_quiz import NewQuiz from canvasapi.outcome import OutcomeGroup, OutcomeLink, OutcomeResult from canvasapi.outcome_import import OutcomeImport from canvasapi.paginated_list import PaginatedList @@ -333,6 +335,23 @@ def test_create_quiz_fail(self, m): with self.assertRaises(RequiredFieldMissing): self.course.create_quiz({}) + # create_new_quiz() + def test_create_new_quiz(self, m): + register_uris( + {"new_quiz": ["create_new_quiz"]}, + m, + base_url=settings.BASE_URL_NEW_QUIZZES, + ) + + title = "New Quiz One" + new_new_quiz = self.course.create_new_quiz(quiz={"title": title}) + + self.assertIsInstance(new_new_quiz, NewQuiz) + self.assertTrue(hasattr(new_new_quiz, "title")) + self.assertEqual(new_new_quiz.title, title) + self.assertTrue(hasattr(new_new_quiz, "course_id")) + self.assertEqual(new_new_quiz.course_id, self.course.id) + # get_quiz() def test_get_quiz(self, m): register_uris({"course": ["get_quiz"]}, m) @@ -390,6 +409,38 @@ def test_get_quizzes(self, m): self.assertTrue(hasattr(quiz_list[0], "course_id")) self.assertEqual(quiz_list[0].course_id, self.course.id) + # get_new_quiz() + def test_get_new_quiz(self, m): + register_uris( + {"new_quiz": ["get_new_quiz"]}, + m, + base_url=settings.BASE_URL_NEW_QUIZZES, + ) + + new_quiz = self.course.get_new_quiz(1) + + self.assertIsInstance(new_quiz, NewQuiz) + self.assertTrue(hasattr(new_quiz, "title")) + self.assertEqual(new_quiz.title, "New Quiz One") + self.assertTrue(hasattr(new_quiz, "course_id")) + self.assertEqual(new_quiz.course_id, self.course.id) + + # get_new_quizzes() + def test_get_new_quizzes(self, m): + register_uris( + {"new_quiz": ["get_new_quizzes"]}, + m, + base_url=settings.BASE_URL_NEW_QUIZZES, + ) + + new_quizzes = self.course.get_new_quizzes() + new_quiz_list = list(new_quizzes) + + self.assertEqual(len(new_quiz_list), 2) + self.assertIsInstance(new_quiz_list[0], NewQuiz) + self.assertTrue(hasattr(new_quiz_list[0], "title")) + self.assertEqual(new_quiz_list[0].title, "New Quiz One") + # get_modules() def test_get_modules(self, m): register_uris({"course": ["list_modules", "list_modules2"]}, m) @@ -848,7 +899,7 @@ def test_create_column_fail(self, m): self.course.create_custom_column(column={}) # create_discussion_topic() - def test_create_discussion_topic(self, m): + def test_create_discussion_topic_no_file(self, m): register_uris({"course": ["create_discussion_topic"]}, m) title = "Topic 1" @@ -858,6 +909,40 @@ def test_create_discussion_topic(self, m): self.assertEqual(title, discussion.title) self.assertEqual(discussion.course_id, 1) + def test_create_discussion_topic_file_path(self, m): + register_uris({"course": ["create_discussion_topic"]}, m) + + filepath = os.path.join("tests", "fixtures", "generic_file.txt") + + title = "Topic 1" + discussion = self.course.create_discussion_topic(attachment=filepath) + self.assertIsInstance(discussion, DiscussionTopic) + self.assertTrue(hasattr(discussion, "course_id")) + self.assertEqual(title, discussion.title) + self.assertEqual(discussion.course_id, 1) + + def test_create_discussion_topic_file_path_invalid(self, m): + register_uris({"course": ["create_discussion_topic"]}, m) + + filepath = "this/path/doesnt/exist" + + with self.assertRaises(IOError): + self.course.create_discussion_topic(attachment=filepath) + + def test_create_discussion_topic_file_obj(self, m): + register_uris({"course": ["create_discussion_topic"]}, m) + + filepath = os.path.join("tests", "fixtures", "generic_file.txt") + + title = "Topic 1" + with open(filepath, "rb") as f: + discussion = self.course.create_discussion_topic(attachment=f) + + self.assertIsInstance(discussion, DiscussionTopic) + self.assertTrue(hasattr(discussion, "course_id")) + self.assertEqual(title, discussion.title) + self.assertEqual(discussion.course_id, 1) + # reorder_pinned_topics() def test_reorder_pinned_topics(self, m): # Custom matcher to test that params are set correctly @@ -1533,8 +1618,6 @@ def test_get_outcome_import_status_latest(self, m): # import_outcome() def test_import_outcome_filepath(self, m): - import os - register_uris({"course": ["import_outcome"]}, m) filepath = os.path.join("tests", "fixtures", "test_import_outcome.csv") @@ -1548,8 +1631,6 @@ def test_import_outcome_filepath(self, m): self.assertEqual(outcome_import.data["import_type"], "instructure_csv") def test_import_outcome_binary(self, m): - import os - register_uris({"course": ["import_outcome"]}, m) filepath = os.path.join("tests", "fixtures", "test_import_outcome.csv") diff --git a/tests/test_new_quiz.py b/tests/test_new_quiz.py new file mode 100644 index 00000000..020533fa --- /dev/null +++ b/tests/test_new_quiz.py @@ -0,0 +1,68 @@ +import unittest + +import requests_mock + +from canvasapi import Canvas +from canvasapi.new_quiz import NewQuiz +from tests import settings +from tests.util import register_uris + + +@requests_mock.Mocker() +class TestNewQuiz(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"]}, m) + register_uris( + {"new_quiz": ["get_new_quiz"]}, + m, + base_url=settings.BASE_URL_NEW_QUIZZES, + ) + + self.course = self.canvas.get_course(1) + self.new_quiz = self.course.get_new_quiz(1) + + # __str__() + def test__str__(self, m): + string = str(self.new_quiz) + self.assertIsInstance(string, str) + + # delete() + def test_delete(self, m): + register_uris( + {"new_quiz": ["delete_new_quiz"]}, + m, + base_url=settings.BASE_URL_NEW_QUIZZES, + ) + + deleted_quiz = self.new_quiz.delete() + + self.assertIsInstance(deleted_quiz, NewQuiz) + self.assertTrue(hasattr(deleted_quiz, "title")) + self.assertEqual(deleted_quiz.title, "New Quiz One") + self.assertTrue(hasattr(deleted_quiz, "course_id")) + self.assertEqual(deleted_quiz.course_id, self.course.id) + + # update() + def test_update(self, m): + register_uris( + {"new_quiz": ["update_new_quiz"]}, + m, + base_url=settings.BASE_URL_NEW_QUIZZES, + ) + + new_title = "New Quiz One - Updated!" + new_instructions = "

This is the updated New Quiz. You got this!

" + new_quiz = self.new_quiz.update( + quiz={"title": new_title, "instructions": new_instructions} + ) + + self.assertIsInstance(new_quiz, NewQuiz) + self.assertTrue(hasattr(new_quiz, "title")) + self.assertEqual(new_quiz.title, new_title) + self.assertTrue(hasattr(new_quiz, "instructions")) + self.assertEqual(new_quiz.instructions, new_instructions) + self.assertTrue(hasattr(new_quiz, "course_id")) + self.assertEqual(new_quiz.course_id, self.course.id) diff --git a/tests/test_page.py b/tests/test_page.py index 01ac1661..e1735cc2 100644 --- a/tests/test_page.py +++ b/tests/test_page.py @@ -33,6 +33,7 @@ def test__str__(self, m): string = str(self.page_course) self.assertIsInstance(string, str) + # edit() def test_edit(self, m): register_uris({"page": ["edit"]}, m) @@ -43,14 +44,23 @@ def test_edit(self, m): self.assertTrue(hasattr(self.page_course, "title")) self.assertEqual(self.page_course.title, new_title) - def test_delete(self, m): - register_uris({"page": ["delete_page"]}, m) + # delete() + def test_delete_course(self, m): + register_uris({"page": ["delete_page_course"]}, m) page = self.page_course deleted_page = page.delete() self.assertIsInstance(deleted_page, Page) + def test_delete_group(self, m): + register_uris({"page": ["delete_page_group"]}, m) + + page = self.page_group + deleted_page = page.delete() + + self.assertIsInstance(deleted_page, Page) + # get_revisions() def test_get_revisions(self, m): register_uris({"page": ["list_revisions", "list_revisions2"]}, m) diff --git a/tests/test_paginated_list.py b/tests/test_paginated_list.py index 3b9e604d..9783e314 100644 --- a/tests/test_paginated_list.py +++ b/tests/test_paginated_list.py @@ -156,6 +156,9 @@ def test_root_element_incorrect(self, m): with self.assertRaises(ValueError): pag_list[0] + self.assertEqual( + pag_list[0], "The key does not exist in the response." + ) def test_root_element(self, m): register_uris({"account": ["get_enrollment_terms"]}, m) @@ -202,3 +205,31 @@ def test_negative_index_for_slice_end(self, m): with self.assertRaises(IndexError): pag_list[:-1] + + def test_paginated_list_no_header(self, m): + register_uris( + {"paginated_list": ["no_header_4_2_pages_p1", "no_header_4_2_pages_p2"]}, m + ) + + pag_list = PaginatedList( + User, + self.requester, + "GET", + "no_header_four_objects_two_pages", + _root="assessments", + ) + + self.assertIsInstance(pag_list, PaginatedList) + self.assertEqual(len(list(pag_list)), 4) + self.assertIsInstance(pag_list[0], User) + + def test_paginated_list_no_header_no_next(self, m): + register_uris({"paginated_list": ["no_header_no_next_key"]}, m) + + pag_list = PaginatedList( + User, self.requester, "GET", "no_header_no_next_key", _root="assessments" + ) + + self.assertIsInstance(pag_list, PaginatedList) + self.assertEqual(len(list(pag_list)), 2) + self.assertIsInstance(pag_list[0], User) diff --git a/tests_requirements.txt b/tests_requirements.txt index 2eeab8b0..a3e5ec58 100644 --- a/tests_requirements.txt +++ b/tests_requirements.txt @@ -5,3 +5,4 @@ coverage flake8 isort requests-mock +urllib3<2