Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BD-24] [TNL-7318]: LTI AGS Implementation - LineItem Service #92

Merged
Merged
Show file tree
Hide file tree
Changes from 7 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
3 changes: 2 additions & 1 deletion lti_consumer/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Admin views for LTI related models.
"""
from django.contrib import admin
from lti_consumer.models import LtiConfiguration
from lti_consumer.models import LtiAgsLineItem, LtiConfiguration


class LtiConfigurationAdmin(admin.ModelAdmin):
Expand All @@ -15,3 +15,4 @@ class LtiConfigurationAdmin(admin.ModelAdmin):


admin.site.register(LtiConfiguration, LtiConfigurationAdmin)
admin.site.register(LtiAgsLineItem)
70 changes: 70 additions & 0 deletions lti_consumer/lti_1p3/ags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""
LTI Advantage Assignments and Grades service implementation
"""


class LtiAgs:
"""
LTI Advantage Consumer

Implements LTI Advantage Services and ties them in
with the LTI Consumer. This only handles the LTI
message claim inclusion and token handling.

Available services:
* Assignments and Grades services (partial support)

Reference: https://www.imsglobal.org/lti-advantage-overview
"""
def __init__(
self,
lineitems_url,
allow_creating_lineitems=True,
results_service_enabled=True,
scores_service_enabled=True
):
"""
Instance class with LTI AGS Global settings.
"""
# If the platform allows creating lineitems, set this
# to True.
self.allow_creating_lineitems = allow_creating_lineitems

# Result and scores services
self.results_service_enabled = results_service_enabled
self.scores_service_enabled = scores_service_enabled

# Lineitems urls
self.lineitems_url = lineitems_url

def get_available_scopes(self):
"""
Retrieves list of available token scopes in this instance.
"""
scopes = []

if self.allow_creating_lineitems:
scopes.append('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem')
else:
scopes.append('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly')

if self.results_service_enabled:
scopes.append('https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly')

if self.scores_service_enabled:
scopes.append('https://purl.imsglobal.org/spec/lti-ags/scope/score')

return scopes

def get_lti_ags_launch_claim(self):
"""
Returns LTI AGS Claim to be injected in the LTI launch message.
"""
ags_claim = {
"https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": {
"scope": self.get_available_scopes(),
"lineitems": self.lineitems_url,
}
}

return ags_claim
7 changes: 6 additions & 1 deletion lti_consumer/lti_1p3/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@
"scope",
])

LTI_1P3_ACCESS_TOKEN_SCOPES = []

LTI_1P3_ACCESS_TOKEN_SCOPES = [
# LTI-AGS Scopes
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have to add https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly and https://purl.imsglobal.org/spec/lti-ags/scope/score here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nedbat The results and score services are being implemented in separate tasks (https://openedx.atlassian.net/browse/TNL-7331 and https://openedx.atlassian.net/browse/TNL-7332) to keep the diff size smaller.

There's already a WIP PR out, based on this branch: #108. Which will be rebased when this lands.



class LTI_1P3_CONTEXT_TYPE(Enum): # pylint: disable=invalid-name
Expand Down
55 changes: 55 additions & 0 deletions lti_consumer/lti_1p3/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
LTI_1P3_CONTEXT_TYPE,
)
from .key_handlers import ToolKeyHandler, PlatformKeyHandler
from .ags import LtiAgs


class LtiConsumer1p3:
Expand Down Expand Up @@ -436,3 +437,57 @@ def set_extra_claim(self, claim):
if not isinstance(claim, dict):
raise ValueError('Invalid extra claim: is not a dict.')
self.extra_claims.update(claim)


class LtiAdvantageConsumer(LtiConsumer1p3):
"""
LTI Advantage Consumer Implementation.

Builds on top of the LTI 1.3 consumer and adds support for
the following LTI Advantage Services:

* Assignments and Grades Service (LTI-AGS): Allows tools to
retrieve and send back grades into the platform.
Note: this is a partial implementation with read-only LineItems.
Reference spec: https://www.imsglobal.org/spec/lti-ags/v2p0
"""
def __init__(self, *args, **kwargs):
"""
Override parent class and set up required LTI Advantage variables.
"""
super(LtiAdvantageConsumer, self).__init__(*args, **kwargs)

# LTI AGS Variables
self.ags = None

@property
def lti_ags(self):
"""
Returns LTI AGS class or throw exception if not set up.
"""
if not self.ags:
raise exceptions.LtiAdvantageServiceNotSetUp(
"The LTI AGS service was not set up for this consumer."
)

return self.ags

def enable_ags(
self,
lineitems_url,
):
"""
Enable LTI Advantage Assignments and Grades Service.

This will include the LTI AGS Claim in the LTI message
and set up the required class.
"""
self.ags = LtiAgs(
lineitems_url=lineitems_url,
allow_creating_lineitems=True,
results_service_enabled=True,
scores_service_enabled=True
)

# Include LTI AGS claim inside the LTI Launch message
self.set_extra_claim(self.ags.get_lti_ags_launch_claim())
4 changes: 4 additions & 0 deletions lti_consumer/lti_1p3/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,7 @@ class RsaKeyNotSet(Lti1p3Exception):

class PreflightRequestValidationFailure(Lti1p3Exception):
pass


class LtiAdvantageServiceNotSetUp(Lti1p3Exception):
pass
16 changes: 16 additions & 0 deletions lti_consumer/lti_1p3/extensions/rest_framework/parsers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""
LTI 1.3 Django extensions - Content parsers

Used by DRF views to render content in LTI APIs.
"""

from rest_framework import parsers


class LineItemParser(parsers.JSONParser):
"""
Line Item Parser.

It's the same as JSON parser, but uses a custom media_type.
"""
media_type = 'application/vnd.ims.lis.v2.lineitem+json'
44 changes: 44 additions & 0 deletions lti_consumer/lti_1p3/extensions/rest_framework/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""
Django REST Framework extensions for LTI 1.3 & LTI Advantage implementation.

Implements a custom authorization classes to be used by any of the
LTI Advantage extensions.
"""
from rest_framework import permissions


class LtiAgsPermissions(permissions.BasePermission):
"""
LTI AGS Permissions.

This checks if the token included in the request
has the allowed scopes to read/write LTI AGS items
(LineItems, Results, Score).

LineItem scopes: https://www.imsglobal.org/spec/lti-ags/v2p0#scope-and-allowed-http-methods
Results: Not implemented yet.
Score: Not implemented yet.
"""
def has_permission(self, request, view):
# Retrieves token from request, which was already checked by
# the Authentication class, so we assume it's a sane value.
auth_token = request.headers.get('Authorization', '').split()[1]
giovannicimolin marked this conversation as resolved.
Show resolved Hide resolved

if view.action in ['list', 'retrieve']:
# We don't need to wrap this around a try-catch because
# the token was already tested by the Authentication class.
has_perm = request.lti_consumer.check_token(
auth_token,
[
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
],
)
return has_perm
elif view.action in ['create', 'update', 'partial_update', 'delete']:
has_perm = request.lti_consumer.check_token(
auth_token,
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem'
giovannicimolin marked this conversation as resolved.
Show resolved Hide resolved
)
return has_perm
return False
giovannicimolin marked this conversation as resolved.
Show resolved Hide resolved
28 changes: 28 additions & 0 deletions lti_consumer/lti_1p3/extensions/rest_framework/renderers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""
LTI 1.3 Django extensions - Content renderers

Used by DRF views to render content in LTI APIs.
"""
from rest_framework import renderers


class LineItemsRenderer(renderers.JSONRenderer):
"""
Line Items Renderer.

It's a JSON parser, but uses a custom media_type.
giovannicimolin marked this conversation as resolved.
Show resolved Hide resolved
Reference: https://www.imsglobal.org/spec/lti-ags/v2p0#media-types-and-schemas
"""
media_type = 'application/vnd.ims.lis.v2.lineitemcontainer+json'
format = 'json'


class LineItemRenderer(renderers.JSONRenderer):
"""
Line Item Renderer.

It's a JSON parser, but uses a custom media_type.
giovannicimolin marked this conversation as resolved.
Show resolved Hide resolved
Reference: https://www.imsglobal.org/spec/lti-ags/v2p0#media-types-and-schemas
"""
media_type = 'application/vnd.ims.lis.v2.lineitem+json'
format = 'json'
91 changes: 91 additions & 0 deletions lti_consumer/lti_1p3/extensions/rest_framework/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""
Serializers for LTI-related endpoints
"""
import six
giovannicimolin marked this conversation as resolved.
Show resolved Hide resolved

from rest_framework import serializers
from rest_framework.reverse import reverse
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey

from lti_consumer.models import LtiAgsLineItem


class UsageKeyField(serializers.Field):
"""
Serializer field for a model UsageKey field.

Recreated here since we cannot import directly from
from the platform like so:
`from openedx.core.lib.api.serializers import UsageKeyField`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this means this code should go into the opaque-keys library? (Not now, but eventually.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nedbat Yes, this serializer should be moved from the core to opaque-keys.
If you prefer, I can move this to compat.py and mock the serializer in tests.
What do you say?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am fine with merging this as is, and bringing up this need with the owners of opaque-keys to address.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nedbat Thanks for that! Can you ping the owners on this use case? I'm not sure if there's other places duplicating this code (or avoiding UsageKey serialization on plugins at all).

"""
# pylint: disable=arguments-differ
def to_representation(self, data):
"""Convert a usage key to unicode. """
return six.text_type(data)

def to_internal_value(self, data):
"""Convert unicode to a usage key. """
try:
return UsageKey.from_string(data)
except InvalidKeyError:
raise serializers.ValidationError(u"Invalid usage key: {}".format(data))


class LtiAgsLineItemSerializer(serializers.ModelSerializer):
"""
LTI AGS LineItem Serializer.

This maps out the internally stored LineItemParameters to
the LTI-AGS API Specification, as shown in the example
response below:

{
"id" : "https://lms.example.com/context/2923/lineitems/1",
"scoreMaximum" : 60,
"label" : "Chapter 5 Test",
"resourceId" : "a-9334df-33",
"tag" : "grade",
"resourceLinkId" : "1g3k4dlk49fk"
"startDateTime": "2018-03-06T20:05:02Z"
"endDateTime": "2018-04-06T22:05:03Z"
giovannicimolin marked this conversation as resolved.
Show resolved Hide resolved
}

Note: The platform MUST NOT modify the 'resourceId', 'resourceLinkId' and 'tag' values.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what this means. Why would a serializer ever modify anything?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nedbat That was a straight copy from the IMS LTI-AGS spec (here). I've moved this to the model since it makes more sense to have it there.


Reference:
https://www.imsglobal.org/spec/lti-ags/v2p0#example-application-vnd-ims-lis-v2-lineitem-json-representation
"""
# Id needs to be overriden and be a URL to the LineItem endpoint
id = serializers.SerializerMethodField()

# Mapping from snake_case to camelCase
resourceId = serializers.CharField(source='resource_id')
scoreMaximum = serializers.IntegerField(source='score_maximum')
resourceLinkId = UsageKeyField(required=False, source='resource_link_id')
startDateTime = serializers.DateTimeField(required=False, source='start_date_time')
endDateTime = serializers.DateTimeField(required=False, source='end_date_time')

def get_id(self, obj):
request = self.context.get('request')
return reverse(
'lti_consumer:lti-ags-view-detail',
kwargs={
'lti_config_id': obj.lti_configuration.id,
'pk': obj.pk
},
request=request,
)

class Meta:
model = LtiAgsLineItem
fields = (
'id',
'resourceId',
'scoreMaximum',
'label',
'tag',
'resourceLinkId',
'startDateTime',
'endDateTime',
)
Loading