diff --git a/lms/product/canvas/_plugin/misc.py b/lms/product/canvas/_plugin/misc.py index 9ecb74e8f9..c5c6f87ccd 100644 --- a/lms/product/canvas/_plugin/misc.py +++ b/lms/product/canvas/_plugin/misc.py @@ -70,6 +70,9 @@ def get_assignment_configuration( if deep_linked_config.get("checkpoint_enabled") in ("true", True): assignment_config["checkpoint_enabled"] = True + if due_date := deep_linked_config.get("due_date"): + assignment_config["due_date"] = due_date + return assignment_config @lru_cache(1) # noqa: B019 @@ -154,6 +157,7 @@ def get_deep_linked_assignment_configuration(self, request) -> dict: "group_set", "auto_grading_config", "checkpoint_enabled", + "due_date", # VS, legacy method "vitalsource_book", "book_id", diff --git a/lms/product/plugin/misc.py b/lms/product/plugin/misc.py index 3d9cfdad2b..c25bcff885 100644 --- a/lms/product/plugin/misc.py +++ b/lms/product/plugin/misc.py @@ -1,4 +1,5 @@ import json +from datetime import datetime from typing import TYPE_CHECKING, NotRequired, TypedDict from pyramid.request import Request @@ -16,6 +17,8 @@ class AssignmentConfig(TypedDict): group_set_id: str | None auto_grading_config: NotRequired[AutoGradingConfig | None] checkpoint_enabled: NotRequired[bool] + # datetime from the DB; ISO string from deep-linked params. + due_date: NotRequired[datetime | str] class DeepLinkingPromptForGradableMixin: @@ -127,6 +130,7 @@ def get_deep_linked_assignment_configuration(self, request) -> dict: "deep_linking_uuid", "auto_grading_config", "checkpoint_enabled", + "due_date", ] for param in possible_parameters: @@ -153,6 +157,9 @@ def _assignment_config_from_assignment(assignment: Assignment) -> AssignmentConf if assignment.checkpoint_enabled: config["checkpoint_enabled"] = True + if assignment.due_date: + config["due_date"] = assignment.due_date + return config @staticmethod @@ -171,4 +178,7 @@ def _assignment_config_from_deep_linked_config( if deep_linked_config.get("checkpoint_enabled") in ("true", True): config["checkpoint_enabled"] = True + if due_date := deep_linked_config.get("due_date"): + config["due_date"] = due_date + return config diff --git a/lms/services/assignment.py b/lms/services/assignment.py index 0eb7b42ae9..506a2595b4 100644 --- a/lms/services/assignment.py +++ b/lms/services/assignment.py @@ -1,5 +1,6 @@ import logging from collections.abc import Sequence +from datetime import UTC, datetime from sqlalchemy import Select, func, select, text from sqlalchemy.orm import Session @@ -63,6 +64,7 @@ def update_assignment( # noqa: PLR0913 course: Course, auto_grading_config: dict | None = None, checkpoint_enabled: bool = False, # noqa: FBT001, FBT002 + due_date: str | datetime | None = None, ): """Update an existing assignment.""" if self._misc_plugin.is_speed_grader_launch(request): @@ -96,11 +98,26 @@ def update_assignment( # noqa: PLR0913 ) assignment.course_id = course.id + assignment.due_date = self._normalize_due_date(due_date) self._update_auto_grading_config(assignment, auto_grading_config) self._update_checkpoint(assignment, checkpoint_enabled) return assignment + @staticmethod + def _normalize_due_date(due_date: str | datetime | None) -> datetime | None: + """Parse a due date (ISO string or datetime) to naive UTC. + + Matches the naive `due_date` column and lms's `utcnow()` comparisons. + """ + if due_date is None: + return None + if isinstance(due_date, str): + due_date = datetime.fromisoformat(due_date) + if due_date.tzinfo is not None: + due_date = due_date.astimezone(UTC).replace(tzinfo=None) + return due_date + def _get_copied_from_assignment(self, lti_params) -> Assignment | None: """Return the assignment that the current assignment was copied from.""" @@ -154,6 +171,7 @@ def get_assignment_for_launch(self, request, course: Course) -> Assignment | Non group_set_id = assignment_config.get("group_set_id") auto_grading_config = assignment_config.get("auto_grading_config") checkpoint_enabled = assignment_config.get("checkpoint_enabled", False) + due_date = assignment_config.get("due_date") if not document_url: # We can't find a document_url, we shouldn't try to create an @@ -191,6 +209,7 @@ def get_assignment_for_launch(self, request, course: Course) -> Assignment | Non course, auto_grading_config, checkpoint_enabled=checkpoint_enabled, + due_date=due_date, ) def upsert_assignment_membership( diff --git a/lms/validation/_lti_launch_params.py b/lms/validation/_lti_launch_params.py index 405f1ec83b..b1cc77aa01 100644 --- a/lms/validation/_lti_launch_params.py +++ b/lms/validation/_lti_launch_params.py @@ -191,6 +191,7 @@ class ConfigureAssignmentSchema(_CommonLTILaunchSchema): truthy={"true", "1"}, falsy={"false", "0", ""}, ) + due_date = fields.Str(required=False, load_default=None, allow_none=True) @pre_load def _load_auto_grading_config(self, data, **_kwargs): diff --git a/lms/views/lti/basic_launch.py b/lms/views/lti/basic_launch.py index ee36a93aa8..6f342b9aaa 100644 --- a/lms/views/lti/basic_launch.py +++ b/lms/views/lti/basic_launch.py @@ -352,6 +352,7 @@ def _configure_assignment(self, assignment): checkpoint_enabled=self.request.parsed_params.get( "checkpoint_enabled", False ), + due_date=self.request.parsed_params.get("due_date"), ) def _configure_js_for_file_picker( diff --git a/lms/views/lti/deep_linking.py b/lms/views/lti/deep_linking.py index b4e150558b..1f1d3d87ce 100644 --- a/lms/views/lti/deep_linking.py +++ b/lms/views/lti/deep_linking.py @@ -104,6 +104,8 @@ class DeepLinkingFieldsRequestSchema(JSONPyramidRequestSchema): AutoGradingConfigSchema, required=False, allow_none=True ) checkpoint_enabled = fields.Bool(required=False, load_default=False) + # ISO 8601 string, normalised to naive UTC on persist. + due_date = fields.Str(required=False, allow_none=True) class LTI11DeepLinkingFieldsRequestSchema(DeepLinkingFieldsRequestSchema): @@ -284,6 +286,9 @@ def _get_assignment_configuration(request) -> dict: if request.parsed_params.get("checkpoint_enabled"): params["checkpoint_enabled"] = "true" + if due_date := request.parsed_params.get("due_date"): + params["due_date"] = due_date + if content["type"] == "url": params["url"] = content["url"] else: diff --git a/tests/functional/views/lti/basic_lti_launch_test.py b/tests/functional/views/lti/basic_lti_launch_test.py index 903ebc12b1..f160211fbe 100644 --- a/tests/functional/views/lti/basic_lti_launch_test.py +++ b/tests/functional/views/lti/basic_lti_launch_test.py @@ -1,4 +1,5 @@ import time +from datetime import datetime from urllib.parse import urlencode import oauthlib.common @@ -84,6 +85,27 @@ def test_basic_lti_launch_canvas_deep_linking_canvas_file( == 1 ) + def test_basic_lti_launch_persists_due_date( + self, do_lti_launch, db_session, lti_params, sign_lti_params + ): + post_params = sign_lti_params( + dict( + lti_params, + url="https://due-date.com/document.pdf", + due_date="2026-07-01T12:00:00+00:00", + tool_consumer_info_product_family_code="canvas", + ) + ) + + do_lti_launch(post_params=post_params, status=200) + + assignment = ( + db_session.query(Assignment) + .filter_by(document_url="https://due-date.com/document.pdf") + .one() + ) + assert assignment.due_date == datetime(2026, 7, 1, 12, 0, 0) # noqa: DTZ001 + @pytest.fixture def assignment(self, db_session, application_instance, lti_params): assignment = Assignment( diff --git a/tests/functional/views/lti/deep_linking_test.py b/tests/functional/views/lti/deep_linking_test.py index 05a8847c34..35d2133de8 100644 --- a/tests/functional/views/lti/deep_linking_test.py +++ b/tests/functional/views/lti/deep_linking_test.py @@ -118,6 +118,24 @@ def test_file_picker_to_form_fields_v11( ], } + def test_file_picker_to_form_fields_v11_with_due_date( + self, app, authorization_param + ): + response = app.post_json( + "/lti/1.1/deep_linking/form_fields", + params={ + "content_item_return_url": "https://apps.imsglobal.org/lti/cert/tp/tp_return.php/basic-lti-launch-request", + "content": {"type": "url", "url": "https://example.com"}, + "due_date": "2026-07-01T12:00:00+00:00", + }, + headers={"Authorization": f"Bearer {authorization_param}"}, + status=200, + ) + + content_items = json.loads(response.json["content_items"]) + custom = content_items["@graph"][0]["custom"] + assert custom["due_date"] == "2026-07-01T12:00:00+00:00" + @pytest.fixture def lti_user(self, application_instance, lti_params): return factories.LTIUser( diff --git a/tests/unit/lms/product/canvas/_plugin/misc_test.py b/tests/unit/lms/product/canvas/_plugin/misc_test.py index 1c4e9f1c11..6fec7f8029 100644 --- a/tests/unit/lms/product/canvas/_plugin/misc_test.py +++ b/tests/unit/lms/product/canvas/_plugin/misc_test.py @@ -73,6 +73,15 @@ def test_get_assignment_configuration_with_checkpoint( assert config["checkpoint_enabled"] is True + def test_get_assignment_configuration_with_due_date(self, plugin, pyramid_request): + pyramid_request.params["due_date"] = "2026-07-01T12:00:00+00:00" + + config = plugin.get_assignment_configuration( + pyramid_request, sentinel.assignment, sentinel.historical_assignment + ) + + assert config["due_date"] == "2026-07-01T12:00:00+00:00" + def test_get_assignment_configuration(self, plugin, pyramid_request): config = plugin.get_assignment_configuration( pyramid_request, sentinel.assignment, sentinel.historical_assignment @@ -189,7 +198,9 @@ def test_get_deeplinking_launch_url(self, plugin, pyramid_request): == "http://example.com/lti_launches?param=value" ) - @pytest.mark.parametrize("parameter", ["group_set", "auto_grading_config", "url"]) + @pytest.mark.parametrize( + "parameter", ["group_set", "auto_grading_config", "url", "due_date"] + ) @pytest.mark.parametrize("request_param", (None, sentinel.from_url)) @pytest.mark.parametrize("custom_param", (None, sentinel.from_custom)) def test_get_deep_linked_assignment_configuration( diff --git a/tests/unit/lms/product/plugin/misc_test.py b/tests/unit/lms/product/plugin/misc_test.py index 407133e013..62cf40abb5 100644 --- a/tests/unit/lms/product/plugin/misc_test.py +++ b/tests/unit/lms/product/plugin/misc_test.py @@ -1,4 +1,5 @@ import json +from datetime import datetime from unittest.mock import patch, sentinel import pytest @@ -101,6 +102,33 @@ def test_get_assignment_configuration_with_checkpoint_in_deep_linked_configurati is True ) + def test_get_assignment_configuration_with_due_date_in_existing_db_assignment( + self, plugin, pyramid_request + ): + assignment = factories.Assignment( + document_url=sentinel.document_url, + due_date=datetime(2026, 7, 1, 12, 0, 0), # noqa: DTZ001 + ) + pyramid_request.lti_params["resource_link_id"] = sentinel.link_id + + result = plugin.get_assignment_configuration(pyramid_request, assignment, None) + + assert result["due_date"] == datetime(2026, 7, 1, 12, 0, 0) # noqa: DTZ001 + + def test_get_assignment_configuration_with_due_date_in_deep_linked_configuration( + self, plugin, get_deep_linked_assignment_configuration + ): + get_deep_linked_assignment_configuration.return_value = { + "due_date": "2026-07-01T12:00:00+00:00" + } + + assert ( + plugin.get_assignment_configuration(sentinel.request, None, None)[ + "due_date" + ] + == "2026-07-01T12:00:00+00:00" + ) + def test_get_assignment_configuration_with_assignment_in_db_copied_assignment( self, plugin, pyramid_request ): @@ -150,6 +178,7 @@ def test_get_deeplinking_launch_url(self, plugin, pyramid_request): {"auto_grading_config": sentinel.auto_grading_config}, ), ({"group_set": sentinel.group_set}, {"group_set": sentinel.group_set}), + ({"due_date": sentinel.due_date}, {"due_date": sentinel.due_date}), ({"other_param": sentinel.other_param}, {}), ], ) diff --git a/tests/unit/lms/services/assignment_test.py b/tests/unit/lms/services/assignment_test.py index 566204e43a..938a26dc29 100644 --- a/tests/unit/lms/services/assignment_test.py +++ b/tests/unit/lms/services/assignment_test.py @@ -182,6 +182,32 @@ def test_update_assignment_keeps_existing_checkpoint( assert assignment.checkpoint_enabled is True + @pytest.mark.parametrize( + "due_date,expected", + [ + (None, None), + ("2026-07-01T12:00:00", datetime(2026, 7, 1, 12, 0, 0)), # noqa: DTZ001 + ("2026-07-01T12:00:00+02:00", datetime(2026, 7, 1, 10, 0, 0)), # noqa: DTZ001 + ( + datetime(2026, 7, 1, 12, 0, 0), # noqa: DTZ001 + datetime(2026, 7, 1, 12, 0, 0), # noqa: DTZ001 + ), + ], + ) + def test_update_assignment_with_due_date( + self, svc, pyramid_request, course, due_date, expected + ): + assignment = svc.update_assignment( + pyramid_request, + factories.Assignment(), + sentinel.document_url, + sentinel.group_set_id, + course, + due_date=due_date, + ) + + assert assignment.due_date == expected + @pytest.mark.parametrize( "param", ( @@ -251,6 +277,26 @@ def test_get_assignment_for_launch_existing( assert assignment.is_gradable == misc_plugin.is_assignment_gradable.return_value assert assignment.course_id == course.id + def test_get_assignment_for_launch_sets_due_date( + self, + pyramid_request, + svc, + misc_plugin, + get_assignment, + _get_copied_from_assignment, # noqa: PT019 + course, + ): + misc_plugin.get_assignment_configuration.return_value = { + "document_url": sentinel.document_url, + "group_set_id": sentinel.group_set_id, + "due_date": "2026-07-01T12:00:00+00:00", + } + get_assignment.return_value = factories.Assignment() + + assignment = svc.get_assignment_for_launch(pyramid_request, course) + + assert assignment.due_date == datetime(2026, 7, 1, 12, 0, 0) # noqa: DTZ001 + def test_get_assignment_returns_None_with_when_no_document( self, pyramid_request, svc, misc_plugin, course ): diff --git a/tests/unit/lms/views/lti/basic_launch_test.py b/tests/unit/lms/views/lti/basic_launch_test.py index a5e3c12a03..be45901229 100644 --- a/tests/unit/lms/views/lti/basic_launch_test.py +++ b/tests/unit/lms/views/lti/basic_launch_test.py @@ -81,6 +81,7 @@ def test_configure_assignment_callback( course=course_service.get_from_launch.return_value, auto_grading_config=sentinel.auto_grading_config, checkpoint_enabled=False, + due_date=None, ) _show_document.assert_called_once_with( assignment_service.create_assignment.return_value, diff --git a/tests/unit/lms/views/lti/deep_linking_test.py b/tests/unit/lms/views/lti/deep_linking_test.py index cf49cae704..3d71556ff6 100644 --- a/tests/unit/lms/views/lti/deep_linking_test.py +++ b/tests/unit/lms/views/lti/deep_linking_test.py @@ -295,6 +295,10 @@ def test_it_for_v11( {"auto_grading_config": '{"key": "value"}'}, ), ({"checkpoint_enabled": True}, {"checkpoint_enabled": "true"}), + ( + {"due_date": "2026-07-01T12:00:00+00:00"}, + {"due_date": "2026-07-01T12:00:00+00:00"}, + ), ], ) def test__get_assignment_configuration(