Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bases/rsptx/admin_server_api/routers/lti1p3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
27 changes: 22 additions & 5 deletions bases/rsptx/assignment_server_api/routers/grader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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} "
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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}"
Expand All @@ -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} "
Expand Down
7 changes: 6 additions & 1 deletion bases/rsptx/rsmanage/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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})


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
Expand Down Expand Up @@ -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":
Expand Down
20 changes: 17 additions & 3 deletions components/rsptx/grading_helpers/regrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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


Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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.

Expand Down Expand Up @@ -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}")
Expand Down
79 changes: 46 additions & 33 deletions components/rsptx/lti1p3/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -93,13 +68,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,
):
Comment on lines 92 to 98
"""
Attempt to send a score update to any linked LTI 1.3 tools for a given user and assignment.
Expand All @@ -109,6 +104,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)
Expand All @@ -120,17 +116,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)
Expand All @@ -148,21 +150,26 @@ 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,
)


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.

: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}")

Expand Down Expand Up @@ -263,15 +270,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:
Expand Down
13 changes: 13 additions & 0 deletions test/components/rsptx/grading_helpers/test_regrade_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
Loading
Loading