pylti1.3 icon indicating copy to clipboard operation
pylti1.3 copied to clipboard

Unable to link a programmatic grade

Open Jack-2025 opened this issue 4 years ago • 6 comments

Hello everyone,

I wanted to confirm whether this an issue with pylti1.3 or if I'm missing something, when adding multiple assignment grades through the LTI Assigments and Grades Services with the programmatic mode active, rather than the declarative/default grading mode.

I'm able to register and read back grades (line items) into the LMS. But none of them are registered as the actual score on the LMS side. If my understanding is correct, one of these line items needs to be selected by specifying the resourceLinkId. pylti1.3 does allow setting the resource_id, but not the resource_link_id.

Am I missing something?

I'm using as reference this score() function from the repo

def score(launch_id, earned_score, time_spent):
    tool_conf = ToolConfJsonFile(get_lti_config_path())
    flask_request = FlaskRequest()
    launch_data_storage = get_launch_data_storage()
    message_launch = ExtendedFlaskMessageLaunch.from_cache(launch_id, flask_request, tool_conf,
                                                           launch_data_storage=launch_data_storage)

    resource_link_id = message_launch.get_launch_data() \
        .get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {}).get('id')

    if not message_launch.has_ags():
        raise Forbidden("Don't have grades!")

    sub = message_launch.get_launch_data().get('sub')
    timestamp = datetime.datetime.utcnow().isoformat() + 'Z'
    earned_score = int(earned_score)
    time_spent = int(time_spent)

    grades = message_launch.get_ags()
    sc = Grade()
    sc.set_score_given(earned_score) \
        .set_score_maximum(100) \
        .set_timestamp(timestamp) \
        .set_activity_progress('Completed') \
        .set_grading_progress('FullyGraded') \
        .set_user_id(sub)

    sc_line_item = LineItem()
    sc_line_item.set_tag('score') \
        .set_score_maximum(100) \
        .set_label('Score')
    if resource_link_id:
        sc_line_item.set_resource_id(resource_link_id)

    grades.put_grade(sc, sc_line_item)

    tm = Grade()
    tm.set_score_given(time_spent) \
        .set_score_maximum(999) \
        .set_timestamp(timestamp) \
        .set_activity_progress('Completed') \
        .set_grading_progress('FullyGraded') \
        .set_user_id(sub)

    tm_line_item = LineItem()
    tm_line_item.set_tag('time') \
        .set_score_maximum(999) \
        .set_label('Time Taken')
    if resource_link_id:
        tm_line_item.set_resource_id(resource_link_id)

    result = grades.put_grade(tm, tm_line_item)

    return jsonify({'success': True, 'result': result.get('body')})

Jack-2025 avatar Nov 07 '21 04:11 Jack-2025

hi @Jack-2025

To be honest i don't understand what it the difference between "programmatic" and "declarative" grading modes from your message but as i understand you've asked how to send grades into correct gradebook endpoint of the external LMS, right?

To implement this you need only two things: lineitem ID and external user ID. And both of them could be retrieved from the LTI initial launch message.

Here is an example:

  1. LTI launch. Saving all necessary data in the DB (process 1):
class Assignment:

    def __init__(self, title, score, lti_lineitem: str, lti_jwt_sub: str):
        self.title = title
        self.score = score
        self.lti_lineitem = lti_lineitem
        self.lti_jwt_sub = lti_jwt_sub

    def save_to_db(self):
        pass


    @classmethod
    def restore_from_db(cls)
        pass


message_launch_data = message_launch.get_launch_data()
lti_jwt_sub = message_launch_data.get('sub')  # external user id
endpoint = message_launch_data.get('https://purl.imsglobal.org/spec/lti-ags/claim/endpoint', {})
lineitem = endpoint.get('lineitem')

assignment = Assignment('Test', 0.5, lineitem, external_user_id)
assignment.save_to_db()
  1. Sending score to the existing external gradebook (process 2):
assignment = Assignment.restore_from_db()

ags = message_launch.get_ags()

line_item = ags.find_lineitem_by_id(assignment.lti_lineitem)
if not line_item:
    raise OutcomeServiceSendScoreError("Lineitem not found in the external LMS: " + assignment.lti_lineitem)

timestamp = datetime.datetime.utcnow().isoformat()

# score: 0 <= assignment.score <= 1
gr = Grade()
gr.set_score_given(assignment.score)\
  .set_score_maximum(1)\
  .set_timestamp(timestamp)\
  .set_activity_progress('Submitted')\
  .set_grading_progress('FullyGraded')\
  .set_user_id(assignment.lti_jwt_sub)

result = ags.put_grade(gr, line_item)

pylti1.3 does allow setting the resource_id, but not the resource_link_id.

In my opinion it doesn't matter. You can find any lineitem manually using built-in method: ags.find_lineitem(prop_name, prop_value) where prop_name could be id / tag / resourceId / anything

dmitry-viskov avatar Nov 08 '21 14:11 dmitry-viskov

If JWT body doesn't contain https://purl.imsglobal.org/spec/lti-ags/claim/endpoint / lineitem keys it means that LMS item is ungraded.

dmitry-viskov avatar Nov 08 '21 14:11 dmitry-viskov

@dmitry-viskov edx assumes that resourceLinkId exists and has a check for it in order to submit grades from LTIAgsScore https://github.com/openedx/xblock-lti-consumer/blob/582597a6a7fa758be5b1a32511c4f8acd116394b/lti_consumer/signals.py#L30

If we create a PR to fix it, will you consider it?

Alain1405 avatar Jan 24 '22 14:01 Alain1405

@Jack-2025 can you check if #62 fixes it for you?

Alain1405 avatar Jan 26 '22 15:01 Alain1405

@Alain1405 and @dmitry-viskov, thanks for your support!

@Alain1405 I applied the PR and modified the code in my original post to include this for both grade posts:

if resource_link_id:
    line_item.set_resource_id(resource_link_id)
    **line_item.set_resource_link_id(resource_link_id)**

I should mention, I'm also using open edx with programmatic grading enabled. @dmitry-viskov by programmatic grading I mean the mode where the tool is in charge of creating the line items, whereas for the declarative mode the LMS will create one line item for you.

edx will take in both grade posts without error but only the last one is shown for that exercise on the grading page. That's one step forward, before none of the grades were being logged. Maybe this current problem is related to edx?

Jack-2025 avatar Jan 29 '22 05:01 Jack-2025

There are two models of interaction to pushing grades to the platform in the LTI AGS services: Declarative: the platform creates a LineItem (equivalent of a gradebook line/grade) and tools can only push results to that item. 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.

Here is the explanation of Declarative VS Programmatic Grades from edX Consumer at https://github.com/openedx/xblock-lti-consumer/blob/582597a6a7fa758be5b1a32511c4f8acd116394b/docs/decisions/0005-lti-1p3-score-linking-improved.rst

Alain1405 avatar Jan 29 '22 12:01 Alain1405