Sending LTI Outcomes to Google Classroom

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:

  1. 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.
  2. 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(thisnew 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:

  1. Authenticate the LTI Request.
  2. Lookup the “offline” TokenResponse for the TeacherId.
  3. Using TokenResponse, create a UserCredential for the teacher.
  4. Using the teacher’s UserCredential, create an instance of the ClassroomService.
  5. Using the ClassroomService, get the CourseWork for the assignment.
  6. Then get the student’s StudentSubmission.
  7. 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.

This entry was posted in Google, LTI and tagged , , , , , . Bookmark the permalink.

1 Response to Sending LTI Outcomes to Google Classroom

  1. James Rissler says:

    Very cool

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.