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 @@
-
-
@@ -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 @@ Результаты практических заданий:
{% endif %}
+ {% if returnToLtiPlatformUrl %}
+
+ {% else %}
+ {% endif %}
@@ -137,13 +143,22 @@
Результаты практических заданий:
if (testName) document.getElementById("test_name").textContent = testName;
if (sectionName) document.getElementById("section_name").textContent = sectionName;
- const backBtn = document.getElementById("backToTests");
- if (backBtn) {
- backBtn.addEventListener('click', function () {
+ const backToTestsBtn = document.getElementById("backToTests");
+ if (backToTestsBtn) {
+ backToTestsBtn.addEventListener('click', function () {
sessionStorage.clear();
window.location.href = '/quiz/test/all';
});
}
+
+ const returnToLtiPlatformUrl = "{{ returnToLtiPlatformUrl }}";
+ const backToPlatformBtn = document.getElementById("backToPlatform");
+ if (backToPlatformBtn) {
+ backToPlatformBtn.addEventListener('click', function () {
+ sessionStorage.clear();
+ window.location.href = returnToLtiPlatformUrl;
+ });
+ }
console.log({{ data | tojson | safe }});
});