Repository: ucfopen/canvasapi

Issue:
Course.get_enabled_features throws AttributeError: 'str' object has no attribute 'update'
# Describe the bug

When trying to access the enabled features for a course using Course.get_enabled_features, an AttributeError is thrown. Full output from an ipython session is below. I am guessing that this is because the API call to `/features/enabled` does not return output in the same format as the call to `/features` (see `curl` output below), but I have not been able to verify that this is the cause of the problem.

# To Reproduce

Steps to reproduce the behavior:

```
In [18]: canvas = Canvas(API_URL, API_KEY)

In [19]: course = canvas.get_course(COURSE_ID)

In [20]: features = course.get_features()

In [21]: type(features)
Out[21]: canvasapi.paginated_list.PaginatedList

In [22]: features[0]
Out[22]: Feature(_requester=<canvasapi.requester.Requester object at 0x7fca7374aad0>, feature=analytics_2,<. . .>

In [23]: enabled_features = course.get_enabled_features()

In [24]: type(enabled_features)
Out[24]: canvasapi.paginated_list.PaginatedList

In [25]: enabled_features[0]
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-25-59616ad1fc05> in <module>
----> 1 enabled_features[0]

/usr/local/lib/python3.10/dist-packages/canvasapi/paginated_list.py in __getitem__(self, index)
     13             if index < 0:
     14                 raise IndexError("Cannot negative index a PaginatedList")
---> 15             self._get_up_to_index(index)
     16             return self._elements[index]
     17         else:

/usr/local/lib/python3.10/dist-packages/canvasapi/paginated_list.py in _get_up_to_index(self, index)
     87     def _get_up_to_index(self, index):
     88         while len(self._elements) <= index and self._has_next():
---> 89             self._grow()
     90
     91     def _grow(self):

/usr/local/lib/python3.10/dist-packages/canvasapi/paginated_list.py in _grow(self)
     90
     91     def _grow(self):
---> 92         new_elements = self._get_next_page()
     93         self._elements += new_elements
     94         return new_elements

/usr/local/lib/python3.10/dist-packages/canvasapi/paginated_list.py in _get_next_page(self)
     80         for element in data:
     81             if element is not None:
---> 82                 element.update(self._extra_attribs)
     83                 content.append(self._content_class(self._requester, element))
     84
```


# Expected behavior

get_enabled_features() should return a response of the same format as get_features()

# Environment information

<!--(please complete the following information)-->

- Python version: 3.10.6
- CanvasAPI version: 3.0.0

# Additional context

The API call to `/features/enabled` returns a simple list as output, rather than a full dict of feature information.

```
curl "https://<API_URL>/api/v1/courses/<COURSE_ID>/features" -H "Authorization: Bearer <API_TOKEN>"
[{"feature":"analytics_2","applies_to":"Course","beta":true,"type":"feature_option","display_name":"New Course and User Analytics","description":"Show new analytics for course and user data. Data may take 24 hours to appear in New Analytics after setting this feature flag to ON. For full details, please see the \u003ca href=\"https://community.canvaslms.com/t5/New-Analytics-Users/gh-p/analytics\"\u003eNew Analytics user group\u003c/a\u003e in the Canvas Community.","feature_flag":{"context_id":1,"context_type":"Account","feature":"analytics_2","state":"allowed_on","locking_account_id":null,"transitions":{"off":{"locked":false},"on":{"locked":false}},"locked":false,"parent_state":"allowed_on"}},{"feature":"anonymous_instructor_annotations","applies_to":"Course","root_opt_in":false,"type":"setting","display_name":"Anonymous Instructor Annotations","description":"Anonymize all instructor comments and annotations within DocViewer","feature_flag":{"context_id":1,"context_type":"Account","feature":"anonymous_instructor_annotations","state":"on","locking_account_id":null,"transitions":{"off":{"locked":false}},"locked":true,"parent_state":"on"}},{"feature":"epub_export","applies_to":"Course","root_opt_in":true,"type":"setting","display_name":"ePub Exporting","description":"This enables users to generate and download course ePub.","feature_flag":{"context_id":1,"context_type":"Account","feature":"epub_export","state":"allowed","locking_account_id":null,"transitions":{"off":{"locked":false},"on":{"locked":false}},"locked":false,"parent_state":"allowed"}},{"feature":"final_grades_override","applies_to":"Course","root_opt_in":true,"type":"setting","display_name":"Final Grade Override","description":"Enable ability to alter the final grade for the entire course without changing scores for assignments.","feature_flag":{"context_id":65221,"context_type":"Course","feature":"final_grades_override","state":"on","locking_account_id":null,"transitions":{"off":{"locked":true},"on":{}},"locked":false,"parent_state":"allowed"}},{"feature":"outcome_gradebook","applies_to":"Course","root_opt_in":false,"type":"setting","display_name":"Learning Mastery Gradebook","description":"Learning Mastery Gradebook provides a way for teachers to quickly view student and course\nprogress on course learning outcomes. Outcomes are presented in a Gradebook-like\nformat and student progress is displayed both as a numerical score and as mastered/near\nmastery/remedial.","feature_flag":{"feature":"outcome_gradebook","state":"allowed","transitions":{"off":{"locked":false},"on":{"locked":false}},"locked":false,"parent_state":"allowed"}},{"feature":"quizzes_next","applies_to":"Course","type":"feature_option","display_name":"New Quizzes","description":"This feature enhances the Canvas experience for quizzing. When this flag is enabled, the New Quizzes tool will be available. For full details, please see the \u003ca href=\"https://community.canvaslms.com/t5/New-Quizzes-Users/gh-p/quizzes\"\u003eNew Quizzes user group\u003c/a\u003e in the Canvas Community.","feature_flag":{"context_id":1,"context_type":"Account","feature":"quizzes_next","state":"on","locking_account_id":null,"transitions":{"off":{"locked":false}},"locked":true,"parent_state":"on"}}]

curl "https://<API_URL>/api/v1/courses/<COURSE_ID>/features/enabled" -H "Authorization: Bearer <API_TOKEN>"
["analytics_2","anonymous_instructor_annotations","final_grades_override","quizzes_next","quiz_log_auditing","anonymous_marking","moderated_grading","new_quizzes_by_default"]
```

This is confirmed by the Canvas API docs: https://canvas.instructure.com/doc/api/feature_flags.html#method.feature_flags.enabled_features.


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/ucfopen/canvasapi/pull/592?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ucfopen) Report
> Merging [#592](https://codecov.io/gh/ucfopen/canvasapi/pull/592?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ucfopen) (5cbefc2) into [develop](https://codecov.io/gh/ucfopen/canvasapi/commit/f4e725a15a6b9149aba2e74214410c192dfd6d13?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ucfopen) (f4e725a) will **not change** coverage.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff            @@
##           develop      #592   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           68        70    +2     
  Lines         3534      3632   +98     
=========================================
+ Hits          3534      3632   +98     
```


| [Impacted Files](https://codecov.io/gh/ucfopen/canvasapi/pull/592?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ucfopen) | Coverage Δ | |
|---|---|---|
| [canvasapi/communication\_channel.py](https://codecov.io/gh/ucfopen/canvasapi/pull/592?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ucfopen#diff-Y2FudmFzYXBpL2NvbW11bmljYXRpb25fY2hhbm5lbC5weQ==) | `100.00% <ø> (ø)` | |
| [canvasapi/paginated\_list.py](https://codecov.io/gh/ucfopen/canvasapi/pull/592?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ucfopen#diff-Y2FudmFzYXBpL3BhZ2luYXRlZF9saXN0LnB5) | `100.00% <ø> (ø)` | |
| [canvasapi/account.py](https://codecov.io/gh/ucfopen/canvasapi/pull/592?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ucfopen#diff-Y2FudmFzYXBpL2FjY291bnQucHk=) | `100.00% <100.00%> (ø)` | |
| [canvasapi/course.py](https://codecov.io/gh/ucfopen/canvasapi/pull/592?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ucfopen#diff-Y2FudmFzYXBpL2NvdXJzZS5weQ==) | `100.00% <100.00%> (ø)` | |
| [canvasapi/user.py](https://codecov.io/gh/ucfopen/canvasapi/pull/592?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ucfopen#diff-Y2FudmFzYXBpL3VzZXIucHk=) | `100.00% <100.00%> (ø)` | |
| [canvasapi/\_\_init\_\_.py](https://codecov.io/gh/ucfopen/canvasapi/pull/592?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ucfopen#diff-Y2FudmFzYXBpL19faW5pdF9fLnB5) | `100.00% <0.00%> (ø)` | |
| [canvasapi/util.py](https://codecov.io/gh/ucfopen/canvasapi/pull/592?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ucfopen#diff-Y2FudmFzYXBpL3V0aWwucHk=) | `100.00% <0.00%> (ø)` | |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ucfopen)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1943da5..41030af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,7 @@
 
 - Fixed an issue where kwargs were not passed along to Canvas in `Course.get_module()`. (Thanks, [@bennettscience](https://github.com/bennettscience))
 - Fixed an issue where not all functions allowed arbitrary keyword arguments. Added a test to detect and prevent this for the future.
+- Fixed an issue with `Course.get_enabled_features()` where it would throw an error trying to paginate. It now returns a list of strings directly. (Thanks, [@bennettscience](https://github.com/bennettscience))
 
 ## [3.0.0] - 2022-09-21
 
diff --git a/canvasapi/account.py b/canvasapi/account.py
index d7f71d6..a1c39c1 100644
--- a/canvasapi/account.py
+++ b/canvasapi/account.py
@@ -979,18 +979,16 @@ class Account(CanvasObject):
         :calls: `GET /api/v1/accounts/:account_id/features/enabled \
         <https://canvas.instructure.com/doc/api/feature_flags.html#method.feature_flags.enabled_features>`_
 
-        :rtype: :class:`canvasapi.paginated_list.PaginatedList` of
-            :class:`canvasapi.feature.Feature`
+        :rtype: `list` of `str`
         """
-        return PaginatedList(
-            Feature,
-            self._requester,
+        response = self._requester.request(
             "GET",
             "accounts/{}/features/enabled".format(self.id),
-            {"account_id": self.id},
             _kwargs=combine_kwargs(**kwargs),
         )
 
+        return response.json()
+
     def get_enrollment(self, enrollment, **kwargs):
         """
         Get an enrollment object by ID.
diff --git a/canvasapi/course.py b/canvasapi/course.py
index e57a88f..0c3e227 100644
--- a/canvasapi/course.py
+++ b/canvasapi/course.py
@@ -1127,18 +1127,16 @@ class Course(CanvasObject):
         :calls: `GET /api/v1/courses/:course_id/features/enabled \
         <https://canvas.instructure.com/doc/api/feature_flags.html#method.feature_flags.enabled_features>`_
 
-        :rtype: :class:`canvasapi.paginated_list.PaginatedList` of
-            :class:`canvasapi.feature.Feature`
+        :rtype: `list` of `str`
         """
-        return PaginatedList(
-            Feature,
-            self._requester,
+        response = self._requester.request(
             "GET",
             "courses/{}/features/enabled".format(self.id),
-            {"course_id": self.id},
             _kwargs=combine_kwargs(**kwargs),
         )
 
+        return response.json()
+
     def get_enrollments(self, **kwargs):
         """
         List all of the enrollments in this course.
diff --git a/canvasapi/user.py b/canvasapi/user.py
index e3b62f9..6256f70 100644
--- a/canvasapi/user.py
+++ b/canvasapi/user.py
@@ -451,18 +451,16 @@ class User(CanvasObject):
         :calls: `GET /api/v1/users/:user_id/features/enabled \
         <https://canvas.instructure.com/doc/api/feature_flags.html#method.feature_flags.enabled_features>`_
 
-        :rtype: :class:`canvasapi.paginated_list.PaginatedList` of
-            :class:`canvasapi.feature.Feature`
+        :rtype: `list` of `str`
         """
-        return PaginatedList(
-            Feature,
-            self._requester,
+        response = self._requester.request(
             "GET",
             "users/{}/features/enabled".format(self.id),
-            {"user_id": self.id},
             _kwargs=combine_kwargs(**kwargs),
         )
 
+        return response.json()
+
     def get_enrollments(self, **kwargs):
         """
         List all of the enrollments for this user.


Test patch:
diff --git a/tests/fixtures/account.json b/tests/fixtures/account.json
index 0588d64..673581e 100644
--- a/tests/fixtures/account.json
+++ b/tests/fixtures/account.json
@@ -1845,16 +1845,8 @@
 		"method": "GET",
 		"endpoint": "accounts/1/features/enabled",
 		"data": [
-			{
-				"name": "feature_one",
-				"display_name": "Feature One",
-				"applies_to": "Account"
-			},
-			{
-				"name": "feature_two",
-				"display_name": "Feature Two",
-				"applies_to": "Account"
-			}
+			"feature_one",
+			"feature_two"
 		],
 		"status_code": 200
 	},
diff --git a/tests/fixtures/course.json b/tests/fixtures/course.json
index 6e3f8a9..81b3130 100644
--- a/tests/fixtures/course.json
+++ b/tests/fixtures/course.json
@@ -2214,16 +2214,8 @@
 		"method": "GET",
 		"endpoint": "courses/1/features/enabled",
 		"data": [
-			{
-				"name": "feature_test",
-				"display_name": "Feature Test",
-				"applies_to": "Course"
-			},
-			{
-				"name": "feature_second",
-				"display_name": "Feature Second",
-				"applies_to": "Course"
-			}
+			"feature_one",
+			"feature_two"
 		],
 		"status_code": 200
 	},
diff --git a/tests/fixtures/user.json b/tests/fixtures/user.json
index 557bf33..fc32c5b 100644
--- a/tests/fixtures/user.json
+++ b/tests/fixtures/user.json
@@ -183,16 +183,16 @@
 		"status_code": 200
 	},
 	"create_folder": {
-        "method": "POST",
-        "endpoint": "users/1/folders",
-        "data": {
-            "id": 2,
-            "name": "Test String",
-            "locked": false,
-            "hidden": false
-        },
-        "status_code": 200
-    },
+		"method": "POST",
+		"endpoint": "users/1/folders",
+		"data": {
+			"id": 2,
+			"name": "Test String",
+			"locked": false,
+			"hidden": false
+		},
+		"status_code": 200
+	},
 	"edit": {
 		"method": "PUT",
 		"endpoint": "users/1",
@@ -243,39 +243,39 @@
 			"size": 1024
 		},
 		"status_code": 200
-    },
-    "get_file_quota": {
-        "method": "GET",
-        "endpoint": "users/1/files/quota",
-        "data": {
-            "quota": 889234510,
-            "quota_used": 476231098
-        },
-        "status_code": 200
-    },
+	},
+	"get_file_quota": {
+		"method": "GET",
+		"endpoint": "users/1/files/quota",
+		"data": {
+			"quota": 889234510,
+			"quota_used": 476231098
+		},
+		"status_code": 200
+	},
 	"get_folder": {
-        "method": "GET",
-        "endpoint": "users/1/folders/1",
-        "data": {
-            "id": 1,
-            "files_count": 10,
-            "folders_count": 2,
-            "name": "Folder 1",
-            "full_name": "Folder 1"
-        },
-        "status_code": 200
-    },
+		"method": "GET",
+		"endpoint": "users/1/folders/1",
+		"data": {
+			"id": 1,
+			"files_count": 10,
+			"folders_count": 2,
+			"name": "Folder 1",
+			"full_name": "Folder 1"
+		},
+		"status_code": 200
+	},
 	"get_folder_2": {
-        "method": "GET",
-        "endpoint": "users/1/folders/2",
-        "data": {
-            "id": 2,
-            "files_count": 10,
-            "folders_count": 2,
-            "name": "Folder 2",
-            "full_name": "Folder 1"
-        },
-        "status_code": 200
+		"method": "GET",
+		"endpoint": "users/1/folders/2",
+		"data": {
+			"id": 2,
+			"files_count": 10,
+			"folders_count": 2,
+			"name": "Folder 2",
+			"full_name": "Folder 1"
+		},
+		"status_code": 200
 	},
 	"observer_pairing_codes": {
 		"method": "POST",
@@ -479,26 +479,26 @@
 		"status_code": 200
 	},
 	"list_folders": {
-        "method": "GET",
-        "endpoint": "users/1/folders",
-        "data": [
-            {
-                "id": 2,
-                "files_count": 0,
-                "folders_count": 0,
-                "name": "Folder 2",
-                "full_name": "user_files/Folder 2"
-            },
-            {
-                "id": 3,
-                "files_count": 0,
-                "folders_count": 0,
-                "name": "Folder 3",
-                "full_name": "user_files/Folder 3"
-            }
-        ],
-        "status_code": 200
-    },
+		"method": "GET",
+		"endpoint": "users/1/folders",
+		"data": [
+			{
+				"id": 2,
+				"files_count": 0,
+				"folders_count": 0,
+				"name": "Folder 2",
+				"full_name": "user_files/Folder 2"
+			},
+			{
+				"id": 3,
+				"files_count": 0,
+				"folders_count": 0,
+				"name": "Folder 3",
+				"full_name": "user_files/Folder 3"
+			}
+		],
+		"status_code": 200
+	},
 	"merge": {
 		"method": "PUT",
 		"endpoint": "users/1/merge_into/2",
@@ -804,18 +804,22 @@
 		"method": "GET",
 		"endpoint": "search/recipients",
 		"data": [
-  			{
-  				"id": "group_1",
-  				"name": "the group",
-  				"type": "context",
-  				"user_count": 3
-  			},
-  			{
-  				"id": 2,
-  				"name": "greg",
-  				"common_courses": {},
-  				"common_groups": {"1": ["Member"]}
-  			}
+			{
+				"id": "group_1",
+				"name": "the group",
+				"type": "context",
+				"user_count": 3
+			},
+			{
+				"id": 2,
+				"name": "greg",
+				"common_courses": {},
+				"common_groups": {
+					"1": [
+						"Member"
+					]
+				}
+			}
 		],
 		"status_code": 200
 	},
@@ -915,7 +919,9 @@
 						"grading_type": "points",
 						"course_id": 1,
 						"name": "Test assignment",
-						"submission_types": ["online_text_entry"]
+						"submission_types": [
+							"online_text_entry"
+						]
 					}
 				],
 				"courses": [
@@ -996,7 +1002,9 @@
 						"grading_type": "points",
 						"course_id": 1,
 						"name": "Test assignment",
-						"submission_types": ["online_text_entry"]
+						"submission_types": [
+							"online_text_entry"
+						]
 					}
 				],
 				"courses": [
@@ -1132,16 +1140,8 @@
 		"method": "GET",
 		"endpoint": "users/1/features/enabled",
 		"data": [
-			{
-				"name": "feature_one",
-				"display_name": "Feature One",
-				"applies_to": "User"
-			},
-			{
-				"name": "feature_two",
-				"display_name": "Feature Two",
-				"applies_to": "User"
-			}
+			"feature_one",
+			"feature_two"
 		],
 		"status_code": 200
 	},
@@ -1196,7 +1196,10 @@
 			"use_justification": "fair_use",
 			"license": "private",
 			"message": "2 files updated",
-			"file_ids": [1,2]
+			"file_ids": [
+				1,
+				2
+			]
 		},
 		"status_code": 200
 	},
@@ -1205,7 +1208,10 @@
 		"endpoint": "users/1/usage_rights",
 		"data": {
 			"message": "2 files updated",
-			"file_ids": [1,2]
+			"file_ids": [
+				1,
+				2
+			]
 		},
 		"status_code": 200
 	},
@@ -1226,11 +1232,11 @@
 		],
 		"status_code": 200
 	},
-    "resolve_path": {
+	"resolve_path": {
 		"method": "GET",
 		"endpoint": "users/1/folders/by_path/Folder_Level_1/Folder_Level_2/Folder_Level_3",
 		"data": [
-            {
+			{
 				"id": 2,
 				"files_count": 0,
 				"folders_count": 1,
@@ -1261,18 +1267,18 @@
 		],
 		"status_code": 200
 	},
-    "resolve_path_null": {
+	"resolve_path_null": {
 		"method": "GET",
 		"endpoint": "users/1/folders/by_path",
 		"data": [
-            {
+			{
 				"id": 2,
 				"files_count": 0,
 				"folders_count": 1,
 				"name": "my_files",
 				"full_name": "my_files"
 			}
-        ],
+		],
 		"status_code": 200
 	}
 }
diff --git a/tests/test_account.py b/tests/test_account.py
index 7c95f66..9a0d04e 100644
--- a/tests/test_account.py
+++ b/tests/test_account.py
@@ -1416,8 +1416,8 @@ class TestAccountReport(unittest.TestCase):
 
         features = self.account.get_enabled_features()
 
-        self.assertIsInstance(features, PaginatedList)
-        self.assertIsInstance(features[0], Feature)
+        self.assertIsInstance(features, list)
+        self.assertIsInstance(features[0], str)
 
     # get_feature_flag()
     def test_get_feature_flag(self, m):
diff --git a/tests/test_course.py b/tests/test_course.py
index 5dedb2e..21b483b 100644
--- a/tests/test_course.py
+++ b/tests/test_course.py
@@ -1687,8 +1687,8 @@ class TestCourse(unittest.TestCase):
 
         enabled_features = self.course.get_enabled_features()
 
-        self.assertIsInstance(enabled_features, PaginatedList)
-        self.assertIsInstance(enabled_features[0], Feature)
+        self.assertIsInstance(enabled_features, list)
+        self.assertIsInstance(enabled_features[0], str)
 
     # get_feature_flag()
     def test_get_feature_flag(self, m):
diff --git a/tests/test_user.py b/tests/test_user.py
index a219709..c727602 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -550,8 +550,8 @@ class TestUser(unittest.TestCase):
 
         features = self.user.get_enabled_features()
 
-        self.assertIsInstance(features, PaginatedList)
-        self.assertIsInstance(features[0], Feature)
+        self.assertIsInstance(features, list)
+        self.assertIsInstance(features[0], str)
 
     # get_feature_flag()
     def test_get_feature_flag(self, m):