Repository: ucfopen/canvasapi

Issue:
Wrap “Grade or comment on multiple submissions” methods
The Canvas REST API offers [a suite of "Grade or comment on multiple submissions" methods](https://canvas.instructure.com/doc/api/submissions.html#method.submissions_api.bulk_update) that `canvasapi` 0.9.0 does not yet wrap. I recently had cause to use one of these methods, so proper `canvasapi` versions would be appreciated.

In my particular case I need to update grades for multiple assignments by multiple students. That presumably makes the `POST /api/v1/courses/:course_id/submissions/update_grades` entry point the right thing to use, although it is not clear to me whether one can indeed update multiple assignments for multiple students all in a single call.

Discussion:

Patch:
diff --git a/canvasapi/assignment.py b/canvasapi/assignment.py
index bbc4bda..03d1b56 100644
--- a/canvasapi/assignment.py
+++ b/canvasapi/assignment.py
@@ -5,6 +5,7 @@ from six import python_2_unicode_compatible
 from canvasapi.canvas_object import CanvasObject
 from canvasapi.exceptions import RequiredFieldMissing
 from canvasapi.paginated_list import PaginatedList
+from canvasapi.progress import Progress
 from canvasapi.submission import Submission
 from canvasapi.user import UserDisplay
 from canvasapi.util import combine_kwargs, obj_or_id
@@ -144,6 +145,27 @@ class Assignment(CanvasObject):
 
         return Submission(self._requester, response_json)
 
+    def submissions_bulk_update(self, **kwargs):
+        """
+        Update the grading and comments on multiple student's assignment
+        submissions in an asynchronous job.
+
+        :calls: `POST /api/v1/courses/:course_id/assignments/:assignment_id/ \
+            submissions/update_grades \
+        <https://canvas.instructure.com/doc/api/submissions.html#method.submissions_api.bulk_update>`_
+
+        :rtype: :class:`canvasapi.progress.Progress`
+        """
+        response = self._requester.request(
+            'POST',
+            'courses/{}/assignments/{}/submissions/update_grades'.format(
+                self.course_id,
+                self.id
+            ),
+            _kwargs=combine_kwargs(**kwargs)
+        )
+        return Progress(self._requester, response.json())
+
 
 @python_2_unicode_compatible
 class AssignmentGroup(CanvasObject):
diff --git a/canvasapi/course.py b/canvasapi/course.py
index 725506c..5a6e6aa 100644
--- a/canvasapi/course.py
+++ b/canvasapi/course.py
@@ -11,6 +11,7 @@ from canvasapi.exceptions import RequiredFieldMissing
 from canvasapi.folder import Folder
 from canvasapi.page import Page
 from canvasapi.paginated_list import PaginatedList
+from canvasapi.progress import Progress
 from canvasapi.tab import Tab
 from canvasapi.submission import Submission
 from canvasapi.upload import Uploader
@@ -2143,6 +2144,25 @@ class Course(CanvasObject):
             _kwargs=combine_kwargs(**kwargs)
         )
 
+    def submissions_bulk_update(self, **kwargs):
+        """
+        Update the grading and comments on multiple student's assignment
+        submissions in an asynchronous job.
+
+        :calls: `POST /api/v1/courses/:course_id/submissions/update_grades \
+        <https://canvas.instructure.com/doc/api/submissions.html#method.submissions_api.bulk_update>`_
+
+        :rtype: :class:`canvasapi.progress.Progress`
+        """
+        response = self._requester.request(
+            'POST',
+            'courses/{}/submissions/update_grades'.format(
+                self.id
+            ),
+            _kwargs=combine_kwargs(**kwargs)
+        )
+        return Progress(self._requester, response.json())
+
 
 @python_2_unicode_compatible
 class CourseNickname(CanvasObject):
diff --git a/canvasapi/section.py b/canvasapi/section.py
index 7a9a95f..0d22736 100644
--- a/canvasapi/section.py
+++ b/canvasapi/section.py
@@ -5,6 +5,7 @@ from six import python_2_unicode_compatible
 
 from canvasapi.canvas_object import CanvasObject
 from canvasapi.paginated_list import PaginatedList
+from canvasapi.progress import Progress
 from canvasapi.submission import Submission
 from canvasapi.util import combine_kwargs, obj_or_id
 
@@ -373,3 +374,22 @@ class Section(CanvasObject):
             'user_id': user_id
         })
         return submission.mark_unread(**kwargs)
+
+    def submissions_bulk_update(self, **kwargs):
+        """
+        Update the grading and comments on multiple student's assignment
+        submissions in an asynchronous job.
+
+        :calls: `POST /api/v1/sections/:section_id/submissions/update_grades \
+        <https://canvas.instructure.com/doc/api/submissions.html#method.submissions_api.bulk_update>`_
+
+        :rtype: :class:`canvasapi.progress.Progress`
+        """
+        response = self._requester.request(
+            'POST',
+            'sections/{}/submissions/update_grades'.format(
+                self.id
+            ),
+            _kwargs=combine_kwargs(**kwargs)
+        )
+        return Progress(self._requester, response.json())


Test patch:
diff --git a/tests/fixtures/assignment.json b/tests/fixtures/assignment.json
index 47f38b6..aec1393 100644
--- a/tests/fixtures/assignment.json
+++ b/tests/fixtures/assignment.json
@@ -84,5 +84,22 @@
 			"submission_type": "online_upload"
 		},
 		"status_code": 200
+	},
+	"update_submissions": {
+		"method": "POST",
+		"endpoint": "courses/1/assignments/1/submissions/update_grades",
+		"data": {
+			"id": 3,
+			"context_id": 1,
+			"context_type": "Course",
+			"user_id": null,
+			"tag": "submissions_update",
+			"completion": null,
+			"workflow_state": "queued",
+			"updated_at": "2013-01-15T15:04:00Z",
+			"message": null,
+			"url": "https://canvas.example.edu/api/v1/progress/3"
+		},
+		"status_code": 200
 	}
 }
diff --git a/tests/fixtures/course.json b/tests/fixtures/course.json
index 2f30d67..9f1f08e 100644
--- a/tests/fixtures/course.json
+++ b/tests/fixtures/course.json
@@ -1458,5 +1458,22 @@
 			}
 		],
 		"status_code": 200
+	},
+	"update_submissions": {
+		"method": "POST",
+		"endpoint": "courses/1/submissions/update_grades",
+		"data": {
+			"id": 3,
+			"context_id": 1,
+			"context_type": "Course",
+			"user_id": null,
+			"tag": "submissions_update",
+			"completion": null,
+			"workflow_state": "queued",
+			"updated_at": "2013-01-15T15:04:00Z",
+			"message": null,
+			"url": "https://canvas.example.edu/api/v1/progress/3"
+		},
+		"status_code": 200
 	}
 }
diff --git a/tests/fixtures/progress.json b/tests/fixtures/progress.json
index 8642747..3be42a0 100644
--- a/tests/fixtures/progress.json
+++ b/tests/fixtures/progress.json
@@ -16,5 +16,21 @@
 			"url": "https://canvas.example.edu/api/v1/progress/1"
 		},
 		"status_code": 200
+	},
+	"course_progress": {
+		"method": "GET",
+		"endpoint": "progress/3",
+		"data":{
+			"id": 3,
+			"context_id": 1,
+			"context_type": "Course",
+			"user_id": null,
+			"tag": "submissions_update",
+			"completion": 100,
+			"workflow_state": "completed",
+			"updated_at": "2013-01-15T15:04:00Z",
+			"message": null,
+			"url": "https://canvas.example.edu/api/v1/progress/3"
+		}
 	}
-}
\ No newline at end of file
+}
diff --git a/tests/fixtures/section.json b/tests/fixtures/section.json
index 19c860b..c4caecb 100644
--- a/tests/fixtures/section.json
+++ b/tests/fixtures/section.json
@@ -182,5 +182,22 @@
 		"method": "DELETE",
 		"endpoint": "sections/1/assignments/1/submissions/1/read",
 		"status_code": 204
+	},
+	"update_submissions": {
+		"method": "POST",
+		"endpoint": "sections/1/submissions/update_grades",
+		"data": {
+			"id": 3,
+			"context_id": 1,
+			"context_type": "Course",
+			"user_id": null,
+			"tag": "submissions_update",
+			"completion": null,
+			"workflow_state": "queued",
+			"updated_at": "2013-01-15T15:04:00Z",
+			"message": null,
+			"url": "https://canvas.example.edu/api/v1/progress/3"
+		},
+		"status_code": 200
 	}
 }
diff --git a/tests/test_assignment.py b/tests/test_assignment.py
index e0adc34..e5fec7c 100644
--- a/tests/test_assignment.py
+++ b/tests/test_assignment.py
@@ -6,6 +6,7 @@ import requests_mock
 from canvasapi import Canvas
 from canvasapi.assignment import Assignment, AssignmentGroup
 from canvasapi.exceptions import RequiredFieldMissing
+from canvasapi.progress import Progress
 from canvasapi.submission import Submission
 from canvasapi.user import UserDisplay
 from tests import settings
@@ -101,6 +102,23 @@ class TestAssignment(unittest.TestCase):
         string = str(self.assignment)
         self.assertIsInstance(string, str)
 
+    # submissions_bulk_update()
+    def test_submissions_bulk_update(self, m):
+        register_uris({'assignment': ['update_submissions']}, m)
+        register_uris({'progress': ['course_progress']}, m)
+        progress = self.assignment.submissions_bulk_update(grade_data={
+            '1': {
+                'posted_grade': 97
+             },
+            '2': {
+                'posted_grade': 98
+            }
+        })
+        self.assertIsInstance(progress, Progress)
+        self.assertTrue(progress.context_type == "Course")
+        progress = progress.query()
+        self.assertTrue(progress.context_type == "Course")
+
 
 @requests_mock.Mocker()
 class TestAssignmentGroup(unittest.TestCase):
diff --git a/tests/test_course.py b/tests/test_course.py
index e6e728d..48053dc 100644
--- a/tests/test_course.py
+++ b/tests/test_course.py
@@ -22,6 +22,7 @@ from canvasapi.folder import Folder
 from canvasapi.group import Group, GroupCategory
 from canvasapi.module import Module
 from canvasapi.outcome import OutcomeGroup, OutcomeLink
+from canvasapi.progress import Progress
 from canvasapi.quiz import Quiz
 from canvasapi.rubric import Rubric
 from canvasapi.section import Section
@@ -1387,6 +1388,25 @@ class TestCourse(unittest.TestCase):
         self.assertEqual(migration_systems[1].requires_file_upload, False)
         self.assertEqual(migration_systems[1].name, "Dummy Importer 02")
 
+    # submissions_bulk_update()
+    def test_submissions_bulk_update(self, m):
+        register_uris({'course': ['update_submissions']}, m)
+        register_uris({'progress': ['course_progress']}, m)
+        progress = self.course.submissions_bulk_update(grade_data={
+            '1': {
+                '1': {
+                    'posted_grade': 97
+                },
+                '2': {
+                    'posted_grade': 98
+                }
+            }
+        })
+        self.assertIsInstance(progress, Progress)
+        self.assertTrue(progress.context_type == "Course")
+        progress = progress.query()
+        self.assertTrue(progress.context_type == "Course")
+
 
 @requests_mock.Mocker()
 class TestCourseNickname(unittest.TestCase):
diff --git a/tests/test_section.py b/tests/test_section.py
index 92b500c..eef7694 100644
--- a/tests/test_section.py
+++ b/tests/test_section.py
@@ -8,6 +8,7 @@ from six import text_type
 from canvasapi import Canvas
 from canvasapi.enrollment import Enrollment
 from canvasapi.exceptions import RequiredFieldMissing
+from canvasapi.progress import Progress
 from canvasapi.section import Section
 from canvasapi.submission import Submission
 from tests import settings
@@ -319,3 +320,21 @@ class TestSection(unittest.TestCase):
 
             self.assertEqual(len(warning_list), 1)
             self.assertEqual(warning_list[-1].category, DeprecationWarning)
+
+    def test_submissions_bulk_update(self, m):
+        register_uris({'section': ['update_submissions']}, m)
+        register_uris({'progress': ['course_progress']}, m)
+        progress = self.section.submissions_bulk_update(grade_data={
+            '1': {
+                '1': {
+                    'posted_grade': 97
+                },
+                '2': {
+                    'posted_grade': 98
+                }
+            }
+        })
+        self.assertIsInstance(progress, Progress)
+        self.assertTrue(progress.context_type == "Course")
+        progress = progress.query()
+        self.assertTrue(progress.context_type == "Course")