diff --git a/lms/product/canvas/_plugin/misc.py b/lms/product/canvas/_plugin/misc.py index c3318dfc7a..9ecb74e8f9 100644 --- a/lms/product/canvas/_plugin/misc.py +++ b/lms/product/canvas/_plugin/misc.py @@ -59,14 +59,17 @@ def get_assignment_configuration( group_set_id=request.params.get("group_set"), ) - if auto_grading_config := self.get_deep_linked_assignment_configuration( - request - ).get("auto_grading_config"): + deep_linked_config = self.get_deep_linked_assignment_configuration(request) + + if auto_grading_config := deep_linked_config.get("auto_grading_config"): # Auto grading is a complex structure, deserialize it beforehand assignment_config["auto_grading_config"] = cast( "AutoGradingConfig", json.loads(auto_grading_config) ) + if deep_linked_config.get("checkpoint_enabled") in ("true", True): + assignment_config["checkpoint_enabled"] = True + return assignment_config @lru_cache(1) # noqa: B019 @@ -150,6 +153,7 @@ def get_deep_linked_assignment_configuration(self, request) -> dict: possible_parameters = [ "group_set", "auto_grading_config", + "checkpoint_enabled", # VS, legacy method "vitalsource_book", "book_id", diff --git a/lms/product/plugin/misc.py b/lms/product/plugin/misc.py index 221793959b..3d9cfdad2b 100644 --- a/lms/product/plugin/misc.py +++ b/lms/product/plugin/misc.py @@ -15,6 +15,7 @@ class AssignmentConfig(TypedDict): document_url: str | None group_set_id: str | None auto_grading_config: NotRequired[AutoGradingConfig | None] + checkpoint_enabled: NotRequired[bool] class DeepLinkingPromptForGradableMixin: @@ -125,6 +126,7 @@ def get_deep_linked_assignment_configuration(self, request) -> dict: "group_set", "deep_linking_uuid", "auto_grading_config", + "checkpoint_enabled", ] for param in possible_parameters: @@ -148,6 +150,9 @@ def _assignment_config_from_assignment(assignment: Assignment) -> AssignmentConf if auto_grading_config := assignment.auto_grading_config: config["auto_grading_config"] = auto_grading_config.asdict() + if assignment.checkpoint_enabled: + config["checkpoint_enabled"] = True + return config @staticmethod @@ -163,4 +168,7 @@ def _assignment_config_from_deep_linked_config( if auto_grading_config := deep_linked_config.get("auto_grading_config"): config["auto_grading_config"] = json.loads(auto_grading_config) + if deep_linked_config.get("checkpoint_enabled") in ("true", True): + config["checkpoint_enabled"] = True + return config diff --git a/lms/resources/_js_config/__init__.py b/lms/resources/_js_config/__init__.py index ba1f304971..d2d70932d5 100644 --- a/lms/resources/_js_config/__init__.py +++ b/lms/resources/_js_config/__init__.py @@ -1,6 +1,6 @@ import functools import re -from datetime import timedelta +from datetime import UTC, timedelta from enum import Enum, StrEnum from typing import Any from urllib.parse import urlparse @@ -471,6 +471,43 @@ def enable_instructor_dashboard_entry_point(self, assignment): } self._config["hypothesisClient"] = self._hypothesis_client + def enable_toolbar_checkpoint( + self, assignment, *, h_revealed=False, h_reveal_date=None + ): + toolbar_config = self._config.get("instructorToolbar", {}) + + due_date_iso = ( + assignment.due_date.replace(tzinfo=UTC).isoformat() + if assignment.due_date + else None + ) + + toolbar_config["courseCheckpointConfig"] = { + "revealed": h_revealed, + "revealDate": h_reveal_date, + "revealUrl": self._request.route_url( + "api.checkpoint.reveal", assignment_id=assignment.id + ), + } + toolbar_config["assignmentDueDate"] = due_date_iso + toolbar_config["assignmentCheckpointEnabled"] = True + self._config["instructorToolbar"] = toolbar_config + + def enable_student_checkpoint(self, assignment, *, h_revealed=False): + due_date_iso = ( + assignment.due_date.replace(tzinfo=UTC).isoformat() + if assignment.due_date + else None + ) + + self._config["studentToolbar"] = { + "courseCheckpointConfig": { + "revealed": h_revealed, + }, + "assignmentDueDate": due_date_iso, + "assignmentCheckpointEnabled": True, + } + def enable_toolbar_editing(self): toolbar_config = self._get_toolbar_config() diff --git a/lms/routes.py b/lms/routes.py index b6cd13146d..73e6d38e97 100644 --- a/lms/routes.py +++ b/lms/routes.py @@ -45,6 +45,11 @@ def includeme(config): # noqa: PLR0915 ) config.add_route("api.sync", "/api/sync", request_method="POST") + config.add_route( + "api.checkpoint.reveal", + "/api/assignments/{assignment_id}/checkpoint/reveal", + request_method="POST", + ) config.add_route( "api.courses.group_sets.list", "/api/courses/{course_id}/group_sets" ) diff --git a/lms/services/assignment.py b/lms/services/assignment.py index 0e62ffda0b..0eb7b42ae9 100644 --- a/lms/services/assignment.py +++ b/lms/services/assignment.py @@ -62,6 +62,7 @@ def update_assignment( # noqa: PLR0913 group_set_id, course: Course, auto_grading_config: dict | None = None, + checkpoint_enabled: bool = False, # noqa: FBT001, FBT002 ): """Update an existing assignment.""" if self._misc_plugin.is_speed_grader_launch(request): @@ -96,6 +97,7 @@ def update_assignment( # noqa: PLR0913 assignment.course_id = course.id self._update_auto_grading_config(assignment, auto_grading_config) + self._update_checkpoint(assignment, checkpoint_enabled) return assignment @@ -151,6 +153,7 @@ def get_assignment_for_launch(self, request, course: Course) -> Assignment | Non document_url = assignment_config.get("document_url") 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) if not document_url: # We can't find a document_url, we shouldn't try to create an @@ -181,7 +184,13 @@ def get_assignment_for_launch(self, request, course: Course) -> Assignment | Non # It often will be the same one while launching the assignment again but # it might for example be an updated deep linked URL or similar. return self.update_assignment( - request, assignment, document_url, group_set_id, course, auto_grading_config + request, + assignment, + document_url, + group_set_id, + course, + auto_grading_config, + checkpoint_enabled=checkpoint_enabled, ) def upsert_assignment_membership( @@ -373,6 +382,19 @@ def get_assignment_sections(self, assignment) -> Sequence[Grouping]: .order_by(Grouping.lms_name.asc()) ).all() + def _update_checkpoint( + self, + assignment: Assignment, + checkpoint_enabled: bool, # noqa: FBT001 + ) -> None: + """Mark an assignment as checkpoint_enabled. + + Checkpoints can only be enabled at creation time -- once enabled + they are never disabled by an edit. + """ + if checkpoint_enabled and not assignment.checkpoint_enabled: + assignment.checkpoint_enabled = True + def _update_auto_grading_config( self, assignment: Assignment, auto_grading_config: dict | None ) -> None: diff --git a/lms/services/h_api.py b/lms/services/h_api.py index 071eca11df..1c657425b4 100644 --- a/lms/services/h_api.py +++ b/lms/services/h_api.py @@ -217,6 +217,72 @@ def get_annotation_counts( ) return response.json() + def sync_checkpoints( + self, + authority: str, + checkpoints: list[dict], + user: dict | None = None, + ) -> list[dict] | None: + """Sync checkpoint data to h via the bulk checkpoint endpoint. + + :param authority: The h authority + :param checkpoints: List of dicts with group_authority_provided_id, + document_uri, and optionally reveal_date + :param user: Optional dict with 'username' and 'role' to set + their lms_role in the group memberships + :return: List of checkpoint results from h, each containing + revealed and reveal_date fields, or None if no checkpoints + """ + if not checkpoints: + return None + + payload: dict = { + "authority": authority, + "checkpoints": checkpoints, + } + if user: + payload["user"] = user + + response = self._api_request( + "POST", + path="bulk/checkpoint", + body=json.dumps(payload), + headers={ + "Content-Type": "application/json", + }, + ) + return response.json() + + def reveal_checkpoints( + self, + authority: str, + checkpoints: list[dict], + ) -> list[dict] | None: + """Reveal checkpoints in h, making annotations visible immediately. + + :param authority: The h authority + :param checkpoints: List of dicts with group_authority_provided_id + and document_uri + :return: List of reveal results from h, or None if no checkpoints + """ + if not checkpoints: + return None + + payload = { + "authority": authority, + "checkpoints": checkpoints, + } + + response = self._api_request( + "POST", + path="bulk/checkpoint/reveal", + body=json.dumps(payload), + headers={ + "Content-Type": "application/json", + }, + ) + return response.json() + def _api_request(self, method, path, body=None, headers=None, stream=False): # noqa: FBT002 """ Send any kind of HTTP request to the h API and return the response. diff --git a/lms/services/lti_h.py b/lms/services/lti_h.py index 85e06ac989..ca39bbb622 100644 --- a/lms/services/lti_h.py +++ b/lms/services/lti_h.py @@ -1,9 +1,33 @@ from h_api.bulk_api import CommandBuilder -from lms.models import Grouping +from lms.models import Assignment, Grouping from lms.services import HAPI +def checkpoint_sync_data(assignment: Assignment | None, lti_user) -> dict | None: + """Build the checkpoint payload to sync to h for a Hide & Reveal assignment. + + Returns None when the assignment is missing or doesn't have checkpoint + enabled, so callers can pass the result straight through to + `LTIHService.sync(..., checkpoint_data=...)`. + + reveal_date is not sent — h is the source of truth for the reveal state. + h's upsert uses coalesce to preserve an existing reveal_date when NULL + is sent. + """ + if not (assignment and assignment.checkpoint_enabled): + return None + + role = "instructor" if lti_user.is_instructor else "student" + return { + "document_uri": assignment.document_url, + "user": { + "username": lti_user.h_user.username, + "role": role, + }, + } + + class LTIHService: """ Copy LTI users and courses to h users and groups. @@ -25,7 +49,12 @@ def __init__(self, _context, request) -> None: self._h_api: HAPI = request.find_service(HAPI) self._group_info_service = request.find_service(name="group_info") - def sync(self, groupings: list[Grouping], group_info_params: dict): + def sync( + self, + groupings: list[Grouping], + group_info_params: dict, + checkpoint_data: dict | None = None, + ) -> list[dict] | None: """ Sync standard data to h for an LTI launch with the provided groups. @@ -34,6 +63,9 @@ def sync(self, groupings: list[Grouping], group_info_params: dict): :param groupings: groupings to sync to H :param group_info_params: params to add for each in `GroupInfo` + :param checkpoint_data: optional dict with document_uri and reveal_date + to sync a checkpoint for each grouping + :return: checkpoint results from h, or None :raise HTTPInternalServerError: if we can't sync to h for any reason :raise ApplicationInstanceNotFound: if @@ -47,6 +79,10 @@ def sync(self, groupings: list[Grouping], group_info_params: dict): grouping=grouping, params=group_info_params ) + if checkpoint_data: + return self._sync_checkpoints(groupings, checkpoint_data) + return None + def _yield_commands(self, groupings): # Note! - Syncing a user to `h` currently has an implication for # reporting and so billing and will as long as our billing metric is @@ -77,6 +113,23 @@ def _user_upsert(self, h_user, ref="user_0"): ref, ) + def _sync_checkpoints( + self, groupings: list[Grouping], checkpoint_data: dict + ) -> list[dict] | None: + checkpoints = [ + { + "group_authority_provided_id": grouping.authority_provided_id, + "document_uri": checkpoint_data["document_uri"], + } + for grouping in groupings + ] + + return self._h_api.sync_checkpoints( + authority=self._authority, + checkpoints=checkpoints, + user=checkpoint_data.get("user"), + ) + def _group_upsert(self, grouping, ref): return CommandBuilder.group.upsert( { diff --git a/lms/validation/_lti_launch_params.py b/lms/validation/_lti_launch_params.py index 8147ebb689..405f1ec83b 100644 --- a/lms/validation/_lti_launch_params.py +++ b/lms/validation/_lti_launch_params.py @@ -185,6 +185,12 @@ class ConfigureAssignmentSchema(_CommonLTILaunchSchema): auto_grading_config = fields.Nested( AutoGradingConfigSchema, required=False, allow_none=True ) + checkpoint_enabled = fields.Bool( + required=False, + load_default=False, + truthy={"true", "1"}, + falsy={"false", "0", ""}, + ) @pre_load def _load_auto_grading_config(self, data, **_kwargs): diff --git a/lms/views/api/checkpoint.py b/lms/views/api/checkpoint.py new file mode 100644 index 0000000000..47a405a6a9 --- /dev/null +++ b/lms/views/api/checkpoint.py @@ -0,0 +1,79 @@ +from pyramid.httpexceptions import HTTPForbidden, HTTPNotFound +from pyramid.view import view_config + +from lms.models import Grouping +from lms.security import Permissions +from lms.services import HAPI + + +@view_config( + route_name="api.checkpoint.reveal", + request_method="POST", + renderer="json", + permission=Permissions.API, +) +def reveal_checkpoint(request): + if not request.lti_user.is_instructor: + message = "Only instructors can reveal annotations" + raise HTTPForbidden(message) + + assignment_id = int(request.matchdict["assignment_id"]) + assignment_service = request.find_service(name="assignment") + assignment = assignment_service.get_by_id(assignment_id) + + # Scope the assignment to the caller. get_by_id looks up by raw primary key, + # so without these checks any instructor could reveal any assignment by id. + # - Tenant scope: the assignment's course must belong to the caller's + # application instance (guards cross-institution reveal). + # - Membership scope: the caller must be a member of the assignment (guards + # an instructor of a different course within the same institution). + if ( + not assignment + or not assignment.checkpoint_enabled + or not assignment.course + or assignment.course.application_instance_id + != request.lti_user.application_instance_id + or not assignment_service.is_member(assignment, request.lti_user.h_user.userid) + ): + message = "Assignment or checkpoint not found" + raise HTTPNotFound(message) + + # Reveal directly in h — h is the source of truth for reveal state. + authority = request.registry.settings["h_authority"] + h_api = request.find_service(HAPI) + # If the assignment has section/group groupings, only reveal those — + # not the course group. The course group's checkpoint may be shared + # with other assignments that use the same URL, so revealing it would + # affect those assignments too. + all_groupings = assignment.groupings.all() + non_course_groupings = [ + group for group in all_groupings if group.type != Grouping.Type.COURSE + ] + reveal_groupings = non_course_groupings if non_course_groupings else all_groupings + + checkpoints = [ + { + "group_authority_provided_id": grouping.authority_provided_id, + "document_uri": assignment.document_url, + } + for grouping in reveal_groupings + ] + + if not checkpoints: + message = "No groupings found for this assignment" + raise HTTPNotFound(message) + + results = h_api.reveal_checkpoints( + authority=authority, + checkpoints=checkpoints, + ) + + # Return the reveal date from h's response + reveal_date = None + if results: + for result in results: + if result.get("revealed"): + reveal_date = result.get("reveal_date") + break + + return {"revealed": True, "reveal_date": reveal_date} diff --git a/lms/views/api/sync.py b/lms/views/api/sync.py index 0ace7c2264..b1cbe2a41b 100644 --- a/lms/views/api/sync.py +++ b/lms/views/api/sync.py @@ -3,6 +3,7 @@ from lms.product.plugin.grouping import GroupError from lms.security import Permissions +from lms.services.lti_h import checkpoint_sync_data from lms.validation._base import PyramidRequestSchema @@ -67,17 +68,44 @@ def sync(request): grading_student_id=grading_student_id, ) - # Sync the groups over to H so they are ready to be annotated against - request.find_service(name="lti_h").sync( - groupings, request.parsed_params["group_info"] - ) - - # Store the relationship between the assignment and the groupings + # Look up the assignment so we can sync checkpoint data for the actual + # groupings (sections or canvas groups), not just the course group. assignment = assignment_service.get_assignment( course.application_instance.tool_consumer_instance_guid, request.parsed_params["resource_link_id"], ) + + # Sync the groups over to H so they are ready to be annotated against. + # Also sync checkpoint data if the assignment has checkpoint enabled. + h_checkpoint_results = request.find_service(name="lti_h").sync( + groupings, + request.parsed_params["group_info"], + checkpoint_data=checkpoint_sync_data(assignment, request.lti_user), + ) + + # Store the relationship between the assignment and the groupings assignment_service.upsert_assignment_groupings(assignment, groupings=groupings) authority = request.registry.settings["h_authority"] - return [group.groupid(authority) for group in groupings] + groups = [group.groupid(authority) for group in groupings] + + response: dict = {"groups": groups} + + if h_checkpoint_results: + # Derive the checkpoint state from h's response. If any group's + # checkpoint is revealed, report it as revealed. + revealed = any(result.get("revealed") for result in h_checkpoint_results) + reveal_date = next( + ( + result.get("reveal_date") + for result in h_checkpoint_results + if result.get("revealed") + ), + None, + ) + response["checkpoint"] = { + "revealed": revealed, + "revealDate": reveal_date, + } + + return response diff --git a/lms/views/lti/basic_launch.py b/lms/views/lti/basic_launch.py index 7c5074ecb7..ee36a93aa8 100644 --- a/lms/views/lti/basic_launch.py +++ b/lms/views/lti/basic_launch.py @@ -17,11 +17,12 @@ from pyramid.view import view_config, view_defaults from lms.events import LTIEvent -from lms.models import Assignment +from lms.models import Assignment, Grouping from lms.product.plugin.misc import MiscPlugin # noqa: TC001 from lms.security import Permissions from lms.services import LTIGradingService, UserService, VitalSourceService from lms.services.assignment import AssignmentService # noqa: TC001 +from lms.services.lti_h import checkpoint_sync_data from lms.validation import BasicLTILaunchSchema, ConfigureAssignmentSchema LOG = logging.getLogger(__name__) @@ -172,13 +173,33 @@ def edit_assignment_callback(self): self._configure_assignment(assignment) return self._show_document(assignment) - def _show_document(self, assignment): + def _show_document(self, assignment): # noqa: C901, PLR0912 """Display a document to the user for annotation or grading.""" + # Determine the grouping type to decide whether to sync checkpoint + # data for the course group. When the assignment uses sections/groups, + # the checkpoint sync happens in the client-side sync (POST /api/sync) + # which resolves the actual groupings — we must NOT create a course + # checkpoint in that case, to avoid contaminating the course group's + # checkpoint state. + grouping_type = self.request.find_service( + name="grouping" + ).get_launch_grouping_type(self.request, self.course, assignment) + uses_course_grouping = grouping_type == Grouping.Type.COURSE + + checkpoint_data = ( + checkpoint_sync_data(assignment, self.request.lti_user) + if uses_course_grouping + else None + ) + # Before any LTI assignments launch, create or update the Hypothesis # user and group corresponding to the LTI user and course. - self.request.find_service(name="lti_h").sync( - [self.course], self.request.lti_params + # For course-grouping assignments, also sync checkpoint data to h. + h_checkpoint_results = self.request.find_service(name="lti_h").sync( + [self.course], + self.request.lti_params, + checkpoint_data=checkpoint_data, ) # Store the relationship between the assignment and the user @@ -199,6 +220,30 @@ def _show_document(self, assignment): ): self.context.js_config.enable_toolbar_editing() + if assignment.checkpoint_enabled: + # Use the checkpoint state from h (source of truth). + # + # h_checkpoint_results is only available for course-grouping + # assignments. For section/group assignments, the frontend will + # update the checkpoint state after the client-side sync. + h_revealed = False + h_reveal_date = None + if uses_course_grouping and h_checkpoint_results: + for result in h_checkpoint_results: + if result.get("revealed"): + h_revealed = True + h_reveal_date = result.get("reveal_date") + break + + if self.request.lti_user.is_instructor: + self.context.js_config.enable_toolbar_checkpoint( + assignment, h_revealed=h_revealed, h_reveal_date=h_reveal_date + ) + else: + self.context.js_config.enable_student_checkpoint( + assignment, h_revealed=h_revealed + ) + if self.request.product.use_toolbar_grading and assignment.is_gradable: if self.request.lti_user.is_instructor: # Get the list of students to display in the drop down @@ -304,6 +349,9 @@ def _configure_assignment(self, assignment): group_set_id=self.request.parsed_params.get("group_set"), course=self.course, auto_grading_config=self.request.parsed_params.get("auto_grading_config"), + checkpoint_enabled=self.request.parsed_params.get( + "checkpoint_enabled", False + ), ) def _configure_js_for_file_picker( diff --git a/lms/views/lti/deep_linking.py b/lms/views/lti/deep_linking.py index 3dde93e868..b4e150558b 100644 --- a/lms/views/lti/deep_linking.py +++ b/lms/views/lti/deep_linking.py @@ -103,6 +103,7 @@ class DeepLinkingFieldsRequestSchema(JSONPyramidRequestSchema): auto_grading_config = fields.Nested( AutoGradingConfigSchema, required=False, allow_none=True ) + checkpoint_enabled = fields.Bool(required=False, load_default=False) class LTI11DeepLinkingFieldsRequestSchema(DeepLinkingFieldsRequestSchema): @@ -280,6 +281,9 @@ def _get_assignment_configuration(request) -> dict: # Custom params must be str, encode these settings as JSON params["auto_grading_config"] = json.dumps(auto_grading_config) + if request.parsed_params.get("checkpoint_enabled"): + params["checkpoint_enabled"] = "true" + if content["type"] == "url": params["url"] = content["url"] else: diff --git a/tests/unit/lms/product/canvas/_plugin/misc_test.py b/tests/unit/lms/product/canvas/_plugin/misc_test.py index 169d26f7b7..1c4e9f1c11 100644 --- a/tests/unit/lms/product/canvas/_plugin/misc_test.py +++ b/tests/unit/lms/product/canvas/_plugin/misc_test.py @@ -62,6 +62,17 @@ def test_get_assignment_configuration_with_auto_grading_config( "auto_grading_config": {"some": "value"}, } + def test_get_assignment_configuration_with_checkpoint( + self, plugin, pyramid_request + ): + pyramid_request.params["checkpoint_enabled"] = "true" + + config = plugin.get_assignment_configuration( + pyramid_request, sentinel.assignment, sentinel.historical_assignment + ) + + assert config["checkpoint_enabled"] is True + def test_get_assignment_configuration(self, plugin, pyramid_request): config = plugin.get_assignment_configuration( pyramid_request, sentinel.assignment, sentinel.historical_assignment diff --git a/tests/unit/lms/product/plugin/misc_test.py b/tests/unit/lms/product/plugin/misc_test.py index 15de13a8f2..407133e013 100644 --- a/tests/unit/lms/product/plugin/misc_test.py +++ b/tests/unit/lms/product/plugin/misc_test.py @@ -77,6 +77,30 @@ def test_get_assignment_configuration_with_auto_grading_in_deep_linked_configura == auto_grading_config.asdict() ) + def test_get_assignment_configuration_with_checkpoint_in_existing_db_assignment( + self, plugin, pyramid_request + ): + assignment = factories.Assignment(checkpoint_enabled=True) + pyramid_request.lti_params["resource_link_id"] = sentinel.link_id + + result = plugin.get_assignment_configuration(pyramid_request, assignment, None) + + assert result["checkpoint_enabled"] is True + + def test_get_assignment_configuration_with_checkpoint_in_deep_linked_configuration( + self, plugin, get_deep_linked_assignment_configuration + ): + get_deep_linked_assignment_configuration.return_value = { + "checkpoint_enabled": "true" + } + + assert ( + plugin.get_assignment_configuration(sentinel.request, None, None)[ + "checkpoint_enabled" + ] + is True + ) + def test_get_assignment_configuration_with_assignment_in_db_copied_assignment( self, plugin, pyramid_request ): diff --git a/tests/unit/lms/resources/_js_config/__init___test.py b/tests/unit/lms/resources/_js_config/__init___test.py index c05b23b8eb..9aa927f0c0 100644 --- a/tests/unit/lms/resources/_js_config/__init___test.py +++ b/tests/unit/lms/resources/_js_config/__init___test.py @@ -1,5 +1,5 @@ -from datetime import timedelta -from unittest.mock import create_autospec, patch, sentinel +from datetime import datetime, timedelta +from unittest.mock import MagicMock, create_autospec, patch, sentinel import pytest from h_matchers import Any @@ -602,6 +602,67 @@ def test_instructor_toolbar( assert js_config.asdict()["instructorToolbar"] == expected +class TestCheckpointToolbar: + def test_enable_toolbar_checkpoint_unrevealed(self, js_config): + assignment = MagicMock() + assignment.id = 42 + assignment.due_date = None + + js_config.enable_toolbar_checkpoint(assignment) + + config = js_config.asdict() + toolbar = config["instructorToolbar"] + checkpoint = toolbar["courseCheckpointConfig"] + assert checkpoint["revealed"] is False + assert checkpoint["revealDate"] is None + assert "revealUrl" in checkpoint + assert toolbar["assignmentDueDate"] is None + assert toolbar["assignmentCheckpointEnabled"] is True + + def test_enable_toolbar_checkpoint_revealed(self, js_config): + assignment = MagicMock() + assignment.id = 42 + assignment.due_date = datetime(2026, 8, 1, 23, 59, 0) # noqa: DTZ001 + + js_config.enable_toolbar_checkpoint( + assignment, + h_revealed=True, + h_reveal_date="2026-07-01T12:00:00", + ) + + config = js_config.asdict() + toolbar = config["instructorToolbar"] + checkpoint = toolbar["courseCheckpointConfig"] + assert checkpoint["revealed"] is True + assert checkpoint["revealDate"] == "2026-07-01T12:00:00" + assert toolbar["assignmentDueDate"] == "2026-08-01T23:59:00+00:00" + assert toolbar["assignmentCheckpointEnabled"] is True + + def test_enable_student_checkpoint_hidden(self, js_config): + assignment = MagicMock() + assignment.due_date = None + + js_config.enable_student_checkpoint(assignment) + + config = js_config.asdict() + toolbar = config["studentToolbar"] + assert toolbar["courseCheckpointConfig"]["revealed"] is False + assert toolbar["assignmentDueDate"] is None + assert toolbar["assignmentCheckpointEnabled"] is True + + def test_enable_student_checkpoint_revealed(self, js_config): + assignment = MagicMock() + assignment.due_date = datetime(2026, 8, 1, 23, 59, 0) # noqa: DTZ001 + + js_config.enable_student_checkpoint(assignment, h_revealed=True) + + config = js_config.asdict() + toolbar = config["studentToolbar"] + assert toolbar["courseCheckpointConfig"]["revealed"] is True + assert toolbar["assignmentDueDate"] == "2026-08-01T23:59:00+00:00" + assert toolbar["assignmentCheckpointEnabled"] is True + + class TestSetFocusedUser: def test_it_sets_the_focused_user_if_theres_a_focused_user_param( self, h_api, js_config diff --git a/tests/unit/lms/services/assignment_test.py b/tests/unit/lms/services/assignment_test.py index a8f587d774..566204e43a 100644 --- a/tests/unit/lms/services/assignment_test.py +++ b/tests/unit/lms/services/assignment_test.py @@ -138,6 +138,50 @@ def test_update_assignment_removes_auto_grading_config( assert not assignment.auto_grading_config assert not db_session.get(AutoGradingConfig, auto_grading_config.id) + def test_update_assignment_with_checkpoint(self, svc, pyramid_request, course): + assignment = factories.Assignment() + + assignment = svc.update_assignment( + pyramid_request, + assignment, + sentinel.document_url, + sentinel.group_set_id, + course, + checkpoint_enabled=True, + ) + + assert assignment.checkpoint_enabled is True + + def test_update_assignment_without_checkpoint(self, svc, pyramid_request, course): + assignment = factories.Assignment() + + assignment = svc.update_assignment( + pyramid_request, + assignment, + sentinel.document_url, + sentinel.group_set_id, + course, + checkpoint_enabled=False, + ) + + assert assignment.checkpoint_enabled is False + + def test_update_assignment_keeps_existing_checkpoint( + self, svc, pyramid_request, course + ): + assignment = factories.Assignment(checkpoint_enabled=True) + + assignment = svc.update_assignment( + pyramid_request, + assignment, + sentinel.document_url, + sentinel.group_set_id, + course, + checkpoint_enabled=True, + ) + + assert assignment.checkpoint_enabled is True + @pytest.mark.parametrize( "param", ( diff --git a/tests/unit/lms/services/h_api_test.py b/tests/unit/lms/services/h_api_test.py index 3fcbd67d32..7973733d2c 100644 --- a/tests/unit/lms/services/h_api_test.py +++ b/tests/unit/lms/services/h_api_test.py @@ -266,6 +266,95 @@ def test_get_groups(self, h_api, http_service): for group in groups ] + def test_sync_checkpoints(self, h_api, _api_request): # noqa: PT019 + checkpoints = [ + { + "group_authority_provided_id": "group1", + "document_uri": "https://example.com/doc", + } + ] + _api_request.return_value.json.return_value = [{"revealed": False}] + + result = h_api.sync_checkpoints( + authority="lms.hypothes.is", checkpoints=checkpoints + ) + + _api_request.assert_called_once_with( + "POST", + path="bulk/checkpoint", + body=json.dumps( + {"authority": "lms.hypothes.is", "checkpoints": checkpoints} + ), + headers={"Content-Type": "application/json"}, + ) + assert result == [{"revealed": False}] + + def test_sync_checkpoints_with_user(self, h_api, _api_request): # noqa: PT019 + checkpoints = [ + { + "group_authority_provided_id": "group1", + "document_uri": "https://example.com/doc", + } + ] + user = {"username": "teacher", "role": "instructor"} + _api_request.return_value.json.return_value = [{"revealed": False}] + + h_api.sync_checkpoints( + authority="lms.hypothes.is", + checkpoints=checkpoints, + user=user, + ) + + _api_request.assert_called_once_with( + "POST", + path="bulk/checkpoint", + body=json.dumps( + { + "authority": "lms.hypothes.is", + "checkpoints": checkpoints, + "user": user, + } + ), + headers={"Content-Type": "application/json"}, + ) + + def test_sync_checkpoints_with_no_checkpoints(self, h_api, _api_request): # noqa: PT019 + result = h_api.sync_checkpoints(authority="lms.hypothes.is", checkpoints=[]) + + _api_request.assert_not_called() + assert result is None + + def test_reveal_checkpoints(self, h_api, _api_request): # noqa: PT019 + checkpoints = [ + { + "group_authority_provided_id": "group1", + "document_uri": "https://example.com/doc", + } + ] + _api_request.return_value.json.return_value = [ + {"revealed": True, "reveal_date": "2026-07-01T12:00:00"} + ] + + result = h_api.reveal_checkpoints( + authority="lms.hypothes.is", checkpoints=checkpoints + ) + + _api_request.assert_called_once_with( + "POST", + path="bulk/checkpoint/reveal", + body=json.dumps( + {"authority": "lms.hypothes.is", "checkpoints": checkpoints} + ), + headers={"Content-Type": "application/json"}, + ) + assert result == [{"revealed": True, "reveal_date": "2026-07-01T12:00:00"}] + + def test_reveal_checkpoints_with_no_checkpoints(self, h_api, _api_request): # noqa: PT019 + result = h_api.reveal_checkpoints(authority="lms.hypothes.is", checkpoints=[]) + + _api_request.assert_not_called() + assert result is None + def test__api_request(self, h_api, http_service): h_api._api_request(sentinel.method, "dummy-path", body=sentinel.raw_body) # noqa: SLF001 diff --git a/tests/unit/lms/services/lti_h_test.py b/tests/unit/lms/services/lti_h_test.py index 7d0e4e9fda..cfddcb9e26 100644 --- a/tests/unit/lms/services/lti_h_test.py +++ b/tests/unit/lms/services/lti_h_test.py @@ -69,6 +69,27 @@ def test_sync_upserts_the_GroupInfo_into_the_db( grouping=grouping, params=sentinel.params ) + def test_sync_syncs_checkpoints(self, h_api, lti_h_svc): + groupings = factories.Course.create_batch(2) + checkpoint_data = { + "document_uri": "https://example.com/doc", + "user": {"username": "teacher", "role": "instructor"}, + } + + lti_h_svc.sync(groupings, sentinel.params, checkpoint_data=checkpoint_data) + + h_api.sync_checkpoints.assert_called_once_with( + authority=lti_h_svc._authority, # noqa: SLF001 + checkpoints=[ + { + "group_authority_provided_id": grouping.authority_provided_id, + "document_uri": "https://example.com/doc", + } + for grouping in groupings + ], + user={"username": "teacher", "role": "instructor"}, + ) + @pytest.fixture def lti_h_svc(self, pyramid_request): return LTIHService(None, pyramid_request) diff --git a/tests/unit/lms/views/api/checkpoint_test.py b/tests/unit/lms/views/api/checkpoint_test.py new file mode 100644 index 0000000000..e1c5bcbbee --- /dev/null +++ b/tests/unit/lms/views/api/checkpoint_test.py @@ -0,0 +1,207 @@ +from unittest.mock import MagicMock + +import pytest +from pyramid.httpexceptions import HTTPForbidden, HTTPNotFound + +from lms.models import Grouping +from lms.services import HAPI +from lms.views.api.checkpoint import reveal_checkpoint + + +@pytest.mark.usefixtures("assignment_service", "h_api") +class TestRevealCheckpoint: + def test_it_rejects_non_instructors(self, pyramid_request): + pyramid_request.matchdict = {"assignment_id": "1"} + + with pytest.raises(HTTPForbidden): + reveal_checkpoint(pyramid_request) + + @pytest.mark.usefixtures("user_is_instructor") + def test_it_reveals_checkpoint_via_h( + self, pyramid_request, assignment_service, h_api + ): + assignment = self._assignment_with_checkpoint( + pyramid_request.lti_user.application_instance_id + ) + assignment_service.get_by_id.return_value = assignment + h_api.reveal_checkpoints.return_value = [ + {"revealed": True, "reveal_date": "2026-07-01T12:00:00"} + ] + pyramid_request.matchdict = {"assignment_id": "1"} + + result = reveal_checkpoint(pyramid_request) + + assignment_service.get_by_id.assert_called_once_with(1) + assert result["revealed"] is True + assert result["reveal_date"] == "2026-07-01T12:00:00" + h_api.reveal_checkpoints.assert_called_once() + + @pytest.mark.usefixtures("user_is_instructor") + def test_it_returns_404_when_assignment_not_found( + self, pyramid_request, assignment_service + ): + assignment_service.get_by_id.return_value = None + pyramid_request.matchdict = {"assignment_id": "999"} + + with pytest.raises(HTTPNotFound): + reveal_checkpoint(pyramid_request) + + @pytest.mark.usefixtures("user_is_instructor") + def test_it_returns_404_when_checkpoint_not_enabled( + self, pyramid_request, assignment_service + ): + assignment = MagicMock() + assignment.checkpoint_enabled = False + assignment_service.get_by_id.return_value = assignment + pyramid_request.matchdict = {"assignment_id": "1"} + + with pytest.raises(HTTPNotFound): + reveal_checkpoint(pyramid_request) + + @pytest.mark.usefixtures("user_is_instructor") + def test_it_returns_404_for_assignment_in_another_application_instance( + self, pyramid_request, assignment_service, h_api + ): + # Security: an instructor must not be able to reveal an assignment that + # belongs to a different application instance (tenant) than their own. + assignment = self._assignment_with_checkpoint() + assignment.course.application_instance_id = ( + pyramid_request.lti_user.application_instance_id + 1 + ) + assignment_service.get_by_id.return_value = assignment + pyramid_request.matchdict = {"assignment_id": "1"} + + with pytest.raises(HTTPNotFound): + reveal_checkpoint(pyramid_request) + + h_api.reveal_checkpoints.assert_not_called() + + @pytest.mark.usefixtures("user_is_instructor") + def test_it_returns_404_when_instructor_is_not_a_member_of_the_assignment( + self, pyramid_request, assignment_service, h_api + ): + # Security: even within the same tenant, an instructor must not reveal an + # assignment they are not a member of (e.g. one in a different course). + assignment = self._assignment_with_checkpoint() + assignment.course.application_instance_id = ( + pyramid_request.lti_user.application_instance_id + ) + assignment_service.get_by_id.return_value = assignment + assignment_service.is_member.return_value = False + pyramid_request.matchdict = {"assignment_id": "1"} + + with pytest.raises(HTTPNotFound): + reveal_checkpoint(pyramid_request) + + h_api.reveal_checkpoints.assert_not_called() + + @pytest.mark.usefixtures("user_is_instructor") + def test_it_returns_404_when_assignment_has_no_course( + self, pyramid_request, assignment_service, h_api + ): + # Defensive: course is nullable, so the tenant check must not raise + # AttributeError — a course-less assignment 404s. + assignment = self._assignment_with_checkpoint() + assignment.course = None + assignment_service.get_by_id.return_value = assignment + pyramid_request.matchdict = {"assignment_id": "1"} + + with pytest.raises(HTTPNotFound): + reveal_checkpoint(pyramid_request) + + h_api.reveal_checkpoints.assert_not_called() + + @pytest.mark.usefixtures("user_is_instructor") + def test_it_reveals_only_non_course_groupings( + self, pyramid_request, assignment_service, h_api + ): + assignment = self._assignment_with_checkpoint( + pyramid_request.lti_user.application_instance_id + ) + course_grouping = MagicMock() + course_grouping.type = Grouping.Type.COURSE + course_grouping.authority_provided_id = "course1" + section_grouping = MagicMock() + section_grouping.type = Grouping.Type.CANVAS_SECTION + section_grouping.authority_provided_id = "section1" + assignment.groupings.all.return_value = [course_grouping, section_grouping] + assignment_service.get_by_id.return_value = assignment + h_api.reveal_checkpoints.return_value = [ + {"revealed": True, "reveal_date": "2026-07-01T12:00:00"} + ] + pyramid_request.matchdict = {"assignment_id": "1"} + + reveal_checkpoint(pyramid_request) + + call_kwargs = h_api.reveal_checkpoints.call_args[1] + assert len(call_kwargs["checkpoints"]) == 1 + assert ( + call_kwargs["checkpoints"][0]["group_authority_provided_id"] == "section1" + ) + + @pytest.mark.usefixtures("user_is_instructor") + def test_it_returns_null_reveal_date_when_no_result_revealed( + self, pyramid_request, assignment_service, h_api + ): + assignment = self._assignment_with_checkpoint( + pyramid_request.lti_user.application_instance_id + ) + assignment_service.get_by_id.return_value = assignment + h_api.reveal_checkpoints.return_value = [{"revealed": False}] + pyramid_request.matchdict = {"assignment_id": "1"} + + result = reveal_checkpoint(pyramid_request) + + assert result["revealed"] is True + assert result["reveal_date"] is None + + @pytest.mark.usefixtures("user_is_instructor") + def test_it_returns_null_reveal_date_when_h_returns_no_results( + self, pyramid_request, assignment_service, h_api + ): + # reveal_checkpoints is typed list | None; guard against the None case. + assignment = self._assignment_with_checkpoint( + pyramid_request.lti_user.application_instance_id + ) + assignment_service.get_by_id.return_value = assignment + h_api.reveal_checkpoints.return_value = None + pyramid_request.matchdict = {"assignment_id": "1"} + + result = reveal_checkpoint(pyramid_request) + + assert result == {"revealed": True, "reveal_date": None} + + @pytest.mark.usefixtures("user_is_instructor") + def test_it_returns_404_when_no_groupings( + self, pyramid_request, assignment_service + ): + assignment = self._assignment_with_checkpoint( + pyramid_request.lti_user.application_instance_id + ) + assignment.groupings.all.return_value = [] + assignment_service.get_by_id.return_value = assignment + pyramid_request.matchdict = {"assignment_id": "1"} + + with pytest.raises(HTTPNotFound): + reveal_checkpoint(pyramid_request) + + def _assignment_with_checkpoint(self, application_instance_id=None): + assignment = MagicMock() + assignment.checkpoint_enabled = True + assignment.document_url = "https://example.com/doc" + if application_instance_id is not None: + assignment.course.application_instance_id = application_instance_id + grouping = MagicMock() + grouping.type = Grouping.Type.CANVAS_SECTION + grouping.authority_provided_id = "group1" + assignment.groupings.all.return_value = [grouping] + return assignment + + @pytest.fixture + def h_api(self, mock_service): + return mock_service(HAPI) + + @pytest.fixture + def pyramid_request(self, pyramid_request): + pyramid_request.matchdict = {} + return pyramid_request diff --git a/tests/unit/lms/views/api/sync_test.py b/tests/unit/lms/views/api/sync_test.py index 09f8be289e..1c08a58e3d 100644 --- a/tests/unit/lms/views/api/sync_test.py +++ b/tests/unit/lms/views/api/sync_test.py @@ -31,7 +31,9 @@ def test_it_with_sections( grading_student_id=sentinel.grading_student_id, ) lti_h_service.sync.assert_called_once_with( - grouping_service.get_sections.return_value, sentinel.group_info + grouping_service.get_sections.return_value, + sentinel.group_info, + checkpoint_data=None, ) assignment_service.get_assignment.assert_called_once_with( course.application_instance.tool_consumer_instance_guid, @@ -42,7 +44,7 @@ def test_it_with_sections( groupings=grouping_service.get_groups.return_value, ) - assert returned_ids == [ + assert returned_ids["groups"] == [ group.groupid(TEST_SETTINGS["h_authority"]) for group in grouping_service.get_sections.return_value ] @@ -73,7 +75,9 @@ def test_it_with_groups( group_set_id=course.get_mapped_group_set_id.return_value, ) lti_h_service.sync.assert_called_once_with( - grouping_service.get_groups.return_value, sentinel.group_info + grouping_service.get_groups.return_value, + sentinel.group_info, + checkpoint_data=None, ) assignment_service.get_assignment.assert_called_once_with( course.application_instance.tool_consumer_instance_guid, @@ -84,7 +88,7 @@ def test_it_with_groups( groupings=grouping_service.get_groups.return_value, ) - assert returned_ids == [ + assert returned_ids["groups"] == [ group.groupid(TEST_SETTINGS["h_authority"]) for group in grouping_service.get_groups.return_value ] @@ -137,7 +141,9 @@ def test_it_with_groups_course_copy_fix( ) lti_h_service.sync.assert_called_once_with( - grouping_service.get_groups.return_value, sentinel.group_info + grouping_service.get_groups.return_value, + sentinel.group_info, + checkpoint_data=None, ) assignment_service.get_assignment.assert_called_once_with( course.application_instance.tool_consumer_instance_guid, @@ -148,7 +154,7 @@ def test_it_with_groups_course_copy_fix( groupings=grouping_service.get_groups.return_value, ) - assert returned_ids == [ + assert returned_ids["groups"] == [ group.groupid(TEST_SETTINGS["h_authority"]) for group in grouping_service.get_groups.return_value ] @@ -180,6 +186,127 @@ def test_it_with_groups_course_copy_doesnt_fix_it( group_set_id=course.get_mapped_group_set_id.return_value, ) + @pytest.mark.usefixtures("course_service") + def test_it_syncs_checkpoint_data_with_sections( + self, + pyramid_request, + grouping_service, + assignment_service, + lti_h_service, + ): + assignment = assignment_service.get_assignment.return_value + assignment.checkpoint_enabled = True + assignment.document_url = "https://example.com/doc" + + sync(pyramid_request) + + lti_h_service.sync.assert_called_once_with( + grouping_service.get_sections.return_value, + sentinel.group_info, + checkpoint_data={ + "document_uri": "https://example.com/doc", + "user": { + "username": pyramid_request.lti_user.h_user.username, + "role": "student", + }, + }, + ) + + @pytest.mark.usefixtures("course_service", "user_is_instructor") + def test_it_syncs_checkpoint_data_with_instructor( + self, + pyramid_request, + grouping_service, + assignment_service, + lti_h_service, + ): + assignment = assignment_service.get_assignment.return_value + assignment.checkpoint_enabled = True + assignment.document_url = "https://example.com/doc" + + sync(pyramid_request) + + lti_h_service.sync.assert_called_once_with( + grouping_service.get_sections.return_value, + sentinel.group_info, + checkpoint_data={ + "document_uri": "https://example.com/doc", + "user": { + "username": pyramid_request.lti_user.h_user.username, + "role": "instructor", + }, + }, + ) + + @pytest.mark.usefixtures("course_copy_plugin", "course_service") + def test_it_syncs_checkpoint_data_with_groups( + self, + pyramid_request, + grouping_service, + assignment_service, + lti_h_service, + ): + pyramid_request.parsed_params["group_set_id"] = sentinel.group_set_id + assignment = assignment_service.get_assignment.return_value + assignment.checkpoint_enabled = True + assignment.document_url = "https://example.com/doc" + + sync(pyramid_request) + + lti_h_service.sync.assert_called_once_with( + grouping_service.get_groups.return_value, + sentinel.group_info, + checkpoint_data={ + "document_uri": "https://example.com/doc", + "user": { + "username": pyramid_request.lti_user.h_user.username, + "role": "student", + }, + }, + ) + + @pytest.mark.usefixtures("grouping_service", "course_service") + def test_it_returns_checkpoint_state_from_h( + self, + pyramid_request, + assignment_service, + lti_h_service, + ): + assignment = assignment_service.get_assignment.return_value + assignment.checkpoint_enabled = True + assignment.document_url = "https://example.com/doc" + lti_h_service.sync.return_value = [ + {"revealed": True, "reveal_date": "2026-07-01T12:00:00"} + ] + + result = sync(pyramid_request) + + assert result["checkpoint"] == { + "revealed": True, + "revealDate": "2026-07-01T12:00:00", + } + + @pytest.mark.usefixtures("grouping_service", "course_service") + def test_it_omits_checkpoint_state_when_h_returns_no_results( + self, + pyramid_request, + assignment_service, + lti_h_service, + ): + assignment = assignment_service.get_assignment.return_value + assignment.checkpoint_enabled = True + assignment.document_url = "https://example.com/doc" + lti_h_service.sync.return_value = None + + result = sync(pyramid_request) + + assert "checkpoint" not in result + + @pytest.fixture + def assignment_service(self, assignment_service): + assignment_service.get_assignment.return_value.checkpoint_enabled = False + return assignment_service + @pytest.fixture def grouping_service(self, grouping_service): grouping_service.get_sections.return_value = ( diff --git a/tests/unit/lms/views/lti/basic_launch_test.py b/tests/unit/lms/views/lti/basic_launch_test.py index fd152aa9ef..a5e3c12a03 100644 --- a/tests/unit/lms/views/lti/basic_launch_test.py +++ b/tests/unit/lms/views/lti/basic_launch_test.py @@ -3,7 +3,7 @@ import pytest -from lms.models import LTIParams +from lms.models import Grouping, LTIParams from lms.resources import LTILaunchResource from lms.resources._js_config import JSConfig from lms.security import Permissions @@ -80,6 +80,7 @@ def test_configure_assignment_callback( group_set_id=sentinel.group_set, course=course_service.get_from_launch.return_value, auto_grading_config=sentinel.auto_grading_config, + checkpoint_enabled=False, ) _show_document.assert_called_once_with( assignment_service.create_assignment.return_value, @@ -283,7 +284,9 @@ def test__show_document( result = svc._show_document(assignment) # noqa: SLF001 lti_h_service.sync.assert_called_once_with( - [course_service.get_from_launch.return_value], pyramid_request.lti_params + [course_service.get_from_launch.return_value], + pyramid_request.lti_params, + checkpoint_data=None, ) assignment_service.upsert_assignment_membership.assert_called_once_with( @@ -396,6 +399,68 @@ def test__show_document_configures_toolbar( assert result == {} + @pytest.mark.usefixtures("pyramid_request") + def test__show_document_enables_checkpoint_toolbar_for_instructor( + self, svc, request, context + ): + request.getfixturevalue("user_is_instructor") + assignment = factories.Assignment(checkpoint_enabled=True) + + svc._show_document(assignment) # noqa: SLF001 + + context.js_config.enable_toolbar_checkpoint.assert_called_once() + context.js_config.enable_student_checkpoint.assert_not_called() + + @pytest.mark.usefixtures("pyramid_request") + def test__show_document_passes_h_revealed_for_course_grouping( + self, svc, request, context, lti_h_service, grouping_service + ): + request.getfixturevalue("user_is_instructor") + grouping_service.get_launch_grouping_type.return_value = Grouping.Type.COURSE + lti_h_service.sync.return_value = [ + {"revealed": True, "reveal_date": "2026-07-01T12:00:00"} + ] + assignment = factories.Assignment(checkpoint_enabled=True) + + svc._show_document(assignment) # noqa: SLF001 + + context.js_config.enable_toolbar_checkpoint.assert_called_once_with( + assignment, h_revealed=True, h_reveal_date="2026-07-01T12:00:00" + ) + + @pytest.mark.usefixtures("pyramid_request") + def test__show_document_passes_h_revealed_false_when_no_result_revealed( + self, svc, request, context, lti_h_service, grouping_service + ): + request.getfixturevalue("user_is_instructor") + grouping_service.get_launch_grouping_type.return_value = Grouping.Type.COURSE + lti_h_service.sync.return_value = [{"revealed": False}] + assignment = factories.Assignment(checkpoint_enabled=True) + + svc._show_document(assignment) # noqa: SLF001 + + context.js_config.enable_toolbar_checkpoint.assert_called_once_with( + assignment, h_revealed=False, h_reveal_date=None + ) + + @pytest.mark.usefixtures("pyramid_request") + def test__show_document_enables_student_checkpoint_for_student(self, svc, context): + assignment = factories.Assignment(checkpoint_enabled=True) + + svc._show_document(assignment) # noqa: SLF001 + + context.js_config.enable_student_checkpoint.assert_called_once() + context.js_config.enable_toolbar_checkpoint.assert_not_called() + + @pytest.mark.usefixtures("pyramid_request") + def test__show_document_no_checkpoint_config_without_checkpoint(self, svc, context): + assignment = factories.Assignment(checkpoint_enabled=False) + + svc._show_document(assignment) # noqa: SLF001 + + context.js_config.enable_toolbar_checkpoint.assert_not_called() + context.js_config.enable_student_checkpoint.assert_not_called() + @pytest.fixture def assignment(self): return factories.Assignment(is_gradable=False) diff --git a/tests/unit/lms/views/lti/deep_linking_test.py b/tests/unit/lms/views/lti/deep_linking_test.py index d357a8112d..cf49cae704 100644 --- a/tests/unit/lms/views/lti/deep_linking_test.py +++ b/tests/unit/lms/views/lti/deep_linking_test.py @@ -294,6 +294,7 @@ def test_it_for_v11( {"auto_grading_config": {"key": "value"}}, {"auto_grading_config": '{"key": "value"}'}, ), + ({"checkpoint_enabled": True}, {"checkpoint_enabled": "true"}), ], ) def test__get_assignment_configuration(