diff --git a/lms/resources/_js_config/__init__.py b/lms/resources/_js_config/__init__.py index 2a2e4bc6b3..45a905a6ff 100644 --- a/lms/resources/_js_config/__init__.py +++ b/lms/resources/_js_config/__init__.py @@ -189,6 +189,18 @@ def enable_lti_launch_mode(self): "allowedOrigins": self._request.registry.settings["rpc_allowed_origins"] } + def _create_assignment_api(self): + if not self._context.is_canvas: + return None + + return { + "path": self._request.route_path("canvas_api.assignments.create"), + "data": { + "ext_lti_assignment_id": self._request.params["ext_lti_assignment_id"], + "course_id": self._request.params["custom_canvas_course_id"], + }, + } + def enable_content_item_selection_mode(self, form_action, form_fields): """ Put the JavaScript code into "content item selection" mode. @@ -212,6 +224,7 @@ def enable_content_item_selection_mode(self, form_action, form_fields): "filePicker": { "formAction": form_action, "formFields": form_fields, + "createAssignmentAPI": self._create_assignment_api(), # Specific config for pickers "blackboard": FilePickerConfig.blackboard_config(*args), "canvas": FilePickerConfig.canvas_config(*args), diff --git a/lms/routes.py b/lms/routes.py index bde7a8615f..3b50c79079 100644 --- a/lms/routes.py +++ b/lms/routes.py @@ -75,6 +75,9 @@ def includeme(config): ) config.add_route("canvas_api.sync", "/api/canvas/sync", request_method="POST") + config.add_route( + "canvas_api.assignments.create", "/api/canvas/assignment", request_method="POST" + ) config.add_route("lti_api.submissions.record", "/api/lti/submissions") config.add_route("lti_api.result.read", "/api/lti/result", request_method="GET") diff --git a/lms/static/scripts/frontend_apps/config.js b/lms/static/scripts/frontend_apps/config.js index 6918a25b89..e738df27fc 100644 --- a/lms/static/scripts/frontend_apps/config.js +++ b/lms/static/scripts/frontend_apps/config.js @@ -59,6 +59,7 @@ import { createContext } from 'preact'; * @typedef FilePickerConfig * @prop {string} formAction * @prop {Object.} formFields + * @prop {APICallInfo} createAssignmentAPI * @prop {Object} blackboard * @prop {boolean} blackboard.enabled * @prop {APICallInfo} blackboard.listFiles diff --git a/lms/validation/__init__.py b/lms/validation/__init__.py index bcc13aaa81..f926508687 100644 --- a/lms/validation/__init__.py +++ b/lms/validation/__init__.py @@ -44,6 +44,7 @@ def foo_view(request): ... """ from lms.validation._api import ( + APICanvasCreateAssignment, APIReadResultSchema, APIRecordResultSchema, APIRecordSpeedgraderSchema, diff --git a/lms/validation/_api.py b/lms/validation/_api.py index d239084953..ec28241e98 100644 --- a/lms/validation/_api.py +++ b/lms/validation/_api.py @@ -1,6 +1,7 @@ """Schema for JSON APIs exposed to the frontend.""" import marshmallow +from marshmallow import Schema from webargs import fields from lms.validation._base import JSONPyramidRequestSchema, PyramidRequestSchema @@ -69,3 +70,29 @@ class APIRecordResultSchema(JSONPyramidRequestSchema): """ Score — i.e. grade — for this submission. A value between 0 and 1, inclusive. """ + + +class APICanvasCreateAssignment(PyramidRequestSchema): + """Schema for validating assignment creationg requests made by our frontend.""" + + class Content(Schema): + class File(Schema): + display_name = fields.Str(required=True) + id = fields.Integer(required=True) + updated_at = fields.String(required=True) + size = fields.Integer(required=True) + + type = fields.Str(required=True) + url = fields.Str(required=False, allow_none=True) + file = fields.Nested(File, required=False, allow_none=True) + + ext_lti_assignment_id = fields.Str(required=True) + """Canvas only assignment unique identifier""" + + course_id = fields.Integer(required=True) + """Course ID for the assignment. We'd need it to construct the document_url""" + + groupset = fields.Integer(required=False, allow_none=True) + """Groupset the assignment belongs to if created as a smalls groups assignment""" + + content = fields.Nested(Content, required=True) diff --git a/lms/views/api/canvas/assignments.py b/lms/views/api/canvas/assignments.py new file mode 100644 index 0000000000..7852f439cf --- /dev/null +++ b/lms/views/api/canvas/assignments.py @@ -0,0 +1,50 @@ +from pyramid.view import view_config, view_defaults + +from lms.security import Permissions +from lms.validation import APICanvasCreateAssignment + + +@view_defaults(request_method="POST", renderer="json", permission=Permissions.API) +class AssignmentsAPIViews: + def __init__(self, request): + self.request = request + self.assignment_service = request.find_service(name="assignment") + self.application_instance = request.find_service( + name="application_instance" + ).get() + + @view_config( + route_name="canvas_api.assignments.create", + request_method="POST", + schema=APICanvasCreateAssignment, + ) + def create(self): + """ + Create an assignment in the DB. + + Note that at this point the assignment.resource_link_id == None, it will be filled on first launch. + """ + params = self.request.parsed_params + content = params["content"] + content_type = content["type"] + + url = None + extra = {} + if content_type == "url": + url = content["url"] + elif content_type == "file": + url = f"canvas://file/course/{params['course_id']}/file_id/{params['content']['file']['id']}" + extra = {"canvas_file": params["content"]["file"]} + else: + raise ValueError("Unhandled content type on assignment") + + if groupset := params.get("groupset"): + extra["canvas_groupset"] = groupset + + assignment = self.assignment_service.set_document_url( + self.application_instance.tool_consumer_instance_guid, + url, + ext_lti_assignment_id=params["ext_lti_assignment_id"], + extra=extra, + ) + return {"ext_lti_assignment_id": assignment.ext_lti_assignment_id} diff --git a/tests/factories/assignment.py b/tests/factories/assignment.py index 4d442a62c5..9183b1d621 100644 --- a/tests/factories/assignment.py +++ b/tests/factories/assignment.py @@ -10,4 +10,5 @@ resource_link_id=RESOURCE_LINK_ID, tool_consumer_instance_guid=TOOL_CONSUMER_INSTANCE_GUID, document_url=Faker("uri"), + ext_lti_assignment_id=None, ) diff --git a/tests/unit/lms/resources/_js_config/__init___test.py b/tests/unit/lms/resources/_js_config/__init___test.py index bffe938304..0ca38f51cf 100644 --- a/tests/unit/lms/resources/_js_config/__init___test.py +++ b/tests/unit/lms/resources/_js_config/__init___test.py @@ -69,6 +69,46 @@ def test_it_adds_picker_config( application_instance_service.get.return_value, ) + def test_with_create_assignment_api(self, js_config, context): + context.is_canvas = True + + js_config.enable_content_item_selection_mode( + mock.sentinel.form_action, mock.sentinel.form_fields + ) + config = js_config.asdict() + + assert config == Any.dict.containing( + { + "mode": "content-item-selection", + "filePicker": Any.dict.containing( + { + "createAssignmentAPI": { + "path": "/api/canvas/assignment", + "data": { + "ext_lti_assignment_id": "ext_lti_assignment_id", + "course_id": "test_course_id", + }, + } + } + ), + } + ) + + def test_with_create_assignment_api_non_canvas(self, js_config, context): + context.is_canvas = False + + js_config.enable_content_item_selection_mode( + mock.sentinel.form_action, mock.sentinel.form_fields + ) + config = js_config.asdict() + + assert config == Any.dict.containing( + { + "mode": "content-item-selection", + "filePicker": Any.dict.containing({"createAssignmentAPI": None}), + } + ) + @pytest.fixture(autouse=True) def FilePickerConfig(self, patch): return patch("lms.resources._js_config.FilePickerConfig") @@ -710,6 +750,7 @@ def pyramid_request(pyramid_request): "context_id": "test_course_id", "custom_canvas_course_id": "test_course_id", "custom_canvas_user_id": "test_user_id", + "ext_lti_assignment_id": "ext_lti_assignment_id", } ) return pyramid_request diff --git a/tests/unit/lms/views/api/canvas/assignments_test.py b/tests/unit/lms/views/api/canvas/assignments_test.py new file mode 100644 index 0000000000..b7954404a4 --- /dev/null +++ b/tests/unit/lms/views/api/canvas/assignments_test.py @@ -0,0 +1,99 @@ +import pytest + +from lms.views.api.canvas.assignments import AssignmentsAPIViews + + +class TestAssignmentsAPIViews: + @pytest.mark.parametrize( + "request_body,expected_url,expected_extra", + [ + ( + { + "content": {"type": "url", "url": "https://example.com"}, + "ext_lti_assignment_id": "EXT_LTI_ASSIGNMENT_ID", + }, + "https://example.com", + {}, + ), + ( + { + "content": {"type": "url", "url": "https://example.com"}, + "ext_lti_assignment_id": "EXT_LTI_ASSIGNMENT_ID", + "groupset": 125, + }, + "https://example.com", + {"canvas_groupset": 125}, + ), + ( + { + "content": { + "type": "url", + "url": "https://drive.google.com/uc?id=DRIVE_ID&export=download", + }, + "ext_lti_assignment_id": "EXT_LTI_ASSIGNMENT_ID", + }, + "https://drive.google.com/uc?id=DRIVE_ID&export=download", + {}, + ), + ( + { + "content": { + "type": "file", + "file": { + "size": 205792, + "display_name": "A Third File.pdf", + "id": 652, + "updated_at": "2021-05-17T15:15:47Z", + }, + }, + "ext_lti_assignment_id": "EXT_LTI_ASSIGNMENT_ID", + "course_id": "COURSE_ID", + }, + "canvas://file/course/COURSE_ID/file_id/652", + { + "canvas_file": { + "size": 205792, + "display_name": "A Third File.pdf", + "id": 652, + "updated_at": "2021-05-17T15:15:47Z", + } + }, + ), + ], + ) + def test_create( + self, + application_instance_service, + assignment_service, + pyramid_request, + request_body, + expected_url, + expected_extra, + ): + pyramid_request.parsed_params = request_body + + result = AssignmentsAPIViews(pyramid_request).create() + + assignment_service.set_document_url.assert_called_once_with( + application_instance_service.get.return_value.tool_consumer_instance_guid, + expected_url, + ext_lti_assignment_id="EXT_LTI_ASSIGNMENT_ID", + extra=expected_extra, + ) + assert result == { + "ext_lti_assignment_id": assignment_service.set_document_url.return_value.ext_lti_assignment_id + } + + @pytest.mark.usefixtures("application_instance_service", "assignment_service") + def test_create_unknown_type( + self, + pyramid_request, + ): + pyramid_request.parsed_params = { + "content": {"type": "nothing"}, + "ext_lti_assignment_id": "EXT_LTI_ASSIGNMENT_ID", + "course_id": "COURSE_ID", + } + + with pytest.raises(ValueError): + AssignmentsAPIViews(pyramid_request).create()