Repository: ucfopen/canvasapi
Pull request #382 (2020-06-26 17:38:04)

Release v0.16.0
Long overdue, but here it is! 🎉 


Patch:
diff --git a/.gitignore b/.gitignore
index e829f1b..3cfe0c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,4 @@
 \#*#
 .vscode/settings.json
 setup.sh
+.python-version
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ccfacf6..cf8ffb9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,44 @@
 # Change Log
 
+## [Unreleased]
+
+## [0.16.0] - 2020-06-26
+
+### New Endpoint Coverage
+
+- Enrollment Terms
+    - Get a Single Enrollment Term (Thanks, [@lcamacho](https://github.com/lcamacho))
+- Files
+    - 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))
+- Quiz Statistics (Thanks, [@andrew-gardener](https://github.com/andrew-gardener))
+
+### General
+
+- Updated README to use updated parameters for getting a user's courses by enrollment state (Thanks,[@Vishvak365](https://github.com/Vishvak365))
+
+### 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
+- :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
+- 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
+
+- Fixed an issue where `Quiz.get_submission()` ignored data added from using the `include` kwarg. (Thanks,[@Mike-Nahmias](https://github.com/Mike-Nahmias))
+- Fixed the broken `__str__` method on the `ChangeRecord` class (Thanks,[@Mike-Nahmias](https://github.com/Mike-Nahmias))
+- Fixed an issue where printing an `AccountReport` would fail due to not having an ID (Thanks,[@Mike-Nahmias](https://github.com/Mike-Nahmias))
+- Fixed an issue where `"report_type"` was passed improperly (Thanks,[@brucespang](https://github.com/brucespang))
+- Fixed some new `flake8` issues (Thanks,[@dsavransky](https://github.com/dsavransky) and [@jonespm](https://github.com/jonespm))
+- Fixed an incorrect docstring for `Course.create_page()` (Thanks,[@dsavransky](https://github.com/dsavransky))
+- Fixed an issue where extra whitespace in the user-supplied canvas URL would break `PaginatedList` (Thanks,[@amorqiu](https://github.com/amorqiu))
+
 ## [0.15.0] - 2019-11-19
 
 ### New Endpoint Coverage
@@ -403,6 +442,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/v0.16.0...develop
+[0.16.0]: https://github.com/ucfopen/canvasapi/compare/v0.15.0...v0.16.0
 [0.15.0]: https://github.com/ucfopen/canvasapi/compare/v0.14.0...v0.15.0
 [0.14.0]: https://github.com/ucfopen/canvasapi/compare/v0.13.0...v0.14.0
 [0.13.0]: https://github.com/ucfopen/canvasapi/compare/v0.12.0...v0.13.0
diff --git a/canvasapi/__init__.py b/canvasapi/__init__.py
index 5dd4b6f..bc14ad6 100644
--- a/canvasapi/__init__.py
+++ b/canvasapi/__init__.py
@@ -6,4 +6,4 @@ from canvasapi.canvas import Canvas
 
 __all__ = ["Canvas"]
 
-__version__ = "0.15.0"
+__version__ = "0.16.0"
diff --git a/canvasapi/account.py b/canvasapi/account.py
index d03cea3..f8817a2 100644
--- a/canvasapi/account.py
+++ b/canvasapi/account.py
@@ -970,6 +970,27 @@ class Account(CanvasObject):
         )
         return Enrollment(self._requester, response.json())
 
+    def get_enrollment_term(self, term, **kwargs):
+        """
+        Retrieve the details for an enrollment term in the account. Includes overrides by default.
+
+        :calls: `GET /api/v1/accounts/:account_id/terms/:id \
+        <https://canvas.instructure.com/doc/api/enrollment_terms.html#method.terms_api.show>`_
+
+        :param term: The object or ID of the enrollment term to retrieve.
+        :type term: :class:`canvasapi.enrollment_term.EnrollmentTerm` or int
+
+        :rtype: :class:`canvasapi.enrollment_term.EnrollmentTerm`
+        """
+        from canvasapi.enrollment_term import EnrollmentTerm
+
+        term_id = obj_or_id(term, "term", (EnrollmentTerm,))
+
+        response = self._requester.request(
+            "GET", "accounts/{}/terms/{}".format(self.id, term_id)
+        )
+        return EnrollmentTerm(self._requester, response.json())
+
     def get_enrollment_terms(self, **kwargs):
         """
         List enrollment terms for a context.
@@ -1925,7 +1946,11 @@ class AccountNotification(CanvasObject):
 @python_2_unicode_compatible
 class AccountReport(CanvasObject):
     def __str__(self):
-        return "{} ({})".format(self.report, self.id)
+        try:
+            return "{} ({})".format(self.report, self.id)
+        except AttributeError:
+            # Print params if not a report instance
+            return "{} ({})".format(self.report, self.parameters)
 
     def delete_report(self, **kwargs):
         """
diff --git a/canvasapi/blueprint.py b/canvasapi/blueprint.py
index ad40862..f8e7fae 100644
--- a/canvasapi/blueprint.py
+++ b/canvasapi/blueprint.py
@@ -236,8 +236,8 @@ class BlueprintMigration(CanvasObject):
 
 @python_2_unicode_compatible
 class ChangeRecord(CanvasObject):
-    def __str__(self):  # pragma: no cover
-        return "{} {}".format(self.id, self.template_id)
+    def __str__(self):
+        return "{} {}".format(self.asset_id, self.asset_name)
 
 
 @python_2_unicode_compatible
diff --git a/canvasapi/canvas.py b/canvasapi/canvas.py
index 1ce81bd..3321e06 100644
--- a/canvasapi/canvas.py
+++ b/canvasapi/canvas.py
@@ -3,6 +3,7 @@ from __future__ import absolute_import, division, print_function, unicode_litera
 import warnings
 
 from canvasapi.account import Account
+from canvasapi.comm_message import CommMessage
 from canvasapi.course import Course
 from canvasapi.course_epub_export import CourseEpubExport
 from canvasapi.current_user import CurrentUser
@@ -14,7 +15,6 @@ from canvasapi.paginated_list import PaginatedList
 from canvasapi.requester import Requester
 from canvasapi.section import Section
 from canvasapi.user import User
-from canvasapi.comm_message import CommMessage
 from canvasapi.util import combine_kwargs, get_institution_url, obj_or_id
 
 
@@ -59,12 +59,10 @@ class Canvas(object):
                 UserWarning,
             )
 
-        # Ensure that the user-supplied access token contains no leading or
-        # trailing spaces that may cause issues when communicating with
-        # the API.
+        # Ensure that the user-supplied access token and base_url contain no leading or
+        # trailing spaces that might cause issues when communicating with the API.
         access_token = access_token.strip()
-
-        base_url = new_url + "/api/v1/"
+        base_url = base_url.strip()
 
         self.__requester = Requester(base_url, access_token)
 
@@ -1184,6 +1182,33 @@ class Canvas(object):
             _kwargs=combine_kwargs(**kwargs),
         )
 
+    def graphql(self, query, variables=None, **kwargs):
+        """
+        Makes a GraphQL formatted request to Canvas
+
+        :calls: `POST /api/graphql \
+        <https://canvas.instructure.com/doc/api/file.graphql.html>`_
+
+        :param query: The GraphQL query to execute as a String
+        :type query: str
+        :param variables: The variable values as required by the supplied query
+        :type variables: dict
+
+        :rtype: dict
+        """
+        response = self.__requester.request(
+            "POST",
+            "graphql",
+            headers={"Content-Type": "application/json"},
+            _kwargs=combine_kwargs(**kwargs)
+            + [("query", query), ("variables", variables)],
+            # Needs to call special endpoint without api/v1
+            _url=self.__requester.original_url + "/api/graphql",
+            json=True,
+        )
+
+        return response.json()
+
     def list_appointment_groups(self, **kwargs):
         """
         List appointment groups.
diff --git a/canvasapi/course.py b/canvasapi/course.py
index 08d723b..2903a35 100644
--- a/canvasapi/course.py
+++ b/canvasapi/course.py
@@ -358,6 +358,26 @@ class Course(CanvasObject):
         )
         return GroupCategory(self._requester, response.json())
 
+    def create_late_policy(self, **kwargs):
+        """
+        Create a late policy. If the course already has a late policy, a bad_request
+        is returned since there can only be one late policy per course.
+
+        :calls: `POST /api/v1/courses/:id/late_policy \
+        <https://canvas.instructure.com/doc/api/late_policy.html#method.late_policy.create>`_
+
+        :rtype: :class:`canvasapi.course.LatePolicy`
+        """
+
+        response = self._requester.request(
+            "POST",
+            "courses/{}/late_policy".format(self.id),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+        late_policy_json = response.json()
+
+        return LatePolicy(self._requester, late_policy_json["late_policy"])
+
     def create_module(self, module, **kwargs):
         """
         Create a new module.
@@ -394,10 +414,10 @@ class Course(CanvasObject):
         :calls: `POST /api/v1/courses/:course_id/pages \
         <https://canvas.instructure.com/doc/api/pages.html#method.wiki_pages_api.create>`_
 
-        :param title: The title for the page.
-        :type title: dict
+        :param wiki_page: The title for the page.
+        :type wiki_page: dict
         :returns: The created page.
-        :rtype: :class:`canvasapi.course.Course`
+        :rtype: :class:`canvasapi.page.Page`
         """
 
         if isinstance(wiki_page, dict) and "title" in wiki_page:
@@ -554,6 +574,25 @@ class Course(CanvasObject):
 
         return Page(self._requester, page_json)
 
+    def edit_late_policy(self, **kwargs):
+        """
+        Patch a late policy. No body is returned upon success.
+
+        :calls: `PATCH /api/v1/courses/:id/late_policy \
+        <https://canvas.instructure.com/doc/api/late_policy.html#method.late_policy.update>`_
+
+        :returns: True if Late Policy was updated successfully. False otherwise.
+        :rtype: bool
+        """
+
+        response = self._requester.request(
+            "PATCH",
+            "courses/{}/late_policy".format(self.id),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
+        return response.status_code == 204
+
     def enroll_user(self, user, enrollment_type, **kwargs):
         """
         Create a new user enrollment for a course or a section.
@@ -1410,6 +1449,25 @@ class Course(CanvasObject):
             _kwargs=combine_kwargs(**kwargs),
         )
 
+    def get_late_policy(self, **kwargs):
+        """
+        Returns the late policy for a course.
+
+        :calls: `GET /api/v1/courses/:id/late_policy \
+        <https://canvas.instructure.com/doc/api/late_policy.html#method.late_policy.show>`_
+
+        :rtype: :class:`canvasapi.course.LatePolicy`
+        """
+
+        response = self._requester.request(
+            "GET",
+            "courses/{}/late_policy".format(self.id),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+        late_policy_json = response.json()
+
+        return LatePolicy(self._requester, late_policy_json["late_policy"])
+
     def get_licenses(self, **kwargs):
         """
         Returns a paginated list of the licenses that can be applied to the
@@ -1698,6 +1756,29 @@ class Course(CanvasObject):
 
         return Quiz(self._requester, quiz_json)
 
+    def get_quiz_overrides(self, **kwargs):
+        """
+        Retrieve the actual due-at, unlock-at,
+        and available-at dates for quizzes based on
+        the assignment overrides active for the current API user.
+
+        :calls: `GET /api/v1/courses/:course_id/quizzes/assignment_overrides \
+        <https://canvas.instructure.com/doc/api/quiz_assignment_overrides.html#method.quizzes/quiz_assignment_overrides.index>`_
+
+        :rtype: :class:`canvasapi.paginated_list.PaginatedList` of
+            :class:`canvasapi.quiz.QuizAssignmentOverrideSet`
+        """
+        from canvasapi.quiz import QuizAssignmentOverrideSet
+
+        return PaginatedList(
+            QuizAssignmentOverrideSet,
+            self._requester,
+            "GET",
+            "courses/{}/quizzes/assignment_overrides".format(self.id),
+            _root="quiz_assignment_overrides",
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
     def get_quizzes(self, **kwargs):
         """
         Return a list of quizzes belonging to this course.
@@ -2577,6 +2658,28 @@ class Course(CanvasObject):
         )
         return Course(self._requester, response.json())
 
+    def resolve_path(self, full_path, **kwargs):
+        """
+        Returns the paginated list of all of the folders in the given
+        path starting at the course root folder.
+
+        :calls: `GET /api/v1/courses/:course_id/folders/by_path/*full_path \
+        <https://canvas.instructure.com/doc/api/files.html#method.folders.resolve_path>`_
+
+        :param full_path: Full path to resolve, relative to course root
+        :type full_path: string
+
+        :rtype: :class:`canvasapi.paginated_list.PaginatedList` of
+            :class:`canvasapi.folder.Folder`
+        """
+        return PaginatedList(
+            Folder,
+            self._requester,
+            "GET",
+            "courses/{0}/folders/by_path/{1}".format(self.id, full_path),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
     def set_quiz_extensions(self, quiz_extensions, **kwargs):
         """
         Set extensions for student all quiz submissions in a course.
@@ -2725,8 +2828,8 @@ class Course(CanvasObject):
         :calls: `PUT /api/v1/courses/:id \
         <https://canvas.instructure.com/doc/api/courses.html#method.courses.update>`_
 
-        :returns: True if the course was updated, False otherwise.
-        :rtype: bool
+        :returns: `True` if the course was updated, `False` otherwise.
+        :rtype: `bool`
         """
         response = self._requester.request(
             "PUT", "courses/{}".format(self.id), _kwargs=combine_kwargs(**kwargs)
@@ -2878,3 +2981,9 @@ class CourseNickname(CanvasObject):
             "DELETE", "users/self/course_nicknames/{}".format(self.course_id)
         )
         return CourseNickname(self._requester, response.json())
+
+
+@python_2_unicode_compatible
+class LatePolicy(CanvasObject):
+    def __str__(self):
+        return "Late Policy {}".format(self.id)
diff --git a/canvasapi/exceptions.py b/canvasapi/exceptions.py
index e68f232..e118bc5 100644
--- a/canvasapi/exceptions.py
+++ b/canvasapi/exceptions.py
@@ -65,3 +65,9 @@ class Conflict(CanvasException):
     """Canvas had a conflict with an existing resource."""
 
     pass
+
+
+class UnprocessableEntity(CanvasException):
+    """Canvas was unable to process the entity."""
+
+    pass
diff --git a/canvasapi/quiz.py b/canvasapi/quiz.py
index 4e149c0..1c3072f 100644
--- a/canvasapi/quiz.py
+++ b/canvasapi/quiz.py
@@ -8,6 +8,8 @@ from canvasapi.canvas_object import CanvasObject
 from canvasapi.exceptions import RequiredFieldMissing
 from canvasapi.paginated_list import PaginatedList
 from canvasapi.quiz_group import QuizGroup
+from canvasapi.submission import Submission
+from canvasapi.user import User
 from canvasapi.util import combine_kwargs, obj_or_id
 
 
@@ -141,7 +143,7 @@ class Quiz(CanvasObject):
                 "Param `report_type` must be a either 'student_analysis' or 'item_analysis'"
             )
 
-        kwargs["report_type"] = report_type
+        kwargs["quiz_report"] = {"report_type": report_type}
 
         response = self._requester.request(
             "POST",
@@ -374,9 +376,45 @@ class Quiz(CanvasObject):
 
         response_json = response.json()["quiz_submissions"][0]
         response_json.update({"course_id": self.course_id})
+        if len(response.json().get("quizzes", [])) > 0:
+            response_json.update(
+                {"quiz": Quiz(self._requester, response.json()["quizzes"][0])}
+            )
+        if len(response.json().get("submissions", [])) > 0:
+            response_json.update(
+                {
+                    "submission": Submission(
+                        self._requester, response.json()["submissions"][0]
+                    )
+                }
+            )
+        if len(response.json().get("users", [])) > 0:
+            response_json.update(
+                {"user": User(self._requester, response.json()["users"][0])}
+            )
 
         return QuizSubmission(self._requester, response_json)
 
+    def get_statistics(self, **kwargs):
+        """
+        Get statistics for for all quiz versions, or the latest quiz version.
+
+        :calls: `GET /api/v1/courses/:course_id/quizzes/:quiz_id/statistics \
+        <https://canvas.instructure.com/doc/api/quiz_statistics.html#method.quizzes/quiz_statistics.index>`_
+
+        :rtype: :class:`canvasapi.paginated_list.PaginatedList` of
+            :class:`canvasapi.quiz.QuizStatistic`
+        """
+        return PaginatedList(
+            QuizStatistic,
+            self._requester,
+            "GET",
+            "courses/{}/quizzes/{}/statistics".format(self.course_id, self.id),
+            {"course_id": self.course_id},
+            _root="quiz_statistics",
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
     def get_submissions(self, **kwargs):
         """
         Get a list of all submissions for this quiz.
@@ -452,6 +490,12 @@ class Quiz(CanvasObject):
         ]
 
 
+@python_2_unicode_compatible
+class QuizStatistic(CanvasObject):
+    def __str__(self):
+        return "Quiz Statistic {}".format(self.id)
+
+
 @python_2_unicode_compatible
 class QuizSubmission(CanvasObject):
     def __str__(self):
@@ -842,3 +886,9 @@ class QuizSubmissionQuestion(CanvasObject):
         super(QuizSubmissionQuestion, self).set_attributes(question)
 
         return True
+
+
+@python_2_unicode_compatible
+class QuizAssignmentOverrideSet(CanvasObject):
+    def __str__(self):
+        return "Overrides for quiz_id {}".format(self.quiz_id)
diff --git a/canvasapi/requester.py b/canvasapi/requester.py
index 36ad89c..fbb3cf9 100644
--- a/canvasapi/requester.py
+++ b/canvasapi/requester.py
@@ -13,6 +13,7 @@ from canvasapi.exceptions import (
     InvalidAccessToken,
     ResourceDoesNotExist,
     Unauthorized,
+    UnprocessableEntity,
 )
 from canvasapi.util import clean_headers
 
@@ -32,12 +33,14 @@ class Requester(object):
         :param access_token: The API key to authenticate requests with.
         :type access_token: str
         """
-        self.base_url = base_url
+        # Preserve the original base url and add "/api/v1" to it
+        self.original_url = base_url
+        self.base_url = base_url + "/api/v1/"
         self.access_token = access_token
         self._session = requests.Session()
         self._cache = []
 
-    def _delete_request(self, url, headers, data=None):
+    def _delete_request(self, url, headers, data=None, **kwargs):
         """
         Issue a DELETE request to the specified endpoint with the data provided.
 
@@ -50,7 +53,7 @@ class Requester(object):
         """
         return self._session.delete(url, headers=headers, data=data)
 
-    def _get_request(self, url, headers, params=None):
+    def _get_request(self, url, headers, params=None, **kwargs):
         """
         Issue a GET request to the specified endpoint with the data provided.
 
@@ -63,7 +66,7 @@ class Requester(object):
         """
         return self._session.get(url, headers=headers, params=params)
 
-    def _patch_request(self, url, headers, data=None):
+    def _patch_request(self, url, headers, data=None, **kwargs):
         """
         Issue a PATCH request to the specified endpoint with the data provided.
 
@@ -76,7 +79,7 @@ class Requester(object):
         """
         return self._session.patch(url, headers=headers, data=data)
 
-    def _post_request(self, url, headers, data=None):
+    def _post_request(self, url, headers, data=None, json=False):
         """
         Issue a POST request to the specified endpoint with the data provided.
 
@@ -86,7 +89,11 @@ class Requester(object):
         :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
         """
+        if json:
+            return self._session.post(url, headers=headers, json=dict(data))
 
         # Grab file from data.
         files = None
@@ -103,7 +110,7 @@ class Requester(object):
 
         return self._session.post(url, headers=headers, data=data, files=files)
 
-    def _put_request(self, url, headers, data=None):
+    def _put_request(self, url, headers, data=None, **kwargs):
         """
         Issue a PUT request to the specified endpoint with the data provided.
 
@@ -124,6 +131,7 @@ class Requester(object):
         use_auth=True,
         _url=None,
         _kwargs=None,
+        json=False,
         **kwargs
     ):
         """
@@ -146,6 +154,10 @@ class Requester(object):
         :param _kwargs: A list of 2-tuples representing processed
             keyword arguments to be sent to Canvas as params or data.
         :type _kwargs: `list`
+        :param json: Whether or not to treat the data as json instead of form data.
+            currently only the POST request of GraphQL is using this parameter.
+            For all other methods it's just passed and ignored.
+        :type json: `bool`
         :rtype: str
         """
         full_url = _url if _url else "{}{}".format(self.base_url, endpoint)
@@ -194,7 +206,7 @@ class Requester(object):
         if _kwargs:
             logger.debug("Data: {data}".format(data=pformat(_kwargs)))
 
-        response = req_method(full_url, headers, _kwargs)
+        response = req_method(full_url, headers, _kwargs, json=json)
         logger.info(
             "Response: {method} {url} {status}".format(
                 method=method, url=full_url, status=response.status_code
@@ -231,6 +243,8 @@ class Requester(object):
             raise ResourceDoesNotExist("Not Found")
         elif response.status_code == 409:
             raise Conflict(response.text)
+        elif response.status_code == 422:
+            raise UnprocessableEntity(response.text)
         elif response.status_code > 400:
             # generic catch-all for error codes
             raise CanvasException(
diff --git a/docs/course-ref.rst b/docs/course-ref.rst
index f2df1d3..3d43f67 100644
--- a/docs/course-ref.rst
+++ b/docs/course-ref.rst
@@ -10,4 +10,11 @@ CourseNickname
 ==============
 
 .. autoclass:: canvasapi.course.CourseNickname
-	:members:
+    :members:
+
+==========
+LatePolicy
+==========
+
+.. autoclass:: canvasapi.course.LatePolicy
+    :members:
diff --git a/docs/exceptions.rst b/docs/exceptions.rst
index c8ffec5..cc3419b 100644
--- a/docs/exceptions.rst
+++ b/docs/exceptions.rst
@@ -21,6 +21,8 @@ Quick Guide
 +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+
 | :class:`~canvasapi.exceptions.Conflict`             | 409             | Canvas had a conflict with an existing resource.                                |
 +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+
+| :class:`~canvasapi.exceptions.UnprocessableEntity`  | 422             | Canvas was unable to process the request.                                       |
++-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+
 | :class:`~canvasapi.exceptions.RequiredFieldMissing` | N/A             | A required keyword argument was not included.                                   |
 +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+
 | :class:`~canvasapi.exceptions.CanvasException`      | N/A             | An unknown error was thrown.                                                    |
diff --git a/docs/quiz-ref.rst b/docs/quiz-ref.rst
index 74a24a4..32b779d 100644
--- a/docs/quiz-ref.rst
+++ b/docs/quiz-ref.rst
@@ -5,6 +5,13 @@ Quiz
 .. autoclass:: canvasapi.quiz.Quiz
     :members:
 
+=========================
+QuizAssignmentOverrideSet
+=========================
+
+.. autoclass:: canvasapi.quiz.QuizAssignmentOverrideSet
+    :members:
+
 =============
 QuizExtension
 =============
@@ -32,3 +39,17 @@ QuizSubmission
 
 .. autoclass:: canvasapi.quiz.QuizSubmission
     :members:
+
+===================
+QuizSubmissionEvent
+===================
+
+.. autoclass:: canvasapi.quiz.QuizSubmissionEvent
+    :members:
+
+======================
+QuizSubmissionQuestion
+======================
+
+.. autoclass:: canvasapi.quiz.QuizSubmissionQuestion
+    :members:


Test patch:
diff --git a/tests/fixtures/account.json b/tests/fixtures/account.json
index af424bc..819ac40 100644
--- a/tests/fixtures/account.json
+++ b/tests/fixtures/account.json
@@ -315,6 +315,15 @@
 		],
 		"status_code": 200
 	},
+	"get_enrollment_term": {
+		"method": "GET",
+		"endpoint": "accounts/1/terms/1",
+		"data": {
+			"id": 1,
+			"name": "Enrollment Term 1"
+		},
+		"status_code": 200
+	},
 	"get_enrollment_terms": {
 		"method": "GET",
 		"endpoint": "accounts/1/terms",
@@ -578,10 +587,12 @@
 		"endpoint": "accounts/1/reports",
 		"data": [
 			{
-				"id": 1
+				"id": 1,
+				"report": "sis_export_csv"
 			},
 			{
-				"id": 2
+				"id": 2,
+				"report": "grade_export_csv"
 			}
 		],
 		"headers": {
diff --git a/tests/fixtures/course.json b/tests/fixtures/course.json
index 0fab725..45b03aa 100644
--- a/tests/fixtures/course.json
+++ b/tests/fixtures/course.json
@@ -409,6 +409,50 @@
 		},
 		"status_code": 200
 	},
+	"get_quiz_overrides": {
+		"method": "GET",
+		"endpoint": "courses/1/quizzes/assignment_overrides",
+		"data": {
+			"quiz_assignment_overrides": [{
+				"quiz_id": "1",
+				"due_dates": [{
+					"id": 1,
+					"due_at": "2014-02-21T06:59:59Z",
+					"unlock_at": null,
+					"lock_at": "2014-02-21T06:59:59Z",
+					"title": "Project X",
+					"base": true
+				}],
+				"all_dates": [{
+					"id": 1,
+					"due_at": "2014-02-21T06:59:59Z",
+					"unlock_at": null,
+					"lock_at": "2014-02-21T06:59:59Z",
+					"title": "Project X",
+					"base": true
+				}]
+			},{
+				"quiz_id": "2",
+				"due_dates": [{
+					"id": 1,
+					"due_at": "2014-02-21T06:59:59Z",
+					"unlock_at": null,
+					"lock_at": "2014-02-21T06:59:59Z",
+					"title": "Project X",
+					"base": true
+				}],
+				"all_dates": [{
+					"id": 1,
+					"due_at": "2014-02-21T06:59:59Z",
+					"unlock_at": null,
+					"lock_at": "2014-02-21T06:59:59Z",
+					"title": "Project X",
+					"base": true
+				}]
+			}]
+		},
+		"status_code": 200
+	},
 	"get_recent_students": {
 		"method": "GET",
 		"endpoint": "courses/1/recent_students",
@@ -2044,6 +2088,51 @@
 		},
 		"status_code": 200
 	},
+	"get_late_policy": {
+		"method": "GET",
+		"endpoint": "courses/1/late_policy",
+		"data": {
+			"late_policy": {
+				"id": 123,
+				"course_id": 123,
+				"missing_submission_deduction_enabled": true,
+				"missing_submission_deduction": 12.34,
+				"late_submission_deduction_enabled": true,
+				"late_submission_deduction": 12.34,
+				"late_submission_interval": "hour",
+				"late_submission_minimum_percent_enabled": true,
+				"late_submission_minimum_percent": 12.34,
+				"created_at": "2012-07-01T23:59:00-06:00",
+				"updated_at": "2012-07-01T23:59:00-06:00"
+			}
+		},
+		"status_code": 200
+	},
+	"edit_late_policy": {
+		"method": "PATCH",
+		"endpoint": "courses/1/late_policy",
+		"status_code": 204
+	},
+	"create_late_policy": {
+		"method": "POST",
+		"endpoint": "courses/1/late_policy",
+		"data": {
+			"late_policy": {
+				"id": 123,
+				"course_id": 123,
+				"missing_submission_deduction_enabled": true,
+				"missing_submission_deduction": 12.34,
+				"late_submission_deduction_enabled": true,
+				"late_submission_deduction": 12.34,
+				"late_submission_interval": "hour",
+				"late_submission_minimum_percent_enabled": true,
+				"late_submission_minimum_percent": 12.34,
+				"created_at": "2012-07-01T23:59:00-06:00",
+				"updated_at": "2012-07-01T23:59:00-06:00"
+			}
+		},
+		"status_code": 200
+	},
 	"get_licenses": {
 		"method": "GET",
 		"endpoint": "courses/1/content_licenses",
@@ -2060,5 +2149,41 @@
 			}
 		],
 		"status_code": 200
+	},
+    "resolve_path": {
+		"method": "GET",
+		"endpoint": "courses/1/folders/by_path/Folder_Level_1/Folder_Level_2/Folder_Level_3",
+		"data": [
+            {
+				"id": 2,
+				"files_count": 0,
+				"folders_count": 1,
+				"name": "course_files",
+				"full_name": "course_files"
+			},
+
+			{
+				"id": 3,
+				"files_count": 0,
+				"folders_count": 1,
+				"name": "Folder_Level_1",
+				"full_name": "course_files"
+			},
+			{
+				"id": 4,
+				"files_count": 0,
+				"folders_count": 1,
+				"name": "Folder_Level_2",
+				"full_name": "course_files/Folder_Level_1/Folder_Level_2"
+			},
+			{
+				"id": 5,
+				"files_count": 0,
+				"folders_count": 0,
+				"name": "Folder_Level_3",
+				"full_name": "course_files/Folder_Level_1/Folder_Level_2/Folder_Level_3"
+			}
+		],
+		"status_code": 200
 	}
 }
diff --git a/tests/fixtures/graphql.json b/tests/fixtures/graphql.json
new file mode 100644
index 0000000..5f19eaf
--- /dev/null
+++ b/tests/fixtures/graphql.json
@@ -0,0 +1,21 @@
+{
+	"graphql": {
+		"method": "POST",
+		"endpoint": "graphql",
+		"data": {
+			"term": {
+				"coursesConnection": {
+				  "nodes": [
+					{
+						"_id": "1",
+						"assignmentsConnection": {
+							"nodes": []
+						}
+					}
+					]
+				}
+			}
+		},
+		"status_code": 200
+	}
+}
\ No newline at end of file
diff --git a/tests/fixtures/quiz.json b/tests/fixtures/quiz.json
index 22e753a..253e05c 100644
--- a/tests/fixtures/quiz.json
+++ b/tests/fixtures/quiz.json
@@ -219,6 +219,30 @@
 					"validation_token": "this is a validation token",
 					"score": 0
 				}
+			],
+			"quizzes": [
+				{
+					"id": 1,
+					"title": "Test Quiz",
+					"quiz_type": "survey"
+				}
+			],
+			"submissions": [
+				{
+					"id": 1,
+					"body": "user: 1, quiz: 1, score: 1.0, time: 2020-01-01 00:00:00Z",
+					"grade": "1",
+					"score": 1,
+					"assignment_id": 1234,
+					"user_id": 1
+				}
+			],
+			"users": [
+				{
+					"id": 1,
+					"name": "Test User",
+					"login_id": "testuser@example.com"
+				}
 			]
 		},
 		"status_code": 200
@@ -303,6 +327,234 @@
 		},
 		"status_code": 200
 	},
+	"get_statistics": {
+		"method": "GET",
+		"endpoint": "courses/1/quizzes/1/statistics",
+		"data": {
+			"quiz_statistics": [
+                {
+                    "id": "1",
+                    "url": "http://localhost:3000/api/v1/courses/12/quizzes/43/statistics",
+                    "html_url": "http://localhost:3000/courses/12/quizzes/43/statistics",
+                    "multiple_attempts_exist": false,
+                    "generated_at": "2019-11-20T18:48:20Z",
+                    "includes_all_versions": false,
+                    "includes_sis_ids": true,
+                    "points_possible": null,
+                    "anonymous_survey": false,
+                    "speed_grader_url": null,
+                    "quiz_submissions_zip_url": "http://localhost:3000/courses/12/quizzes/43/submissions?zip=1",
+                    "question_statistics": [
+                        {
+                            "id": "47",
+                            "question_type": "multiple_dropdowns_question",
+                            "question_text": "\n<p>What is your year of study? [year]</p>\n                        <p>What is your program? [program]</p>\n                    ",
+                            "position": 1,
+                            "responses": 3,
+                            "answered": 3,
+                            "correct": 0,
+                            "partially_correct": 2,
+                            "incorrect": 1,
+                            "answer_sets": [
+                                {
+                                    "id": "84cdc76cabf41bd7c961f6ab12f117d8",
+                                    "text": "year",
+                                    "answers": [
+                                        {
+                                            "id": "6100",
+                                            "text": "1",
+                                            "correct": true,
+                                            "responses": 0,
+                                            "user_names": []
+                                        },
+                                        {
+                                            "id": "8629",
+                                            "text": "2",
+                                            "correct": false,
+                                            "responses": 1,
+                                            "user_names": [
+                                                "Student12 Student12"
+                                            ]
+                                        },
+                                        {
+                                            "id": "6110",
+                                            "text": "3",
+                                            "correct": false,
+                                            "responses": 2,
+                                            "user_names": [
+                                                "Student11 Student11",
+                                                "Student10 Student10"
+                                            ]
+                                        },
+                                        {
+                                            "id": "9262",
+                                            "text": "4",
+                                            "correct": false,
+                                            "responses": 0,
+                                            "user_names": []
+                                        },
+                                        {
+                                            "id": "279",
+                                            "text": "5",
+                                            "correct": false,
+                                            "responses": 0,
+                                            "user_names": []
+                                        },
+                                        {
+                                            "id": "5721",
+                                            "text": "6+",
+                                            "correct": false,
+                                            "responses": 0,
+                                            "user_names": []
+                                        }
+                                    ]
+                                },
+                                {
+                                    "id": "a9c449d4fa44e9e5a41c574ae55ce4d9",
+                                    "text": "program",
+                                    "answers": [
+                                        {
+                                            "id": "7270",
+                                            "text": "CompSci",
+                                            "correct": true,
+                                            "responses": 2,
+                                            "user_names": [
+                                                "Student11 Student11",
+                                                "Student10 Student10"
+                                            ]
+                                        },
+                                        {
+                                            "id": "2688",
+                                            "text": "Other",
+                                            "correct": false,
+                                            "responses": 1,
+                                            "user_names": [
+                                                "Student12 Student12"
+                                            ]
+                                        }
+                                    ]
+                                }
+                            ]
+                        },
+                        {
+                            "id": "48",
+                            "question_type": "multiple_choice_question",
+                            "question_text": "How good are you at programming?",
+                            "position": 2,
+                            "responses": 3,
+                            "answers": [
+                                {
+                                    "id": "1833",
+                                    "text": "Great",
+                                    "correct": true,
+                                    "responses": 0,
+                                    "user_names": []
+                                },
+                                {
+                                    "id": "7532",
+                                    "text": "Good",
+                                    "correct": false,
+                                    "responses": 2,
+                                    "user_names": [
+                                        "Student12 Student12",
+                                        "Student10 Student10"
+                                    ]
+                                },
+                                {
+                                    "id": "136",
+                                    "text": "Mediocre",
+                                    "correct": false,
+                                    "responses": 0,
+                                    "user_names": []
+                                },
+                                {
+                                    "id": "9694",
+                                    "text": "Bad",
+                                    "correct": false,
+                                    "responses": 1,
+                                    "user_names": [
+                                        "Student11 Student11"
+                                    ]
+                                },
+                                {
+                                    "id": "6452",
+                                    "text": "Terrible",
+                                    "correct": false,
+                                    "responses": 0,
+                                    "user_names": []
+                                }
+                            ],
+                            "answered_student_count": 3,
+                            "top_student_count": 2,
+                            "middle_student_count": 0,
+                            "bottom_student_count": 1,
+                            "correct_student_count": 0,
+                            "incorrect_student_count": 3,
+                            "correct_student_ratio": 0.0,
+                            "incorrect_student_ratio": 1.0,
+                            "correct_top_student_count": 0,
+                            "correct_middle_student_count": 0,
+                            "correct_bottom_student_count": 0,
+                            "variance": 0.0,
+                            "stdev": 0.0,
+                            "difficulty_index": 0.0,
+                            "alpha": null,
+                            "point_biserials": [
+                                {
+                                    "answer_id": 1833,
+                                    "point_biserial": null,
+                                    "correct": true,
+                                    "distractor": false
+                                },
+                                {
+                                    "answer_id": 7532,
+                                    "point_biserial": -0.49999999999999994,
+                                    "correct": false,
+                                    "distractor": true
+                                },
+                                {
+                                    "answer_id": 136,
+                                    "point_biserial": null,
+                                    "correct": false,
+                                    "distractor": true
+                                },
+                                {
+                                    "answer_id": 9694,
+                                    "point_biserial": 0.49999999999999994,
+                                    "correct": false,
+                                    "distractor": true
+                                },
+                                {
+                                    "answer_id": 6452,
+                                    "point_biserial": null,
+                                    "correct": false,
+                                    "distractor": true
+                                }
+                            ]
+                        }
+                    ],
+                    "submission_statistics": {
+                        "scores": {
+                            "0": 1,
+                            "25": 2
+                        },
+                        "score_average": 0.3333333333333333,
+                        "score_high": 0.5,
+                        "score_low": 0.0,
+                        "score_stdev": 0.2357022603955158,
+                        "correct_count_average": 0.0,
+                        "incorrect_count_average": 1.333333333333333,
+                        "duration_average": 6.0,
+                        "unique_count": 3
+                    },
+                    "links": {
+                        "quiz": "http://localhost:3000/api/v1/courses/12/quizzes/43"
+                    }
+                }
+			]
+		},
+		"status_code": 200
+	},
 	"create_report": {
 		"method": "POST",
 		"endpoint": "courses/1/quizzes/1/reports",
diff --git a/tests/fixtures/requests.json b/tests/fixtures/requests.json
index 5d00c69..ca123fc 100644
--- a/tests/fixtures/requests.json
+++ b/tests/fixtures/requests.json
@@ -32,6 +32,12 @@
 		"data": {},
 		"status_code": 409
 	},
+	"422": {
+		"method": "ANY",
+		"endpoint": "422",
+		"data": {},
+		"status_code": 422
+	},
 	"500": {
 		"method": "ANY",
 		"endpoint": "500",
diff --git a/tests/settings.py b/tests/settings.py
index 4fbbcab..679cc11 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -1,10 +1,12 @@
 from __future__ import absolute_import, division, print_function, unicode_literals
 
 BASE_URL = "https://example.com"
+BASE_URL_GRAPHQL = "https://example.com/api/"
 BASE_URL_WITH_VERSION = "https://example.com/api/v1/"
 BASE_URL_AS_HTTP = "http://example.com"
 BASE_URL_AS_BLANK = ""
 BASE_URL_AS_INVALID = "example.com"
+BASE_URL_WITH_EXTRA_SPACES = " https://example.com "
 API_KEY = "123"
 
 INVALID_ID = 9001
diff --git a/tests/test_account.py b/tests/test_account.py
index e79d275..d3b3657 100644
--- a/tests/test_account.py
+++ b/tests/test_account.py
@@ -286,6 +286,7 @@ class TestAccount(unittest.TestCase):
         self.assertIsInstance(report, AccountReport)
         self.assertTrue(hasattr(report, "title"))
         self.assertEqual(report.title, "Zero Activity")
+        self.assertIsInstance(str(report), str)
 
     # get_index_of_reports()
     def test_get_index_of_reports(self, m):
@@ -310,6 +311,7 @@ class TestAccount(unittest.TestCase):
         self.assertEqual(len(reports_list), 4)
         self.assertIsInstance(reports_list[0], AccountReport)
         self.assertTrue(hasattr(reports_list[0], "id"))
+        self.assertIsInstance(str(reports_list[0]), str)
 
     # get_subaccounts()
     def test_get_subaccounts(self, m):
@@ -574,6 +576,19 @@ class TestAccount(unittest.TestCase):
             self.assertEqual(len(warning_list), 1)
             self.assertEqual(warning_list[-1].category, DeprecationWarning)
 
+    # get_enrollment_term()
+    def test_get_enrollment_term(self, m):
+        register_uris({"account": ["get_enrollment_term"]}, m)
+
+        enrollment_term = self.account.get_enrollment_term(1)
+
+        self.assertIsInstance(enrollment_term, EnrollmentTerm)
+
+        self.assertTrue(hasattr(enrollment_term, "id"))
+        self.assertEqual(enrollment_term.id, 1)
+        self.assertTrue(hasattr(enrollment_term, "name"))
+        self.assertEqual(enrollment_term.name, "Enrollment Term 1")
+
     # get_enrollment_terms()
     def test_get_enrollment_terms(self, m):
         register_uris({"account": ["get_enrollment_terms"]}, m)
diff --git a/tests/test_blueprint.py b/tests/test_blueprint.py
index fd4b60a..6b3c3b7 100644
--- a/tests/test_blueprint.py
+++ b/tests/test_blueprint.py
@@ -196,3 +196,25 @@ class TestBlueprintMigration(unittest.TestCase):
         import_details = self.b_import.get_import_details()
         self.assertIsInstance(import_details, PaginatedList)
         self.assertIsInstance(import_details[0], ChangeRecord)
+
+
+@requests_mock.Mocker()
+class TestChangeRecord(unittest.TestCase):
+    def setUp(self):
+        self.canvas = Canvas(settings.BASE_URL, settings.API_KEY)
+
+        with requests_mock.Mocker() as m:
+            requires = {
+                "course": ["get_blueprint", "get_by_id"],
+                "blueprint": ["get_unsynced_changes"],
+            }
+            register_uris(requires, m)
+
+            self.course = self.canvas.get_course(1)
+            self.blueprint = self.course.get_blueprint(1)
+            self.change_record = self.blueprint.get_unsynced_changes()[0]
+
+    # __str__()
+    def test__str__(self, m):
+        string = str(self.change_record)
+        self.assertIsInstance(string, str)
diff --git a/tests/test_canvas.py b/tests/test_canvas.py
index 7a5a78d..4256dce 100644
--- a/tests/test_canvas.py
+++ b/tests/test_canvas.py
@@ -12,20 +12,19 @@ from canvasapi import Canvas
 from canvasapi.account import Account
 from canvasapi.appointment_group import AppointmentGroup
 from canvasapi.calendar_event import CalendarEvent
+from canvasapi.comm_message import CommMessage
 from canvasapi.conversation import Conversation
 from canvasapi.course import Course, CourseNickname
 from canvasapi.course_epub_export import CourseEpubExport
 from canvasapi.discussion_topic import DiscussionTopic
-from canvasapi.exceptions import RequiredFieldMissing
+from canvasapi.exceptions import RequiredFieldMissing, ResourceDoesNotExist
 from canvasapi.file import File
 from canvasapi.group import Group, GroupCategory
-from canvasapi.exceptions import ResourceDoesNotExist
 from canvasapi.outcome import Outcome, OutcomeGroup
 from canvasapi.paginated_list import PaginatedList
 from canvasapi.progress import Progress
 from canvasapi.section import Section
 from canvasapi.user import User
-from canvasapi.comm_message import CommMessage
 from tests import settings
 from tests.util import register_uris
 
@@ -75,6 +74,12 @@ class TestCanvas(unittest.TestCase):
         client = Canvas(settings.BASE_URL, " 12345 ")
         self.assertEqual(client._Canvas__requester.access_token, "12345")
 
+    def test_init_strips_extra_spaces_in_base_url(self, m):
+        client = Canvas(settings.BASE_URL_WITH_EXTRA_SPACES, "12345")
+        self.assertEqual(
+            client._Canvas__requester.base_url, settings.BASE_URL_WITH_VERSION
+        )
+
     # create_account()
     def test_create_account(self, m):
         register_uris({"account": ["create"]}, m)
@@ -832,3 +837,34 @@ class TestCanvas(unittest.TestCase):
         self.assertEqual(comm_messages[0].subject, "example subject line")
         self.assertEqual(comm_messages[1].id, 2)
         self.assertEqual(comm_messages[1].subject, "My Subject")
+
+    def test_graphql(self, m):
+        register_uris({"graphql": ["graphql"]}, m, base_url=settings.BASE_URL_GRAPHQL)
+        query = """
+        query MyQuery($termid: ID!) {
+            term(id: $termid) {
+                coursesConnection {
+                    nodes {
+                        _id
+                        assignmentsConnection {
+                        nodes {
+                            name
+                            _id
+                            expectsExternalSubmission
+                        }
+                        }
+                    }
+                    edges {
+                        node {
+                        id
+                        }
+                    }
+                }
+            }
+        }
+        """
+        variables = {"termid": 125}
+
+        graphql_response = self.canvas.graphql(query=query, variables=variables)
+        # Just a super simple check right now that it gets back a dict respose
+        self.assertIsInstance(graphql_response, dict)
diff --git a/tests/test_course.py b/tests/test_course.py
index b07c9ba..43cd36b 100644
--- a/tests/test_course.py
+++ b/tests/test_course.py
@@ -12,7 +12,7 @@ from canvasapi import Canvas
 from canvasapi.assignment import Assignment, AssignmentGroup, AssignmentOverride
 from canvasapi.blueprint import BlueprintSubscription
 from canvasapi.blueprint import BlueprintTemplate
-from canvasapi.course import Course, CourseNickname, Page
+from canvasapi.course import Course, CourseNickname, Page, LatePolicy
 from canvasapi.discussion_topic import DiscussionTopic
 from canvasapi.gradebook_history import (
     Day,
@@ -37,7 +37,7 @@ from canvasapi.outcome import OutcomeGroup, OutcomeLink
 from canvasapi.outcome_import import OutcomeImport
 from canvasapi.paginated_list import PaginatedList
 from canvasapi.progress import Progress
-from canvasapi.quiz import Quiz, QuizExtension
+from canvasapi.quiz import Quiz, QuizExtension, QuizAssignmentOverrideSet
 from canvasapi.rubric import Rubric, RubricAssociation
 from canvasapi.section import Section
 from canvasapi.submission import GroupedSubmission, Submission
@@ -308,6 +308,29 @@ class TestCourse(unittest.TestCase):
         with self.assertRaises(ResourceDoesNotExist):
             self.course.get_quiz(settings.INVALID_ID)
 
+    # get_quiz_overrides()
+    def test_get_quiz_overrides(self, m):
+        register_uris({"course": ["get_quiz_overrides"]}, m)
+
+        overrides = self.course.get_quiz_overrides()
+        override_list = list(overrides)
+
+        self.assertEqual(len(override_list), 2)
+        self.assertIsInstance(override_list[0], QuizAssignmentOverrideSet)
+        self.assertTrue(hasattr(override_list[0], "quiz_id"))
+        self.assertTrue(hasattr(override_list[0], "due_dates"))
+        self.assertTrue(hasattr(override_list[0], "all_dates"))
+
+        attributes = ("id", "due_at", "unlock_at", "lock_at", "title", "base")
+
+        self.assertTrue(
+            all(attribute in override_list[0].due_dates[0] for attribute in attributes)
+        )
+
+        self.assertTrue(
+            all(attribute in override_list[0].all_dates[0] for attribute in attributes)
+        )
+
     # get_quizzes()
     def test_get_quizzes(self, m):
         register_uris({"course": ["list_quizzes", "list_quizzes2"]}, m)
@@ -464,7 +487,7 @@ class TestCourse(unittest.TestCase):
         self.assertTrue(hasattr(front_page, "url"))
         self.assertTrue(hasattr(front_page, "title"))
 
-    # create_front_page()
+    # edit_front_page()
     def test_edit_front_page(self, m):
         register_uris({"course": ["edit_front_page"]}, m)
 
@@ -474,6 +497,75 @@ class TestCourse(unittest.TestCase):
         self.assertTrue(hasattr(new_front_page, "url"))
         self.assertTrue(hasattr(new_front_page, "title"))
 
+    # edit_late_policy()
+    def test_edit_late_policy(self, m):
+        register_uris({"course": ["edit_late_policy"]}, m)
+
+        late_policy_result = self.course.edit_late_policy(
+            late_policy={"missing_submission_deduction": 5}
+        )
+
+        self.assertTrue(late_policy_result)
+
+    # get_late_policy
+    def test_get_late_policy(self, m):
+        register_uris({"course": ["get_late_policy"]}, m)
+
+        late_policy = self.course.get_late_policy()
+
+        self.assertIsInstance(late_policy, LatePolicy)
+
+        attributes = (
+            "id",
+            "course_id",
+            "missing_submission_deduction_enabled",
+            "missing_submission_deduction",
+            "late_submission_deduction_enabled",
+            "late_submission_deduction",
+            "late_submission_interval",
+            "late_submission_minimum_percent_enabled",
+            "late_submission_minimum_percent",
+            "created_at",
+            "updated_at",
+        )
+
+        for attribute in attributes:
+            self.assertTrue(hasattr(late_policy, attribute))
+
+    def test_create_late_policy(self, m):
+        register_uris({"course": ["create_late_policy"]}, m)
+
+        late_policy = self.course.create_late_policy(
+            late_policy={
+                "missing_submission_deduction_enabled": True,
+                "missing_submission_deduction": 12.34,
+                "late_submission_deduction_enabled": True,
+                "late_submission_deduction": 12.34,
+                "late_submission_interval": "hour",
+                "late_submission_minimum_percent_enabled": True,
+                "late_submission_minimum_percent": 12.34,
+            }
+        )
+
+        self.assertIsInstance(late_policy, LatePolicy)
+
+        attributes = (
+            "id",
+            "course_id",
+            "missing_submission_deduction_enabled",
+            "missing_submission_deduction",
+            "late_submission_deduction_enabled",
+            "late_submission_deduction",
+            "late_submission_interval",
+            "late_submission_minimum_percent_enabled",
+            "late_submission_minimum_percent",
+            "created_at",
+            "updated_at",
+        )
+
+        for attribute in attributes:
+            self.assertTrue(hasattr(late_policy, attribute))
+
     # get_page()
     def test_get_page(self, m):
         register_uris({"course": ["get_page"]}, m)
@@ -1900,14 +1992,28 @@ class TestCourse(unittest.TestCase):
         self.assertIsInstance(licenses, PaginatedList)
         licenses = list(licenses)
 
-        for l in licenses:
-            self.assertIsInstance(l, License)
-            self.assertTrue(hasattr(l, "id"))
-            self.assertTrue(hasattr(l, "name"))
-            self.assertTrue(hasattr(l, "url"))
+        for lic in licenses:
+            self.assertIsInstance(lic, License)
+            self.assertTrue(hasattr(lic, "id"))
+            self.assertTrue(hasattr(lic, "name"))
+            self.assertTrue(hasattr(lic, "url"))
 
         self.assertEqual(2, len(licenses))
 
+    # resolve_path()
+    def test_resolve_path(self, m):
+        register_uris({"course": ["resolve_path"]}, m)
+
+        full_path = "Folder_Level_1/Folder_Level_2/Folder_Level_3"
+        folders = self.course.resolve_path(full_path)
+        folder_list = [folder for folder in folders]
+        self.assertEqual(len(folder_list), 4)
+        self.assertIsInstance(folder_list[0], Folder)
+        for folder_name, folder in zip(
+            ("course_files/" + full_path).split("/"), folders
+        ):
+            self.assertEqual(folder_name, folder.name)
+
 
 @requests_mock.Mocker()
 class TestCourseNickname(unittest.TestCase):
@@ -1931,3 +2037,31 @@ class TestCourseNickname(unittest.TestCase):
 
         self.assertIsInstance(deleted_nick, CourseNickname)
         self.assertTrue(hasattr(deleted_nick, "nickname"))
+
+
+@requests_mock.Mocker()
+class TestLatePolicy(unittest.TestCase):
+    def setUp(self):
+        self.canvas = Canvas(settings.BASE_URL, settings.API_KEY)
+
+        self.late_policy = LatePolicy(
+            self.canvas._Canvas__requester,
+            {
+                "id": 123,
+                "course_id": 123,
+                "missing_submission_deduction_enabled": True,
+                "missing_submission_deduction": 12.34,
+                "late_submission_deduction_enabled": True,
+                "late_submission_deduction": 12.34,
+                "late_submission_interval": "hour",
+                "late_submission_minimum_percent_enabled": True,
+                "late_submission_minimum_percent": 12.34,
+                "created_at": "2012-07-01T23:59:00-06:00",
+                "updated_at": "2012-07-01T23:59:00-06:00",
+            },
+        )
+
+    # __str__()
+    def test__str__(self, m):
+        string = str(self.late_policy)
+        self.assertIsInstance(string, str)
diff --git a/tests/test_group.py b/tests/test_group.py
index 1adb489..ef61acc 100644
--- a/tests/test_group.py
+++ b/tests/test_group.py
@@ -656,11 +656,11 @@ class TestGroup(unittest.TestCase):
         self.assertIsInstance(licenses, PaginatedList)
         licenses = list(licenses)
 
-        for l in licenses:
-            self.assertIsInstance(l, License)
-            self.assertTrue(hasattr(l, "id"))
-            self.assertTrue(hasattr(l, "name"))
-            self.assertTrue(hasattr(l, "url"))
+        for lic in licenses:
+            self.assertIsInstance(lic, License)
+            self.assertTrue(hasattr(lic, "id"))
+            self.assertTrue(hasattr(lic, "name"))
+            self.assertTrue(hasattr(lic, "url"))
 
         self.assertEqual(2, len(licenses))
 
diff --git a/tests/test_quiz.py b/tests/test_quiz.py
index 826e55e..cf0b010 100644
--- a/tests/test_quiz.py
+++ b/tests/test_quiz.py
@@ -9,17 +9,21 @@ from canvasapi import Canvas
 from canvasapi.exceptions import RequiredFieldMissing
 from canvasapi.quiz import (
     Quiz,
+    QuizStatistic,
     QuizSubmission,
     QuizSubmissionQuestion,
     QuizQuestion,
     QuizExtension,
     QuizSubmissionEvent,
     QuizReport,
+    QuizAssignmentOverrideSet,
 )
 from canvasapi.quiz_group import QuizGroup
 from canvasapi.paginated_list import PaginatedList
 from tests import settings
 from tests.util import register_uris
+from canvasapi.user import User
+from canvasapi.submission import Submission
 
 
 @requests_mock.Mocker()
@@ -296,24 +300,45 @@ class TestQuiz(unittest.TestCase):
         self.assertIsInstance(report, QuizReport)
         self.assertEqual(report.quiz_id, 1)
 
+    # get_quiz_report
+    def test_get_statistics(self, m):
+        register_uris({"quiz": ["get_statistics"]}, m)
+
+        statistics = self.quiz.get_statistics()
+
+        self.assertIsInstance(statistics, PaginatedList)
+
+        statistic_list = [statistic for statistic in statistics]
+
+        self.assertEqual(len(statistic_list), 1)
+        self.assertIsInstance(statistic_list[0], QuizStatistic)
+        self.assertEqual(statistic_list[0].id, "1")
+        self.assertTrue(hasattr(statistic_list[0], "question_statistics"))
+        self.assertEqual(len(statistic_list[0].question_statistics), 2)
+
     # get_quiz_submission
     def test_get_quiz_submission(self, m):
         register_uris({"quiz": ["get_quiz_submission"]}, m)
 
         quiz_id = 1
-        submission = self.quiz.get_quiz_submission(quiz_id)
+        quiz_submission = self.quiz.get_quiz_submission(
+            quiz_id, include=["quiz", "submission", "user"]
+        )
 
-        self.assertIsInstance(submission, QuizSubmission)
-        self.assertTrue(hasattr(submission, "id"))
-        self.assertEqual(submission.quiz_id, quiz_id)
-        self.assertTrue(hasattr(submission, "quiz_version"))
-        self.assertEqual(submission.quiz_version, 1)
-        self.assertTrue(hasattr(submission, "user_id"))
-        self.assertEqual(submission.user_id, 1)
-        self.assertTrue(hasattr(submission, "validation_token"))
-        self.assertEqual(submission.validation_token, "this is a validation token")
-        self.assertTrue(hasattr(submission, "score"))
-        self.assertEqual(submission.score, 0)
+        self.assertIsInstance(quiz_submission, QuizSubmission)
+        self.assertTrue(hasattr(quiz_submission, "id"))
+        self.assertEqual(quiz_submission.quiz_id, quiz_id)
+        self.assertTrue(hasattr(quiz_submission, "quiz_version"))
+        self.assertEqual(quiz_submission.quiz_version, 1)
+        self.assertTrue(hasattr(quiz_submission, "user_id"))
+        self.assertEqual(quiz_submission.user_id, 1)
+        self.assertTrue(hasattr(quiz_submission, "validation_token"))
+        self.assertEqual(quiz_submission.validation_token, "this is a validation token")
+        self.assertTrue(hasattr(quiz_submission, "score"))
+        self.assertEqual(quiz_submission.score, 0)
+        self.assertIsInstance(quiz_submission.quiz, Quiz)
+        self.assertIsInstance(quiz_submission.submission, Submission)
+        self.assertIsInstance(quiz_submission.user, User)
 
     # create_submission
     def test_create_submission(self, m):
@@ -595,6 +620,27 @@ class TestQuizQuestion(unittest.TestCase):
         self.assertEqual(self.question.question_name, question_dict["question_name"])
 
 
+@requests_mock.Mocker()
+class TestQuizStatistic(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"], "quiz": ["get_by_id", "get_statistics"]}, m
+            )
+
+            self.course = self.canvas.get_course(1)
+            self.quiz = self.course.get_quiz(1)
+            self.quiz_statistics = self.quiz.get_statistics()
+            self.quiz_statistic = self.quiz_statistics[0]
+
+    # __str__()
+    def test__str__(self, m):
+        string = str(self.quiz_statistic)
+        self.assertIsInstance(string, str)
+
+
 @requests_mock.Mocker()
 class TestQuizSubmissionEvent(unittest.TestCase):
     def setUp(self):
@@ -692,3 +738,19 @@ class TestQuizSubmissionQuestion(unittest.TestCase):
         self.assertIsInstance(result, bool)
         self.assertTrue(result)
         self.assertFalse(self.submission_question.flagged)
+
+
+@requests_mock.Mocker()
+class TestQuizAssignmentOverrideSet(unittest.TestCase):
+    def setUp(self):
+        self.canvas = Canvas(settings.BASE_URL, settings.API_KEY)
+
+        self.override_set = QuizAssignmentOverrideSet(
+            self.canvas._Canvas__requester,
+            {"quiz_id": "1", "due_dates": None, "all_dates": None},
+        )
+
+    # __str__()
+    def test__str__(self, m):
+        string = str(self.override_set)
+        self.assertIsInstance(string, str)
diff --git a/tests/test_requester.py b/tests/test_requester.py
index bd17c11..3225f33 100644
--- a/tests/test_requester.py
+++ b/tests/test_requester.py
@@ -14,6 +14,7 @@ from canvasapi.exceptions import (
     InvalidAccessToken,
     ResourceDoesNotExist,
     Unauthorized,
+    UnprocessableEntity,
 )
 from tests import settings
 from tests.util import register_uris
@@ -145,6 +146,12 @@ class TestRequester(unittest.TestCase):
         with self.assertRaises(Conflict):
             self.requester.request("GET", "409")
 
+    def test_request_422(self, m):
+        register_uris({"requests": ["422"]}, m)
+
+        with self.assertRaises(UnprocessableEntity):
+            self.requester.request("GET", "422")
+
     def test_request_500(self, m):
         register_uris({"requests": ["500"]}, m)
 
diff --git a/tests/test_user.py b/tests/test_user.py
index bd6b3bb..dc027d8 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -617,11 +617,11 @@ class TestUser(unittest.TestCase):
         self.assertIsInstance(licenses, PaginatedList)
         licenses = list(licenses)
 
-        for l in licenses:
-            self.assertIsInstance(l, License)
-            self.assertTrue(hasattr(l, "id"))
-            self.assertTrue(hasattr(l, "name"))
-            self.assertTrue(hasattr(l, "url"))
+        for lic in licenses:
+            self.assertIsInstance(lic, License)
+            self.assertTrue(hasattr(lic, "id"))
+            self.assertTrue(hasattr(lic, "name"))
+            self.assertTrue(hasattr(lic, "url"))
 
         self.assertEqual(2, len(licenses))
 
diff --git a/tests/util.py b/tests/util.py
index 64d7cee..ef5dd70 100644
--- a/tests/util.py
+++ b/tests/util.py
@@ -4,11 +4,10 @@ import os
 
 import requests_mock
 
-from canvasapi.util import get_institution_url
 from tests import settings
 
 
-def register_uris(requirements, requests_mocker):
+def register_uris(requirements, requests_mocker, base_url=None):
     """
     Given a list of required fixtures and an requests_mocker object,
     register each fixture as a uri with the mocker.
@@ -17,6 +16,8 @@ def register_uris(requirements, requests_mocker):
     :param requirements: dict
     :param requests_mocker: requests_mock.mocker.Mocker
     """
+    if base_url is None:
+        base_url = settings.BASE_URL_WITH_VERSION
     for fixture, objects in requirements.items():
         try:
             with open("tests/fixtures/{}.json".format(fixture)) as file:
@@ -39,11 +40,7 @@ def register_uris(requirements, requests_mocker):
             if obj["endpoint"] == "ANY":
                 url = requests_mock.ANY
             else:
-                url = (
-                    get_institution_url(settings.BASE_URL)
-                    + "/api/v1/"
-                    + obj["endpoint"]
-                )
+                url = base_url + obj["endpoint"]
 
             try:
                 requests_mocker.register_uri(