LTI Grade Synchronization#
The Grader Service includes an LTI 1.3 Advantage grade sync plugin that can push grades to one or more LTI-compatible platforms (e.g. Moodle, Canvas).
Overview#
When a grade sync is triggered - either manually via the REST API or automatically after feedback generation - the plugin:
Selects which configured LTI platforms to sync to.
For each platform, requests a bearer token via OAuth 2.0 client credentials with a signed JWT assertion.
Resolves the NRPS membership and AGS lineitem URLs for the lecture / assignment.
Fetches course members from the platform and matches them to grader submissions.
Creates or finds a lineitem for the assignment.
Pushes grades to the platform.
Errors on one platform do not prevent syncing to the remaining platforms.
Enabling the Plugin#
# grader_service_config.py
c.LTISyncGrades.enabled = True
# Optional: automatically sync grades when feedback is generated
c.LTISyncGrades.sync_on_feedback = True
enabled and sync_on_feedback accept either a bool value or a callable with the signature:
def check(lecture: dict, assignment: dict, submissions: list[dict]) -> bool
Configuring LTI Systems#
Each LTI platform is described as a dictionary in the systems list:
c.LTISyncGrades.systems = [
{
"name": "MyMoodle", # unique identifier (required)
"url_pattern": "https://moodle.university.edu", # base URL pattern (required)
"client_id": "my-client-id", # OAuth2 client ID (required)
"token_url": "https://moodle.university.edu/mod/lti/token.php", # token endpoint (required)
"private_key_path": "/secrets/lti/moodle_private_key.pem", # path to PEM-encoded RSA private key for JWT signing (required)
"platform_url": "https://moodle.university.edu",
"deployment_id": "1",
},
{
"name": "Canvas",
"url_pattern": "https://canvas.college.edu",
"client_id": "canvas-client-id",
"token_url": "https://canvas.college.edu/login/oauth2/token",
"platform_url": "https://canvas.college.edu",
"private_key_path": "/secrets/lti/canvas_private_key.pem",
"deployment_id": "2",
},
]
System Configuration Fields#
Field |
Required |
Default |
Description |
|---|---|---|---|
|
Yes |
- |
Unique identifier for this platform. |
|
Yes |
- |
Base URL of the LTI platform, used for matching. |
|
Yes |
- |
OAuth 2.0 client ID registered with the platform. |
|
Yes |
- |
OAuth 2.0 token endpoint of the platform. |
|
Yes |
|
Path to the PEM-encoded RSA private key file. |
|
No |
|
Base URL of the platform. |
|
No |
|
LTI deployment ID. |
Configurable Hooks#
Three hooks can be overridden to customize the sync behaviour:
resolve_lti_urls#
Resolves the LTI membership and lineitem URLs for a given platform, lecture and assignment.
This hook must be configured - the default raises NotImplementedError.
def my_resolve_lti_urls(platform, lecture, assignment, submissions):
"""
Args:
platform: LTIPlatformConfig instance for the target system.
lecture: Serialized lecture dict.
assignment: Serialized assignment dict.
submissions: List of serialized submission dicts.
Returns:
dict with keys ``lineitems_url`` and ``membership_url``.
"""
return {
"lineitems_url": f"{platform.platform_url}/mod/lti/services.php/{platform.deployment_id}/lineitems",
"membership_url": f"{platform.platform_url}/mod/lti/services.php/{platform.deployment_id}/memberships",
}
c.LTISyncGrades.resolve_lti_urls = my_resolve_lti_urls
username_match#
Matches an LTI member to a grader submission.
This hook must be configured - the default always returns False.
def my_username_match(member, submission, platform, log):
"""
Args:
member: LTI member dict from NRPS response.
submission: Grader submission dict.
platform: LTIPlatformConfig for the current system.
log: Logger instance.
Returns:
True if the member matches the submission's user.
"""
# Example: match by email
email = member.get("email", "")
username = submission.get("user", "").get("username", "")
return email.split("@")[0] == username
c.LTISyncGrades.username_match = my_username_match
select_systems_for_sync#
Controls which platforms are synced to for a given lecture / assignment. The default syncs to all configured platforms.
def my_select_systems(lecture, assignment, submissions, platforms):
"""
Args:
lecture: Serialized lecture dict.
assignment: Serialized assignment dict.
submissions: List of serialized submission dicts.
platforms: List of all LTIPlatformConfig instances.
Returns:
List of platform *names* to sync to.
"""
# Example: only sync to platforms whose url_pattern appears in lecture code
code = lecture.get("code", "")
return [p.name for p in platforms if p.url_pattern in code]
c.LTISyncGrades.select_systems_for_sync = my_select_systems
Key ID (kid) and JWT Signing#
Each platform’s JWT assertions include a kid (Key ID) header derived from the platform name:
kid = sha256(name.encode()).hexdigest()[:16]
This allows the receiving LTI platform to look up the correct public key from the JWKS endpoint.
JWKS Endpoint#
The Grader Service exposes a public JWKS (JSON Web Key Set) endpoint at:
GET /api/lti/jwks
This endpoint requires no authentication and returns the RSA public keys for all configured LTI platforms. Each key includes its kid, alg (RS256), and use (sig).
Example response:
{
"keys": [
{
"kty": "RSA",
"n": "...",
"e": "AQAB",
"kid": "b6f9abc123456789",
"alg": "RS256",
"use": "sig"
}
]
}
Register this URL in your LTI platform’s tool configuration so the platform can verify the Grader Service’s JWT assertions.
Helm Chart Configuration#
When deploying with the Helm chart, configure LTI in values.yaml:
ltiSyncGrades:
enabled: true
sync_on_feedback: false
systems:
- name: "MyMoodle"
url_pattern: "https://moodle.university.edu"
client_id: "my-client-id"
token_url: "https://moodle.university.edu/mod/lti/token.php"
platform_url: "https://moodle.university.edu"
private_key_path: "/secrets/lti/private_key.pem"
deployment_id: "1"
API Endpoint#
Trigger a grade sync via the REST API:
PUT /api/lectures/{lecture_id}/assignments/{assignment_id}/submissions/lti
The response contains results for each platform:
{
"synced_platforms": [
{
"platform": "MyMoodle",
"syncable_users": 42,
"synced_user": 40
},
{
"platform": "Canvas",
"syncable_users": 30,
"synced_user": 30
}
]
}
If a platform fails, its entry will include an error field:
{
"platform": "FailedPlatform",
"error": "Unable to request token...",
"syncable_users": 0,
"synced_user": 0
}