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/Dockerfile b/front/Dockerfile index 14fec740..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 -CMD ["/app/run_app.sh"] +CMD ["/app/run_app.sh"] \ No newline at end of file diff --git a/front/requirements.txt b/front/requirements.txt index c5cc715f..8e07122d 100644 --- a/front/requirements.txt +++ b/front/requirements.txt @@ -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 diff --git a/front/run_app.sh b/front/run_app.sh index 7c0dfeca..9cb66018 100644 --- a/front/run_app.sh +++ b/front/run_app.sh @@ -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 \ No newline at end of file diff --git a/front/src/app.py b/front/src/app.py index e08c3f1f..1aad011d 100644 --- a/front/src/app.py +++ b/front/src/app.py @@ -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, @@ -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, @@ -95,6 +99,8 @@ SessionQuestion, ) +from lti.lti_provider import get_jwks, cache + from quiz.controller.image_controller import image_routes app = Flask( @@ -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) @@ -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) @@ -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/
', 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/lti_actions/base.py b/front/src/lti/lti_actions/base.py new file mode 100644 index 00000000..882227f2 --- /dev/null +++ b/front/src/lti/lti_actions/base.py @@ -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 \ 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_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_provider.py b/front/src/lti/lti_provider.py new file mode 100644 index 00000000..48cf9df3 --- /dev/null +++ b/front/src/lti/lti_provider.py @@ -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) diff --git a/front/src/miminet_auth.py b/front/src/miminet_auth.py index dc5e650c..fb80e9f9 100644 --- a/front/src/miminet_auth.py +++ b/front/src/miminet_auth.py @@ -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 @@ -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" diff --git a/front/src/miminet_model.py b/front/src/miminet_model.py index 443cece7..14c7f21c 100644 --- a/front/src/miminet_model.py +++ b/front/src/miminet_model.py @@ -36,6 +36,9 @@ class User(db.Model, UserMixin): # type:ignore[name-defined] id = db.Column(BigInteger, primary_key=True, unique=True, autoincrement=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) email = db.Column(Text, unique=True, 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 4ec3521e..92b0356d 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"]} @@ -133,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 31ea2898..98cada67 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 ( @@ -10,12 +10,12 @@ 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 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 +34,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/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 23b49855..932bda46 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_provider as lti +from flask import session from quiz.service.network_upload_service import prepare_task MOSCOW_TZ = ZoneInfo("Europe/Moscow") @@ -96,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, @@ -347,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 @@ -403,7 +409,7 @@ def answer_on_session_question(session_question_id: str, answer, user: User): if score != max_score and len(hints) == 0: hints.append("По вашему решению не предусмотрены подсказки.") - + network = Network.query.filter_by(guid=session_question.network_guid).first() network.author_id = 0 db.session.add(network) @@ -415,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 53746dde..27b20dfe 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; @@ -322,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); @@ -347,7 +340,7 @@ async function answerQuestion() { } } - handleScoreBasedVisibility(); + handleScoreBasedVisibility(isExam=false); } else { sessionStorage.setItem('is_correct', data['is_correct']); sessionStorage.setItem('explanation', data['explanation']); @@ -389,14 +382,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 +442,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 +457,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/network.html b/front/src/templates/network.html index 195b969d..e912cc34 100644 --- a/front/src/templates/network.html +++ b/front/src/templates/network.html @@ -18,7 +18,6 @@
-
-
+
diff --git a/front/src/templates/quiz/createQuestionForm.html b/front/src/templates/quiz/createQuestionForm.html new file mode 100644 index 00000000..25adcf5d --- /dev/null +++ b/front/src/templates/quiz/createQuestionForm.html @@ -0,0 +1,1074 @@ +{% extends "base.html" %} +{% block title %}{{ network.title }}{% endblock %} +{% block og %} + + + + + +{% endblock %} + +{% block content %} +
+ {% if network %} + + {% endif %} +
+
+
+
+ + +
+
Устройства
+
+
+ +
+ Свитч (L2) +
+
+
+ +
+ Хост +
+
+
+ +
+ Хаб (L1) +
+
+
+ +
+ Роутер (L3) +
+
+
+ +
+ Сервер +
+
+
+
+ +
+
Настройки
+
+ Тут будут настройки устройств. Выделите любое на схеме. +
+ +
+ +
+
+ +
+
+
+
+ +
+
+ Ожидание 10-30 сек. +
+
+
+ +
+
+
+
+
+
+
+
+{% endblock %} + +{% block network %} + + + + +{% endblock %} + 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 %} diff --git a/front/src/templates/quiz/sessionResult.html b/front/src/templates/quiz/sessionResult.html index 0764b5b4..911bf517 100644 --- a/front/src/templates/quiz/sessionResult.html +++ b/front/src/templates/quiz/sessionResult.html @@ -48,7 +48,7 @@
{{ q.question_text }}
- Посмотреть мой ответ + Посмотреть ответ
@@ -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 @@

Результаты практических заданий: