-
Notifications
You must be signed in to change notification settings - Fork 81
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
Changes from 7 commits
f3422fc
94e33ca
d1203e8
82f7084
ef0cc83
48e50d2
9467b86
309b323
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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' |
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
|
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' |
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` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @nedbat Yes, this serializer should be moved from the core to There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
""" | ||
# 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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
||
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', | ||
) |
There was a problem hiding this comment.
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
andhttps://purl.imsglobal.org/spec/lti-ags/scope/score
here?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@nedbat The
results
andscore
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.