Repository: ucfopen/canvasapi (language: python)

Issue:
Request to add ePortfolios API
**What resource needs additional coverage?**
- ePortfolios API

**What endpoints need to be covered?**
- https://canvas.instructure.com/doc/api/e_portfolios.html

I'm an L3 support engineer at Instructure. I put in a request to have these endpoints added, and engineering delivered. Hope you're as happy as I am; it makes dealing ePortfolio spam easier.


Discussion / hints:
I don't do much with ePortfolios, so I didn't see this update to the API. Is an `ePortfolioPageObject` a property on an `ePortfolioObject`? I see there's a separate endpoint to get the pages for a given`ePortfolioObject` id, but are those ever returned as a nested array by Canvas?
I've started a PR for this.

Fix patch:
diff --git a/AUTHORS.md b/AUTHORS.md
index eaf346e9..e25c95ed 100644
--- a/AUTHORS.md
+++ b/AUTHORS.md
@@ -20,6 +20,7 @@ Patches and Suggestions
 - Ashutosh Saxena [@Xx-Ashutosh-xX](https://github.com/Xx-Ashutosh-xX)
 - Ben Liblit [@liblit](https://github.com/liblit)
 - Bill Wrbican [@wjw27](https://github.com/wjw27)
+- [@Birdmaaan4](https://github.com/Birdmaaan4)
 - [@blepabyte](https://github.com/blepabyte)
 - Bradford Lynch [@bradfordlynch](https://github.com/bradfordlynch)
 - Brian Bennett [@bennettscience](https://github.com/bennettscience)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76b1945f..a53821fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
 - Grade Change Log for Assignments, Courses, and Users (Thanks, [@matthewf-ucsd](https://github.com/matthewf-ucsd))
 - Content Migrations: List items for selective import (Thanks, [@matthewf-ucsd](https://github.com/matthewf-ucsd))
 - List observers of a User (Thanks, [@bennettscience](https://github.com/bennettscience))
+- ePortfolio endpoints (Thanks, [@Birdmaaan4](https://github.com/Birdmaaan4) and [@bennettscience](https://github.com/bennettscience))
 
 ### General
 
diff --git a/canvasapi/canvas.py b/canvasapi/canvas.py
index 51b632ca..ede61940 100644
--- a/canvasapi/canvas.py
+++ b/canvasapi/canvas.py
@@ -5,6 +5,7 @@
 from canvasapi.course import Course
 from canvasapi.course_epub_export import CourseEpubExport
 from canvasapi.current_user import CurrentUser
+from canvasapi.eportfolio import EPortfolio
 from canvasapi.exceptions import RequiredFieldMissing
 from canvasapi.file import File
 from canvasapi.folder import Folder
@@ -771,6 +772,27 @@ def get_current_user(self):
         """
         return CurrentUser(self.__requester)
 
+    def get_eportfolio(self, eportfolio, **kwargs):
+        """
+        Get an eportfolio by ID.
+
+        :param eportfolio: The object or ID of the eportfolio to retrieve.
+        :type eportfolio: :class: `canvasapi.eportfolio.EPortfolio` or int
+
+        :calls: `GET /api/v1/eportfolios/:id` \
+            `<https://canvas.instructure.com/doc/api/e_portfolios.html#method.eportfolios_api.show>`_
+
+        :rtype: :class:`canvasapi.eportfolio.EPortfolio`
+        """
+        eportfolio_id = obj_or_id(eportfolio, "eportfolio", (EPortfolio,))
+        response = self.__requester.request(
+            "GET",
+            "eportfolios/{}".format(eportfolio_id),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
+        return EPortfolio(self.__requester, response.json())
+
     def get_epub_exports(self, **kwargs):
         """
         Return a list of epub exports for the associated course.
diff --git a/canvasapi/eportfolio.py b/canvasapi/eportfolio.py
new file mode 100644
index 00000000..f0eecb86
--- /dev/null
+++ b/canvasapi/eportfolio.py
@@ -0,0 +1,86 @@
+from canvasapi.canvas_object import CanvasObject
+from canvasapi.paginated_list import PaginatedList
+from canvasapi.util import combine_kwargs
+
+
+class EPortfolio(CanvasObject):
+    def __str__(self):
+        return "{}".format(self.name)
+
+    def delete(self, **kwargs):
+        """
+        Delete an ePortfolio.
+
+        :calls: `DELETE /api/v1/eportfolios/:id \
+            <https://canvas.instructure.com/doc/api/e_portfolios.html#method.eportfolios_api.delete>`_
+
+        :returns: ePortfolio with deleted date set.
+        :rtype: :class:`canvasapi.eportfolio.EPortfolio`
+        """
+        response = self._requester.request(
+            "DELETE", "eportfolios/{}".format(self.id), _kwargs=combine_kwargs(**kwargs)
+        )
+        return EPortfolio(self._requester, response.json())
+
+    def get_eportfolio_pages(self, **kwargs):
+        """
+        Return a list of pages for an ePortfolio.
+
+        :calls: `GET /api/v1/eportfolios/:eportfolio_id/pages \
+            <https://canvas.instructure.com/doc/api/e_portfolios.html#method.eportfolios_api.pages>`_
+
+        :returns: List of ePortfolio pages.
+        :rtype: :class:`canvasapi.paginated_list.PaginatedList` of
+            :class:`canvasapi.eportfolio.EPortfolioPage`
+        """
+
+        return PaginatedList(
+            EPortfolioPage,
+            self._requester,
+            "GET",
+            "eportfolios/{}/pages".format(self.id),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
+    def moderate_eportfolio(self, **kwargs):
+        """
+        Update the spam_status of an eportfolio.
+        Only available to admins who can `moderate_user_content`.
+
+        :calls: `PUT /api/v1/eportfolios/:eportfolio_id/moderate \
+            <https://canvas.instructure.com/doc/api/e_portfolios.html#method.eportfolios_api.moderate>`_
+
+        :returns: Updated ePortfolio.
+        :rtype: :class:`canvasapi.eportfolio.EPortfolio`
+        """
+        response = self._requester.request(
+            "PUT",
+            "eportfolios/{}/moderate".format(self.id),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
+        return EPortfolio(self._requester, response.json())
+
+    def restore(self, **kwargs):
+        """
+        Restore an ePortfolio back to active that was previously deleted.
+        Only available to admins who can moderate_user_content.
+
+        :calls: `PUT /api/v1/eportfolios/:eportfolio_id/restore \
+            <https://canvas.instructure.com/doc/api/e_portfolios.html#method.eportfolios_api.restore>`_
+
+        :returns: Updated ePortfolio.
+        :rtype: :class:`canvasapi.eportfolio.EPortfolio`
+        """
+        response = self._requester.request(
+            "PUT",
+            "eportfolios/{}/restore".format(self.id),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
+        return EPortfolio(self._requester, response.json())
+
+
+class EPortfolioPage(CanvasObject):
+    def __str__(self):
+        return "{}. {}".format(self.position, self.name)
diff --git a/canvasapi/user.py b/canvasapi/user.py
index 6804e65e..4b42b713 100644
--- a/canvasapi/user.py
+++ b/canvasapi/user.py
@@ -25,7 +25,7 @@ def add_observee(self, observee_id, **kwargs):
 
         :param observee_id: The login id for the user to observe.
         :type observee_id: int
-        :rtype: :class: `canvasapi.user.User`
+        :rtype: :class:`canvasapi.user.User`
         """
 
         response = self._requester.request(
@@ -490,6 +490,25 @@ def get_enrollments(self, **kwargs):
             _kwargs=combine_kwargs(**kwargs),
         )
 
+    def get_eportfolios(self, **kwargs):
+        """
+        Returns a list of ePortfolios for a user.
+
+        :calls: `GET /api/v1/users/:user_id/eportfolios \
+            <https://canvas.instructure.com/doc/api/e_portfolios.html#method.eportfolios_api.index>`_
+        :rtype: :class:`canvasapi.paginated_list.PaginatedList` of
+            :class:`canvasapi.eportfolio.EPortfolio`
+        """
+        from canvasapi.eportfolio import EPortfolio
+
+        return PaginatedList(
+            EPortfolio,
+            self._requester,
+            "GET",
+            "users/{}/eportfolios".format(self.id),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
     def get_feature_flag(self, feature, **kwargs):
         """
         Returns the feature flag that applies to the given user.
@@ -866,6 +885,31 @@ def merge_into(self, destination_user, **kwargs):
         super(User, self).set_attributes(response.json())
         return self
 
+    def moderate_all_eportfolios(self, **kwargs):
+        """
+        Update the spam_status for all active eportfolios of a user.
+        Only available to admins who can moderate_user_content.
+
+        :param eportfolio: The object or ID of the ePortfolio to retrieve.
+        :type eportfolio: :class:`canvasapi.eportfolio.EPortfolio` or int
+
+        :calls: `PUT /api/v1/users/:user_id/eportfolios \
+            <https://canvas.instructure.com/doc/api/e_portfolios.html#method.eportfolios_api.moderate_all>`_
+
+        :returns: A list of all user ePortfolios.
+        :rtype: :class:`canvasapi.paginated_list.PaginatedList` of
+            :class:`canvasapi.eportfolio.EPortfolio`
+        """
+        from canvasapi.eportfolio import EPortfolio
+
+        return PaginatedList(
+            EPortfolio,
+            self._requester,
+            "PUT",
+            "users/{}/eportfolios".format(self.id),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
     def remove_observee(self, observee_id, **kwargs):
         """
         Unregisters a user as being observed by the given user.
@@ -875,7 +919,7 @@ def remove_observee(self, observee_id, **kwargs):
 
         :param observee_id: The login id for the user to observe.
         :type observee_id: int
-        :rtype: :class: `canvasapi.user.User`
+        :rtype: :class:`canvasapi.user.User`
         """
 
         response = self._requester.request(
@@ -963,7 +1007,7 @@ def show_observee(self, observee_id, **kwargs):
 
         :param observee_id: The login id for the user to observe.
         :type observee_id: int
-        :rtype: :class: `canvasapi.user.User`
+        :rtype: :class:`canvasapi.user.User`
         """
 
         response = self._requester.request(
diff --git a/docs/class-reference.rst b/docs/class-reference.rst
index ef92cd24..21c0f2c7 100644
--- a/docs/class-reference.rst
+++ b/docs/class-reference.rst
@@ -27,6 +27,7 @@ Class Reference
     discussion-topic-ref
     enrollment-ref
     enrollment-term-ref
+    eportfolio-ref
     external-tool-ref
     favorite-ref
     feature-ref
diff --git a/docs/eportfolio-ref.rst b/docs/eportfolio-ref.rst
new file mode 100644
index 00000000..922d5769
--- /dev/null
+++ b/docs/eportfolio-ref.rst
@@ -0,0 +1,13 @@
+==========
+EPortfolio
+==========
+
+.. autoclass:: canvasapi.eportfolio.EPortfolio
+    :members:
+
+==============
+EPortfolioPage
+==============
+
+.. autoclass:: canvasapi.eportfolio.EPortfolioPage
+    :members:


Test patch:
diff --git a/tests/fixtures/eportfolio.json b/tests/fixtures/eportfolio.json
new file mode 100644
index 00000000..95f54cd5
--- /dev/null
+++ b/tests/fixtures/eportfolio.json
@@ -0,0 +1,75 @@
+{
+    "delete_eportfolio": {
+        "method": "DELETE",
+        "endpoint": "eportfolios/1",
+        "data": {
+            "id": 1,
+            "name": "ePortfolio 1",
+            "workflow_state": "deleted",
+            "description": "Delete this ePortfolio",
+            "deleted_at": "2022-07-05T21:00:00Z"
+        },
+        "status_code": 200
+    },
+    "get_eportfolio_by_id": {
+        "method": "GET",
+        "endpoint": "eportfolios/1",
+        "data": {
+            "id": 1,
+            "user_id": 1,
+            "workflow_state": "active",
+            "name": "ePortfolio 1",
+            "deleted_at": "null",
+            "spam_status": "null"
+        },
+        "status_code": 200
+    },
+    "get_eportfolio_pages": {
+        "method": "GET",
+        "endpoint": "eportfolios/1/pages",
+        "data": [
+            {
+                "id": 1,
+                "eportfolio_id": 1,
+                "position": 1,
+                "name": "ePortfolio 1",
+                "content": "This is the page of content",
+                "created_at": "2022-07-01T18:00:00Z",
+                "updated_at": "2021-07-04T18:00:00Z"
+            },
+            {
+                "id": 2,
+                "eportfolio_id": 1,
+                "position": 2,
+                "name": "ePortfolio 1",
+                "content": "This is the second page of content",
+                "created_at": "2022-07-02T18:00:00Z",
+                "updated_at": "2021-07-02T18:00:00Z"
+            }
+        ],
+        "status_code": 200
+    },
+    "moderate_eportfolio_as_spam": {
+        "method": "PUT",
+        "endpoint": "eportfolios/1/moderate",
+        "data": {
+            "id": 1,
+            "user_id": 1,
+            "workflow_state": "active",
+            "name": "ePortfolio 1",
+            "deleted_at": "null",
+            "spam_status": "marked_as_spam"
+        }
+    },
+    "restore_deleted_eportfolio": {
+        "method": "PUT",
+        "endpoint": "eportfolios/1/restore",
+        "data": {
+            "id": 1,
+            "user_id": 1,
+            "workflow_state": "active",
+            "name": "ePortfolio 1",
+            "deleted_at": "null"
+        }
+    }
+}
diff --git a/tests/fixtures/user.json b/tests/fixtures/user.json
index babaff54..557bf330 100644
--- a/tests/fixtures/user.json
+++ b/tests/fixtures/user.json
@@ -544,6 +544,24 @@
 		],
 		"status_code": 200
 	},
+	"moderate_user_eportfolios": {
+		"method": "PUT",
+		"endpoint": "users/1/eportfolios",
+		"data": [
+			{
+				"name": "ePortfolio 1",
+				"workflow_state": "active",
+				"created_at": "2022-07-05T21:00:00Z",
+				"spam_status": "marked_as_spam"
+			},
+			{
+				"name": "ePortfolio 2",
+				"workflow_state": "active",
+				"created_at": "2022-07-05T21:00:00Z",
+				"spam_status": "marked_as_spam"
+			}
+		]
+	},
 	"page_views": {
 		"method": "GET",
 		"endpoint": "users/1/page_views",
@@ -1055,6 +1073,44 @@
 		},
 		"status_code": 200
 	},
+	"get_eportfolios": {
+		"method": "GET",
+		"endpoint": "users/1/eportfolios",
+		"data": [
+			{
+				"name": "ePortfolio 1",
+				"workflow_state": "active",
+				"created_at": "2022-07-05T21:00:00Z"
+			},
+			{
+				"name": "ePortfolio 2",
+				"workflow_state": "active",
+				"created_at": "2022-07-05T21:00:00Z"
+			}
+		]
+	},
+	"get_eportfolios_include_deleted": {
+		"method": "GET",
+		"endpoint": "users/1/eportfolios",
+		"data": [
+			{
+				"name": "ePortfolio 1",
+				"workflow_state": "active",
+				"created_at": "2022-07-05T21:00:00Z"
+			},
+			{
+				"name": "ePortfolio 2",
+				"workflow_state": "active",
+				"created_at": "2022-07-05T21:00:00Z"
+			},
+			{
+				"name": "ePortfolio 3",
+				"workflow_state": "deleted",
+				"created_at": "2022-07-05T21:00:00Z",
+				"deleted_at": "2022-07-06T18:00:00Z"
+			}
+		]
+	},
 	"get_features": {
 		"method": "GET",
 		"endpoint": "users/1/features",
diff --git a/tests/test_canvas.py b/tests/test_canvas.py
index 84be6e24..35ece62b 100644
--- a/tests/test_canvas.py
+++ b/tests/test_canvas.py
@@ -14,6 +14,7 @@
 from canvasapi.course import Course, CourseNickname
 from canvasapi.course_epub_export import CourseEpubExport
 from canvasapi.discussion_topic import DiscussionTopic
+from canvasapi.eportfolio import EPortfolio
 from canvasapi.exceptions import RequiredFieldMissing, ResourceDoesNotExist
 from canvasapi.file import File
 from canvasapi.group import Group, GroupCategory
@@ -779,6 +780,15 @@ def test_course_announcements_legacy(self, m):
         self.assertEqual(announcements[0]._parent_type, "course")
         self.assertEqual(announcements[0]._parent_id, "1")
 
+    # get_eportfolio()
+    def test_get_eportfolio(self, m):
+        register_uris({"eportfolio": ["get_eportfolio_by_id"]}, m)
+
+        eportfolio = self.canvas.get_eportfolio(1)
+
+        self.assertIsInstance(eportfolio, EPortfolio)
+        self.assertEqual(eportfolio.name, "ePortfolio 1")
+
     # get_epub_exports()
     def test_get_epub_exports(self, m):
 
diff --git a/tests/test_eportfolio.py b/tests/test_eportfolio.py
new file mode 100644
index 00000000..92b71aba
--- /dev/null
+++ b/tests/test_eportfolio.py
@@ -0,0 +1,73 @@
+import unittest
+
+import requests_mock
+
+from canvasapi import Canvas
+from canvasapi.eportfolio import EPortfolio, EPortfolioPage
+from canvasapi.paginated_list import PaginatedList
+from tests import settings
+from tests.util import register_uris
+
+
+@requests_mock.Mocker()
+class TestEPortfolio(unittest.TestCase):
+    def setUp(self):
+        self.canvas = Canvas(settings.BASE_URL, settings.API_KEY)
+
+        with requests_mock.Mocker() as m:
+            register_uris({"eportfolio": ["get_eportfolio_by_id"]}, m)
+
+            self.eportfolio = self.canvas.get_eportfolio(1)
+
+    def test_str(self, m):
+        eportfolio_string = str(self.eportfolio)
+        self.assertEqual(eportfolio_string, "ePortfolio 1")
+
+    def test_delete_eportfolio(self, m):
+        register_uris({"eportfolio": ["delete_eportfolio"]}, m)
+
+        deleted_eportfolio = self.eportfolio.delete()
+
+        self.assertIsInstance(deleted_eportfolio, EPortfolio)
+        self.assertEqual(deleted_eportfolio.deleted_at, "2022-07-05T21:00:00Z")
+        self.assertEqual(deleted_eportfolio.workflow_state, "deleted")
+
+    def test_get_eportfolio_pages(self, m):
+        register_uris({"eportfolio": ["get_eportfolio_pages"]}, m)
+
+        pages = self.eportfolio.get_eportfolio_pages()
+
+        string_page = str(pages[0])
+
+        self.assertIsInstance(pages, PaginatedList)
+        self.assertIsInstance(pages[0], EPortfolioPage)
+        self.assertIsInstance(pages[1], EPortfolioPage)
+        self.assertEqual(pages[0].position, 1)
+        self.assertEqual(pages[1].position, 2)
+        self.assertEqual(string_page, "1. ePortfolio 1")
+
+    def test_moderate_eportfolio_as_spam(self, m):
+        register_uris({"eportfolio": ["moderate_eportfolio_as_spam"]}, m)
+
+        spam_eportfolio = self.eportfolio.moderate_eportfolio(
+            spam_status="marked_as_spam"
+        )
+
+        self.assertIsInstance(spam_eportfolio, EPortfolio)
+        self.assertEqual(spam_eportfolio.spam_status, "marked_as_spam")
+
+    def test_restore_deleted_eportfolio(self, m):
+        register_uris(
+            {"eportfolio": ["delete_eportfolio", "restore_deleted_eportfolio"]}, m
+        )
+
+        eportfolio = self.eportfolio.delete()
+
+        self.assertIsInstance(eportfolio, EPortfolio)
+        self.assertEqual(eportfolio.workflow_state, "deleted")
+        self.assertEqual(eportfolio.deleted_at, "2022-07-05T21:00:00Z")
+
+        restored = eportfolio.restore()
+
+        self.assertEqual(restored.workflow_state, "active")
+        self.assertEqual(restored.deleted_at, "null")
diff --git a/tests/test_user.py b/tests/test_user.py
index f6574f5c..9093b783 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -13,6 +13,7 @@
 from canvasapi.content_migration import ContentMigration, Migrator
 from canvasapi.course import Course
 from canvasapi.enrollment import Enrollment
+from canvasapi.eportfolio import EPortfolio
 from canvasapi.feature import Feature, FeatureFlag
 from canvasapi.file import File
 from canvasapi.folder import Folder
@@ -279,6 +280,40 @@ def test_create_communication_channels(self, m):
 
         self.assertIsInstance(new_channel, CommunicationChannel)
 
+    # get_eportfolios()
+    def test_get_eportfolios(self, m):
+        register_uris({"user": ["get_eportfolios"]}, m)
+
+        eportfolios = self.user.get_eportfolios()
+        eportfolio_list = [portfolio for portfolio in eportfolios]
+        self.assertIsInstance(eportfolios, PaginatedList)
+        self.assertIsInstance(eportfolios[0], EPortfolio)
+        self.assertEqual(len(eportfolio_list), 2)
+        self.assertEqual(eportfolios[0].name, "ePortfolio 1")
+
+    def test_get_eportfolios_with_deleted(self, m):
+        register_uris({"user": ["get_eportfolios_include_deleted"]}, m)
+
+        eportfolios = self.user.get_eportfolios()
+        eportfolio_list = [portfolio for portfolio in eportfolios]
+        self.assertIsInstance(eportfolios, PaginatedList)
+        self.assertIsInstance(eportfolios[0], EPortfolio)
+        self.assertEqual(len(eportfolio_list), 3)
+        self.assertEqual(eportfolios[2].name, "ePortfolio 3")
+        self.assertEqual(eportfolios[2].workflow_state, "deleted")
+
+    def test_moderate_user_eportfolios(self, m):
+        register_uris({"user": ["moderate_user_eportfolios"]}, m)
+
+        spam_eportfolios = self.user.moderate_all_eportfolios(
+            spam_status="marked_as_spam"
+        )
+        eportfolio_list = [portfolio for portfolio in spam_eportfolios]
+        self.assertIsInstance(spam_eportfolios, PaginatedList)
+        self.assertIsInstance(spam_eportfolios[0], EPortfolio)
+        self.assertEqual(len(eportfolio_list), 2)
+        self.assertEqual(spam_eportfolios[0].spam_status, "marked_as_spam")
+
     # get_files()
     def test_get_files(self, m):
         register_uris({"user": ["get_user_files", "get_user_files2"]}, m)