From aacf30889e27a7ddb231eac3387d3235994efe16 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Wed, 5 Aug 2026 12:41:58 -0700 Subject: [PATCH 1/4] LTI1p3: add instructorTriggered param to attempt_lti1p3_score_updates --- components/rsptx/lti1p3/core.py | 54 +++++++++++++++--- .../rsptx/lti1p3/test_score_updates.py | 55 +++++++++++++++---- 2 files changed, 91 insertions(+), 18 deletions(-) diff --git a/components/rsptx/lti1p3/core.py b/components/rsptx/lti1p3/core.py index 76c9b84c5..cd6cd26d2 100644 --- a/components/rsptx/lti1p3/core.py +++ b/components/rsptx/lti1p3/core.py @@ -93,13 +93,33 @@ def time_now() -> str: """ Get current time formatted the way LTI spec expects it. """ - return ( - datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") - ) + return _format_lti_timestamp(datetime.datetime.now(datetime.timezone.utc)) + + +def _format_lti_timestamp(value: datetime.datetime) -> str: + if value.tzinfo is None: + value = value.replace(tzinfo=datetime.timezone.utc) + else: + value = value.astimezone(datetime.timezone.utc) + return value.isoformat().replace("+00:00", "Z") + + +def _submitted_at_for_score( + rs_assignment: Assignment, score_timestamp: str, instructorTriggered: bool +) -> str: + if not instructorTriggered: + return score_timestamp + if rs_assignment.duedate is None: + return score_timestamp + return _format_lti_timestamp(rs_assignment.duedate - datetime.timedelta(minutes=1)) async def attempt_lti1p3_score_update( - rs_user_id: int, rs_assign_id: int, score: float, force: bool = False + rs_user_id: int, + rs_assign_id: int, + score: float, + force: bool = False, + instructorTriggered: bool = False, ): """ Attempt to send a score update to any linked LTI 1.3 tools for a given user and assignment. @@ -109,6 +129,7 @@ async def attempt_lti1p3_score_update( :param rs_assign_id: The Runestone assignment id :param score: The score to send :param force: If True, will send the score even if the grades are not yet released in RS or the course is set to not auto-update grades + :param instructorTriggered: If True, report submission.submittedAt as just before the assignment deadline. """ rslogger.debug("LTI1p3 - attempt_lti1p3_score_update") lti_assign = await fetch_lti1p3_grading_data_for_assignment(rs_assign_id) @@ -120,17 +141,23 @@ async def attempt_lti1p3_score_update( (await fetch_lti1p3_user(rs_user_id, lti_assign.lti1p3_course.id), score) ] await _send_lti1p3_score_updates( - lti_assign=lti_assign, updates=updates, force=force + lti_assign=lti_assign, + updates=updates, + force=force, + instructorTriggered=instructorTriggered, ) -async def attempt_lti1p3_score_updates(rs_assign_id: int, force: bool = False): +async def attempt_lti1p3_score_updates( + rs_assign_id: int, force: bool = False, instructorTriggered: bool = False +): """ Attempt to send a score update to any linked LTI 1.3 tools for a given assignment. Will return early if no LTI 1.3 data is found for the assignment. :param rs_assign_id: The Runestone assignment id :param force: If True, will send the score even if the grades are not yet released in RS or the course is set to not auto-update grades + :param instructorTriggered: If True, report submission.submittedAt as just before the assignment deadline. """ rslogger.debug("LTI1p3 - attempt_lti1p3_score_updates") lti_assign = await fetch_lti1p3_grading_data_for_assignment(rs_assign_id) @@ -148,7 +175,10 @@ async def attempt_lti1p3_score_updates(rs_assign_id: int, force: bool = False): # updates = [(u, grades_dict.get(u.rs_user_id)) for u in all_users if u.rs_user_id in grades_dict] await _send_lti1p3_score_updates( - lti_assign=lti_assign, updates=updates, force=force + lti_assign=lti_assign, + updates=updates, + force=force, + instructorTriggered=instructorTriggered, ) @@ -156,6 +186,7 @@ async def _send_lti1p3_score_updates( lti_assign: Lti1p3Assignment, updates: List[Tuple[Lti1p3User, int]], force: bool = False, + instructorTriggered: bool = False, ): """ Attempt to send a set of 1+ updates to any linked LTI 1.3 tools for a given assignment. @@ -163,6 +194,7 @@ async def _send_lti1p3_score_updates( :param lti_assign: The Lti1p3Assignment object - must have the LTI 1.3 course and rs assignment linked. LTIcourse should have rs_course and lti_config linked. :param updates: List of tuples (Lti1p3User, score) to send :param force: If True, will send the score even if the grades are not yet released in RS or the course is set to not auto-update grades + :param instructorTriggered: If True, set submission.submittedAt just before the assignment deadline so LMS late policies don't mark instructor-entered grades late. """ rslogger.debug(f"LTI1p3 - _send_lti1p3_score_updates {updates}") @@ -263,15 +295,21 @@ async def _send_lti1p3_score_updates( score = max_score # Send the grade + score_timestamp = time_now() + submitted_at = _submitted_at_for_score( + rs_assignment, score_timestamp, instructorTriggered + ) g = ( Grade() .set_score_given(score) .set_score_maximum(max_score) .set_user_id(lti_user.lti_user_id) - .set_timestamp(time_now()) + .set_timestamp(score_timestamp) .set_activity_progress("Completed") .set_grading_progress("FullyGraded") + .set_extra_claims({"submission": {"submittedAt": submitted_at}}) ) + print(f"-----------Grade to be sent: {g.__dict__}") try: _ = await ags.put_grade(g, line_item) except Exception as e: diff --git a/test/components/rsptx/lti1p3/test_score_updates.py b/test/components/rsptx/lti1p3/test_score_updates.py index d2c3a471a..68a1fb403 100644 --- a/test/components/rsptx/lti1p3/test_score_updates.py +++ b/test/components/rsptx/lti1p3/test_score_updates.py @@ -7,6 +7,8 @@ assignment's points (which the LMS rejects with a 422). """ +import datetime +import json from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -21,10 +23,10 @@ def _lti_user(rs_user_id=1, lti_user_id="lms-user-1"): return SimpleNamespace(rs_user_id=rs_user_id, lti_user_id=lti_user_id) -def _lti_assign(points=10, released=True): +def _lti_assign(points=10, released=True, duedate=None): rs_course = SimpleNamespace(id=1, course_name="course1", timezone="UTC") rs_assignment = SimpleNamespace( - id=7, name="Homework 1", duedate=None, points=points, released=released + id=7, name="Homework 1", duedate=duedate, points=points, released=released ) return SimpleNamespace( lti_lineitem_id="https://lms.example/lineitems/1", @@ -35,7 +37,13 @@ def _lti_assign(points=10, released=True): ) -async def _send(updates, lti_assign=None, show_points="true"): +async def _send( + updates, + lti_assign=None, + show_points="true", + return_payloads=False, + instructorTriggered=False, +): """Drive _send_lti1p3_score_updates and report the grades it sent. Returns the list of (user_id, score_given, score_maximum) actually handed to @@ -45,13 +53,16 @@ async def _send(updates, lti_assign=None, show_points="true"): sent = [] async def fake_put_grade(grade, line_item): - sent.append( - ( - grade.get_user_id(), - grade.get_score_given(), - grade.get_score_maximum(), + if return_payloads: + sent.append(json.loads(grade.get_value())) + else: + sent.append( + ( + grade.get_user_id(), + grade.get_score_given(), + grade.get_score_maximum(), + ) ) - ) return {} ags = MagicMock() @@ -69,11 +80,35 @@ async def fake_put_grade(grade, line_item): patch.object(core, "ServiceConnector"), patch.object(core, "AssignmentsGradesService", return_value=ags), ): - await core._send_lti1p3_score_updates(lti_assign, updates) + await core._send_lti1p3_score_updates( + lti_assign, updates, instructorTriggered=instructorTriggered + ) return sent +async def test_instructor_triggered_score_sets_submitted_at_before_deadline(): + duedate = datetime.datetime(2026, 1, 2, 12, 0, 0) + + sent = await _send( + [(_lti_user(), 5)], + lti_assign=_lti_assign(duedate=duedate), + return_payloads=True, + instructorTriggered=True, + ) + + assert sent[0]["submission"] == {"submittedAt": "2026-01-02T11:59:00Z"} + + +async def test_student_triggered_score_sets_submitted_at_to_timestamp(): + sent = await _send( + [(_lti_user(), 5)], + return_payloads=True, + ) + + assert sent[0]["submission"] == {"submittedAt": sent[0]["timestamp"]} + + async def test_a_user_with_no_lti_mapping_is_skipped_not_fatal(): """fetch_lti1p3_user returns None for a student who never launched through the LMS. That used to raise and abandon everyone after them in the batch.""" From b408f4730b0435a64f2c17faf9cee6f1e61b5969 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Wed, 5 Aug 2026 12:43:50 -0700 Subject: [PATCH 2/4] Add instructorTriggered flag to call paths triggered by instructor action --- .../assignment_server_api/routers/grader.py | 27 +++++++++++++++---- bases/rsptx/rsmanage/core.py | 7 ++++- .../runestone/controllers/admin.py | 8 ++++-- .../runestone/controllers/assignments.py | 2 +- .../runestone/modules/rs_grading.py | 10 +++++-- components/rsptx/grading_helpers/regrade.py | 20 +++++++++++--- .../grading_helpers/test_regrade_batch.py | 13 +++++++++ 7 files changed, 73 insertions(+), 14 deletions(-) diff --git a/bases/rsptx/assignment_server_api/routers/grader.py b/bases/rsptx/assignment_server_api/routers/grader.py index 5c8b1e849..e866cee47 100644 --- a/bases/rsptx/assignment_server_api/routers/grader.py +++ b/bases/rsptx/assignment_server_api/routers/grader.py @@ -653,7 +653,9 @@ async def upsert_grade( recomputed = [] for assignment in await _assignments_to_recompute(course, payload): try: - await recompute_totals_for(course, assignment, [payload.sid]) + await recompute_totals_for( + course, assignment, [payload.sid], instructorTriggered=True + ) recomputed.append(assignment.id) except Exception as e: # pragma: no cover - defensive # A failed roll-up must not lose the grade the instructor just typed. @@ -766,7 +768,13 @@ async def regrade_run( which_to_grade_override=payload.which_to_grade_override, ) report = await regrade_batch( - course, sids, questions, assignment, options, dry_run=False + course, + sids, + questions, + assignment, + options, + dry_run=False, + instructorTriggered=True, ) rslogger.info( f"Regrade run by {user.username} assignment={assignment.id} " @@ -802,7 +810,9 @@ async def recompute_totals( } sids = [s for s in payload.sids if s not in instructor_ids] - processed = await recompute_totals_for(course, assignment, sids) + processed = await recompute_totals_for( + course, assignment, sids, instructorTriggered=True + ) rslogger.info( f"Recompute totals by {user.username} assignment={assignment.id} " f"students={processed}" @@ -930,7 +940,12 @@ async def set_manual_assignment_total( grade = await set_manual_total( student.id, assignment.id, course.course_name, payload.score, True ) - await attempt_lti1p3_score_update(student.id, assignment.id, payload.score) + await attempt_lti1p3_score_update( + student.id, + assignment.id, + payload.score, + instructorTriggered=True, + ) rslogger.info( f"Manual total set by {user.username} assignment={assignment.id} " f"sid={payload.sid} score={payload.score}" @@ -950,7 +965,9 @@ async def set_manual_assignment_total( await set_manual_total( student.id, assignment.id, course.course_name, preserved, False ) - await recompute_totals_for(course, assignment, [payload.sid]) + await recompute_totals_for( + course, assignment, [payload.sid], instructorTriggered=True + ) recomputed = await fetch_grade(student.id, assignment.id) rslogger.info( f"Manual total reverted by {user.username} assignment={assignment.id} " diff --git a/bases/rsptx/rsmanage/core.py b/bases/rsptx/rsmanage/core.py index 68a8e9ce8..31082477f 100644 --- a/bases/rsptx/rsmanage/core.py +++ b/bases/rsptx/rsmanage/core.py @@ -1157,7 +1157,12 @@ async def fixtotals( for a in assignments: assignments_scanned += 1 changes = await recompute_totals_detail( - c, a, sids, dry_run=dry_run, only_existing=not create_missing + c, + a, + sids, + dry_run=dry_run, + only_existing=not create_missing, + instructorTriggered=True, ) students_scanned += len(changes) total_manual += sum(1 for ch in changes if ch.skipped_manual) diff --git a/bases/rsptx/web2py_server/applications/runestone/controllers/admin.py b/bases/rsptx/web2py_server/applications/runestone/controllers/admin.py index 20c54eb30..433533532 100644 --- a/bases/rsptx/web2py_server/applications/runestone/controllers/admin.py +++ b/bases/rsptx/web2py_server/applications/runestone/controllers/admin.py @@ -1272,7 +1272,9 @@ def releasegrades(): if lti_method == "1.3": # need force... released still somehow shows as false in DB asyncio.get_event_loop().run_until_complete( - attempt_lti1p3_score_updates(int(assignmentid), force=True) + attempt_lti1p3_score_updates( + int(assignmentid), force=True, instructorTriggered=True + ) ) if lti_method == "1.1": assignment = _get_assignment(assignmentid) @@ -1303,7 +1305,9 @@ def push_lti_grades(): ) if lti_method == "1.3": asyncio.get_event_loop().run_until_complete( - attempt_lti1p3_score_updates(int(assignmentid), force=True) + attempt_lti1p3_score_updates( + int(assignmentid), force=True, instructorTriggered=True + ) ) if lti_method == "1.1": assignment = _get_assignment(assignmentid) diff --git a/bases/rsptx/web2py_server/applications/runestone/controllers/assignments.py b/bases/rsptx/web2py_server/applications/runestone/controllers/assignments.py index fa52920f5..5c6ed4dfe 100644 --- a/bases/rsptx/web2py_server/applications/runestone/controllers/assignments.py +++ b/bases/rsptx/web2py_server/applications/runestone/controllers/assignments.py @@ -406,7 +406,7 @@ def send_assignment_score_via_LTI(): .first() ) student_row = db((db.auth_user.username == sid)).select(db.auth_user.id).first() - _try_to_send_lti_grade(student_row.id, assignment.id) + _try_to_send_lti_grade(student_row.id, assignment.id, instructorTriggered=True) return json.dumps({"success": True}) diff --git a/bases/rsptx/web2py_server/applications/runestone/modules/rs_grading.py b/bases/rsptx/web2py_server/applications/runestone/modules/rs_grading.py index 800caec86..67fcbc4be 100644 --- a/bases/rsptx/web2py_server/applications/runestone/modules/rs_grading.py +++ b/bases/rsptx/web2py_server/applications/runestone/modules/rs_grading.py @@ -1247,7 +1247,9 @@ def _get_lti_record_for_course(course_id): # Sends LTI 1.1 or 1.3 as appropriate -def _try_to_send_lti_grade(student_row_num, assignment_id, force=False): +def _try_to_send_lti_grade( + student_row_num, assignment_id, force=False, instructorTriggered=False +): assignment = ( current.db((current.db.assignments.id == assignment_id)).select().first() ) @@ -1280,7 +1282,11 @@ def _try_to_send_lti_grade(student_row_num, assignment_id, force=False): if lti_method == "1.3": asyncio.get_event_loop().run_until_complete( attempt_lti1p3_score_update( - student_row_num, assignment.id, grade.score, force=force + student_row_num, + assignment.id, + grade.score, + force=force, + instructorTriggered=instructorTriggered, ) ) if lti_method == "1.1": diff --git a/components/rsptx/grading_helpers/regrade.py b/components/rsptx/grading_helpers/regrade.py index ca5abc02f..89490f6ab 100644 --- a/components/rsptx/grading_helpers/regrade.py +++ b/components/rsptx/grading_helpers/regrade.py @@ -315,6 +315,7 @@ async def _recompute_total_for_user( course_name: str, dry_run: bool = False, only_existing: bool = False, + instructorTriggered: bool = False, ) -> TotalChange: """Roll the student's ``question_grades`` up into their ``grades`` row. @@ -370,7 +371,9 @@ async def _recompute_total_for_user( manual_total=False, ) await upsert_grade(new_grade) - await attempt_lti1p3_score_update(user.id, assignment.id, total) + await attempt_lti1p3_score_update( + user.id, assignment.id, total, instructorTriggered=instructorTriggered + ) return change @@ -380,6 +383,7 @@ async def recompute_totals_detail( sids: Optional[List[str]] = None, dry_run: bool = False, only_existing: bool = False, + instructorTriggered: bool = False, ) -> List[TotalChange]: """Recompute assignment totals for the given students and report what moved. @@ -407,6 +411,7 @@ async def recompute_totals_detail( course.course_name, dry_run=dry_run, only_existing=only_existing, + instructorTriggered=instructorTriggered, ) ) except Exception as e: # pragma: no cover - defensive @@ -418,6 +423,7 @@ async def recompute_totals_for( course: CoursesValidator, assignment: AssignmentValidator, sids: Optional[List[str]] = None, + instructorTriggered: bool = False, ) -> int: """Recompute assignment totals (and push LTI 1.3 scores) for the given students. When ``sids`` is empty/None every student in the course is @@ -426,7 +432,11 @@ async def recompute_totals_for( This is used by the manual multi-grade flow, where individual grades are written through ``POST /grade`` (which does not itself recompute totals). """ - return len(await recompute_totals_detail(course, assignment, sids)) + return len( + await recompute_totals_detail( + course, assignment, sids, instructorTriggered=instructorTriggered + ) + ) async def regrade_batch( @@ -436,6 +446,7 @@ async def regrade_batch( assignment: AssignmentValidator, options: RegradeOptions, dry_run: bool = False, + instructorTriggered: bool = False, ) -> RegradeReport: """Run a re-grade over the student x question matrix. @@ -481,7 +492,10 @@ async def regrade_batch( if user is not None: try: await _recompute_total_for_user( - user, assignment, course.course_name + user, + assignment, + course.course_name, + instructorTriggered=instructorTriggered, ) except Exception as e: # pragma: no cover - defensive rslogger.error(f"recompute totals failed sid={sid}: {e}") diff --git a/test/components/rsptx/grading_helpers/test_regrade_batch.py b/test/components/rsptx/grading_helpers/test_regrade_batch.py index ed8ac517e..ff670df8b 100644 --- a/test/components/rsptx/grading_helpers/test_regrade_batch.py +++ b/test/components/rsptx/grading_helpers/test_regrade_batch.py @@ -161,6 +161,7 @@ async def test_rollup_uses_the_graded_course(): assert fetch_scores.await_args.args == (assignment.id, "testcourse", "student1") assert upsert.await_args.args[0].score == 7.0 assert lti.await_args.args[2] == 7.0 + assert lti.await_args.kwargs == {"instructorTriggered": False} # _effective_deadline @@ -324,6 +325,18 @@ async def test_recompute_totals_for_still_returns_the_processed_count(): assert processed == 1 +async def test_recompute_totals_for_forwards_instructor_triggered_flag(): + fu, fg, fs, up, lti = _patch_rollup( + SimpleNamespace(score=0, manual_total=False), [5] + ) + with fu, fg, fs, up, lti as lti_mock: + await regrade.recompute_totals_for( + _course(), _assignment(), ["student1"], instructorTriggered=True + ) + + assert lti_mock.await_args.kwargs == {"instructorTriggered": True} + + async def test_only_existing_skips_students_with_no_grade_row(): """A bulk repair should not materialise a 0 for a student who never had a total -- that would push a fresh zero to the LMS for a non-submitter.""" From 78f40414b5357913fef32ff0d30091fa0394ef22 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Wed, 5 Aug 2026 12:48:36 -0700 Subject: [PATCH 3/4] LTI1p3 remove out of date documentation --- components/rsptx/lti1p3/core.py | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/components/rsptx/lti1p3/core.py b/components/rsptx/lti1p3/core.py index cd6cd26d2..2310d632f 100644 --- a/components/rsptx/lti1p3/core.py +++ b/components/rsptx/lti1p3/core.py @@ -20,31 +20,6 @@ from rsptx.lti1p3.pylti1p3.service_connector import ServiceConnector from rsptx.lti1p3.pylti1p3.assignments_grades import AssignmentsGradesService -# ================================ -# Notes re LTI 1.3 Implementation -# ================================ -# -# Pathways for grades to get sent to LTI 1.3 platforms: -# 1. User does activity in book -# - grade_submission or score_reading_page call compute_total_score -# - compute_total_score calls attempt_lti1p3_score_update -# - scores are not pushed if assignment is not released -# 2. Instructor releases grades in grading interface -# - api call made to /runestone/admin/releasegrades -# - releasegrades calls attempt_lti1p3_score_updates if the grades are now released -# 3. Instructor presses send lti grades in grading interface -# - api call made to /runestone/admin/push_lti_grades -# - push_lti_grades calls attempt_lti1p3_score_updates -# 4. One of the following: -# - Relase Grade to LTI button pressed in LTI_ONLY mode -# - Assignment launched -# - peer.py send_lti_scores is called -# - Student hits calculate self grade button on assignment -# They call _try_to_send_lti_grade -# - _try_to_send_lti_grade calls attempt_lti1p3_score_update if is 1.3 - -# ================================ - def get_assignment_score_resource_id(course, assignment): """ From 0328dede9bcf214acc0959decd93ec8ea6b47849 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Wed, 5 Aug 2026 12:59:28 -0700 Subject: [PATCH 4/4] LTI1p3 raise exception for invalid user info --- bases/rsptx/admin_server_api/routers/lti1p3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bases/rsptx/admin_server_api/routers/lti1p3.py b/bases/rsptx/admin_server_api/routers/lti1p3.py index aa679ce66..19240e24e 100644 --- a/bases/rsptx/admin_server_api/routers/lti1p3.py +++ b/bases/rsptx/admin_server_api/routers/lti1p3.py @@ -199,7 +199,7 @@ async def login_or_create_user( user = await create_user(new_user) rslogger.info(f"LTI1p3 - Created {user.username} ({user.id})") except Exception as e: - HTTPException(status_code=400, detail=f"Error creating user '{e}'") + raise HTTPException(status_code=400, detail=f"Error creating user '{e}'") else: # have user, make sure their course_id/course_name are updated await update_user(