Repository: ucfopen/canvasapi (language: python)

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 / hints:
I did some digging tonight and it throws an error because the `enabled` endpoint doesn't return the `Link` header like it should for paginated responses. The Canvas docs say it gives a paginated response, but for some reason it does not. Maybe it's because the response is so much smaller only returning feature titles instead of objects?

A hotfix would be to build your own request using `course._requester.request`. You'll get a list back as the response. For a permanent fix, the return on `get_enabled_features` in `course.py` [needs to be adjusted here](https://github.com/ucfopen/canvasapi/blob/f4e725a15a6b9149aba2e74214410c192dfd6d13/canvasapi/course.py#L1132-L1139). I can submit a PR pretty quickly if everyone is okay with only returning JSON for now. 


Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1943da5b..41030af5 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 d7f71d62..a1c39c12 100644
--- a/canvasapi/account.py
+++ b/canvasapi/account.py
@@ -979,18 +979,16 @@ def get_enabled_features(self, **kwargs):
         :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 e57a88f8..0c3e227c 100644
--- a/canvasapi/course.py
+++ b/canvasapi/course.py
@@ -1127,18 +1127,16 @@ def get_enabled_features(self, **kwargs):
         :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 e3b62f99..6256f705 100644
--- a/canvasapi/user.py
+++ b/canvasapi/user.py
@@ -451,18 +451,16 @@ def get_enabled_features(self, **kwargs):
         :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 0588d649..673581ea 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 6e3f8a97..81b31308 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 557bf330..fc32c5b8 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 7c95f662..9a0d04ef 100644
--- a/tests/test_account.py
+++ b/tests/test_account.py
@@ -1416,8 +1416,8 @@ def test_get_enabled_features(self, m):
 
         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 5dedb2ed..21b483bb 100644
--- a/tests/test_course.py
+++ b/tests/test_course.py
@@ -1687,8 +1687,8 @@ def test_get__enabled_features(self, m):
 
         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 a2197097..c7276029 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -550,8 +550,8 @@ def test_get_enabled_features(self, m):
 
         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):