Skip to content

Commit

Permalink
Add initial version of LineItem Implementation
Browse files Browse the repository at this point in the history
Signed-off-by: Giovanni Cimolin da Silva <[email protected]>
  • Loading branch information
giovannicimolin committed Sep 17, 2020
1 parent 425d5c2 commit acaca93
Show file tree
Hide file tree
Showing 33 changed files with 1,211 additions and 16 deletions.
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 @@ -44,7 +44,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',
]


class LTI_1P3_CONTEXT_TYPE(Enum): # pylint: disable=invalid-name
Expand Down
56 changes: 56 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 @@ -378,6 +379,7 @@ def access_token(self, token_request_data):
"access_token": self.key_handler.encode_and_sign(
{
"sub": self.client_id,
"iss": self.iss,
"scopes": scopes_str
},
# Create token valid for 3600 seconds (1h) as per specification
Expand Down Expand Up @@ -435,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())
8 changes: 8 additions & 0 deletions lti_consumer/lti_1p3/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ class TokenSignatureExpired(Lti1p3Exception):
pass


class UnauthorizedToken(Lti1p3Exception):
pass


class NoSuitableKeys(Lti1p3Exception):
pass

Expand Down Expand Up @@ -47,3 +51,7 @@ class RsaKeyNotSet(Lti1p3Exception):

class PreflightRequestValidationFailure(Lti1p3Exception):
pass


class LtiAdvantageServiceNotSetUp(Lti1p3Exception):
pass
Empty file.
Empty file.
75 changes: 75 additions & 0 deletions lti_consumer/lti_1p3/extensions/rest_framework/authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""
Django REST Framework extensions for LTI 1.3 & LTI Advantage implementation.
Implements a custom authentication class to be used by LTI Advantage extensions.
"""
from django.utils.translation import ugettext as _
from rest_framework import authentication
from rest_framework import exceptions

from lti_consumer.models import LtiConfiguration


class Lti1p3ApiAuthentication(authentication.BaseAuthentication):
"""
LTI 1.3 Token based authentication.
Clients should authenticate by passing the token key in the "Authorization".
LTI 1.3 expects a token like the following:
Authorization: Bearer jwt-token
Since the base implementation of this library uses JWT tokens, we expect
a RSA256 signed token that contains the allowed scopes.
"""
keyword = 'Bearer'

def authenticate(self, request):
"""
Authenticate an LTI 1.3 Tool.
This doesn't return a user, but let's the external access and commit
changes.
TODO: Consider creating an user for LTI operations, both to keep track
of changes and to use Django's authorization flow.
"""
auth = request.headers.get('Authorization', '').split()
lti_config_id = request.parser_context['kwargs'].get('lti_config_id')

# Check if auth token is present on request and is correctly formatted.
if not auth or auth[0].lower() != self.keyword.lower():
msg = _('Missing LTI 1.3 authentication token.')
raise exceptions.AuthenticationFailed(msg)

if len(auth) == 1:
msg = _('Invalid token header. No credentials provided.')
raise exceptions.AuthenticationFailed(msg)

if len(auth) > 2:
msg = _('Invalid token header. Token string should not contain spaces.')
raise exceptions.AuthenticationFailed(msg)

# Retrieve LTI configuration or fail if it doesn't exist
try:
lti_configuration = LtiConfiguration.objects.get(pk=lti_config_id)
lti_consumer = lti_configuration.get_lti_consumer()
except:
msg = _('LTI configuration not found.')
raise exceptions.AuthenticationFailed(msg)

# Verify token validity
# This doesn't validate specific permissions, just checks if the token
# is valid or not.
try:
lti_consumer.check_token(auth[1])
except:
msg = _('Invalid token signature.')
raise exceptions.AuthenticationFailed(msg)

# Passing parameters back to the view through the request.
# Not exactly optimal.
request.lti_configuration = lti_configuration
request.lti_consumer = lti_consumer

# Return (None, None) since this isn't tied to any authentication
# backend on Django, and it's just used for LTI endpoints.
return (None, None)
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]

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'
)
return has_perm
return False
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.
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.
Reference: https://www.imsglobal.org/spec/lti-ags/v2p0#media-types-and-schemas
"""
media_type = 'application/vnd.ims.lis.v2.lineitem+json'
format = 'json'
Empty file.
Empty file.
Loading

0 comments on commit acaca93

Please sign in to comment.