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

LTI 1.3 #1023

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft

LTI 1.3 #1023

Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 4 additions & 9 deletions inginious/frontend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
from binascii import hexlify
from pymongo import MongoClient
from werkzeug.exceptions import InternalServerError
from flask_caching import Cache

import inginious.frontend.pages.course_admin.utils as course_admin_utils
import inginious.frontend.pages.taskset_admin.utils as taskset_admin_utils
Expand All @@ -34,7 +33,7 @@
from inginious.frontend.taskset_factory import create_factories
from inginious.common.entrypoints import filesystem_from_config_dict
from inginious.common.filesystems.local import LocalFSProvider
from inginious.frontend.lti_outcome_manager import LTIOutcomeManager
from inginious.frontend.lti_grade_manager import LTIGradeManager
from inginious.frontend.task_problems import get_default_displayable_problem_types
from inginious.frontend.task_dispensers.toc import TableOfContents
from inginious.frontend.task_dispensers.combinatory_test import CombinatoryTest
Expand Down Expand Up @@ -88,8 +87,6 @@ def _put_configuration_defaults(config):
config["SESSION_USE_SIGNER"] = True
config["PERMANENT_SESSION_LIFETIME"] = config['session_parameters']["timeout"]
config["SECRET_KEY"] = config['session_parameters']["secret_key"]
config["CACHE_TYPE"] = "simple"
config["CACHE_DEFAULT_TIMEOUT"] = 600

smtp_conf = config.get('smtp', None)
if smtp_conf is not None:
Expand Down Expand Up @@ -235,9 +232,9 @@ def get_app(config):

client = create_arch(config, fs_provider, zmq_context, taskset_factory)

lti_outcome_manager = LTIOutcomeManager(database, user_manager, course_factory)
lti_grade_manager = LTIGradeManager(database, flask_app)

submission_manager = WebAppSubmissionManager(client, user_manager, database, gridfs, plugin_manager, lti_outcome_manager)
submission_manager = WebAppSubmissionManager(client, user_manager, database, gridfs, plugin_manager, lti_grade_manager)
template_helper = TemplateHelper(plugin_manager, user_manager, config.get('use_minified_js', True))

register_utils(database, user_manager, template_helper)
Expand Down Expand Up @@ -308,7 +305,7 @@ def flask_internalerror(e):
flask_app.default_max_file_size = default_max_file_size
flask_app.backup_dir = config.get("backup_directory", './backup')
flask_app.webterm_link = config.get("webterm", None)
flask_app.lti_outcome_manager = lti_outcome_manager
flask_app.lti_grade_manager = lti_grade_manager
flask_app.allow_registration = config.get("allow_registration", True)
flask_app.allow_deletion = config.get("allow_deletion", True)
flask_app.available_languages = available_languages
Expand All @@ -317,8 +314,6 @@ def flask_internalerror(e):
flask_app.privacy_page = config.get("privacy_page", None)
flask_app.static_directory = config.get("static_directory", "./static")
flask_app.webdav_host = config.get("webdav_host", None)
cache = Cache(flask_app)
flask_app.cache = cache

# Init the mapping of the app
init_flask_mapping(flask_app)
Expand Down
154 changes: 154 additions & 0 deletions inginious/frontend/lti_grade_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
#
# This file is part of INGInious. See the LICENSE and the COPYRIGHTS files for
# more information about the licensing of this file.

""" Implements LTI AGS. """
from datetime import datetime
import logging
import threading
import queue
import time

from pymongo import ReturnDocument

from pylti1p3.contrib.flask import FlaskMessageLaunch
from pylti1p3.grade import Grade
from pylti1p3.lineitem import LineItem
from pylti1p3.launch_data_storage.base import LaunchDataStorage


class MongoLTILaunchDataStorage(LaunchDataStorage):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This could be used to store only the LTI Launch ID in the user session and retrieve the rest from the database.

"""
Stores LTI Launch messages in database during the handshake process and
to submit grades later using the LTIGradeManager.
"""
def __init__(self, database, courseid, taskid, *args, **kwargs) -> None:
self.database = database
self.query_context = (courseid, taskid)
self._session_cookie_name = "" # Disables session scope mechanism in favor of query_context
super().__init__(*args, **kwargs)

def can_set_keys_expiration(self) -> bool:
return False # TODO(mp): I think it's reasonable to clean LTI Launch messages further than a week away tho

def get_value(self, key: str):
entry = self.database.lti_launch.find_one({'key': key, 'context': self.query_context})
return entry.get('value') if entry else None

def set_value(self, key: str, value, exp) -> None:
self.database.lti_launch.find_one_and_update({'key': key, 'context': self.query_context},
{'$set': {'key': key, 'value': value}}, upsert=True)

def check_value(self, key: str) -> bool:
return bool(self.database.lti_launch.find_one({'key': key, 'context': self.query_context}))


class LTIGradeManager(threading.Thread):
""" Waits for grading to complete and submit grade to the LTI Platform. """
def __init__(self, database, app):
super(LTIGradeManager, self).__init__()
self.daemon = True
self._database = database
self._app = app
self._queue = queue.Queue()
self._stopped = False
self._logger = logging.getLogger("inginious.webapp.lti_grade_manager")
self.start()

def stop(self):
self._stopped = True

def run(self):
# Load old tasks from the database
for todo in self._database.lti_grade_queue.find({}):
self._add_to_queue(todo)

try:
while not self._stopped:
time.sleep(0.5)
data = self._queue.get()
mongo_id, username, courseid, taskid, message_launch_id, nb_attempt = data

try:
course = self._app.course_factory.get_course(courseid)
task = course.get_task(taskid)
print(self._app.user_manager.get_task_cache(username, courseid, task.get_id()))
grade = self._app.user_manager.get_task_cache(username, courseid, task.get_id())["grade"]
except Exception:
self._logger.error("An exception occurred while getting a course/LTI secret/grade in LTIGradeManager.", exc_info=True)
continue

try:
message_launch = FlaskMessageLaunch.from_cache(message_launch_id, request=None, tool_config=course.lti_tool(), launch_data_storage=MongoLTILaunchDataStorage(self._app.database, courseid, taskid))
launch_data = message_launch.get_launch_data()
ags = message_launch.get_ags()

if ags.can_put_grade():
sc = Grade()
# TODO(mp): Is there a better timestamp to set with the score? Submission time? Grading time?
sc.set_score_given(grade) \
.set_score_maximum(100.0) \
.set_timestamp(datetime.now().isoformat() + 'Z') \
.set_activity_progress('Completed') \
.set_grading_progress('FullyGraded') \
.set_user_id(launch_data['sub'])

sc_line_item = LineItem()
sc_line_item.set_tag('score') \
.set_score_maximum(100) \
.set_label('Score')
if launch_data:
sc_line_item.set_resource_id(launch_data['https://purl.imsglobal.org/spec/lti/claim/resource_link']['id'])

ags.put_grade(sc, sc_line_item)
self._delete_in_db(mongo_id)
self._logger.debug("Successfully sent grade to LTI Platform: %s", str(data))
continue
except Exception:
self._logger.error("An exception occurred while sending a grade to the LTI Platform.", exc_info=True)

if nb_attempt < 5:
self._logger.debug("An error occurred while sending a grade to the LTI Platform. Retrying...")
self._increment_attempt(mongo_id)
else:
self._logger.error("An error occurred while sending a grade to the LTI Platform. Maximum number of retries reached.")
self._delete_in_db(mongo_id)
except KeyboardInterrupt:
pass

def _add_to_queue(self, mongo_entry):
self._queue.put((mongo_entry["_id"], mongo_entry["username"], mongo_entry["courseid"],
mongo_entry["taskid"], mongo_entry["message_launch_id"], mongo_entry["nb_attempt"]))

def add(self, username, courseid, taskid, message_launch_id):
""" Add a job in the queue
:param username:
:param courseid:
:param taskid:
:param message_launch_id:
"""
search = {"username": username, "courseid": courseid,
"taskid": taskid, "message_launch_id": message_launch_id}

entry = self._database.lti_grade_queue.find_one_and_update(search, {"$set": {"nb_attempt": 0}},
return_document=ReturnDocument.BEFORE, upsert=True)
if entry is None: # and it should be
self._add_to_queue(self._database.lti_grade_queue.find_one(search))

def _delete_in_db(self, mongo_id):
"""
Delete an element from the queue in the database
:param mongo_id:
:return:
"""
self._database.lti_grade_queue.delete_one({"_id": mongo_id})

def _increment_attempt(self, mongo_id):
"""
Increment the number of attempt for an entry and
:param mongo_id:
:return:
"""
entry = self._database.lti_grade_queue.find_one_and_update({"_id": mongo_id}, {"$inc": {"nb_attempt": 1}})
self._add_to_queue(entry)
121 changes: 0 additions & 121 deletions inginious/frontend/lti_outcome_manager.py

This file was deleted.

3 changes: 2 additions & 1 deletion inginious/frontend/pages/course_admin/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,10 @@ def POST_AUTH(self, courseid): # pylint: disable=arguments-differ
iss_config = lti_config[iss]
assert type(iss_config) is list, f'Issuer {iss} must have a list of client_id configuration'
for i, client_config in enumerate(iss_config):
required_keys = {'default', 'client_id', 'auth_login_url', 'auth_token_url', 'key_set_url', 'private_key', 'public_key', 'deployment_ids'}
required_keys = {'default', 'client_id', 'auth_login_url', 'auth_token_url', 'private_key', 'public_key', 'deployment_ids'}
for key in required_keys:
assert key in client_config, f'Missing {key} in client config {i} of issuer {iss}'
assert "key_set_url" in client_config or "key_set" in client_config, f'key_set_url or key_set is missing in client config {i} of issuer {iss}'
tool_conf = ToolConfDict(lti_config)
for iss in lti_config:
for i, client_config in enumerate(lti_config[iss]):
Expand Down
Loading
Loading