From 7cd7c872f57bb8d0b349cde8053fff1e9f7e3f41 Mon Sep 17 00:00:00 2001 From: Arseniy Date: Mon, 10 Nov 2025 18:15:22 +0700 Subject: [PATCH 1/8] lti protocol support --- front/Dockerfile | 6 +- front/requirements.txt | 2 + front/run_app.sh | 2 +- front/run_app_orig.sh | 13 ++ front/src/app.py | 16 +++ .../lti/config/keys/cert_suite_private.key | 27 ++++ front/src/lti/config/keys/private.key | 51 ++++++++ front/src/lti/config/keys/public.jwk.json | 8 ++ front/src/lti/config/keys/public.key | 14 +++ front/src/lti/config/lti_config.json | 12 ++ front/src/lti/lti_support.py | 118 ++++++++++++++++++ front/src/miminet_auth.py | 7 ++ front/src/miminet_model.py | 3 + .../controller/quiz_session_controller.py | 10 +- .../src/quiz/controller/section_controller.py | 14 +-- .../quiz/service/session_question_service.py | 5 + front/src/static/quiz/session_scripts.js | 74 ++++------- front/src/templates/quiz/sessionQuestion.html | 1 + 18 files changed, 321 insertions(+), 62 deletions(-) create mode 100644 front/run_app_orig.sh create mode 100644 front/src/lti/config/keys/cert_suite_private.key create mode 100644 front/src/lti/config/keys/private.key create mode 100644 front/src/lti/config/keys/public.jwk.json create mode 100644 front/src/lti/config/keys/public.key create mode 100644 front/src/lti/config/lti_config.json create mode 100644 front/src/lti/lti_support.py diff --git a/front/Dockerfile b/front/Dockerfile index 7c1c9831..5ee6fe69 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -1,7 +1,7 @@ FROM python:3.11 WORKDIR /app -RUN pip install setuptools --upgrade +RUN pip install setuptools==58.2.0 --upgrade RUN pip install wheel uwsgi ADD ./requirements.txt /app/requirements.txt @@ -11,4 +11,6 @@ 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"] + +ENTRYPOINT ["/bin/sh", "/app/run_app.sh"] diff --git a/front/requirements.txt b/front/requirements.txt index 4d2bd585..53dd72b4 100644 --- a/front/requirements.txt +++ b/front/requirements.txt @@ -51,3 +51,5 @@ wtforms>=2.3.3, <3.1.0 jsonschema==4.25.1 Flask-Admin==1.6.1 psycopg2-binary==2.9.10 +pylti1p3==2.0.0 +flask_caching==2.3.1 diff --git a/front/run_app.sh b/front/run_app.sh index 7c0dfeca..2e173886 100644 --- a/front/run_app.sh +++ b/front/run_app.sh @@ -7,7 +7,7 @@ echo "[!] Running in $MODE mode" python3 app.py "$MODE" # Start the application -nohup uwsgi --ini /app/uwsgi.ini & +exec 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 diff --git a/front/run_app_orig.sh b/front/run_app_orig.sh new file mode 100644 index 00000000..7c0dfeca --- /dev/null +++ b/front/run_app_orig.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +# Use default 'prod' if MODE is not set +MODE="${MODE:-prod}" + +echo "[!] Running in $MODE mode" +python3 app.py "$MODE" + +# Start the application +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 diff --git a/front/src/app.py b/front/src/app.py index 243e9bc4..46f20047 100644 --- a/front/src/app.py +++ b/front/src/app.py @@ -32,6 +32,8 @@ yandex_login, yandex_callback, tg_callback, + lti_login, + lti_callback ) from miminet_config import SECRET_KEY from miminet_host import ( @@ -79,6 +81,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, @@ -94,6 +97,8 @@ SessionQuestion, ) +from lti.lti_support import get_jwks, cache + from quiz.controller.image_controller import image_routes app = Flask( @@ -123,10 +128,14 @@ # 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) @@ -139,6 +148,11 @@ 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("/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) @@ -210,6 +224,8 @@ app.add_url_rule( "/quiz/section/test/all", methods=["GET"], view_func=get_sections_by_test_endpoint ) + +app.add_url_rule('/quiz/sections/
', methods=["GET", "POST"], view_func=get_section_endpoint) app.add_url_rule( "/quiz/question/create", methods=["POST"], view_func=create_question_endpoint diff --git a/front/src/lti/config/keys/cert_suite_private.key b/front/src/lti/config/keys/cert_suite_private.key new file mode 100644 index 00000000..c2170ccc --- /dev/null +++ b/front/src/lti/config/keys/cert_suite_private.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAsW3eobPIj5LsyHcMGckVSSC621uL+0zkeMoWfXfNmvTH+zt5 +WOeEIdz+X7fK+F+lO7ic5WdJEGmp9/cjAf0Z6SsmnvvHlHV/xsWtJm4DiuuF2MAa +hRQ5QEkhaEdh5QM2vAYyc8Nfxe504vA3czuynrW9MsOdZHeVzF+zWhhEl+olC5fW +A1rhTUPpdxuZ0opVIrGJtI/QYfndoN+7zTs/4CXqG6WpB+AZio8j7c6fJLC7J33c +pxB1+O+64Qbh+5sxz46cEByboAB8qerYCmcfxxfBbwyySBBK5X77aNHWA01B1kpO +Q2VB8YKQk+OrXsPgJobPkR9ONWa9DC9JjEdUJwIDAQABAoIBAQCAA+qutt2NIY/v +71zuudO+yHupSzsLXOY3dG+XpTnWhKhJTxb1m00Ndbqe6yfp3nCET2X8anIgAmzc ++RXsGGZ6gmTCLp1IMyK3EuckJBowQFB5G9nGjNnl1R3idCZgqtnx/XKnbZ6LW8o/ +9tu7K6ZrtmrE1riXxWRyadYoufu7ssNTqtj03oh3Tvw+Ze6xvF6hpaxnbVHxJcGt +xZO51L6rGOSFq5CJ81BswyBDOKB/Z2OC0o3m2t4ZF4/2Lf070sB7RoejGD7mhYVe +lEOoC95C14hfcspzmDEb8I/n0MvAxlwddM4KZRilAJ+e2R0rM9M1MnyYsmYUsMNX +EKWcx+/5AoGBAOLtNVbIohpY5kbX4WREJ/0INPbbx0gf68ozEZTjsOzIP7oaIzry +URmxyZzSpx446QCO8s26vuxrPGm7OAteNS7UpDdunzKsaIlZScZQEpE9htp3MKKw +KXaA4l7H55uWWnaUAcDqjEdybhYL6SbPKhOaK53VeHOLro900FiRnfaDAoGBAMgp +O8GwAI3LbD06Fn+DT+3hj/i8wxbWilgJlI+RU+wWfQ421jMKv2dck8zbnzKGxEwA +3WPh6gGMlkavEZ95d0qZ/TOkSh+VIjJuOrjcckRcrKcycYJJUzreO7ENsFbA+8xL +Qp2gNV+NntiChzSUGY5Nup3keoaT9iV13oYDSdqNAoGARDn9Z3I7CqDf2zzcz0CO +pUzqX64EZHL0eX6RMqqibw5l2pYxMW/ZYlhJvZS4GiYSJ9DSv3f+Hya+qytW1lQk +uUfFd8USqDGd3G2z+KPqcTCGcviS7tb4IGDvrn976xNxb2VggZgDRRfqcUZzeu+e +PvaDVpjv9g1xFkCQw5BEZfECgYBcSB5jywhGV14c0FYlDd5g9xiQfj6XnewEcM5M +bp05gJjBX+jbeX4LYnRGA49fFSEVRWTMsxBXDIEQL5C5bJ/iBiLllz4RV4l/pLBw +IDqSaAO1xhztC29S+bidhYkiRjEQ3DXnREC3QCzW9z7sr8ckg5OhTgBrYXYfiTtB +n+yB1QKBgG/J+WhkqMEtZ8CgdoiTIqYKmFsLvl07wETAVU6Nv1sEI+jnhyug0QtQ +yLAlBOVyrXuJ1DZMX6hTRij4L0jvnJFSq0Sv8COuLIH90xdq/NTNQ3LAy60l/3b1 +ojAnnRJORDegdJjCBxJ59Fch6Qfd+e8742DVsJu8zVo2garUVMH3 +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/front/src/lti/config/keys/private.key b/front/src/lti/config/keys/private.key new file mode 100644 index 00000000..1e66949d --- /dev/null +++ b/front/src/lti/config/keys/private.key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- diff --git a/front/src/lti/config/keys/public.jwk.json b/front/src/lti/config/keys/public.jwk.json new file mode 100644 index 00000000..60a52af4 --- /dev/null +++ b/front/src/lti/config/keys/public.jwk.json @@ -0,0 +1,8 @@ +{ + "e": "AQAB", + "n": "uvEnCaUOy1l9gk3wjW3Pib1dBc5g92-6rhvZZOsN1a77fdOqKsrjWG1lDu8kq2nL-wbAzR3DdEPVw_1WUwtr_Q1d5m-7S4ciXT63pENs1EPwWmeN33O0zkGx8I7vdiOTSVoywEyUZe6UyS-ujLfsRc2ImeLP5OHxpE1yULEDSiMLtSvgzEaMvf2AkVq5EL5nLYDWXZWXUnpiT_f7iK47Mp2iQd4KYYG7YZ7lMMPCMBuhej7SOtZQ2FwaBjvZiXDZ172sQYBCiBAmOR3ofTL6aD2-HUxYztVIPCkhyO84mQ7W4BFsOnKW4WRfEySHXd2hZkFMgcFNXY3dA6de519qlcrL0YYx8ZHpzNt0foEzUsgJd8uJMUVvzPZgExwcyIbv5jWYBg0ILgULo7ve7VXG5lMwasW_ch2zKp7tTILnDJwITMjF71h4fn4dMTun_7MWEtSl_iFiALnIL_4_YY717cr4rmcG1424LyxJGRD9L9WjO8etAbPkiRFJUd5fmfqjHkO6fPxyWsMUAu8bfYdVRH7qN_erfGHmykmVGgH8AfK9GLT_cjN4GHA29bK9jMed6SWdrkygbQmlnsCAHrw0RA-QE0t617h3uTrSEr5vkbLz-KThVEBfH84qsweqcac_unKIZ0e2iRuyVnG4cbq8HUdio8gJ62D3wZ0UvVgr4a0", + "alg": "RS256", + "kid": "NtQYzsKs_TWLQ0p3bLmfM7fOwY0nEBVVH3z3Q-zJ06Y", + "kty": "RSA", + "use": "sig" +} diff --git a/front/src/lti/config/keys/public.key b/front/src/lti/config/keys/public.key new file mode 100644 index 00000000..9109ee3d --- /dev/null +++ b/front/src/lti/config/keys/public.key @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuvEnCaUOy1l9gk3wjW3P +ib1dBc5g92+6rhvZZOsN1a77fdOqKsrjWG1lDu8kq2nL+wbAzR3DdEPVw/1WUwtr +/Q1d5m+7S4ciXT63pENs1EPwWmeN33O0zkGx8I7vdiOTSVoywEyUZe6UyS+ujLfs +Rc2ImeLP5OHxpE1yULEDSiMLtSvgzEaMvf2AkVq5EL5nLYDWXZWXUnpiT/f7iK47 +Mp2iQd4KYYG7YZ7lMMPCMBuhej7SOtZQ2FwaBjvZiXDZ172sQYBCiBAmOR3ofTL6 +aD2+HUxYztVIPCkhyO84mQ7W4BFsOnKW4WRfEySHXd2hZkFMgcFNXY3dA6de519q +lcrL0YYx8ZHpzNt0foEzUsgJd8uJMUVvzPZgExwcyIbv5jWYBg0ILgULo7ve7VXG +5lMwasW/ch2zKp7tTILnDJwITMjF71h4fn4dMTun/7MWEtSl/iFiALnIL/4/YY71 +7cr4rmcG1424LyxJGRD9L9WjO8etAbPkiRFJUd5fmfqjHkO6fPxyWsMUAu8bfYdV +RH7qN/erfGHmykmVGgH8AfK9GLT/cjN4GHA29bK9jMed6SWdrkygbQmlnsCAHrw0 +RA+QE0t617h3uTrSEr5vkbLz+KThVEBfH84qsweqcac/unKIZ0e2iRuyVnG4cbq8 +HUdio8gJ62D3wZ0UvVgr4a0CAwEAAQ== +-----END PUBLIC KEY----- diff --git a/front/src/lti/config/lti_config.json b/front/src/lti/config/lti_config.json new file mode 100644 index 00000000..7aca38b7 --- /dev/null +++ b/front/src/lti/config/lti_config.json @@ -0,0 +1,12 @@ +{ + "https://lti-ri.imsglobal.org": [{ + "default": true, + "client_id": "343412", + "auth_login_url": "https://lti-ri.imsglobal.org/platforms/4477/authorizations/new", + "auth_token_url": "https://lti-ri.imsglobal.org/platforms/4477/access_tokens", + "key_set_url": "https://lti-ri.imsglobal.org/platforms/4477/platform_keys/4105.json", + "private_key_file": "./keys/private.key", + "public_key_file": "./keys/public.key", + "deployment_ids": ["1"] + }] +} \ No newline at end of file diff --git a/front/src/lti/lti_support.py b/front/src/lti/lti_support.py new file mode 100644 index 00000000..ab30cfb8 --- /dev/null +++ b/front/src/lti/lti_support.py @@ -0,0 +1,118 @@ +import datetime +from functools import wraps +import os +import pathlib + +from flask_login import login_user, logout_user + +from flask import jsonify, request, session, redirect, url_for +from flask_caching import Cache +from werkzeug.exceptions import Forbidden +from pylti1p3.contrib.flask import FlaskOIDCLogin, FlaskMessageLaunch, FlaskRequest, FlaskCacheDataStorage +from pylti1p3.deep_link_resource import DeepLinkResource +from pylti1p3.grade import Grade +from pylti1p3.lineitem import LineItem +from pylti1p3.tool_config import ToolConfJsonFile +from pylti1p3.registration import Registration + +from miminet_model import User, db + +cache = Cache() + +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() + +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) + message_launch_data = message_launch.get_launch_data() + + if request.args.get("returnUrl") != None: session["returnToLtiPlatformUrl"] = request.args.get("returnUrl") + session["launch_id"] = message_launch.get_launch_id() + + platformUser = User.query.filter( + User.platformUserId == message_launch_data.get("sub"), + User.platformName == message_launch_data.get("iss") + ).first() + + if platformUser is None: + platformUser = User(nick=message_launch_data.get("name"), platformName=message_launch_data.get("iss"), platformUserId=message_launch_data.get("sub")) + db.session.add(platformUser) + db.session.commit() + + login_user(platformUser) + + launchSectionId = message_launch_data.get("https://purl.imsglobal.org/spec/lti/claim/custom").get('section_id') + + return redirect(url_for('get_section_endpoint', section=launchSectionId)) + +def score(score): + if ("launch_id" in session): + tool_conf = ToolConfJsonFile(get_lti_config_path()) + flask_request = FlaskRequest() + launch_data_storage = get_launch_data_storage() + message_launch = ExtendedFlaskMessageLaunch.from_cache(session.get("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' + + grades = message_launch.get_ags() + sc = Grade().set_score_given(score) \ + .set_timestamp(timestamp) \ + .set_user_id(sub) + sc_line_item = LineItem().set_tag('score').set_resource_id(resource_link_id) + grades.put_grade(sc, sc_line_item) + + if "returnToLtiPlatformUrl" in session: session.pop("returnToLtiPlatformUrl") + +def get_jwks(): + tool_conf = ToolConfJsonFile(get_lti_config_path()) + return jsonify({'keys': 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) + +def get_jwk_from_public_key(key_name): + key_path = os.path.join(pathlib.Path(__file__).parent, "config", "keys", key_name) + f = open(key_path, 'r') + key_content = f.read() + jwk = Registration.get_jwk(key_content) + f.close() + return jwk \ No newline at end of file diff --git a/front/src/miminet_auth.py b/front/src/miminet_auth.py index 8012655c..bf6d4711 100644 --- a/front/src/miminet_auth.py +++ b/front/src/miminet_auth.py @@ -17,6 +17,7 @@ login_user, logout_user, ) +import lti.lti_support as lti from google.oauth2 import id_token from google_auth_oauthlib.flow import Flow from requests_oauthlib import OAuth2Session @@ -182,6 +183,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" diff --git a/front/src/miminet_model.py b/front/src/miminet_model.py index 1b59d313..571605da 100644 --- a/front/src/miminet_model.py +++ b/front/src/miminet_model.py @@ -25,6 +25,9 @@ class User(db.Model, UserMixin): # type:ignore[name-defined] id = db.Column(BigInteger, primary_key=True, unique=True, autoincrement=True) + platformName = db.Column(Text, nullable=True) + platformUserId = db.Column(Text, nullable=True) + role = db.Column(BigInteger, default=0, nullable=True) email = db.Column(Text, unique=True, nullable=True) diff --git a/front/src/quiz/controller/quiz_session_controller.py b/front/src/quiz/controller/quiz_session_controller.py index 4ec3521e..497d9fa9 100644 --- a/front/src/quiz/controller/quiz_session_controller.py +++ b/front/src/quiz/controller/quiz_session_controller.py @@ -1,6 +1,6 @@ import json -from flask import request, make_response, jsonify, abort, render_template -from flask_login import login_required, current_user +from flask import request, make_response, jsonify, abort, render_template, session +from flask_login import login_required, current_user, logout_user from quiz.facade.quiz_session_facade import ( start_session, @@ -18,7 +18,6 @@ from quiz.service.network_upload_service import create_check_task - @login_required def answer_on_session_question_endpoint(): res = answer_on_session_question(request.args["id"], request.json, current_user) @@ -87,6 +86,7 @@ def get_question_by_session_question_id_endpoint(): available_from=available_from, session_question_id=session_question_id, available_answer=available_answer, + returnToLtiPlatformUrl=session.get("returnToLtiPlatformUrl") ), status_code, ) @@ -109,6 +109,10 @@ def start_session_endpoint(): def finish_session_endpoint(): code = finish_session(request.args["id"], current_user) + if "launch_id" in session: + logout_user() + session.pop("launch_id") + if code == 404 or code == 403: abort(code) ret = {"message": "Сессия завершена", "id": request.args["id"]} diff --git a/front/src/quiz/controller/section_controller.py b/front/src/quiz/controller/section_controller.py index 31ea2898..60c0105d 100644 --- a/front/src/quiz/controller/section_controller.py +++ b/front/src/quiz/controller/section_controller.py @@ -1,7 +1,7 @@ import json from datetime import datetime -from flask import request, make_response, jsonify, abort, render_template +from flask import redirect, request, make_response, jsonify, abort, render_template, url_for from flask_login import login_required, current_user from quiz.service.section_service import ( @@ -16,6 +16,7 @@ from quiz.service.test_service import get_test from quiz.util.encoder import UUIDEncoder +from quiz.facade.quiz_session_facade import start_session, finish_old_sessions @login_required def create_section_endpoint(): @@ -34,14 +35,11 @@ def create_section_endpoint(): return make_response(jsonify(ret), res[1]) - @login_required -def get_section_endpoint(): - res = get_section(request.args["id"]) - if res[1] == 404: - abort(404) - - return make_response(jsonify(res), res[0]) +def get_section_endpoint(section): + finish_old_sessions(current_user) + session = start_session(section, current_user) + return redirect(url_for('get_question_by_session_question_id_endpoint', question_id=session[1][-1])) @login_required diff --git a/front/src/quiz/service/session_question_service.py b/front/src/quiz/service/session_question_service.py index 23b49855..d5e023c5 100644 --- a/front/src/quiz/service/session_question_service.py +++ b/front/src/quiz/service/session_question_service.py @@ -17,6 +17,9 @@ PracticeAnswerResultDto, calculate_max_score, ) + +import lti.lti_support as lti +from flask import session from quiz.service.network_upload_service import prepare_task MOSCOW_TZ = ZoneInfo("Europe/Moscow") @@ -404,6 +407,8 @@ def answer_on_session_question(session_question_id: str, answer, user: User): if score != max_score and len(hints) == 0: hints.append("По вашему решению не предусмотрены подсказки.") + if "launch_id" in session: lti.score(score) + network = Network.query.filter_by(guid=session_question.network_guid).first() network.author_id = 0 db.session.add(network) diff --git a/front/src/static/quiz/session_scripts.js b/front/src/static/quiz/session_scripts.js index 53746dde..6caea8db 100644 --- a/front/src/static/quiz/session_scripts.js +++ b/front/src/static/quiz/session_scripts.js @@ -47,14 +47,26 @@ function finishQuiz() { .then(response => response.json()) .then(data => { console.log(data); - - window.location.href = sessionResultUrl + '?id=' + sessionId + showResult(); }) .catch(error => { console.error('Error:', error); }); } +function showResult() +{ + if (returnToLtiPlatformUrl != 'None') + { + const testName = sessionStorage.getItem("test_name"); + const isRetakeable = sessionStorage.getItem("is_retakeable"); + sessionStorage.clear() + sessionStorage.setItem("test_name", testName); + sessionStorage.setItem("is_retakeable", isRetakeable); + } + window.location.href = returnToLtiPlatformUrl != 'None' ? returnToLtiPlatformUrl : sessionResultUrl + '?id=' + sessionId; +} + function RunAndWaitSimulation(network_guid) { RunSimulation(network_guid); @@ -161,8 +173,8 @@ function handlePracticeAnswerResult(data) { } } -function handleScoreBasedVisibility() { - const answerButton = document.querySelector('button[name="answerQuestion"]'); +function handleScoreBasedVisibility(isExam) { + const answerButton = isExam ? document.querySelector('button[name="answerExamQuestion"]') : document.querySelector('button[name="answerQuestion"]'); const nextButton = document.querySelector('button[name="nextQuestion"]'); const finishButton = document.querySelector('button[name="finishQuiz"]'); const resultsButton = document.querySelector('button[name="seeResults"]'); @@ -180,14 +192,14 @@ function handleScoreBasedVisibility() { } if (resultsButton) { resultsButton.hidden = false; - resultsButton.textContent = "Посмотреть результаты"; - resultsButton.classList.add('btn-outline-primary'); + resultsButton.textContent = returnToLtiPlatformUrl != 'None' ? "Вернуться на платформу" : "Посмотреть результаты"; + resultsButton.classList.add('btn-outline-primary'); } } else { if (nextButton) { nextButton.hidden = false; nextButton.textContent = "Следующий вопрос"; - nextButton.classList.add('btn-outline-primary'); + nextButton.classList.add('btn-outline-primary'); } if (resultsButton) { resultsButton.hidden = true; @@ -209,40 +221,6 @@ function displayHintsInModal(hints) { } } -function handleExamScoreBasedVisibility() { - const answerButton = document.querySelector('button[name="answerExamQuestion"]'); - const nextButton = document.querySelector('button[name="nextQuestion"]'); - const finishButton = document.querySelector('button[name="finishQuiz"]'); - const resultsButton = document.querySelector('button[name="seeResults"]'); - - if (answerButton) { - answerButton.hidden = true; - } - - if (isLastQuestion) { - if (nextButton) { - nextButton.hidden = true; - } - if (finishButton) { - finishButton.hidden = true; - } - if (resultsButton) { - resultsButton.hidden = false; - resultsButton.textContent = "На страницу результатов"; - resultsButton.classList.add('btn-outline-primary'); - } - } else { - if (nextButton) { - nextButton.hidden = false; - nextButton.textContent = "Следующий вопрос"; - nextButton.classList.add('btn-outline-primary'); - } - if (resultsButton) { - resultsButton.hidden = true; - } - } -} - function showAnswerSavedBanner() { const banner = document.getElementById("answerSavedBanner"); if (banner) { @@ -277,7 +255,7 @@ function answerExamQuestion() { }); showAnswerSavedBanner() - handleExamScoreBasedVisibility() + handleScoreBasedVisibility(isExam=true) } let isAnswering = false; @@ -347,7 +325,7 @@ async function answerQuestion() { } } - handleScoreBasedVisibility(); + handleScoreBasedVisibility(isExam=false); } else { sessionStorage.setItem('is_correct', data['is_correct']); sessionStorage.setItem('explanation', data['explanation']); @@ -389,14 +367,13 @@ function handleUnload(e) { window.removeEventListener('beforeunload', handleUnload); e.preventDefault(); e.returnValue = ''; + finishQuiz(); } -const hasTimer = document.querySelector('[id$="timer"]') !== null; - -if (hasTimer) { +setTimeout(() => { window.addEventListener('beforeunload', handleUnload); -} +}, 1000); // Markdown convert let questionText = document.querySelector('div[id="question_text"]')?.innerHTML; @@ -450,6 +427,7 @@ timer = sessionStorage.getItem('timer'); const questionIds = JSON.parse(sessionStorage.getItem('question_ids')); const questionIndex = parseInt(sessionStorage.getItem('question_index')); const isLastQuestion = questionIndex + 1 >= questionsCount; +const returnToLtiPlatformUrl = sessionStorage.getItem('returnToLtiPlatformUrl'); if (timer !== null && parseInt(timer) !== 0) { setInterval(updateTimer, 1000); @@ -464,7 +442,7 @@ if (timer !== null && parseInt(timer) !== 0) { // Add event listener for finishQuiz and nextQuestion buttons document.querySelector('button[name="finishQuiz"]')?.addEventListener('click', finishQuiz); -document.querySelector('button[name="seeResults"]')?.addEventListener('click', seeResults); +document.querySelector('button[name="seeResults"]').addEventListener('click', seeResults); document.querySelector('button[name="answerQuestion"]')?.addEventListener('click', answerQuestion); document.querySelector('button[name="answerExamQuestion"]')?.addEventListener('click', answerExamQuestion); document.querySelector('button[name="nextQuestion"]')?.addEventListener('click', nextQuestion); diff --git a/front/src/templates/quiz/sessionQuestion.html b/front/src/templates/quiz/sessionQuestion.html index ab905137..4d3118ab 100644 --- a/front/src/templates/quiz/sessionQuestion.html +++ b/front/src/templates/quiz/sessionQuestion.html @@ -26,6 +26,7 @@ const questionType = '{{ question.question_type }}' const sessionQuestionId = '{{ session_question_id }}' + sessionStorage.setItem('returnToLtiPlatformUrl', '{{ returnToLtiPlatformUrl }}'); {% endblock %} From 6a9640608554e0231ba0d7654048cabac9b37878 Mon Sep 17 00:00:00 2001 From: Arseniy Date: Tue, 18 Nov 2025 15:11:45 +0700 Subject: [PATCH 2/8] make ready to merge --- front/Dockerfile | 4 +-- front/run_app.sh | 4 +-- front/run_app_orig.sh | 13 --------- front/src/app.py | 2 +- .../lti/config/keys/cert_suite_private.key | 27 ------------------- front/src/lti/config/keys/public.jwk.json | 8 ------ front/src/lti/lti_support.py | 17 ++++-------- front/src/miminet_model.py | 2 +- 8 files changed, 10 insertions(+), 67 deletions(-) delete mode 100644 front/run_app_orig.sh delete mode 100644 front/src/lti/config/keys/cert_suite_private.key delete mode 100644 front/src/lti/config/keys/public.jwk.json diff --git a/front/Dockerfile b/front/Dockerfile index 5ee6fe69..768d2b73 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -11,6 +11,4 @@ ADD ./src /app ADD run_app.sh /app/run_app.sh RUN chmod +x /app/run_app.sh -# CMD ["/app/run_app.sh"] - -ENTRYPOINT ["/bin/sh", "/app/run_app.sh"] +CMD ["/app/run_app.sh"] diff --git a/front/run_app.sh b/front/run_app.sh index 2e173886..9cb66018 100644 --- a/front/run_app.sh +++ b/front/run_app.sh @@ -7,7 +7,7 @@ echo "[!] Running in $MODE mode" python3 app.py "$MODE" # Start the application -exec uwsgi --ini /app/uwsgi.ini +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 \ No newline at end of file diff --git a/front/run_app_orig.sh b/front/run_app_orig.sh deleted file mode 100644 index 7c0dfeca..00000000 --- a/front/run_app_orig.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh - -# Use default 'prod' if MODE is not set -MODE="${MODE:-prod}" - -echo "[!] Running in $MODE mode" -python3 app.py "$MODE" - -# Start the application -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 diff --git a/front/src/app.py b/front/src/app.py index 46f20047..bb06f9a1 100644 --- a/front/src/app.py +++ b/front/src/app.py @@ -151,7 +151,7 @@ # 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("/jwks", methods=["GET"], view_func=get_jwks) +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) diff --git a/front/src/lti/config/keys/cert_suite_private.key b/front/src/lti/config/keys/cert_suite_private.key deleted file mode 100644 index c2170ccc..00000000 --- a/front/src/lti/config/keys/cert_suite_private.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEowIBAAKCAQEAsW3eobPIj5LsyHcMGckVSSC621uL+0zkeMoWfXfNmvTH+zt5 -WOeEIdz+X7fK+F+lO7ic5WdJEGmp9/cjAf0Z6SsmnvvHlHV/xsWtJm4DiuuF2MAa -hRQ5QEkhaEdh5QM2vAYyc8Nfxe504vA3czuynrW9MsOdZHeVzF+zWhhEl+olC5fW -A1rhTUPpdxuZ0opVIrGJtI/QYfndoN+7zTs/4CXqG6WpB+AZio8j7c6fJLC7J33c -pxB1+O+64Qbh+5sxz46cEByboAB8qerYCmcfxxfBbwyySBBK5X77aNHWA01B1kpO -Q2VB8YKQk+OrXsPgJobPkR9ONWa9DC9JjEdUJwIDAQABAoIBAQCAA+qutt2NIY/v -71zuudO+yHupSzsLXOY3dG+XpTnWhKhJTxb1m00Ndbqe6yfp3nCET2X8anIgAmzc -+RXsGGZ6gmTCLp1IMyK3EuckJBowQFB5G9nGjNnl1R3idCZgqtnx/XKnbZ6LW8o/ -9tu7K6ZrtmrE1riXxWRyadYoufu7ssNTqtj03oh3Tvw+Ze6xvF6hpaxnbVHxJcGt -xZO51L6rGOSFq5CJ81BswyBDOKB/Z2OC0o3m2t4ZF4/2Lf070sB7RoejGD7mhYVe -lEOoC95C14hfcspzmDEb8I/n0MvAxlwddM4KZRilAJ+e2R0rM9M1MnyYsmYUsMNX -EKWcx+/5AoGBAOLtNVbIohpY5kbX4WREJ/0INPbbx0gf68ozEZTjsOzIP7oaIzry -URmxyZzSpx446QCO8s26vuxrPGm7OAteNS7UpDdunzKsaIlZScZQEpE9htp3MKKw -KXaA4l7H55uWWnaUAcDqjEdybhYL6SbPKhOaK53VeHOLro900FiRnfaDAoGBAMgp -O8GwAI3LbD06Fn+DT+3hj/i8wxbWilgJlI+RU+wWfQ421jMKv2dck8zbnzKGxEwA -3WPh6gGMlkavEZ95d0qZ/TOkSh+VIjJuOrjcckRcrKcycYJJUzreO7ENsFbA+8xL -Qp2gNV+NntiChzSUGY5Nup3keoaT9iV13oYDSdqNAoGARDn9Z3I7CqDf2zzcz0CO -pUzqX64EZHL0eX6RMqqibw5l2pYxMW/ZYlhJvZS4GiYSJ9DSv3f+Hya+qytW1lQk -uUfFd8USqDGd3G2z+KPqcTCGcviS7tb4IGDvrn976xNxb2VggZgDRRfqcUZzeu+e -PvaDVpjv9g1xFkCQw5BEZfECgYBcSB5jywhGV14c0FYlDd5g9xiQfj6XnewEcM5M -bp05gJjBX+jbeX4LYnRGA49fFSEVRWTMsxBXDIEQL5C5bJ/iBiLllz4RV4l/pLBw -IDqSaAO1xhztC29S+bidhYkiRjEQ3DXnREC3QCzW9z7sr8ckg5OhTgBrYXYfiTtB -n+yB1QKBgG/J+WhkqMEtZ8CgdoiTIqYKmFsLvl07wETAVU6Nv1sEI+jnhyug0QtQ -yLAlBOVyrXuJ1DZMX6hTRij4L0jvnJFSq0Sv8COuLIH90xdq/NTNQ3LAy60l/3b1 -ojAnnRJORDegdJjCBxJ59Fch6Qfd+e8742DVsJu8zVo2garUVMH3 ------END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/front/src/lti/config/keys/public.jwk.json b/front/src/lti/config/keys/public.jwk.json deleted file mode 100644 index 60a52af4..00000000 --- a/front/src/lti/config/keys/public.jwk.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "e": "AQAB", - "n": "uvEnCaUOy1l9gk3wjW3Pib1dBc5g92-6rhvZZOsN1a77fdOqKsrjWG1lDu8kq2nL-wbAzR3DdEPVw_1WUwtr_Q1d5m-7S4ciXT63pENs1EPwWmeN33O0zkGx8I7vdiOTSVoywEyUZe6UyS-ujLfsRc2ImeLP5OHxpE1yULEDSiMLtSvgzEaMvf2AkVq5EL5nLYDWXZWXUnpiT_f7iK47Mp2iQd4KYYG7YZ7lMMPCMBuhej7SOtZQ2FwaBjvZiXDZ172sQYBCiBAmOR3ofTL6aD2-HUxYztVIPCkhyO84mQ7W4BFsOnKW4WRfEySHXd2hZkFMgcFNXY3dA6de519qlcrL0YYx8ZHpzNt0foEzUsgJd8uJMUVvzPZgExwcyIbv5jWYBg0ILgULo7ve7VXG5lMwasW_ch2zKp7tTILnDJwITMjF71h4fn4dMTun_7MWEtSl_iFiALnIL_4_YY717cr4rmcG1424LyxJGRD9L9WjO8etAbPkiRFJUd5fmfqjHkO6fPxyWsMUAu8bfYdVRH7qN_erfGHmykmVGgH8AfK9GLT_cjN4GHA29bK9jMed6SWdrkygbQmlnsCAHrw0RA-QE0t617h3uTrSEr5vkbLz-KThVEBfH84qsweqcac_unKIZ0e2iRuyVnG4cbq8HUdio8gJ62D3wZ0UvVgr4a0", - "alg": "RS256", - "kid": "NtQYzsKs_TWLQ0p3bLmfM7fOwY0nEBVVH3z3Q-zJ06Y", - "kty": "RSA", - "use": "sig" -} diff --git a/front/src/lti/lti_support.py b/front/src/lti/lti_support.py index ab30cfb8..76b58c2c 100644 --- a/front/src/lti/lti_support.py +++ b/front/src/lti/lti_support.py @@ -2,6 +2,7 @@ from functools import wraps import os import pathlib +import uuid from flask_login import login_user, logout_user @@ -59,17 +60,17 @@ def launch(): platformUser = User.query.filter( User.platformUserId == message_launch_data.get("sub"), - User.platformName == message_launch_data.get("iss") + User.platform == message_launch_data.get("iss") ).first() if platformUser is None: - platformUser = User(nick=message_launch_data.get("name"), platformName=message_launch_data.get("iss"), platformUserId=message_launch_data.get("sub")) + platformUser = User(nick=message_launch_data.get("name", f"platformUser_{uuid.uuid4().hex[:8]}"), platform=message_launch_data.get("iss"), platformUserId=message_launch_data.get("sub")) db.session.add(platformUser) db.session.commit() login_user(platformUser) - launchSectionId = message_launch_data.get("https://purl.imsglobal.org/spec/lti/claim/custom").get('section_id') + launchSectionId = message_launch_data.get("https://purl.imsglobal.org/spec/lti/claim/custom").get('task') return redirect(url_for('get_section_endpoint', section=launchSectionId)) @@ -107,12 +108,4 @@ 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) - -def get_jwk_from_public_key(key_name): - key_path = os.path.join(pathlib.Path(__file__).parent, "config", "keys", key_name) - f = open(key_path, 'r') - key_content = f.read() - jwk = Registration.get_jwk(key_content) - f.close() - return jwk \ No newline at end of file + return FlaskCacheDataStorage(cache) \ No newline at end of file diff --git a/front/src/miminet_model.py b/front/src/miminet_model.py index 571605da..4b779ea9 100644 --- a/front/src/miminet_model.py +++ b/front/src/miminet_model.py @@ -25,7 +25,7 @@ class User(db.Model, UserMixin): # type:ignore[name-defined] id = db.Column(BigInteger, primary_key=True, unique=True, autoincrement=True) - platformName = db.Column(Text, nullable=True) + platform = db.Column(Text, nullable=True) platformUserId = db.Column(Text, nullable=True) role = db.Column(BigInteger, default=0, nullable=True) From 654380261402c57596db20a27f60ae662678a92e Mon Sep 17 00:00:00 2001 From: Arseniy Date: Fri, 19 Dec 2025 14:52:40 +0700 Subject: [PATCH 3/8] maintain different types of launches --- front/Dockerfile | 6 +- front/run_app.sh | 4 +- front/run_app_orig.sh | 13 --- front/src/app.py | 2 +- .../lti/config/keys/cert_suite_private.key | 27 ------ front/src/lti/config/keys/public.jwk.json | 8 -- front/src/lti/config/lti_config.json | 10 +++ front/src/lti/lti_actions/base.py | 59 ++++++++++++ front/src/lti/lti_actions/section_solving.py | 42 +++++++++ .../src/lti/lti_actions/submission_review.py | 6 ++ front/src/lti/lti_actions/task_creation.py | 12 +++ front/src/lti/lti_support.py | 89 ++++++------------- front/src/miminet_model.py | 2 +- 13 files changed, 162 insertions(+), 118 deletions(-) delete mode 100644 front/run_app_orig.sh delete mode 100644 front/src/lti/config/keys/cert_suite_private.key delete mode 100644 front/src/lti/config/keys/public.jwk.json create mode 100644 front/src/lti/lti_actions/base.py create mode 100644 front/src/lti/lti_actions/section_solving.py create mode 100644 front/src/lti/lti_actions/submission_review.py create mode 100644 front/src/lti/lti_actions/task_creation.py diff --git a/front/Dockerfile b/front/Dockerfile index 5ee6fe69..7c1c9831 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -1,7 +1,7 @@ FROM python:3.11 WORKDIR /app -RUN pip install setuptools==58.2.0 --upgrade +RUN pip install setuptools --upgrade RUN pip install wheel uwsgi ADD ./requirements.txt /app/requirements.txt @@ -11,6 +11,4 @@ ADD ./src /app ADD run_app.sh /app/run_app.sh RUN chmod +x /app/run_app.sh -# CMD ["/app/run_app.sh"] - -ENTRYPOINT ["/bin/sh", "/app/run_app.sh"] +CMD ["/app/run_app.sh"] diff --git a/front/run_app.sh b/front/run_app.sh index 2e173886..9cb66018 100644 --- a/front/run_app.sh +++ b/front/run_app.sh @@ -7,7 +7,7 @@ echo "[!] Running in $MODE mode" python3 app.py "$MODE" # Start the application -exec uwsgi --ini /app/uwsgi.ini +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 \ No newline at end of file diff --git a/front/run_app_orig.sh b/front/run_app_orig.sh deleted file mode 100644 index 7c0dfeca..00000000 --- a/front/run_app_orig.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh - -# Use default 'prod' if MODE is not set -MODE="${MODE:-prod}" - -echo "[!] Running in $MODE mode" -python3 app.py "$MODE" - -# Start the application -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 diff --git a/front/src/app.py b/front/src/app.py index 46f20047..bb06f9a1 100644 --- a/front/src/app.py +++ b/front/src/app.py @@ -151,7 +151,7 @@ # 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("/jwks", methods=["GET"], view_func=get_jwks) +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) diff --git a/front/src/lti/config/keys/cert_suite_private.key b/front/src/lti/config/keys/cert_suite_private.key deleted file mode 100644 index c2170ccc..00000000 --- a/front/src/lti/config/keys/cert_suite_private.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEowIBAAKCAQEAsW3eobPIj5LsyHcMGckVSSC621uL+0zkeMoWfXfNmvTH+zt5 -WOeEIdz+X7fK+F+lO7ic5WdJEGmp9/cjAf0Z6SsmnvvHlHV/xsWtJm4DiuuF2MAa -hRQ5QEkhaEdh5QM2vAYyc8Nfxe504vA3czuynrW9MsOdZHeVzF+zWhhEl+olC5fW -A1rhTUPpdxuZ0opVIrGJtI/QYfndoN+7zTs/4CXqG6WpB+AZio8j7c6fJLC7J33c -pxB1+O+64Qbh+5sxz46cEByboAB8qerYCmcfxxfBbwyySBBK5X77aNHWA01B1kpO -Q2VB8YKQk+OrXsPgJobPkR9ONWa9DC9JjEdUJwIDAQABAoIBAQCAA+qutt2NIY/v -71zuudO+yHupSzsLXOY3dG+XpTnWhKhJTxb1m00Ndbqe6yfp3nCET2X8anIgAmzc -+RXsGGZ6gmTCLp1IMyK3EuckJBowQFB5G9nGjNnl1R3idCZgqtnx/XKnbZ6LW8o/ -9tu7K6ZrtmrE1riXxWRyadYoufu7ssNTqtj03oh3Tvw+Ze6xvF6hpaxnbVHxJcGt -xZO51L6rGOSFq5CJ81BswyBDOKB/Z2OC0o3m2t4ZF4/2Lf070sB7RoejGD7mhYVe -lEOoC95C14hfcspzmDEb8I/n0MvAxlwddM4KZRilAJ+e2R0rM9M1MnyYsmYUsMNX -EKWcx+/5AoGBAOLtNVbIohpY5kbX4WREJ/0INPbbx0gf68ozEZTjsOzIP7oaIzry -URmxyZzSpx446QCO8s26vuxrPGm7OAteNS7UpDdunzKsaIlZScZQEpE9htp3MKKw -KXaA4l7H55uWWnaUAcDqjEdybhYL6SbPKhOaK53VeHOLro900FiRnfaDAoGBAMgp -O8GwAI3LbD06Fn+DT+3hj/i8wxbWilgJlI+RU+wWfQ421jMKv2dck8zbnzKGxEwA -3WPh6gGMlkavEZ95d0qZ/TOkSh+VIjJuOrjcckRcrKcycYJJUzreO7ENsFbA+8xL -Qp2gNV+NntiChzSUGY5Nup3keoaT9iV13oYDSdqNAoGARDn9Z3I7CqDf2zzcz0CO -pUzqX64EZHL0eX6RMqqibw5l2pYxMW/ZYlhJvZS4GiYSJ9DSv3f+Hya+qytW1lQk -uUfFd8USqDGd3G2z+KPqcTCGcviS7tb4IGDvrn976xNxb2VggZgDRRfqcUZzeu+e -PvaDVpjv9g1xFkCQw5BEZfECgYBcSB5jywhGV14c0FYlDd5g9xiQfj6XnewEcM5M -bp05gJjBX+jbeX4LYnRGA49fFSEVRWTMsxBXDIEQL5C5bJ/iBiLllz4RV4l/pLBw -IDqSaAO1xhztC29S+bidhYkiRjEQ3DXnREC3QCzW9z7sr8ckg5OhTgBrYXYfiTtB -n+yB1QKBgG/J+WhkqMEtZ8CgdoiTIqYKmFsLvl07wETAVU6Nv1sEI+jnhyug0QtQ -yLAlBOVyrXuJ1DZMX6hTRij4L0jvnJFSq0Sv8COuLIH90xdq/NTNQ3LAy60l/3b1 -ojAnnRJORDegdJjCBxJ59Fch6Qfd+e8742DVsJu8zVo2garUVMH3 ------END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/front/src/lti/config/keys/public.jwk.json b/front/src/lti/config/keys/public.jwk.json deleted file mode 100644 index 60a52af4..00000000 --- a/front/src/lti/config/keys/public.jwk.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "e": "AQAB", - "n": "uvEnCaUOy1l9gk3wjW3Pib1dBc5g92-6rhvZZOsN1a77fdOqKsrjWG1lDu8kq2nL-wbAzR3DdEPVw_1WUwtr_Q1d5m-7S4ciXT63pENs1EPwWmeN33O0zkGx8I7vdiOTSVoywEyUZe6UyS-ujLfsRc2ImeLP5OHxpE1yULEDSiMLtSvgzEaMvf2AkVq5EL5nLYDWXZWXUnpiT_f7iK47Mp2iQd4KYYG7YZ7lMMPCMBuhej7SOtZQ2FwaBjvZiXDZ172sQYBCiBAmOR3ofTL6aD2-HUxYztVIPCkhyO84mQ7W4BFsOnKW4WRfEySHXd2hZkFMgcFNXY3dA6de519qlcrL0YYx8ZHpzNt0foEzUsgJd8uJMUVvzPZgExwcyIbv5jWYBg0ILgULo7ve7VXG5lMwasW_ch2zKp7tTILnDJwITMjF71h4fn4dMTun_7MWEtSl_iFiALnIL_4_YY717cr4rmcG1424LyxJGRD9L9WjO8etAbPkiRFJUd5fmfqjHkO6fPxyWsMUAu8bfYdVRH7qN_erfGHmykmVGgH8AfK9GLT_cjN4GHA29bK9jMed6SWdrkygbQmlnsCAHrw0RA-QE0t617h3uTrSEr5vkbLz-KThVEBfH84qsweqcac_unKIZ0e2iRuyVnG4cbq8HUdio8gJ62D3wZ0UvVgr4a0", - "alg": "RS256", - "kid": "NtQYzsKs_TWLQ0p3bLmfM7fOwY0nEBVVH3z3Q-zJ06Y", - "kty": "RSA", - "use": "sig" -} diff --git a/front/src/lti/config/lti_config.json b/front/src/lti/config/lti_config.json index 7aca38b7..9ca82d8e 100644 --- a/front/src/lti/config/lti_config.json +++ b/front/src/lti/config/lti_config.json @@ -8,5 +8,15 @@ "private_key_file": "./keys/private.key", "public_key_file": "./keys/public.key", "deployment_ids": ["1"] + }], + "http://localhost:7088": [{ + "default": true, + "client_id": "343412", + "auth_login_url": "http://localhost:7088/lti/authorize", + "auth_token_url": "", + "key_set_url": "http://localhost:7088/lti/jwks", + "private_key_file": "./keys/private.key", + "public_key_file": "./keys/public.key", + "deployment_ids": ["1"] }] } \ No newline at end of file diff --git a/front/src/lti/lti_actions/base.py b/front/src/lti/lti_actions/base.py new file mode 100644 index 00000000..f925b7f6 --- /dev/null +++ b/front/src/lti/lti_actions/base.py @@ -0,0 +1,59 @@ +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 +import uuid + +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): + session["launch_id"] = self.message_launch.get_launch_id() + + self._handle_user() + + return self._process() + + def _handle_user(self): + platform_user = User.query.filter( + User.platformUserId == self.launch_data.get("sub"), + User.platform == self.launch_data.get("iss") + ).first() + + if platform_user is None: + platform_user = User( + nick=self.launch_data.get("name", f"platformUser_{uuid.uuid4().hex[:8]}"), + platform=self.launch_data.get("iss"), + platformUserId=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 BaseActionResultSender(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): + session["launch_id"] = self.message_launch.get_launch_id() + + return self._send(result) + + @abstractmethod + def _send(self, result: T) -> bool: + pass \ No newline at end of file diff --git a/front/src/lti/lti_actions/section_solving.py b/front/src/lti/lti_actions/section_solving.py new file mode 100644 index 00000000..84c2ec86 --- /dev/null +++ b/front/src/lti/lti_actions/section_solving.py @@ -0,0 +1,42 @@ +from .base import BaseActionHandler, BaseActionResultSender +from flask import redirect, url_for +from datetime import datetime +from pylti1p3.grade import Grade +from pylti1p3.lineitem import LineItem + +class SectionSolvingHandler(BaseActionHandler): + + def _process(self): + resource_link = self.launch_data.get("https://purl.imsglobal.org/spec/lti/claim/resource_link") + launch_section_id = resource_link.get('id') + + return redirect(url_for('get_section_endpoint', section=launch_section_id)) + + +class SectionSolvingResultSender(BaseActionResultSender[float]): + + def send(self, score: float) -> bool: + if not self.message_launch.has_ags(): + raise Exception("LTI launch doesn't have AGS permissions") + + resource_link = self.launch_data.get( + "https://purl.imsglobal.org/spec/lti/claim/resource_link" + ) + resource_link_id = resource_link.get('id') if resource_link else None + + sub = self.launch_data.get('sub') + timestamp = datetime.utcnow().isoformat() + 'Z' + + grades = self.message_launch.get_ags() + + grade = Grade() \ + .set_score_given(score) \ + .set_timestamp(timestamp) \ + .set_user_id(sub) + + line_item = LineItem() \ + .set_tag('score') \ + .set_resource_id(resource_link_id) + + grades.put_grade(grade, line_item) + return True diff --git a/front/src/lti/lti_actions/submission_review.py b/front/src/lti/lti_actions/submission_review.py new file mode 100644 index 00000000..6563dc80 --- /dev/null +++ b/front/src/lti/lti_actions/submission_review.py @@ -0,0 +1,6 @@ +from .base import BaseActionHandler + +class SubmissionReviewHandler(BaseActionHandler): + + def _process(self): + pass diff --git a/front/src/lti/lti_actions/task_creation.py b/front/src/lti/lti_actions/task_creation.py new file mode 100644 index 00000000..f0efaf4e --- /dev/null +++ b/front/src/lti/lti_actions/task_creation.py @@ -0,0 +1,12 @@ +from .base import BaseActionHandler +from flask import render_template, session + +class TaskCreationHandler(BaseActionHandler): + + def _process(self): + dl_settings = self.launch_data.get("https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings") + + if "deep_link_return_url" in dl_settings: + session["deep_link_return_url"] = dl_settings["deep_link_return_url"] + + return render_template("lti/deep_link_selector.html", launch_data=self.launch_data) \ No newline at end of file diff --git a/front/src/lti/lti_support.py b/front/src/lti/lti_support.py index ab30cfb8..0bd15ecc 100644 --- a/front/src/lti/lti_support.py +++ b/front/src/lti/lti_support.py @@ -1,21 +1,12 @@ -import datetime -from functools import wraps import os import pathlib -from flask_login import login_user, logout_user - -from flask import jsonify, request, session, redirect, url_for +from flask import jsonify, request, session from flask_caching import Cache -from werkzeug.exceptions import Forbidden from pylti1p3.contrib.flask import FlaskOIDCLogin, FlaskMessageLaunch, FlaskRequest, FlaskCacheDataStorage -from pylti1p3.deep_link_resource import DeepLinkResource -from pylti1p3.grade import Grade -from pylti1p3.lineitem import LineItem from pylti1p3.tool_config import ToolConfJsonFile -from pylti1p3.registration import Registration -from miminet_model import User, db +from front.src.lti.lti_actions import ActionHandlerFactory, ActionResultSenderFactory cache = Cache() @@ -33,6 +24,7 @@ def validate_nonce(self): return self return super().validate_nonce() + def login(): tool_conf = ToolConfJsonFile(get_lti_config_path()) launch_data_storage = get_launch_data_storage() @@ -47,72 +39,45 @@ def 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) - message_launch_data = message_launch.get_launch_data() - if request.args.get("returnUrl") != None: session["returnToLtiPlatformUrl"] = request.args.get("returnUrl") - session["launch_id"] = message_launch.get_launch_id() - - platformUser = User.query.filter( - User.platformUserId == message_launch_data.get("sub"), - User.platformName == message_launch_data.get("iss") - ).first() + if request.args.get("returnUrl"): + session["returnToLtiPlatformUrl"] = request.args.get("returnUrl") - if platformUser is None: - platformUser = User(nick=message_launch_data.get("name"), platformName=message_launch_data.get("iss"), platformUserId=message_launch_data.get("sub")) - db.session.add(platformUser) - db.session.commit() - - login_user(platformUser) - - launchSectionId = message_launch_data.get("https://purl.imsglobal.org/spec/lti/claim/custom").get('section_id') - - return redirect(url_for('get_section_endpoint', section=launchSectionId)) - -def score(score): - if ("launch_id" in session): - tool_conf = ToolConfJsonFile(get_lti_config_path()) - flask_request = FlaskRequest() - launch_data_storage = get_launch_data_storage() - message_launch = ExtendedFlaskMessageLaunch.from_cache(session.get("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') + handler = ActionHandlerFactory.create_handler(message_launch) + return handler.handle() - 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' - - grades = message_launch.get_ags() - sc = Grade().set_score_given(score) \ - .set_timestamp(timestamp) \ - .set_user_id(sub) - sc_line_item = LineItem().set_tag('score').set_resource_id(resource_link_id) - grades.put_grade(sc, sc_line_item) +def send(result): + 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.get("launch_id"), flask_request, tool_conf, + launch_data_storage=launch_data_storage + ) + + result_sender = ActionResultSenderFactory.create_sender(message_launch) + result_sender.send(result) + + if "returnToLtiPlatformUrl" in session: session.pop("returnToLtiPlatformUrl") - if "returnToLtiPlatformUrl" in session: session.pop("returnToLtiPlatformUrl") def get_jwks(): tool_conf = ToolConfJsonFile(get_lti_config_path()) - return jsonify({'keys': tool_conf.get_jwks()}) + 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) - -def get_jwk_from_public_key(key_name): - key_path = os.path.join(pathlib.Path(__file__).parent, "config", "keys", key_name) - f = open(key_path, 'r') - key_content = f.read() - jwk = Registration.get_jwk(key_content) - f.close() - return jwk \ No newline at end of file + return FlaskCacheDataStorage(cache) \ No newline at end of file diff --git a/front/src/miminet_model.py b/front/src/miminet_model.py index 571605da..4b779ea9 100644 --- a/front/src/miminet_model.py +++ b/front/src/miminet_model.py @@ -25,7 +25,7 @@ class User(db.Model, UserMixin): # type:ignore[name-defined] id = db.Column(BigInteger, primary_key=True, unique=True, autoincrement=True) - platformName = db.Column(Text, nullable=True) + platform = db.Column(Text, nullable=True) platformUserId = db.Column(Text, nullable=True) role = db.Column(BigInteger, default=0, nullable=True) From 70d5f87096edf400ba2fb6cf64468c7e253f4225 Mon Sep 17 00:00:00 2001 From: gruwitdacrew <127012764+gruwitdacrew@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:04:40 +0700 Subject: [PATCH 4/8] fix merging conflict results --- front/src/lti/lti_support.py | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/front/src/lti/lti_support.py b/front/src/lti/lti_support.py index 01bc23b8..cbd8e72a 100644 --- a/front/src/lti/lti_support.py +++ b/front/src/lti/lti_support.py @@ -1,6 +1,5 @@ import os import pathlib -import uuid from flask import jsonify, request, session from flask_caching import Cache @@ -47,29 +46,13 @@ def launch(): launch_data_storage = get_launch_data_storage() message_launch = ExtendedFlaskMessageLaunch(flask_request, tool_conf, launch_data_storage=launch_data_storage) -<<<<<<< HEAD if request.args.get("returnUrl"): session["returnToLtiPlatformUrl"] = request.args.get("returnUrl") handler = ActionHandlerFactory.create_handler(message_launch) return handler.handle() -======= - if request.args.get("returnUrl") != None: session["returnToLtiPlatformUrl"] = request.args.get("returnUrl") - session["launch_id"] = message_launch.get_launch_id() - - platformUser = User.query.filter( - User.platformUserId == message_launch_data.get("sub"), - User.platform == message_launch_data.get("iss") - ).first() - - if platformUser is None: - platformUser = User(nick=message_launch_data.get("name", f"platformUser_{uuid.uuid4().hex[:8]}"), platform=message_launch_data.get("iss"), platformUserId=message_launch_data.get("sub")) - db.session.add(platformUser) - db.session.commit() ->>>>>>> 6a9640608554e0231ba0d7654048cabac9b37878 -<<<<<<< HEAD def send(result): if "launch_id" not in session: raise Exception("No active LTI launch") @@ -87,9 +70,6 @@ def send(result): result_sender.send(result) if "returnToLtiPlatformUrl" in session: session.pop("returnToLtiPlatformUrl") -======= - launchSectionId = message_launch_data.get("https://purl.imsglobal.org/spec/lti/claim/custom").get('task') ->>>>>>> 6a9640608554e0231ba0d7654048cabac9b37878 def get_jwks(): @@ -100,4 +80,4 @@ 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) \ No newline at end of file + return FlaskCacheDataStorage(cache) From 2c6fb92fdd0a534d5d8d5762763911e903f713b2 Mon Sep 17 00:00:00 2001 From: Arseniy Date: Mon, 23 Feb 2026 20:56:27 +0700 Subject: [PATCH 5/8] added SubmissionReview and SectionCreate support --- .gitignore | 3 +- front/src/app.py | 5 +- front/src/lti/config/lti_config.json | 12 +- front/src/lti/lti_actions/base.py | 39 +- .../lti/lti_actions/quiz_session_retrieval.py | 11 + front/src/lti/lti_actions/section_creation.py | 29 + .../src/lti/lti_actions/section_retrieval.py | 55 + front/src/lti/lti_actions/section_solving.py | 42 - .../src/lti/lti_actions/submission_review.py | 6 - front/src/lti/lti_actions/task_creation.py | 12 - .../lti/{lti_support.py => lti_provider.py} | 52 +- front/src/miminet_auth.py | 2 +- front/src/miminet_model.py | 4 +- .../quiz/controller/question_controller.py | 79 +- .../controller/quiz_session_controller.py | 2 +- .../src/quiz/controller/section_controller.py | 1 - front/src/quiz/entity/entity.py | 4 +- front/src/quiz/facade/question_facade.py | 65 +- .../quiz/service/session_question_service.py | 11 +- front/src/static/quiz/session_scripts.js | 21 +- front/src/templates/base.html | 16 +- front/src/templates/network.html | 3 +- .../templates/quiz/createQuestionForm.html | 1074 +++++++++++++++++ front/src/templates/quiz/sessionResult.html | 4 +- .../src/templates/quiz/userSessionResult.html | 25 +- 25 files changed, 1372 insertions(+), 205 deletions(-) create mode 100644 front/src/lti/lti_actions/quiz_session_retrieval.py create mode 100644 front/src/lti/lti_actions/section_creation.py create mode 100644 front/src/lti/lti_actions/section_retrieval.py delete mode 100644 front/src/lti/lti_actions/section_solving.py delete mode 100644 front/src/lti/lti_actions/submission_review.py delete mode 100644 front/src/lti/lti_actions/task_creation.py rename front/src/lti/{lti_support.py => lti_provider.py} (51%) create mode 100644 front/src/templates/quiz/createQuestionForm.html diff --git a/.gitignore b/.gitignore index 0f6a4998..cac691be 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ back/rabbitmq/rabbitmq/ .env *.log *.ini -front/rabbitmq/rabbitmq \ No newline at end of file +front/rabbitmq/rabbitmq +front/src/lti/config/ \ No newline at end of file diff --git a/front/src/app.py b/front/src/app.py index bb06f9a1..d98f8521 100644 --- a/front/src/app.py +++ b/front/src/app.py @@ -35,6 +35,7 @@ lti_login, lti_callback ) +import lti.lti_provider as lti from miminet_config import SECRET_KEY from miminet_host import ( delete_job, @@ -97,7 +98,7 @@ SessionQuestion, ) -from lti.lti_support import get_jwks, cache +from lti.lti_provider import get_jwks, cache from quiz.controller.image_controller import image_routes @@ -228,7 +229,7 @@ app.add_url_rule('/quiz/sections/
', 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( diff --git a/front/src/lti/config/lti_config.json b/front/src/lti/config/lti_config.json index 9ca82d8e..e2c36564 100644 --- a/front/src/lti/config/lti_config.json +++ b/front/src/lti/config/lti_config.json @@ -1,7 +1,7 @@ { "https://lti-ri.imsglobal.org": [{ "default": true, - "client_id": "343412", + "client_id": "343411", "auth_login_url": "https://lti-ri.imsglobal.org/platforms/4477/authorizations/new", "auth_token_url": "https://lti-ri.imsglobal.org/platforms/4477/access_tokens", "key_set_url": "https://lti-ri.imsglobal.org/platforms/4477/platform_keys/4105.json", @@ -9,12 +9,12 @@ "public_key_file": "./keys/public.key", "deployment_ids": ["1"] }], - "http://localhost:7088": [{ + "http://localhost:5000": [{ "default": true, - "client_id": "343412", - "auth_login_url": "http://localhost:7088/lti/authorize", - "auth_token_url": "", - "key_set_url": "http://localhost:7088/lti/jwks", + "client_id": "5fe60bdc-0d63-11f1-a93e-db8c444a7739", + "auth_login_url": "http://localhost:5000/lti/authorize", + "auth_token_url": "http://host.docker.internal:8080/api/v1/lti/access_token", + "key_set_url": "http://host.docker.internal:8080/api/v1/lti/jwks", "private_key_file": "./keys/private.key", "public_key_file": "./keys/public.key", "deployment_ids": ["1"] diff --git a/front/src/lti/lti_actions/base.py b/front/src/lti/lti_actions/base.py index f925b7f6..882227f2 100644 --- a/front/src/lti/lti_actions/base.py +++ b/front/src/lti/lti_actions/base.py @@ -4,7 +4,20 @@ from flask_login import login_user from pylti1p3.contrib.flask import FlaskMessageLaunch from miminet_model import User, db -import uuid + +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): @@ -12,29 +25,29 @@ def __init__(self, message_launch: FlaskMessageLaunch): self.launch_data = message_launch.get_launch_data() def handle(self): - session["launch_id"] = self.message_launch.get_launch_id() - + 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.platformUserId == self.launch_data.get("sub"), - User.platform == self.launch_data.get("iss") + 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", f"platformUser_{uuid.uuid4().hex[:8]}"), - platform=self.launch_data.get("iss"), - platformUserId=self.launch_data.get("sub") + 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 @@ -44,14 +57,14 @@ def _process(self): T = TypeVar('T') -class BaseActionResultSender(Generic[T], ABC): +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): - session["launch_id"] = self.message_launch.get_launch_id() - + if "returnToLtiPlatformUrl" in session: session.pop("returnToLtiPlatformUrl") + return self._send(result) @abstractmethod diff --git a/front/src/lti/lti_actions/quiz_session_retrieval.py b/front/src/lti/lti_actions/quiz_session_retrieval.py new file mode 100644 index 00000000..2dd39833 --- /dev/null +++ b/front/src/lti/lti_actions/quiz_session_retrieval.py @@ -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)) diff --git a/front/src/lti/lti_actions/section_creation.py b/front/src/lti/lti_actions/section_creation.py new file mode 100644 index 00000000..c98d115c --- /dev/null +++ b/front/src/lti/lti_actions/section_creation.py @@ -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]) \ No newline at end of file diff --git a/front/src/lti/lti_actions/section_retrieval.py b/front/src/lti/lti_actions/section_retrieval.py new file mode 100644 index 00000000..48c7cc1d --- /dev/null +++ b/front/src/lti/lti_actions/section_retrieval.py @@ -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) diff --git a/front/src/lti/lti_actions/section_solving.py b/front/src/lti/lti_actions/section_solving.py deleted file mode 100644 index 84c2ec86..00000000 --- a/front/src/lti/lti_actions/section_solving.py +++ /dev/null @@ -1,42 +0,0 @@ -from .base import BaseActionHandler, BaseActionResultSender -from flask import redirect, url_for -from datetime import datetime -from pylti1p3.grade import Grade -from pylti1p3.lineitem import LineItem - -class SectionSolvingHandler(BaseActionHandler): - - def _process(self): - resource_link = self.launch_data.get("https://purl.imsglobal.org/spec/lti/claim/resource_link") - launch_section_id = resource_link.get('id') - - return redirect(url_for('get_section_endpoint', section=launch_section_id)) - - -class SectionSolvingResultSender(BaseActionResultSender[float]): - - def send(self, score: float) -> bool: - if not self.message_launch.has_ags(): - raise Exception("LTI launch doesn't have AGS permissions") - - resource_link = self.launch_data.get( - "https://purl.imsglobal.org/spec/lti/claim/resource_link" - ) - resource_link_id = resource_link.get('id') if resource_link else None - - sub = self.launch_data.get('sub') - timestamp = datetime.utcnow().isoformat() + 'Z' - - grades = self.message_launch.get_ags() - - grade = Grade() \ - .set_score_given(score) \ - .set_timestamp(timestamp) \ - .set_user_id(sub) - - line_item = LineItem() \ - .set_tag('score') \ - .set_resource_id(resource_link_id) - - grades.put_grade(grade, line_item) - return True diff --git a/front/src/lti/lti_actions/submission_review.py b/front/src/lti/lti_actions/submission_review.py deleted file mode 100644 index 6563dc80..00000000 --- a/front/src/lti/lti_actions/submission_review.py +++ /dev/null @@ -1,6 +0,0 @@ -from .base import BaseActionHandler - -class SubmissionReviewHandler(BaseActionHandler): - - def _process(self): - pass diff --git a/front/src/lti/lti_actions/task_creation.py b/front/src/lti/lti_actions/task_creation.py deleted file mode 100644 index f0efaf4e..00000000 --- a/front/src/lti/lti_actions/task_creation.py +++ /dev/null @@ -1,12 +0,0 @@ -from .base import BaseActionHandler -from flask import render_template, session - -class TaskCreationHandler(BaseActionHandler): - - def _process(self): - dl_settings = self.launch_data.get("https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings") - - if "deep_link_return_url" in dl_settings: - session["deep_link_return_url"] = dl_settings["deep_link_return_url"] - - return render_template("lti/deep_link_selector.html", launch_data=self.launch_data) \ No newline at end of file diff --git a/front/src/lti/lti_support.py b/front/src/lti/lti_provider.py similarity index 51% rename from front/src/lti/lti_support.py rename to front/src/lti/lti_provider.py index cbd8e72a..214b6ba7 100644 --- a/front/src/lti/lti_support.py +++ b/front/src/lti/lti_provider.py @@ -1,29 +1,15 @@ import os import pathlib -from flask import jsonify, request, session +from flask import jsonify, session from flask_caching import Cache -from pylti1p3.contrib.flask import FlaskOIDCLogin, FlaskMessageLaunch, FlaskRequest, FlaskCacheDataStorage +from pylti1p3.contrib.flask import FlaskOIDCLogin, FlaskRequest, FlaskCacheDataStorage from pylti1p3.tool_config import ToolConfJsonFile +from lti.lti_actions.base import ExtendedFlaskMessageLaunch -from front.src.lti.lti_actions import ActionHandlerFactory, ActionResultSenderFactory - -cache = Cache() - -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() +from lti.lti_actions import ActionHandlerFactory, ActionResultSenderFactory +cache = Cache() def login(): tool_conf = ToolConfJsonFile(get_lti_config_path()) @@ -31,8 +17,7 @@ def login(): 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') + 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\ @@ -44,32 +29,25 @@ 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) - - if request.args.get("returnUrl"): - session["returnToLtiPlatformUrl"] = request.args.get("returnUrl") + session["launch_id"] = message_launch.get_launch_id() handler = ActionHandlerFactory.create_handler(message_launch) return handler.handle() -def send(result): - if "launch_id" not in session: - raise Exception("No active LTI launch") - +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) - message_launch = ExtendedFlaskMessageLaunch.from_cache( - session.get("launch_id"), flask_request, tool_conf, - launch_data_storage=launch_data_storage - ) - - result_sender = ActionResultSenderFactory.create_sender(message_launch) - result_sender.send(result) - - if "returnToLtiPlatformUrl" in session: session.pop("returnToLtiPlatformUrl") + result_sender = ActionResultSenderFactory.create_sender(message_launch, result_type) + return result_sender.send(result) def get_jwks(): diff --git a/front/src/miminet_auth.py b/front/src/miminet_auth.py index bf6d4711..4b89d98f 100644 --- a/front/src/miminet_auth.py +++ b/front/src/miminet_auth.py @@ -17,7 +17,7 @@ login_user, logout_user, ) -import lti.lti_support as lti +import lti.lti_provider as lti from google.oauth2 import id_token from google_auth_oauthlib.flow import Flow from requests_oauthlib import OAuth2Session diff --git a/front/src/miminet_model.py b/front/src/miminet_model.py index 4b779ea9..2b16c7cb 100644 --- a/front/src/miminet_model.py +++ b/front/src/miminet_model.py @@ -25,8 +25,8 @@ class User(db.Model, UserMixin): # type:ignore[name-defined] id = db.Column(BigInteger, primary_key=True, unique=True, autoincrement=True) - platform = db.Column(Text, nullable=True) - platformUserId = db.Column(Text, nullable=True) + platform_client_id = db.Column(Text, nullable=True) + platform_user_id = db.Column(Text, nullable=True) role = db.Column(BigInteger, default=0, nullable=True) diff --git a/front/src/quiz/controller/question_controller.py b/front/src/quiz/controller/question_controller.py index 71720ee8..ddf656f6 100644 --- a/front/src/quiz/controller/question_controller.py +++ b/front/src/quiz/controller/question_controller.py @@ -1,12 +1,16 @@ import json +import uuid -from flask import request, abort, make_response, jsonify +from flask import render_template, request, abort, make_response, jsonify, session from flask_login import login_required, current_user from quiz.facade.question_facade import create_question, delete_question from quiz.service.question_service import get_questions_by_section from quiz.util.encoder import UUIDEncoder +from miminet_model import Network, db +from quiz.entity.entity import Test + @login_required def get_questions_by_section_endpoint(): @@ -22,31 +26,56 @@ def get_questions_by_section_endpoint(): @login_required def create_question_endpoint(): - section_id = request.args.get("id", None) - res = create_question(section_id, request.json, current_user) - if res[1] == 404 and "message" in res[0]: - msg = res[0]["message"] - ret = {"message": f"{msg}"} - elif res[1] == 404: - ret = {"message": "Не существует данного раздела", "id": section_id} - elif res[1] == 403: - ret = {"message": "Нельзя создать вопрос по чужому разделу", "id": section_id} - elif res[1] == 400 and "missing" in res[0]: - ret = {"message": "Некоторые изображения отсутствуют", "details": res[0]} - elif res[1] == 400 and "message" in res[0]: - ret = { - "message": "Ваши требования не удовлетворяют шаблону.", - "details": res[0], - } - elif res[1] == 400: - ret = { - "message": "Нельзя создать вопрос с данными параметрами в данном разделе", - "id": section_id, - } - else: - ret = {"message": "Вопрос создан", "id": res[0]} + if request.method == "POST": + section_id = request.args.get("section_id", None) + res = create_question(section_id, request.json, current_user) + if res[2] == 404 and "message" in res[1]: + msg = res[1]["message"] + ret = {"message": f"{msg}"} + elif res[2] == 404: + ret = {"message": "Не существует данного раздела", "section_id": section_id} + elif res[2] == 403: + ret = {"message": "Нельзя создать вопрос по чужому разделу", "section_id": section_id} + elif res[2] == 400 and "missing" in res[1]: + ret = {"message": "Некоторые изображения отсутствуют", "details": res[1]} + elif res[2] == 400 and "message" in res[1]: + ret = { + "message": "Ваши требования не удовлетворяют шаблону.", + "details": res[1], + } + elif res[2] == 400: + ret = { + "message": "Нельзя создать вопрос с данными параметрами в данном разделе", + "section_id": section_id + } + else: + ret = {"message": "Вопрос создан", "question_ids": res[1], "section_id": res[0]} + + if "launch_id" in session and res[2] == 201: return make_response(res[0]) + return make_response(jsonify(ret), res[2]) + + elif request.method == "GET": + network_id = request.args.get("network_id", None) + if network_id is None: + network = Network( + guid=uuid.uuid4(), + author_id=current_user.id, + title="Сеть для нового задания", + description="Создайте сеть, которая будет начальной конфигурацией в данном задании", + is_task=True, + ) + + db.session.add(network) + db.session.commit() + else: + network = Network.query.filter( + Network.guid == network_id + ).first() + + if network.author_id != current_user.id: + raise Exception("Используйте созданную вами сеть") - return make_response(jsonify(ret), res[1]) + return make_response(render_template("quiz/createQuestionForm.html", network=network, is_lti=("launch_id" in session), mimishark_nav=1)) @login_required diff --git a/front/src/quiz/controller/quiz_session_controller.py b/front/src/quiz/controller/quiz_session_controller.py index 497d9fa9..92b0356d 100644 --- a/front/src/quiz/controller/quiz_session_controller.py +++ b/front/src/quiz/controller/quiz_session_controller.py @@ -137,7 +137,7 @@ def session_result_endpoint(): return make_response("Error", status) return make_response( - render_template("quiz/userSessionResult.html", data=res), status + render_template("quiz/userSessionResult.html", data=res, returnToLtiPlatformUrl=session.get("returnToLtiPlatformUrl")) ) diff --git a/front/src/quiz/controller/section_controller.py b/front/src/quiz/controller/section_controller.py index 60c0105d..98cada67 100644 --- a/front/src/quiz/controller/section_controller.py +++ b/front/src/quiz/controller/section_controller.py @@ -10,7 +10,6 @@ get_deleted_sections_by_test, delete_section, edit_section, - get_section, publish_or_unpublish_test_by_section, ) from quiz.service.test_service import get_test diff --git a/front/src/quiz/entity/entity.py b/front/src/quiz/entity/entity.py index 7012d2eb..2cd30aa7 100644 --- a/front/src/quiz/entity/entity.py +++ b/front/src/quiz/entity/entity.py @@ -1,7 +1,7 @@ import json import uuid -from sqlalchemy import func, BigInteger, Text, Boolean, TIMESTAMP, ForeignKey +from sqlalchemy import Integer, func, BigInteger, Text, Boolean, TIMESTAMP, ForeignKey from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import declared_attr from sqlalchemy.types import TypeDecorator @@ -131,6 +131,7 @@ class Section( is_exam = db.Column(Boolean, default=False) meta_description = db.Column(Text, default="") results_available_from = db.Column(TIMESTAMP(timezone=True), nullable=True) + max_score = db.Column(Integer) test = db.relationship("Test", back_populates="sections") questions = db.relationship("Question", back_populates="section") @@ -201,6 +202,7 @@ class QuizSession( __tablename__ = "quiz_session" guid = db.Column(Text, default=lambda: str(uuid.uuid4())) + score = db.Column(Integer, default=0) section_id = db.Column(BigInteger, ForeignKey(Section.id)) finished_at = db.Column(TIMESTAMP(timezone=True)) diff --git a/front/src/quiz/facade/question_facade.py b/front/src/quiz/facade/question_facade.py index 4e9c2956..5485780a 100644 --- a/front/src/quiz/facade/question_facade.py +++ b/front/src/quiz/facade/question_facade.py @@ -3,8 +3,10 @@ import logging import os +from flask import session + +import lti.lti_provider as lti from quiz.facade.json_schema_validation import validate_requirements -from copy import deepcopy from miminet_model import db, User, Network from quiz.entity.entity import ( @@ -25,16 +27,8 @@ def create_single_question(section_id: str, question_dict, user: User): if validation_result is not True: return {"message": validation_result}, 400 - if section_id: - section = Section.query.filter_by(id=section_id).first() - if section is None or section.is_deleted: - return None, 404 - elif section.created_by_id != user.id: - return None, 403 - question = Question() - if section_id: - question.section_id = section_id + question.section_id = section_id question.created_by_id = user.id question.text = question_dict["text"] @@ -104,25 +98,16 @@ def create_single_question(section_id: str, question_dict, user: User): net_guid = question_dict["start_configuration"] return {"message": f"Сеть {net_guid} не найдена"}, 404 - original_network = json.loads(net.network) - modified_network = deepcopy(original_network) - modified_network.pop("packets", None) - modified_network.pop("pcap", None) - - net_copy = Network( - guid=str(uuid.uuid4()), - author_id=user.id, - network=json.dumps(modified_network), - title=net.title, - description="Task start configuration copy", - preview_uri=net.preview_uri, - is_task=True, - ) - - db.session.add(net_copy) + question_network = json.loads(net.network) + question_network.pop("packets", None) + question_network.pop("pcap", None) + + net.network = json.dumps(question_network) + + db.session.add(net) db.session.commit() - practice_question.start_configuration = net_copy.guid + practice_question.start_configuration = net.guid practice_question.created_by_id = user.id requirements = question_dict.get("requirements") @@ -170,19 +155,37 @@ def create_question(section_id: str, question_data, user: User): Возвращает кортеж: (список созданных ID, HTTP статус) """ created_ids = [] + + if section_id: + section = Section.query.filter_by(id=section_id).first() + if (section is None or section.is_deleted) and isinstance(question_data, list): + return None, None, 404 + elif section.created_by_id != user.id: + return None, None, 403 + else: + section = Section() + section.name = question_data["text"] + section.description = question_data["description"] + section.max_score = question_data["max_score"] + db.session.add(section) + db.session.commit() + if isinstance(question_data, list): for q_data in question_data: - q_id, status = create_single_question(section_id, q_data, user) + q_id, status = create_single_question(section.id, q_data, user) if status == 201: created_ids.append(q_id) else: logging.error("Ошибка создания вопроса: %s (код %s)", q_data, status) if not created_ids: - return None, 400 - return created_ids, 201 + return None, None, 400 + return section.id, created_ids, 201 else: - return create_single_question(section_id, question_data, user) + q_id, status = create_single_question(section.id, question_data, user) + + if "launch_id" in session: return lti.send(section), q_id, status + return section.id, q_id, status def delete_question(question_id: str, user: User): diff --git a/front/src/quiz/service/session_question_service.py b/front/src/quiz/service/session_question_service.py index d5e023c5..932bda46 100644 --- a/front/src/quiz/service/session_question_service.py +++ b/front/src/quiz/service/session_question_service.py @@ -18,7 +18,7 @@ calculate_max_score, ) -import lti.lti_support as lti +import lti.lti_provider as lti from flask import session from quiz.service.network_upload_service import prepare_task @@ -99,7 +99,7 @@ def get_session_question_data(session_question_id: str): result = { "id": sq.id, "quiz_session_id": sq.quiz_session_id, - "test_name": test.name, + "test_name": getattr(test, 'name', ''), "section_name": section.name, "question_ids": question_ids, "question_index": current_index, @@ -350,6 +350,9 @@ def answer_on_session_question(session_question_id: str, answer, user: User): session_question = SessionQuestion.query.filter_by(id=session_question_id).first() if session_question.created_by_id != user.id: return None, 403 + + if "launch_id" in session: lti.send(session_question, result_type="solution") + question = session_question.question # practice @@ -406,8 +409,6 @@ def answer_on_session_question(session_question_id: str, answer, user: User): if score != max_score and len(hints) == 0: hints.append("По вашему решению не предусмотрены подсказки.") - - if "launch_id" in session: lti.score(score) network = Network.query.filter_by(guid=session_question.network_guid).first() network.author_id = 0 @@ -420,6 +421,8 @@ def answer_on_session_question(session_question_id: str, answer, user: User): db.session.add(session_question) db.session.commit() + if "launch_id" in session: lti.send(session_question, result_type="solution_score") + return ( PracticeAnswerResultDto(score, question.explanation, max_score, hints), 200, diff --git a/front/src/static/quiz/session_scripts.js b/front/src/static/quiz/session_scripts.js index 6caea8db..27b20dfe 100644 --- a/front/src/static/quiz/session_scripts.js +++ b/front/src/static/quiz/session_scripts.js @@ -300,16 +300,31 @@ async function answerQuestion() { return; } - fetch(answerQuestionURL + '?id=' + questionId, { + const fetchOptions = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ answer }) - }) + }; + + if (returnToLtiPlatformUrl && returnToLtiPlatformUrl !== 'None') { + fetchOptions.keepalive = true; + + fetch(answerQuestionURL + '?id=' + questionId, fetchOptions); + finishQuiz(); + + setTimeout(() => { + window.location.href = returnToLtiPlatformUrl; + }, 500); + + return; + } + + fetch(answerQuestionURL + '?id=' + questionId, fetchOptions) .then(response => response.json()) .then(data => { - sessionStorage.setItem('answer', JSON.stringify(answer)); // Сохраняем факт ответа + sessionStorage.setItem('answer', JSON.stringify(answer)); if (questionType === "practice") { handlePracticeAnswerResult(data); diff --git a/front/src/templates/base.html b/front/src/templates/base.html index 7e97ccda..698f7973 100644 --- a/front/src/templates/base.html +++ b/front/src/templates/base.html @@ -121,11 +121,11 @@ @@ -82,7 +82,7 @@
- Посмотреть мой ответ + Посмотреть ответ {% endif %} diff --git a/front/src/templates/quiz/userSessionResult.html b/front/src/templates/quiz/userSessionResult.html index 022029be..4fac94e8 100644 --- a/front/src/templates/quiz/userSessionResult.html +++ b/front/src/templates/quiz/userSessionResult.html @@ -45,7 +45,7 @@

Практические задания:

- Посмотреть мой ответ + Посмотреть ответ @@ -88,7 +88,7 @@

Результаты практических заданий: - Посмотреть мой ответ + Посмотреть ответ {% endif %} @@ -123,9 +123,15 @@

Результаты практических заданий: + {% if returnToLtiPlatformUrl %} + + {% else %} + {% endif %}

@@ -137,13 +143,22 @@

Результаты практических заданий: Date: Mon, 23 Feb 2026 21:11:17 +0700 Subject: [PATCH 6/8] remove cached gitignore --- front/src/lti/config/keys/private.key | 51 --------------------------- front/src/lti/config/keys/public.key | 14 -------- front/src/lti/config/lti_config.json | 22 ------------ 3 files changed, 87 deletions(-) delete mode 100644 front/src/lti/config/keys/private.key delete mode 100644 front/src/lti/config/keys/public.key delete mode 100644 front/src/lti/config/lti_config.json diff --git a/front/src/lti/config/keys/private.key b/front/src/lti/config/keys/private.key deleted file mode 100644 index 1e66949d..00000000 --- a/front/src/lti/config/keys/private.key +++ /dev/null @@ -1,51 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -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 ------END RSA PRIVATE KEY----- diff --git a/front/src/lti/config/keys/public.key b/front/src/lti/config/keys/public.key deleted file mode 100644 index 9109ee3d..00000000 --- a/front/src/lti/config/keys/public.key +++ /dev/null @@ -1,14 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuvEnCaUOy1l9gk3wjW3P -ib1dBc5g92+6rhvZZOsN1a77fdOqKsrjWG1lDu8kq2nL+wbAzR3DdEPVw/1WUwtr -/Q1d5m+7S4ciXT63pENs1EPwWmeN33O0zkGx8I7vdiOTSVoywEyUZe6UyS+ujLfs -Rc2ImeLP5OHxpE1yULEDSiMLtSvgzEaMvf2AkVq5EL5nLYDWXZWXUnpiT/f7iK47 -Mp2iQd4KYYG7YZ7lMMPCMBuhej7SOtZQ2FwaBjvZiXDZ172sQYBCiBAmOR3ofTL6 -aD2+HUxYztVIPCkhyO84mQ7W4BFsOnKW4WRfEySHXd2hZkFMgcFNXY3dA6de519q -lcrL0YYx8ZHpzNt0foEzUsgJd8uJMUVvzPZgExwcyIbv5jWYBg0ILgULo7ve7VXG -5lMwasW/ch2zKp7tTILnDJwITMjF71h4fn4dMTun/7MWEtSl/iFiALnIL/4/YY71 -7cr4rmcG1424LyxJGRD9L9WjO8etAbPkiRFJUd5fmfqjHkO6fPxyWsMUAu8bfYdV -RH7qN/erfGHmykmVGgH8AfK9GLT/cjN4GHA29bK9jMed6SWdrkygbQmlnsCAHrw0 -RA+QE0t617h3uTrSEr5vkbLz+KThVEBfH84qsweqcac/unKIZ0e2iRuyVnG4cbq8 -HUdio8gJ62D3wZ0UvVgr4a0CAwEAAQ== ------END PUBLIC KEY----- diff --git a/front/src/lti/config/lti_config.json b/front/src/lti/config/lti_config.json deleted file mode 100644 index e2c36564..00000000 --- a/front/src/lti/config/lti_config.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "https://lti-ri.imsglobal.org": [{ - "default": true, - "client_id": "343411", - "auth_login_url": "https://lti-ri.imsglobal.org/platforms/4477/authorizations/new", - "auth_token_url": "https://lti-ri.imsglobal.org/platforms/4477/access_tokens", - "key_set_url": "https://lti-ri.imsglobal.org/platforms/4477/platform_keys/4105.json", - "private_key_file": "./keys/private.key", - "public_key_file": "./keys/public.key", - "deployment_ids": ["1"] - }], - "http://localhost:5000": [{ - "default": true, - "client_id": "5fe60bdc-0d63-11f1-a93e-db8c444a7739", - "auth_login_url": "http://localhost:5000/lti/authorize", - "auth_token_url": "http://host.docker.internal:8080/api/v1/lti/access_token", - "key_set_url": "http://host.docker.internal:8080/api/v1/lti/jwks", - "private_key_file": "./keys/private.key", - "public_key_file": "./keys/public.key", - "deployment_ids": ["1"] - }] -} \ No newline at end of file From 2885c8dc56b2858aeddb93952f4a5a34a2d56a91 Mon Sep 17 00:00:00 2001 From: Arseniy Date: Wed, 25 Feb 2026 19:05:10 +0700 Subject: [PATCH 7/8] move factory code from __init__.py to factory.py --- front/Dockerfile | 2 +- front/src/lti/lti_actions/factory.py | 44 ++++++++++++++++++++++++++++ front/src/lti/lti_provider.py | 2 +- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 front/src/lti/lti_actions/factory.py diff --git a/front/Dockerfile b/front/Dockerfile index 7c1c9831..4d9ea6ef 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -11,4 +11,4 @@ ADD ./src /app ADD run_app.sh /app/run_app.sh RUN chmod +x /app/run_app.sh -CMD ["/app/run_app.sh"] +ENTRYPOINT ["/bin/sh", "/app/run_app.sh"] \ No newline at end of file diff --git a/front/src/lti/lti_actions/factory.py b/front/src/lti/lti_actions/factory.py new file mode 100644 index 00000000..cdb96e9a --- /dev/null +++ b/front/src/lti/lti_actions/factory.py @@ -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() \ No newline at end of file diff --git a/front/src/lti/lti_provider.py b/front/src/lti/lti_provider.py index 214b6ba7..48cf9df3 100644 --- a/front/src/lti/lti_provider.py +++ b/front/src/lti/lti_provider.py @@ -7,7 +7,7 @@ from pylti1p3.tool_config import ToolConfJsonFile from lti.lti_actions.base import ExtendedFlaskMessageLaunch -from lti.lti_actions import ActionHandlerFactory, ActionResultSenderFactory +from lti.lti_actions.factory import ActionHandlerFactory, ActionResultSenderFactory cache = Cache() From 97a63197a2b1ccb2b86f47ef3537216e6690d753 Mon Sep 17 00:00:00 2001 From: Arseniy Date: Wed, 25 Feb 2026 19:08:56 +0700 Subject: [PATCH 8/8] return original Dockerfile --- front/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/Dockerfile b/front/Dockerfile index cf8324f1..c3820ed6 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -15,4 +15,4 @@ ADD ./src /app ADD run_app.sh /app/run_app.sh RUN chmod +x /app/run_app.sh -ENTRYPOINT ["/bin/sh", "/app/run_app.sh"] \ No newline at end of file +CMD ["/app/run_app.sh"] \ No newline at end of file