In “Using LTI Tools in Google Classroom” I showed how I used Google’s Classroom share button to assign a link that launches an LTI Tool. For example,
https://hostname/gc2lti/{nonce}?url={lti tool}
Where gc2lti
is routed to the Gc2LtiController, {nonce}
is a unique random number to differentiate between multiple assignments of the same LTI Tool, and {lti tool}
is the URL of the actual LTI Tool. Gc2LtiController receives the launch request from Google Classroom (a very plain GET request), uses Google APIs to create a well-formed LTI Basic Launch Request, and then POSTs it to the LTI Tool. However, that LTI request did not support outcomes. So, there was no way for the LTI Tool to send outcomes back to Google Classroom.
In this blog post I show how I added support for LTI 1.x outcomes.
I used Visual Studio Community 2017 and .NET Core 2.0 for everything in this POC, and I’ve put the entire solution (both versions) up in github. There is also a running version of this version (with outcomes) on Azure. |
Google Classroom Requirements
Google has two requirements for sending grades back to Google Classroom via the Google Classroom API:
- Tools (or apps…I use the terms interchangeably) can only assign grades to assignments created using the same Web Client ID. Tools cannot assign grades to assignments the teacher manually created, or to assignments created by other apps, including the Google share button. That meant creating a replacement for the Classroom share button that uses my Web Client ID.
- Only the teacher can assign a grade to an assignment. If the Tool was launched by a student, then the Tool must impersonate the teacher that used the Tool to create the assignment, when it comes time to send the grade back to Google Classroom. That means capturing the teacher’s OAuth token when they create the assignment.
Once those two requirements are dealt with, assigning the grade is simple. Let’s get started.
Custom Classroom Share Button
My version of a custom Classroom share button is in the HomeController
with associated razor views. The default page (/Index) does not require authentication. The Index
action displays a sample “catalog” page with the custom share button. When you click on the button, it opens a new window and starts the sharing process with the
Course
action,
<a href="Share" title="Share to Classroom" onclick="share('@Model.Url', '@Model.Title', '@Model.Description');return false;"> <img src="images/32x32_yellow_stroke_icon@1x.png"/> Share to Classroom </a> <script type="text/javascript"> function share(shareUrl, title, description) { window.open(`Home/Course?url=${encodeURI(shareUrl)}&title=${title}&description=${description}`, "_blank", "toolbar=no,width=640,height=400,left=100,top=100"); return false; } </script>
The Course
action does require Google authorization and this is when the teacher’s authorization token is captured for later use when saving the grade in Google Classroom.
var result = await new AuthorizationCodeMvcApp(this, new AppFlowTeacherMetadata(ClientId, ClientSecret, Db)) .AuthorizeAsync(cancellationToken) .ConfigureAwait(false); if (!await SaveOfflineToken(cancellationToken, classroomService, result)) { return RedirectToAction("Course", model); }
Normally the AuthResult
returned by AuthorizationCodeMvcApp
has an AccessToken
that expires in 1 hour, but no RefreshToken
. This is sometimes called the “automatic” AccessToken
. But I need an AuthResult
that includes a RefreshToken
so that the grade can be recorded hours or days later.
AuthorizationCodeMvcApp
will only return an AuthResult
with a RefreshToken
when the user explicitly grants the Tool permission. This is sometimes called the “offline” AccessToken
. SaveOfflineToken
checks to see if the AuthResult
has a RefreshToken
. If not, the user’s credential is revoked and the user is redirected back to the Course
action. This time through, Google will require the user to login and explicitly grant permission to the Tool. Once I have an AuthResult
with a RefreshToken
, I save it and a reference to the corresponding UserId
so I can retrieve it later.
Once the AuthResult with an “offline” token is recorded, the user (teacher) picks the Google Course which will get the assignment, and then is sent to the Confirm
action. Confirm
asks the teacher to confirm the title, instructions, LTI Tool URL, and the maximum number of points possible for this assignment.
If an assignment does not have MaxPoints, Google will not display the grade to the either the teacher or the student.
When the user clicks on Assign, the Assign
action is invoked which creates the Google CourseWork in the Course.
When the CourseWork is created, a Google server will GET the link URL to grab a screenshot and link title. Even if the link title is specified in the API call, Google will overwrite it with the title of the web page at https://hostname/gc2lti. For that reason, Gc2LtiController tries to identify these probing GETs and return a nice looking thumbnail with a page title that matches the assignment title.
Converting a Launch from Google Classroom into an LTI Basic Launch Request
In the previous post I showed how to use Google’s Classroom share button to insert a specially formatted link for the LTI Tool so that a simple GET request (which is all you can count on from Google) can be turned into an LTI Basic Launch Request. This version of the POC works the same way…the custom Classroom share button inserts a similar special link so that the Gc2LtiController
can intercept the GET request, attach all the required LTI parameters, and POST it to the LTI Tool.
There is one big enhancement in this version of Gc2LtiController
versus the previous version. This version of Gc2LtiController
fills in LisResultSourcedId
and LisOutcomeServiceUrl
so the Tool knows to send outcomes.
LisOutcomeServiceUrl
points to a new OutcomesController
. And LisResultSourcedId
includes the Google CourseId, CourseWorkId, StudentId, and TeacherId,
var lisResultSourcedId = new LisResultSourcedId { CourseId = ltiRequest.ContextId, CourseWorkId = ltiRequest.ResourceLinkId, StudentId = ltiRequest.UserId, TeacherId = courseWork.CreatorUserId }; ltiRequest.LisResultSourcedId = JsonConvert.SerializeObject(lisResultSourcedId, Formatting.None);
Converting an LTI Outcomes Request into a Google Classroom Grade
So now the LTI Tool has been launched and it is time to send a grade back to the assignment in Google Classroom.
OutcomesController
receives the LTI Outcomes request from the Tool, parses LisResultSourcedId
into CourseId, CourseWorkId, StudentId, and TeacherId; and then calls the Google Classroom API to set the grade for the assignment.
Google Classroom grades are part of student CourseWork Submissions. There are two types of grades: assignedGrade and draftGrade. Only teachers see draftGrade. It is primarily for situations where the teacher must evaluate the submission before assigning a final grade (assignedGrade). Students see assignedGrade. OutcomesController
reads the assignedGrade and writes both the assignedGrade and draftGrade.
The Google Classroom API supports reading (get) and writing (patch) grades, but not support deleting deleting them. OutcomesController
handles all 3 LTI Outcomes requests (ReadResult
, ReplaceResult
, and DeleteResult
), but returns Not Implemented
when the request is to DeleteResult
.
ReplaceResult
When the request is to ReplaceResult
, OutcomesController
performs these steps:
- Authenticate the LTI Request.
- Lookup the “offline” TokenResponse for the TeacherId.
- Using TokenResponse, create a UserCredential for the teacher.
- Using the teacher’s UserCredential, create an instance of the ClassroomService.
- Using the ClassroomService, get the CourseWork for the assignment.
- Then get the student’s StudentSubmission.
- Set the AssignedGrade and DraftGrade = {LTI Result} * CourseWork.MaxPoints.
1. Authenticate the LTI Request
I took several shortcuts in the POC. For example, I do not use .ConfigureAwait(false)
on any of the async
calls. And I have hardcoded the “secret”.
var response = new ReplaceResultResponse(); var ltiRequest = await Request.ParseLtiRequestAsync(); var signature = ltiRequest.GenerateSignature("secret"); if (!ltiRequest.Signature.Equals(signature)) { response.StatusCode = StatusCodes.Status401Unauthorized; return response; }
2. Lookup the “offline” TokenResponse for the TeacherId
The RefreshToken in the “offline” TokenResponse will work until the user revokes permissions for your Tool (or you change the permissions (Scopes) you ask for). If you can’t save a grade due to insufficient permissions, invalidate the teacher’s AccessToken and then have the student ask their teacher to launch the assignment. The teacher will be asked to grant your Tool the necessary permissions.
var lisResultSourcedId = JsonConvert.DeserializeObject<LisResultSourcedId>(arg.Result.SourcedId); var googleUser = await Db.GoogleUsers.FindAsync(lisResultSourcedId.TeacherId); var appFlow = new AppFlowTeacherMetadata(ClientId, ClientSecret, Db); var token = await appFlow.Flow.LoadTokenAsync(googleUser.UserId, CancellationToken.None);
3. Using the TokenResponse, create a UserCredential for the teacher
var credential = new UserCredential(appFlow.Flow, googleUser.UserId, token);
4. Using the teacher’s UserCredential, create an instance of the ClassroomService
using (var classroomService = new ClassroomService(new BaseClientService.Initializer { HttpClientInitializer = credential, ApplicationName = "gc2lti" }))
5. Using the ClassroomService, get the CourseWork for the assignment
var courseWorkRequest = classroomService.Courses.CourseWork.Get ( lisResultSourcedId.CourseId, lisResultSourcedId.CourseWorkId ); var courseWork = await courseWorkRequest.ExecuteAsync();
6. Then get the student’s StudentSubmission
var submissionsRequest = classroomService.Courses.CourseWork.StudentSubmissions.List ( lisResultSourcedId.CourseId, lisResultSourcedId.CourseWorkId ); submissionsRequest.UserId = lisResultSourcedId.StudentId; var submissionsResponse = await submissionsRequest.ExecuteAsync(); if (submissionsResponse.StudentSubmissions == null) { response.StatusCode = StatusCodes.Status404NotFound; response.StatusDescription = "Submission was not found."; return response; } var submission = submissionsResponse.StudentSubmissions.FirstOrDefault();
7. Finally, set the AssignmentGrade and DraftGrade
LTI Results are always between 0.0 and 1.0. So, if the LTI Result is 0.51 and the CourseWork.MaxPoints are 100, then AssignedGrade = 51. Students will see 51/100.
if (submission == null) { response.StatusCode = StatusCodes.Status404NotFound; response.StatusDescription = "Submission was not found."; } else { submission.AssignedGrade = arg.Result.Score * courseWork.MaxPoints; submission.DraftGrade = submission.AssignedGrade; var patchRequest = classroomService.Courses.CourseWork.StudentSubmissions.Patch ( submission, submission.CourseId, submission.CourseWorkId, submission.Id ); patchRequest.UpdateMask = "AssignedGrade,DraftGrade"; await patchRequest.ExecuteAsync(); response.StatusDescription = $"Score={arg.Result.Score}, AssignedGrade={submission.AssignedGrade}."; }
ReadResult
When the request is to ReadResult
, OutcomesController performs similar steps, then returns {LTI Result} = StudentSubmission.AssignedGrade / CourseWork.MaxPoints.
Very cool