Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ back/rabbitmq/rabbitmq/
.env
*.log
*.ini
front/rabbitmq/rabbitmq
front/rabbitmq/rabbitmq
front/src/lti/config/
2 changes: 1 addition & 1 deletion front/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ ADD ./src /app
ADD run_app.sh /app/run_app.sh
RUN chmod +x /app/run_app.sh

CMD ["/app/run_app.sh"]
CMD ["/app/run_app.sh"]
2 changes: 2 additions & 0 deletions front/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,5 @@ wtforms==3.2.1
jsonschema==4.25.1
Flask-Admin==2.0.1
psycopg2-binary==2.9.10
pylti1p3==2.0.0
flask_caching==2.3.1
2 changes: 1 addition & 1 deletion front/run_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ python3 app.py "$MODE"
nohup uwsgi --ini /app/uwsgi.ini &

# Start celery
exec python3 -m celery -A celery_app worker --loglevel=info --concurrency=${celery_concurrency} -Q common-results-queue,task-checking-queue
exec python3 -m celery -A celery_app worker --loglevel=info --concurrency=${celery_concurrency} -Q common-results-queue,task-checking-queue
19 changes: 18 additions & 1 deletion front/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@
yandex_login,
yandex_callback,
tg_callback,
lti_login,
lti_callback
)
import lti.lti_provider as lti
from miminet_config import SECRET_KEY
from miminet_host import (
delete_job,
Expand Down Expand Up @@ -80,6 +83,7 @@
)
from quiz.controller.section_controller import (
get_sections_by_test_endpoint,
get_section_endpoint
)
from quiz.controller.test_controller import (
get_all_tests_endpoint,
Expand All @@ -95,6 +99,8 @@
SessionQuestion,
)

from lti.lti_provider import get_jwks, cache

from quiz.controller.image_controller import image_routes

app = Flask(
Expand Down Expand Up @@ -169,10 +175,14 @@ def get_database_uri(mode):
# Init LoginManager
login_manager.init_app(app)

# Init LTI cache
cache.init_app(app, config={'CACHE_TYPE': 'simple'})

# Init Sitemap
zero_days_ago = (datetime.now()).date().isoformat()

# App add_url_rule

# Login
app.add_url_rule("/auth/login.html", methods=["GET", "POST"], view_func=login_index)
app.add_url_rule("/auth/google_login", methods=["GET"], view_func=google_login)
Expand All @@ -185,6 +195,11 @@ def get_database_uri(mode):
app.add_url_rule("/user/profile.html", methods=["GET", "POST"], view_func=user_profile)
app.add_url_rule("/auth/logout", methods=["GET"], view_func=logout)

# LTI
app.add_url_rule("/lti/login", methods=["GET", "POST"], view_func=lti_login)
app.add_url_rule("/lti/launch", methods=["POST"], view_func=lti_callback)
app.add_url_rule("/lti/jwks", methods=["GET"], view_func=get_jwks)

# Network
app.add_url_rule("/create_network", methods=["GET"], view_func=create_network)
app.add_url_rule("/web_network", methods=["GET"], view_func=web_network)
Expand Down Expand Up @@ -258,9 +273,11 @@ def get_database_uri(mode):
app.add_url_rule(
"/quiz/section/test/all", methods=["GET"], view_func=get_sections_by_test_endpoint
)

app.add_url_rule('/quiz/sections/<section>', methods=["GET", "POST"], view_func=get_section_endpoint)

app.add_url_rule(
"/quiz/question/create", methods=["POST"], view_func=create_question_endpoint
"/quiz/question/create", methods=["GET", "POST"], view_func=create_question_endpoint
)

app.add_url_rule(
Expand Down
72 changes: 72 additions & 0 deletions front/src/lti/lti_actions/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from abc import ABC, abstractmethod
from typing import Generic, TypeVar
from flask import session
from flask_login import login_user
from pylti1p3.contrib.flask import FlaskMessageLaunch
from miminet_model import User, db

class ExtendedFlaskMessageLaunch(FlaskMessageLaunch):

def validate_nonce(self):
"""
Probably it is bug on "https://lti-ri.imsglobal.org":
site passes invalid "nonce" value during deep links launch.
Because of this in case of iss == http://imsglobal.org just skip nonce validation.

"""
iss = self.get_iss()
if iss == "https://lti-ri.imsglobal.org":
return self
return super().validate_nonce()

class BaseActionHandler(ABC):
def __init__(self, message_launch: FlaskMessageLaunch):
self.message_launch = message_launch
self.launch_data = message_launch.get_launch_data()

def handle(self):
if "https://purl.imsglobal.org/spec/lti/claim/launch_presentation" in self.launch_data:
launch_presentation = self.launch_data.get("https://purl.imsglobal.org/spec/lti/claim/launch_presentation")
session["returnToLtiPlatformUrl"] = launch_presentation.get("return_url", self.message_launch.get_iss())

self._handle_user()
return self._process()

def _handle_user(self):
platform_user = User.query.filter(
User.platform_client_id == self.message_launch.get_client_id(),
User.platform_user_id == self.launch_data.get("sub")
).first()

if platform_user is None:
platform_user = User(
nick=self.launch_data.get("name", ""),
platform_client_id=self.message_launch.get_client_id(),
platform_user_id=self.launch_data.get("sub")
)
db.session.add(platform_user)
db.session.commit()

login_user(platform_user)
return platform_user

@abstractmethod
def _process(self):
pass



T = TypeVar('T')
class BaseResultSender(Generic[T], ABC):
def __init__(self, message_launch: FlaskMessageLaunch):
self.message_launch = message_launch
self.launch_data = message_launch.get_launch_data()

def send(self, result):
if "returnToLtiPlatformUrl" in session: session.pop("returnToLtiPlatformUrl")

return self._send(result)

@abstractmethod
def _send(self, result: T) -> bool:
pass
44 changes: 44 additions & 0 deletions front/src/lti/lti_actions/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from pylti1p3.contrib.flask import FlaskMessageLaunch

from .quiz_session_retrieval import QuizSessionRetrievalHandler
from .section_creation import SectionCreationHandler, SectionSender
from .section_retrieval import QuizSessionScoreSender, QuizSessionSender, SectionRetrievalHandler

class ActionHandlerFactory:
@staticmethod
def create_handler(message_launch: FlaskMessageLaunch):
launch_data = message_launch.get_launch_data()

message_type = launch_data.get(
"https://purl.imsglobal.org/spec/lti/claim/message_type"
)

if message_type == "LtiResourceLinkRequest":
return SectionRetrievalHandler(message_launch)
elif message_type == "LtiDeepLinkingRequest":
return SectionCreationHandler(message_launch)
elif message_type == "LtiSubmissionReviewRequest":
return QuizSessionRetrievalHandler(message_launch)
else:
raise Exception("Unknown lti message type")

class ActionResultSenderFactory:
@staticmethod
def create_sender(message_launch: FlaskMessageLaunch, result_type: str = None):
launch_data = message_launch.get_launch_data()

message_type = launch_data.get("https://purl.imsglobal.org/spec/lti/claim/message_type")

if result_type is None:
if message_type == "LtiResourceLinkRequest":
raise Exception("Specify result_type when send results from LtiResourceLinkRequest")
elif message_type == "LtiDeepLinkingRequest":
return SectionSender(message_launch)
elif result_type == "section" and message_type == "LtiDeepLinkingRequest":
return SectionSender(message_launch)
elif result_type == "solution" and message_type == "LtiResourceLinkRequest":
return QuizSessionSender(message_launch)
elif result_type == "solution_score" and message_type == "LtiResourceLinkRequest":
return QuizSessionScoreSender(message_launch)
else:
raise NotImplementedError()
11 changes: 11 additions & 0 deletions front/src/lti/lti_actions/quiz_session_retrieval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from flask import redirect, session, url_for
from .base import BaseActionHandler

class QuizSessionRetrievalHandler(BaseActionHandler):

def _process(self):
custom = self.launch_data.get("https://purl.imsglobal.org/spec/lti/claim/custom")

quiz_session_id = custom.get('quiz_session_id')

return redirect(url_for('session_result_endpoint', id=quiz_session_id))
29 changes: 29 additions & 0 deletions front/src/lti/lti_actions/section_creation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from quiz.entity.entity import Section
from .base import BaseActionHandler, BaseResultSender
from flask import make_response, redirect, url_for
from pylti1p3.deep_link_resource import DeepLinkResource
from pylti1p3.lineitem import LineItem

class SectionCreationHandler(BaseActionHandler):

def _process(self):
return redirect(url_for("create_question_endpoint"))


class SectionSender(BaseResultSender[Section]):

def _send(self, section: Section) -> bool:

deep_link = self.message_launch.get_deep_link()

line_item = LineItem()\
.set_tag('score')\
.set_score_maximum(section.max_score)

resource = DeepLinkResource()\
.set_title(section.name)\
.set_url("http://127.0.0.1/lti/launch")\
.set_lineitem(line_item)\
.set_custom_params({"section_id": f"{section.id}"})

return deep_link.output_response_form([resource])
55 changes: 55 additions & 0 deletions front/src/lti/lti_actions/section_retrieval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from quiz.entity.entity import QuizSession, SessionQuestion
from .base import BaseActionHandler, BaseResultSender
from flask import redirect, session, url_for
from datetime import datetime
from pylti1p3.grade import Grade

class SectionRetrievalHandler(BaseActionHandler):

def _process(self):
custom = self.launch_data.get("https://purl.imsglobal.org/spec/lti/claim/custom")

section_id = custom.get('section_id')

return redirect(url_for('get_section_endpoint', section=section_id))


class QuizSessionSender(BaseResultSender[SessionQuestion]):

def _send(self, session_question: SessionQuestion) -> bool:
if not self.message_launch.has_ags(): raise Exception("LTI launch doesn't have AGS permissions")

sub = self.launch_data.get('sub')
timestamp = datetime.now().isoformat() + 'Z'

grades = self.message_launch.get_ags()

grade = Grade() \
.set_user_id(sub) \
.set_timestamp(timestamp) \
.set_activity_progress("Completed") \
.set_grading_progress("Pending") \
.set_extra_claims({"quiz_session_id": f"{session_question.quiz_session_id}"})

return grades.put_grade(grade)


class QuizSessionScoreSender(BaseResultSender[SessionQuestion]):

def _send(self, session_question: SessionQuestion) -> bool:
if not self.message_launch.has_ags(): raise Exception("LTI launch doesn't have AGS permissions")

sub = self.launch_data.get('sub')
timestamp = datetime.now().isoformat() + 'Z'

grades = self.message_launch.get_ags()

grade = Grade() \
.set_user_id(sub) \
.set_timestamp(timestamp) \
.set_activity_progress("Completed") \
.set_grading_progress("FullyGraded") \
.set_score_given(session_question.score) \
.set_extra_claims({"quiz_session_id": f"{session_question.quiz_session_id}"})

return grades.put_grade(grade)
61 changes: 61 additions & 0 deletions front/src/lti/lti_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import os
import pathlib

from flask import jsonify, session
from flask_caching import Cache
from pylti1p3.contrib.flask import FlaskOIDCLogin, FlaskRequest, FlaskCacheDataStorage
from pylti1p3.tool_config import ToolConfJsonFile
from lti.lti_actions.base import ExtendedFlaskMessageLaunch

from lti.lti_actions.factory import ActionHandlerFactory, ActionResultSenderFactory

cache = Cache()

def login():
tool_conf = ToolConfJsonFile(get_lti_config_path())
launch_data_storage = get_launch_data_storage()

flask_request = FlaskRequest()
target_link_uri = flask_request.get_param('target_link_uri')
if not target_link_uri: raise Exception('Missing "target_link_uri" param')

oidc_login = FlaskOIDCLogin(flask_request, tool_conf, launch_data_storage=launch_data_storage)
return oidc_login\
.enable_check_cookies()\
.redirect(target_link_uri)


def launch():
tool_conf = ToolConfJsonFile(get_lti_config_path())
flask_request = FlaskRequest()
launch_data_storage = get_launch_data_storage()

message_launch = ExtendedFlaskMessageLaunch(flask_request, tool_conf, launch_data_storage=launch_data_storage)
session["launch_id"] = message_launch.get_launch_id()

handler = ActionHandlerFactory.create_handler(message_launch)
return handler.handle()


def send(result, result_type: str = None):
if "launch_id" not in session: raise Exception("No active LTI launch")

tool_conf = ToolConfJsonFile(get_lti_config_path())
flask_request = FlaskRequest()
launch_data_storage = get_launch_data_storage()

message_launch = ExtendedFlaskMessageLaunch.from_cache(session["launch_id"], flask_request, tool_conf, launch_data_storage=launch_data_storage)

result_sender = ActionResultSenderFactory.create_sender(message_launch, result_type)
return result_sender.send(result)


def get_jwks():
tool_conf = ToolConfJsonFile(get_lti_config_path())
return jsonify(tool_conf.get_jwks())

def get_lti_config_path():
return os.path.join(pathlib.Path(__file__).parent, "config", "lti_config.json")

def get_launch_data_storage():
return FlaskCacheDataStorage(cache)
7 changes: 7 additions & 0 deletions front/src/miminet_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
login_user,
logout_user,
)
import lti.lti_provider as lti
from google.oauth2 import id_token
from google_auth_oauthlib.flow import Flow
from requests_oauthlib import OAuth2Session
Expand Down Expand Up @@ -226,6 +227,12 @@ def google_login():
session["state"] = state
return redirect(authorization_url)

def lti_login():
return lti.login()

def lti_callback():
return lti.launch()


def vk_login():
authorization_link = "https://oauth.vk.com/authorize"
Expand Down
Loading