Skip to content

Commit

Permalink
Merge a418b67 into 2fd330f
Browse files Browse the repository at this point in the history
  • Loading branch information
giovannicimolin committed Apr 9, 2021
2 parents 2fd330f + a418b67 commit 6ed2fb3
Show file tree
Hide file tree
Showing 11 changed files with 320 additions and 81 deletions.
2 changes: 1 addition & 1 deletion docs/decisions/0003-lti-1p3-score-linking.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ LTI Advantage AGS Score Linking
Status
======

In Review
Replaced by :doc:`0005-lti-1p3-score-linking-improved.rst`

Context
=======
Expand Down
80 changes: 80 additions & 0 deletions docs/decisions/0005-lti-1p3-score-linking-improved.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
LTI Advantage AGS Score Linking
-------------------------------

Status
======

Accepted

Context
=======

LTI Advantage provides new ways for LTI tools to push grades back into the platform through the `Assignment and Grades Services (AGS)`_,
which don't map 1:1 with the grading and gradebook structure present in Open edX.

There are two models of interaction to pushing grades to the platform in the LTI AGS services:

1. Declarative: the platform creates a LineItem (equivalent of a gradebook line/grade) and tools can only push results to that item.
2. Programmatic: the tool uses the AGS endpoints to manage its own line items and grades. The tool is responsible for linking each line item to the resourceLinks, which means that a tool might not link a grade to its respective problem.

See a more detailed description in the `Coupled vs decoupled line items`_ section of the spec.

.. _`Assignment and Grades Services (AGS)`: https://www.imsglobal.org/spec/lti-ags/v2p0
.. _`Coupled vs decoupled line items`: https://www.imsglobal.org/spec/lti-ags/v2p0#coupled-vs-decoupled-line-items


Decisions
=========

To achieve full LTI Advantage compatibility on the platform we need to allow both the declarative and programmatic
interaction models to happen. In order to maximize tool support, we re-enabled the programmatic approach.
Note that this comes with it's caveats, explained in the consequences section below.

Given the platform's fixed gradebook structure, the declarative interaction model detailed in the
`LTI-AGS Spec - Declarative interaction model`_ is the default option when setting up an XBlock. This also ensures
we're not changing any setting of blocks already in use.

.. _`LTS-AGS Spec - Declarative interation model`: https://www.imsglobal.org/spec/lti-ags/v2p0#declarative-


Declarative grade handling
~~~~~~~~~~~~~~~~~~~~~~~~~~
This is the default configuration for an LTI 1.3 XBlock.
When the XBlock is set up, a LineItem will be created with the attributes listed in the table below:

.. list-table::
:widths: auto
:header-rows: 1

* - Attribute
- Value
* - lti_configuration
- LTI configuration just created.
* - resource_id
- Blank, this is used by LTI tools in the programmatic interaction model.
* - label
- The problem title, derived from the block's attributes.
* - score_maximum
- Maximum score for this given problem, derived from the block's attributes.
* - tag
- Blank, this is used by LTI tools in the programmatic interaction model.
* - start_date_time
- The problem's start date, if available in the block's attributes.
* - end_date_time
- The problem's end date, if available in the block's attributes.

Using the :code:`declarative` mode, the tool won't be able to manage LineItems, just retrieve them and post grades for students.


Programmatic grade handling
~~~~~~~~~~~~~~~~~~~~~~~~~~~
When the programmatic grade handling is enabled, no LineItems will be created when the consumer is instanced, but the tokens issued
by the Access Token endpoint will have the :code:`lineitem` scope, which allows creating and managing LineItems in the platform.


Consequences
============

* This will make the LTI Consumer XBlock fully compliant to the LTI-AGS specification if the programmatic interaction model is selected in the XBlock settings.
* The :code:`programmatic` approach delegates the grade linking and handling to the tool and scores will only be linked back to the gradebook if the tools sets
a valid :code:`resourceLinkId` as defined in the LTI-AGS specification.
4 changes: 2 additions & 2 deletions lti_consumer/lti_1p3/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ def enable_ags(
self,
lineitems_url,
lineitem_url=None,
allow_programatic_grade_interaction=False,
allow_programmatic_grade_interaction=False,
):
"""
Enable LTI Advantage Assignments and Grades Service.
Expand All @@ -506,7 +506,7 @@ def enable_ags(
self.ags = LtiAgs(
lineitems_url=lineitems_url,
lineitem_url=lineitem_url,
allow_creating_lineitems=allow_programatic_grade_interaction,
allow_creating_lineitems=allow_programmatic_grade_interaction,
results_service_enabled=True,
scores_service_enabled=True,
)
Expand Down
26 changes: 26 additions & 0 deletions lti_consumer/lti_1p3/extensions/rest_framework/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""
Utility functions for LTI views
"""
from rest_framework.negotiation import DefaultContentNegotiation


class IgnoreContentNegotiation(DefaultContentNegotiation):
"""
Helper class to ignore content negotiation on a few rest APIs.
This is used on views that only return a single content type and
content type. Skips the content negotiation step and returns
the available content type.
"""

def select_parser(self, request, parsers):
"""
Select the first parser in the `.parser_classes` list.
"""
return parsers[0]

def select_renderer(self, request, renderers, format_suffix=None):
"""
Select the first renderer in the `.renderer_classes` list.
"""
return (renderers[0], renderers[0].media_type)
16 changes: 16 additions & 0 deletions lti_consumer/lti_xblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,21 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
scope=Scope.settings,
help=_("Enter the LTI Advantage Deep Linking Launch URL. "),
)
lti_advantage_ags_mode = String(
display_name=_("LTI Assignment and Grades Service"),
values=[
{"display_name": _("Disabled"), "value": "disabled"},
{"display_name": _("Allow tools to submit grades only (declarative)"), "value": "declarative"},
{"display_name": _("Allow tools to manage and submit grade (programmatic)"), "value": "programmatic"},
],
default='declarative',
scope=Scope.settings,
help=_(
"Enable the LTI-AGS service and select the functionality enabled for LTI tools. "
"The 'declarative' mode (default) will provide a tool with a LineItem created from the XBlock settings, "
"while the 'programmatic' one will allow tools to manage, create and link the grades."
),
)

# LTI 1.1 fields
lti_id = String(
Expand Down Expand Up @@ -500,6 +515,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
'lti_version', 'lti_1p3_launch_url', 'lti_1p3_oidc_url', 'lti_1p3_tool_public_key',
# LTI Advantage variables
'lti_advantage_deep_linking_enabled', 'lti_advantage_deep_linking_launch_url',
'lti_advantage_ags_mode',
# LTI 1.1 variables
'lti_id', 'launch_url',
# Other parameters
Expand Down
50 changes: 29 additions & 21 deletions lti_consumer/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,30 +266,38 @@ def _get_lti_1p3_consumer(self):
)

# Check if enabled and setup LTI-AGS
if self.block.has_score:

default_values = {
'resource_id': self.block.location,
'score_maximum': self.block.weight,
'label': self.block.display_name,
}

if hasattr(self.block, 'start'):
default_values['start_date_time'] = self.block.start

if hasattr(self.block, 'due'):
default_values['end_date_time'] = self.block.due

# create LineItem if there is none for current lti configuration
lineitem, _ = LtiAgsLineItem.objects.get_or_create(
lti_configuration=self,
resource_link_id=self.block.location,
defaults=default_values
)
if self.block.lti_advantage_ags_mode != 'disabled':
lineitem = None
# If using the declarative approach, we should create a LineItem if it
# doesn't exist. This is because on this mode the tool is not able to create
# and manage lineitems using the AGS endpoints.
if self.block.lti_advantage_ags_mode == 'declarative':
# Set grade attributes
default_values = {
'resource_id': self.block.location,
'score_maximum': self.block.weight,
'label': self.block.display_name,
}

if hasattr(self.block, 'start'):
default_values['start_date_time'] = self.block.start

if hasattr(self.block, 'due'):
default_values['end_date_time'] = self.block.due

# create LineItem if there is none for current lti configuration
lineitem, _ = LtiAgsLineItem.objects.get_or_create(
lti_configuration=self,
resource_link_id=self.block.location,
defaults=default_values
)

consumer.enable_ags(
lineitems_url=get_lti_ags_lineitems_url(self.id),
lineitem_url=get_lti_ags_lineitems_url(self.id, lineitem.id),
lineitem_url=get_lti_ags_lineitems_url(self.id, lineitem.id) if lineitem else None,
allow_programmatic_grade_interaction=(
self.block.lti_advantage_ags_mode == 'programmatic'
)
)

# Check if enabled and setup LTI-DL
Expand Down
8 changes: 6 additions & 2 deletions lti_consumer/plugin/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
LineItemParser,
LineItemScoreParser,
)
from lti_consumer.lti_1p3.extensions.rest_framework.utils import IgnoreContentNegotiation

from lti_consumer.plugin.compat import (
run_xblock_handler,
run_xblock_handler_noauth,
Expand Down Expand Up @@ -344,7 +346,8 @@ def perform_create(self, serializer):
detail=True,
methods=['GET'],
url_path='results/(?P<user_id>[^/.]+)?',
renderer_classes=[LineItemResultsRenderer]
renderer_classes=[LineItemResultsRenderer],
content_negotiation_class=IgnoreContentNegotiation,
)
def results(self, request, user_id=None, **kwargs): # pylint: disable=unused-argument
"""
Expand Down Expand Up @@ -382,7 +385,8 @@ def results(self, request, user_id=None, **kwargs): # pylint: disable=unused-ar
detail=True,
methods=['POST'],
parser_classes=[LineItemScoreParser],
renderer_classes=[LineItemScoreRenderer]
renderer_classes=[LineItemScoreRenderer],
content_negotiation_class=IgnoreContentNegotiation,
)
def scores(self, request, *args, **kwargs): # pylint: disable=unused-argument
"""
Expand Down
56 changes: 43 additions & 13 deletions lti_consumer/signals.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
LTI Consumer related Signal handlers
"""
import logging

from django.db.models.signals import post_save
from django.dispatch import receiver
Expand All @@ -9,21 +10,50 @@
from lti_consumer.plugin import compat


log = logging.getLogger(__name__)


@receiver(post_save, sender=LtiAgsScore, dispatch_uid='publish_grade_on_score_update')
def publish_grade_on_score_update(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Publish grade to xblock whenever score saved/updated and its grading_progress is set to FullyGraded.
This method DOES NOT WORK on Studio, since it relies on APIs only available and configured
in the LMS. Trying to trigger this signal from Studio (from the Django-admin interface, for example)
throw an exception.
"""
if instance.grading_progress == LtiAgsScore.FULLY_GRADED:
block = compat.load_block_as_anonymous_user(instance.line_item.resource_link_id)
if not block.is_past_due() or block.accept_grades_past_due:
user = compat.get_user_from_external_user_id(instance.user_id)
# check if score_given is larger than score_maximum
score = instance.score_given if instance.score_given < instance.score_maximum else instance.score_maximum
compat.publish_grade(
block,
user,
score,
instance.score_maximum,
comment=instance.comment,
)
# Before starting to publish grades to the LMS, check that:
# 1. The grade being submitted in the final one - `FullyGraded`
# 2. This LineItem is linked to a LMS grade - the `LtiResouceLinkId` field is set
# 3. There's a valid grade in this score - `scoreGiven` is set
if instance.grading_progress == LtiAgsScore.FULLY_GRADED \
and instance.line_item.resource_link_id \
and instance.score_given:
try:
# Load block using LMS APIs and check if the block is graded and still accept grades.
block = compat.load_block_as_anonymous_user(instance.line_item.resource_link_id)
if block.has_score and (not block.is_past_due() or block.accept_grades_past_due):
# Map external ID to platform user
user = compat.get_user_from_external_user_id(instance.user_id)

# The LTI AGS spec allow tools to send grades higher than score maximum, so
# we have to cap the score sent to the gradebook to the maximum allowed value.
# Also, this is an normalized score ranging from 0 to 1.
score = min(instance.score_given, instance.score_maximum) / instance.score_maximum

# Set module score using XBlock custom method to do so.
# This saves the score on both the XBlock's K/V store as well as in
# the LMS database.
log.info(
"Publishing LTI grade from block %s to LMS. User: %s (score: %s)",
block.location,
user,
score,
)
block.set_user_module_score(user, score, block.max_score(), instance.comment)

# This is a catch all exception to catch and log any issues related to loading the block
# from the modulestore and other LMS API calls
except Exception as exc:
log.exception("Error while publishing grade to LMS: %s", exc)
raise exc
1 change: 1 addition & 0 deletions lti_consumer/static/js/xblock_studio_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function LtiConsumerXBlockInitStudio(runtime, element) {
"lti_1p3_launch_url",
"lti_1p3_oidc_url",
"lti_1p3_tool_public_key",
"lti_advantage_ags_mode",
"lti_advantage_deep_linking_enabled",
"lti_advantage_deep_linking_launch_url"
];
Expand Down
Loading

0 comments on commit 6ed2fb3

Please sign in to comment.