From 4fb5afc77dffbc25faf98681f6c39b2e7d8e1ad6 Mon Sep 17 00:00:00 2001 From: Mitchell Hewes Date: Fri, 7 Jun 2024 07:41:55 -0600 Subject: [PATCH] Update to ait-cs-IaaS/learners:feature-venjix --- .env.sample | 32 +++ .flaskenv | 1 + .github/workflows/build.yaml | 84 ++++++ .github/workflows/lint.yaml | 34 +++ .gitignore | 152 ++++++++++- .pre-commit-config.yaml | 12 + backend/__init__.py | 1 + backend/classes/SubmissionResponse.py | 26 +- backend/conf/db_models.py | 105 ++++---- backend/functions/database.py | 236 ++++++++++------- backend/functions/execution.py | 52 ++-- backend/functions/helpers.py | 13 +- backend/routes/__init__.py | 1 + backend/routes/cache.py | 10 +- backend/routes/callback.py | 32 ++- backend/routes/executions.py | 248 ++++++++++-------- backend/routes/questionnaires.py | 39 +-- backend/routes/results.py | 52 ++++ backend/routes/setup.py | 4 +- dev-backend.sh | 35 ++- frontend/src/components/LoginForm.vue | 2 + frontend/src/components/Mainpage.vue | 115 ++++++-- frontend/src/components/admin/AdminArea.vue | 12 +- .../src/components/admin/ExerciseCard.vue | 4 - .../admin/QuestionnaireOverview.vue | 33 +-- .../src/components/admin/SubmissionCard.vue | 9 +- .../components/admin/SubmissionsOverview.vue | 2 +- ...gesOverview.vue => VisibilityOverview.vue} | 37 ++- frontend/src/components/general/PageTree.vue | 11 + .../components/sub-components/DataTable.vue | 48 ++-- .../components/sub-components/PartialIcon.vue | 76 ++++++ .../sub-components/Questionnaire.vue | 22 +- .../sub-components/ScenarioTimer.vue | 177 +++++++++++++ frontend/src/helpers/index.ts | 60 +++-- frontend/src/plugins/index.ts | 2 + frontend/src/plugins/webfontloader.ts | 1 + frontend/src/scss/main.scss | 4 + frontend/src/store/actions.ts | 8 +- frontend/src/store/getters.ts | 3 + frontend/src/store/mutations.ts | 12 +- frontend/src/store/state.ts | 4 + frontend/src/types/index.ts | 7 +- 42 files changed, 1369 insertions(+), 449 deletions(-) create mode 100644 .env.sample create mode 100644 .flaskenv create mode 100644 .github/workflows/build.yaml create mode 100644 .github/workflows/lint.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 backend/routes/results.py rename frontend/src/components/admin/{PagesOverview.vue => VisibilityOverview.vue} (74%) create mode 100644 frontend/src/components/sub-components/PartialIcon.vue create mode 100644 frontend/src/components/sub-components/ScenarioTimer.vue diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..3083a2a --- /dev/null +++ b/.env.sample @@ -0,0 +1,32 @@ +# FLASK_ENV="development" +# FLASK_DEBUG="1" +# LEARNERS_CONFIG="config.yml" +# MAIL="True" +# REMOVE_DB="True" +# TESTING="False" +# PROPAGATE_EXCEPTIONS="None" +# PRESERVE_CONTEXT_ON_EXCEPTION="None" +# SECRET_KEY="None" +# PERMANENT_SESSION_LIFETIME="datetime.timedelta(days=31)" +# USE_X_SENDFILE="False" +# SERVER_NAME="None" +# APPLICATION_ROOT="/" +# SESSION_COOKIE_NAME="session" +# SESSION_COOKIE_DOMAIN="None" +# SESSION_COOKIE_PATH="None" +# SESSION_COOKIE_HTTPONLY="True" +# SESSION_COOKIE_SECURE="False" +# SESSION_COOKIE_SAMESITE="None" +# SESSION_REFRESH_EACH_REQUEST="True" +# MAX_CONTENT_LENGTH="None" +# SEND_FILE_MAX_AGE_DEFAULT="None" +# TRAP_BAD_REQUEST_ERRORS="None" +# TRAP_HTTP_EXCEPTIONS="False" +# EXPLAIN_TEMPLATE_LOADING="False" +# PREFERRED_URL_SCHEME="http" +# JSON_AS_ASCII="True" +# JSON_SORT_KEYS="True" +# JSONIFY_PRETTYPRINT_REGULAR="False" +# JSONIFY_MIMETYPE="application/json" +# TEMPLATES_AUTO_RELOAD="None" +# MAX_COOKIE_SIZE="4093" diff --git a/.flaskenv b/.flaskenv new file mode 100644 index 0000000..1499678 --- /dev/null +++ b/.flaskenv @@ -0,0 +1 @@ +FLASK_APP="backend" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..8dd1e05 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,84 @@ +name: build + +on: + push: + tags: + - '**' + +env: + LEARNERS_VERSION: ${{ github.ref_name }} + +jobs: + lint: + name: lint & check formatting + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.10'] + steps: + - uses: actions/checkout@v3 + - name: Install dependencies + run: | + sudo apt update -y + sudo apt install python3-venv -y + sudo apt purge python3-blinker -y + curl https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py + python /tmp/get-pip.py + python -m pip install --upgrade pip setuptools wheel + python -m pip install . + - name: Lint with flake8 + run: | + flake8 backend --count --exit-zero --max-complexity=10 --max-line-length=142 --statistics + - name: lint with black + uses: rickstaa/action-black@v1 + with: + black_args: 'backend --check' + build_wheel: + name: build wheel + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v3 + - name: build + run: | + sudo apt update -y + sudo apt install python3-venv -y + sudo apt purge python3-blinker -y + curl https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py + python /tmp/get-pip.py + python -m pip install --upgrade pip setuptools wheel build + python -m build + - name: Release to GitHub + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + files: | + dist/*.whl + dist/*.tar.gz + # - name: Release to PyPI + # uses: pypa/gh-action-pypi-publish@release/v1 + # with: + # password: ${{ secrets.PYPI_API_TOKEN }} + + build_container: + name: build container + needs: build_wheel + runs-on: ubuntu-latest + steps: + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: get ghcr owner repository + run: | + echo "GHCR_OWNER=${GITHUB_REPOSITORY_OWNER,,}" >>${GITHUB_ENV} + - name: Build and push image + uses: docker/build-push-action@v3 + with: + file: Containerfile + push: true + tags: | + ghcr.io/${{ env.GHCR_OWNER }}/learners:latest + ghcr.io/${{ env.GHCR_OWNER }}/learners:${{ github.ref_name }} diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..b640a8a --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,34 @@ +name: lint + +on: + push: + paths: + - 'learners/**' + branches: + - master + pull_request: +jobs: + lint: + name: lint & check formatting + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.10'] + steps: + - uses: actions/checkout@v3 + - name: Install dependencies + run: | + sudo apt update -y + sudo apt install python3-venv -y + sudo apt purge python3-blinker -y + curl https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py + python /tmp/get-pip.py + python -m pip install --upgrade pip setuptools wheel + python -m pip install . + - name: Lint with flake8 + run: | + flake8 backend --count --exit-zero --max-complexity=10 --max-line-length=142 --statistics + - name: lint with black + uses: rickstaa/action-black@v1 + with: + black_args: 'backend --check' diff --git a/.gitignore b/.gitignore index 496ee2c..3f33d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,151 @@ -.DS_Store \ No newline at end of file +*.retry +.idea/ +.envrc +backend/data.db +develop_config.yml +backend/statics/gen +backend/statics/hugo +backend/statics/uploads/* +backend/statics/documentation +backend/statics/exercises +rendered_hugo_content/ +frontend/node_modules/ +frontend/node_modules/ +frontend/public +frontend/dist +frontend/.DS_Store +public + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# DOCKER +docker/backend/__data/data.db +docker/backend/__templates/* +docker/backend/__webroot/* + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +.db + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + + +# Flask +.DS_Store +.env +*.pyc +*.pyo +env/ +venv/ +.venv/ +env* +dist/ +build/ +*.egg +*.egg-info/ +_mailinglist +.tox/ +.cache/ +.pytest_cache/ +.idea/ +docs/_build/ +.vscode + +# Coverage reports +htmlcov/ +.coverage +.coverage.* +*,cover diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6d6be1d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ + +repos: + - repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace diff --git a/backend/__init__.py b/backend/__init__.py index b3aea44..c01a1ff 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -44,6 +44,7 @@ def main(): app.register_blueprint(routes.stream_api) app.register_blueprint(routes.questionnaires_api) app.register_blueprint(routes.executions_api) + app.register_blueprint(routes.results_api) app.register_blueprint(routes.comments_api) app.register_blueprint(routes.cache_api) app.register_blueprint(routes.callback_api) diff --git a/backend/classes/SubmissionResponse.py b/backend/classes/SubmissionResponse.py index 97b850c..ab1dcc4 100644 --- a/backend/classes/SubmissionResponse.py +++ b/backend/classes/SubmissionResponse.py @@ -1,5 +1,6 @@ import json import string +from backend.functions.helpers import extract_history class SubmissionResponse: @@ -7,55 +8,50 @@ def __init__( self, completed: bool = False, executed: bool = False, - msg: string = "", + status_msg: string = "", response_timestamp: string = "", - connection_failed: bool = False, history: list = [], partial: bool = False, exercise_type: string = "", filename: string = "", uuid: string = "", + script_response: string = "", ): self.completed = completed self.executed = executed - self.msg = msg + self.status_msg = status_msg self.response_timestamp = response_timestamp - self.connection_failed = connection_failed self.history = history self.partial = partial self.exercise_type = exercise_type self.filename = filename self.uuid = uuid + self.script_response = script_response def update(self, executions) -> dict: if not len(executions): return if isinstance(executions, list): - from backend.functions.helpers import extract_history - last_execution = executions[0] self.history = extract_history(executions) else: last_execution = executions - self.msg = last_execution.get("msg") + self.status_msg = last_execution.get("status_msg") self.response_timestamp = last_execution.get("response_timestamp") - self.connection_failed = last_execution.get("connection_failed") self.partial = last_execution.get("partial") self.completed = last_execution.get("completed") + self.executed = last_execution.get("executed") self.exercise_type = last_execution.get("exercise_type") - - if self.connection_failed: - self.executed = False - self.msg = "Connection failed." + self.script_response = last_execution.get("script_response") if self.response_timestamp: if self.exercise_type == "form": self.executed = True else: - # Get error msg + # Get error status_msg error = json.loads(last_execution.get("response_content")).get("stderr") self.executed = bool(not error) - # Apply error msg to msg if none given - self.msg = self.msg or error + # Apply error status_msg to status_msg if none given + self.status_msg = self.status_msg or error diff --git a/backend/conf/db_models.py b/backend/conf/db_models.py index 0f39c98..11ba541 100644 --- a/backend/conf/db_models.py +++ b/backend/conf/db_models.py @@ -8,7 +8,7 @@ class User(db.Model): role = db.Column(db.String(20), unique=False, nullable=False, default="participant") admin = db.Column(db.Integer, nullable=False, default=0) meta = db.Column(db.String(), unique=False, nullable=True) - executions = db.relationship("Execution", backref="user", lazy=True) + submission = db.relationship("Submission", backref="user", lazy=True) usergroups = db.relationship("UsergroupAssociation", back_populates="user") @@ -34,45 +34,6 @@ class UsergroupAssociation(db.Model): user = db.relationship("User", back_populates="usergroups") -class Execution(db.Model): - id = db.Column(db.Integer, primary_key=True) - exercise_type = db.Column(db.String(120), nullable=False) - script = db.Column(db.String(120), nullable=True) - execution_timestamp = db.Column(db.DateTime, nullable=False, default=func.current_timestamp()) - response_timestamp = db.Column(db.DateTime, nullable=True) - response_content = db.Column(db.Text, nullable=True) - form_data = db.Column(db.String(), nullable=True) - msg = db.Column(db.String(240), nullable=True) - execution_uuid = db.Column(db.String(120), unique=True, nullable=True) - completed = db.Column(db.Integer, nullable=False, default=0) - partial = db.Column(db.Integer, nullable=False, default=0) - connection_failed = db.Column(db.Integer, nullable=False, default=0) - user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) - exercise_id = db.Column(db.Integer, db.ForeignKey("exercise.id"), nullable=False) - - -class Attachment(db.Model): - id = db.Column(db.Integer, primary_key=True) - filename = db.Column(db.String(120), nullable=False) - filename_hash = db.Column(db.String(120), nullable=False) - user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) - - -class Exercise(db.Model): - id = db.Column(db.Integer, primary_key=True) - global_exercise_id = db.Column(db.String(32), nullable=False) - local_exercise_id = db.Column(db.Integer, nullable=False) - exercise_type = db.Column(db.String(120), nullable=False) - exercise_name = db.Column(db.String(120), nullable=False) - page_title = db.Column(db.String(120), nullable=False) - parent_page_title = db.Column(db.String(120), nullable=False) - root_weight = db.Column(db.Integer, nullable=False) - parent_weight = db.Column(db.Integer, nullable=False) - child_weight = db.Column(db.Integer, nullable=False) - order_weight = db.Column(db.Integer, nullable=False) - executions = db.relationship("Execution", backref="exercise", lazy=True) - - parent_child_relationship = db.Table( "parent_child_relationship", db.Column("parent_id", db.Integer, db.ForeignKey("page.id"), primary_key=True), @@ -102,7 +63,7 @@ class Page(db.Model): class Cache(db.Model): user_id = db.Column(db.ForeignKey("user.id"), primary_key=True) - global_exercise_id = db.Column(db.ForeignKey("exercise.global_exercise_id"), primary_key=True) + exercise_id = db.Column(db.ForeignKey("exercise.id"), primary_key=True) form_data = db.Column(db.String(), nullable=True) @@ -114,8 +75,7 @@ class Comment(db.Model): class Questionnaire(db.Model): - id = db.Column(db.Integer, primary_key=True) - global_questionnaire_id = db.Column(db.String(32), nullable=False) + id = db.Column(db.String(32), primary_key=True) page_title = db.Column(db.String(120), nullable=False) parent_page_title = db.Column(db.String(120), nullable=False) root_weight = db.Column(db.Integer, nullable=False) @@ -126,14 +86,13 @@ class Questionnaire(db.Model): class QuestionnaireQuestion(db.Model): - global_question_id = db.Column(db.String(32), primary_key=True) - id = db.Column(db.Integer, nullable=False) + id = db.Column(db.String(32), primary_key=True) question = db.Column(db.String(), nullable=False) answer_options = db.Column(db.String(), nullable=False) language = db.Column(db.String(), nullable=False, primary_key=True) multiple = db.Column(db.Integer, nullable=False, default=1) active = db.Column(db.Integer, nullable=False, default=0) - global_questionnaire_id = db.Column(db.String(), db.ForeignKey("questionnaire.global_questionnaire_id"), primary_key=True) + questionnaire_id = db.Column(db.String(), db.ForeignKey("questionnaire.id"), primary_key=True) class QuestionnaireAnswer(db.Model): @@ -141,7 +100,7 @@ class QuestionnaireAnswer(db.Model): answers = db.Column(db.String(), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) timestamp = db.Column(db.DateTime, nullable=False, default=func.current_timestamp()) - global_question_id = db.Column(db.Integer, db.ForeignKey("questionnaire_question.global_question_id"), nullable=False) + question_id = db.Column(db.Integer, db.ForeignKey("questionnaire_question.id"), nullable=False) class TokenBlocklist(db.Model): @@ -150,15 +109,53 @@ class TokenBlocklist(db.Model): created_at = db.Column(db.DateTime, nullable=False) -class VenjixExecution(db.Model): +class Exercise(db.Model): + id = db.Column(db.String(32), primary_key=True) + local_exercise_id = db.Column(db.Integer, nullable=False) + exercise_type = db.Column(db.String(120), nullable=False) + exercise_name = db.Column(db.String(120), nullable=False) + page_title = db.Column(db.String(120), nullable=False) + parent_page_title = db.Column(db.String(120), nullable=False) + root_weight = db.Column(db.Integer, nullable=False) + parent_weight = db.Column(db.Integer, nullable=False) + child_weight = db.Column(db.Integer, nullable=False) + order_weight = db.Column(db.Integer, nullable=False) + script_name = db.Column(db.String(120), nullable=True) + submissions = db.relationship("Submission", backref="exercise", lazy=True) + + +class Attachment(db.Model): + id = db.Column(db.Integer, primary_key=True) + filename = db.Column(db.String(120), nullable=False) + filename_hash = db.Column(db.String(120), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + + +class Submission(db.Model): + # General Fields id = db.Column(db.Integer, primary_key=True) - script = db.Column(db.String(120), nullable=True) + exercise_type = db.Column(db.String(120), nullable=False) execution_timestamp = db.Column(db.DateTime, nullable=False, default=func.current_timestamp()) - response_timestamp = db.Column(db.DateTime, nullable=True) - response_content = db.Column(db.Text, nullable=True) - msg = db.Column(db.String(240), nullable=True) - execution_uuid = db.Column(db.String(120), unique=True, nullable=True) completed = db.Column(db.Integer, nullable=False, default=0) + executed = db.Column(db.Integer, nullable=False, default=0) partial = db.Column(db.Integer, nullable=False, default=0) - connection_failed = db.Column(db.Integer, nullable=False, default=0) + status_msg = db.Column(db.String(240), nullable=True) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + exercise_id = db.Column(db.String(32), db.ForeignKey("exercise.id"), nullable=False) + + # Form Submission + form_data = db.Column(db.String(), nullable=True) + + # Script Submission + execution_uuid = db.Column(db.String(120), unique=True, nullable=True) + response_timestamp = db.Column(db.DateTime, nullable=True) + response_content = db.Column(db.Text, nullable=True) + script_response = db.Column(db.Text, nullable=True) + + +class Timetracker(db.Model): + id = db.Column(db.Integer, primary_key=True) + start_time = db.Column(db.String(120), nullable=True) + pause_time = db.Column(db.String(120), nullable=True) + offset = db.Column(db.Integer, nullable=False, default=0) + running = db.Column(db.Integer, nullable=False, default=0) diff --git a/backend/functions/database.py b/backend/functions/database.py index 956912a..37e61f0 100644 --- a/backend/functions/database.py +++ b/backend/functions/database.py @@ -1,6 +1,6 @@ import hashlib import json -from datetime import datetime, timezone +import datetime import string from typing import Tuple from backend.classes.SSE import SSE_Event @@ -10,10 +10,10 @@ from backend.conf.db_models import ( Attachment, Cache, - Execution, Exercise, Notification, QuestionnaireAnswer, + Submission, User, Page, Comment, @@ -21,7 +21,7 @@ QuestionnaireQuestion, Usergroup, UsergroupAssociation, - VenjixExecution, + Timetracker, ) from backend.database import db from backend.functions.helpers import convert_to_dict, extract_json_content @@ -108,7 +108,8 @@ def db_insert_exercises(app, *args, **kwargs): exercise_json = f"{cfg.statics.get('base_url')}/hugo/exercises.json" exercises = extract_json_content(app, exercise_json) for exercise in exercises: - db_create_or_update(Exercise, ["global_exercise_id"], exercise) + exercise["id"] = exercise.pop("global_exercise_id") + db_create_or_update(Exercise, ["id"], exercise) def db_insert_questionnaires(app, *args, **kwargs): @@ -116,7 +117,7 @@ def db_insert_questionnaires(app, *args, **kwargs): questionnaires = extract_json_content(app, questionnaire_json) for questionnaire in questionnaires: new_questionnaire = { - "global_questionnaire_id": questionnaire["global_questionnaire_id"], + "id": questionnaire["global_questionnaire_id"], "page_title": questionnaire["page_title"], "parent_page_title": questionnaire["parent_page_title"], "root_weight": questionnaire["root_weight"], @@ -125,22 +126,21 @@ def db_insert_questionnaires(app, *args, **kwargs): "order_weight": questionnaire["order_weight"], } - db_create_or_update(Questionnaire, ["global_questionnaire_id"], new_questionnaire) + db_create_or_update(Questionnaire, ["id"], new_questionnaire) db.session.flush() for language in questionnaire["questions"]: for question in questionnaire["questions"][language]: new_question = { - "global_question_id": question["global_question_id"], - "id": question["id"], + "id": question["global_question_id"], "question": question["question"], "answer_options": json.dumps(question["answers"]), "language": language, "multiple": question.get("multiple") or False, - "global_questionnaire_id": questionnaire["global_questionnaire_id"], + "questionnaire_id": questionnaire["global_questionnaire_id"], } - db_create_or_update(QuestionnaireQuestion, ["global_question_id", "language"], new_question) + db_create_or_update(QuestionnaireQuestion, ["id", "language"], new_question) def db_create_or_update(db_model, filter_keys: list = [], passed_element: dict = None, nolog: bool = False) -> bool: @@ -177,37 +177,9 @@ def db_create_or_update(db_model, filter_keys: list = [], passed_element: dict = return True -def db_create_venjix_execution(execution_uuid: str, user_id: int, script_name: str) -> bool: +def db_update_venjix_execution(updates) -> bool: try: - execution = VenjixExecution( - script=script_name, - execution_uuid=execution_uuid, - user_id=user_id, - ) - - db.session.add(execution) - db.session.commit() - return True - - except Exception as e: - logger.exception(e) - - -def db_update_venjix_execution( - execution_uuid: str, - connection_failed: bool = False, - response_timestamp: str = None, - response_content: str = None, - completed: bool = False, - msg: str = None, - partial: bool = False, -) -> bool: - try: - execution = VenjixExecution.query.filter_by(execution_uuid=execution_uuid).first() - for key, value in list(locals().items())[:-1]: - if value: - setattr(execution, key, value) - db.session.commit() + db_create_or_update(Submission, ["execution_uuid"], updates, nolog=True) return True except Exception as e: @@ -218,11 +190,11 @@ def db_update_venjix_execution( def db_get_running_executions_by_name(user_id: int, script: str) -> dict: try: running_executions = ( - VenjixExecution.query.filter_by(user_id=user_id) + Submission.query.filter_by(user_id=user_id) .filter_by(script=script) - .filter_by(connection_failed=False) + .filter_by(executed=True) .filter_by(response_timestamp=None) - .order_by(VenjixExecution.execution_timestamp.desc()) + .order_by(Submission.execution_timestamp.desc()) .all() ) return convert_to_dict(running_executions) @@ -232,42 +204,39 @@ def db_get_running_executions_by_name(user_id: int, script: str) -> dict: return None -def db_get_venjix_execution(execution_uuid: str) -> dict: - try: - execution = generic_getter(VenjixExecution, "execution_uuid", execution_uuid) - return convert_to_dict(execution) +def db_get_submission_by_execution_uuid(execution_uuid: str) -> dict: + return generic_getter(Submission, "execution_uuid", execution_uuid) - except Exception as e: - logger.exception(e) - return None +def db_get_submission_by_exercise_id(exercise_id: str) -> dict: + return generic_getter(Submission, "exercise_id", exercise_id, all=True) -def db_create_execution(exercise_type: str, global_exercise_id: str, data: dict, user_id: int, execution_uuid: str) -> bool: - form_data = json.dumps(data, indent=4, sort_keys=False) +def db_create_submission(exercise_type: str, exercise_id: str, user_id: int, data: dict = None, execution_uuid: str = None) -> bool: try: - exercise_id = db_get_exercise_by_global_exercise_id(global_exercise_id).id - - execution = Execution( + submission = Submission( exercise_type=exercise_type, - script="script", - form_data=form_data, - execution_uuid=execution_uuid, user_id=user_id, exercise_id=exercise_id, ) if exercise_type == "form": - execution.completed = True - execution.response_timestamp = datetime.now(timezone.utc) + form_data = json.dumps(data, indent=4, sort_keys=False) + submission.form_data = form_data + submission.completed = True + submission.executed = True + + if exercise_type == "script": + submission.execution_uuid = execution_uuid - db.session.add(execution) + db.session.add(submission) db.session.commit() - return True + return submission except Exception as e: - logger.exception(e) - return False + logger.error(f"Error creating submission: {e}") + db.session.rollback() + return None def db_create_comment(comment: str, page: str, user_id: int) -> bool: @@ -285,18 +254,84 @@ def db_create_comment(comment: str, page: str, user_id: int) -> bool: return False -def db_get_current_submissions(user_id: int, global_exercise_id: string) -> Tuple[dict, dict]: +def db_set_time(action: str, offset: int = 0) -> bool: + try: + updated_time = { + "id": 0, + } + + if action == "start": + updated_time["start_time"] = datetime.datetime.now(datetime.timezone.utc) + updated_time["running"] = True + + if action == "pause": + updated_time["pause_time"] = datetime.datetime.now(datetime.timezone.utc) + updated_time["running"] = False + + if action == "continue": + current_timer = db_get_time() + current_start_time = current_timer.start_time or datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f%z") + + _current_start_time = datetime.datetime.strptime(current_start_time, "%Y-%m-%d %H:%M:%S.%f%z") + + now_timestamp = datetime.datetime.now(datetime.timezone.utc) + pause_time = datetime.datetime.strptime(current_timer.pause_time, "%Y-%m-%d %H:%M:%S.%f%z") + delta = now_timestamp - pause_time + + updated_time["start_time"] = _current_start_time + delta + updated_time["pause_time"] = None + updated_time["running"] = True + + if action == "reset": + updated_time["start_time"] = None + updated_time["pause_time"] = None + updated_time["running"] = False + updated_time["offset"] = 0 + + if action == "offset": + updated_time["offset"] = offset + + db_create_or_update(Timetracker, ["id"], updated_time) + + print("db_get_time", db_get_time().__dict__) + return db_get_time() + + except Exception as e: + logger.exception(e) + return False + + +def db_get_current_submissions(user_id: int, exercise_id: string) -> Tuple[dict, dict]: try: - exercise = db_get_exercise_by_global_exercise_id(global_exercise_id) + exercise = db_get_exercise_by_id(exercise_id) + submissions = [] + submissions = ( - db.session.query(Execution) + db.session.query(Submission) .filter_by(user_id=user_id) .filter_by(exercise_id=exercise.id) - .order_by(Execution.response_timestamp.desc(), Execution.execution_timestamp.desc()) + .order_by(Submission.execution_timestamp.desc(), Submission.response_timestamp.desc()) .all() ) + return submissions - except Execution as e: + except Exception as e: + logger.exception(e) + return [] + + +def db_get_current_submissions(user_id: int, exercise_id: string) -> Tuple[dict, dict]: + try: + exercise = db_get_exercise_by_id(exercise_id) + submissions = ( + db.session.query(Submission) + .filter_by(user_id=user_id) + .filter_by(exercise_id=exercise.id) + .order_by(Submission.execution_timestamp.desc(), Submission.response_timestamp.desc()) + .all() + ) + return submissions + except Exception as e: logger.exception(e) return None, None @@ -305,23 +340,19 @@ def db_get_exercise_by_name(exercise_name: str) -> dict: return generic_getter(Exercise, "exercise_name", exercise_name) -def db_get_exercise_by_global_exercise_id(global_exercise_id: str) -> dict: - return generic_getter(Exercise, "global_exercise_id", global_exercise_id) - +def db_get_questionnaire_by_id(questionnaire_id: str) -> dict: + return generic_getter(Questionnaire, "id", questionnaire_id) -def db_get_questionnaire_by_global_questionnaire_id(global_questionnaire_id: str) -> dict: - return generic_getter(Questionnaire, "global_questionnaire_id", global_questionnaire_id) +def db_get_questionnaire_question_by_id(question_id: str) -> dict: + return generic_getter(QuestionnaireQuestion, "id", question_id) -def db_get_questionnaire_question_by_global_question_id(global_question_id: str) -> dict: - return generic_getter(QuestionnaireQuestion, "global_question_id", global_question_id) - -def db_get_all_questionnaires_questions(global_questionnaire_id: str) -> dict: +def db_get_all_questionnaires_questions(questionnaire_id: str) -> dict: try: return ( db.session.query(QuestionnaireQuestion) - .filter_by(global_questionnaire_id=global_questionnaire_id) + .filter_by(id=questionnaire_id) .order_by(QuestionnaireQuestion.local_question_id.asc()) .all() ) @@ -330,9 +361,9 @@ def db_get_all_questionnaires_questions(global_questionnaire_id: str) -> dict: return None -def db_get_questionnaire_results_by_global_question_id(global_question_id: str) -> dict: - questionnaire_question = generic_getter(QuestionnaireQuestion, "global_question_id", global_question_id) - questionnaire_answers = generic_getter(QuestionnaireAnswer, "global_question_id", global_question_id, all=True) +def db_get_questionnaire_results_by_question_id(question_id: str) -> dict: + questionnaire_question = generic_getter(QuestionnaireQuestion, "id", question_id) + questionnaire_answers = generic_getter(QuestionnaireAnswer, "question_id", question_id, all=True) labels = json.loads(questionnaire_question.answer_options) results = [0] * len(labels) @@ -368,6 +399,10 @@ def db_get_all_users() -> list: return generic_getter(User, "role", "participant", all=True) +def db_get_participants_userids() -> list: + return [id[0] for id in db.session.query(User).filter_by(role="participant").with_entities(User.id).all()] + + def db_get_all_userids() -> list: return [id[0] for id in db.session.query(User).with_entities(User.id).all()] @@ -396,6 +431,10 @@ def db_get_page_by_id(page_id) -> dict: return generic_getter(Page, "page_id", page_id) +def db_get_time() -> dict: + return generic_getter(Timetracker, "id", 0) + + def db_get_all_active_pages() -> dict: active_pages = [] db_active_pages = generic_getter(Page, "hidden", False, all=True) @@ -553,13 +592,13 @@ def db_convert_ids_to_usernames(user_ids: list = []) -> list: return usernames -def db_get_executions_by_user_exercise(user_id: int, exercise_id: int) -> list: +def db_get_submissions_by_user_exercise(user_id: int, exercise_id: int) -> list: try: return ( - db.session.query(Execution) + db.session.query(Submission) .filter_by(user_id=user_id) .filter_by(exercise_id=exercise_id) - .order_by(Execution.execution_timestamp.desc()) + .order_by(Submission.execution_timestamp.desc()) .all() ) except Exception as e: @@ -572,9 +611,9 @@ def db_get_completed_state(user_id: int, exercise_id: int) -> dict: return ( db.session.query(User) .filter_by(id=user_id) - .join(Execution) + .join(Submission) .filter_by(exercise_id=exercise_id) - .with_entities(Execution.completed) + .with_entities(Submission.completed) .all() ) except Exception as e: @@ -603,9 +642,9 @@ def db_get_filename_from_hash(filename_hash): return None -def db_get_cache_by_ids(user_id: int, global_exercise_id: str) -> dict: +def db_get_cache_by_ids(user_id: int, exercise_id: str) -> dict: try: - return db.session.query(Cache).filter_by(user_id=user_id).filter_by(global_exercise_id=global_exercise_id).first() + return db.session.query(Cache).filter_by(user_id=user_id).filter_by(exercise_id=exercise_id).first() except Exception as e: logger.exception(e) return None @@ -643,7 +682,6 @@ def db_get_grouped_questionnaires() -> list: "answer_options": question.answer_options, "language": question.language, "active": question.active, - "global_question_id": question.global_question_id, } ) @@ -660,16 +698,16 @@ def db_get_grouped_questionnaires() -> list: return None -def db_activate_questioniare_question(global_question_id) -> bool: +def db_activate_questioniare_question(question_id) -> bool: try: - question = db.session.query(QuestionnaireQuestion).filter_by(global_question_id=global_question_id).first() + question = db.session.query(QuestionnaireQuestion).filter_by(id=question_id).first() # Set active state setattr(question, "active", True) db.session.flush() db.session.commit() - questionnaire = db.session.query(Questionnaire).filter_by(global_questionnaire_id=question.global_questionnaire_id).first() + questionnaire = db.session.query(Questionnaire).filter_by(id=question.questionnaire_id).first() question_dict = convert_to_dict(question) # Adjust dict @@ -684,12 +722,12 @@ def db_activate_questioniare_question(global_question_id) -> bool: return False -def db_create_questionnaire_answer(global_question_id: str, answers: str, user_id: int) -> bool: +def db_create_questionnaire_answer(question_id: str, answers: str, user_id: int) -> bool: try: if isinstance(answers, int): answers = [answers] - submission = QuestionnaireAnswer(answers=json.dumps(answers), user_id=user_id, global_question_id=global_question_id) + submission = QuestionnaireAnswer(answers=json.dumps(answers), user_id=user_id, question_id=question_id) db.session.add(submission) db.session.commit() @@ -700,8 +738,8 @@ def db_create_questionnaire_answer(global_question_id: str, answers: str, user_i return False -def db_get_questionnaire_question_answers_by_user(global_question_id: str, user_id: int) -> list: - answers = generic_getter(QuestionnaireAnswer, ["global_question_id", "user_id"], [global_question_id, user_id], all=True) +def db_get_questionnaire_question_answers_by_user(question_id: str, user_id: int) -> list: + answers = generic_getter(QuestionnaireAnswer, ["question_id", "user_id"], [question_id, user_id], all=True) return answers @@ -710,11 +748,11 @@ def db_get_completion_percentage(exercise_id): try: executions = ( - db.session.query(Execution) + db.session.query(Submission) .filter_by(exercise_id=exercise_id) .join(User) .group_by(User.id) - .with_entities(db.func.max(Execution.completed)) + .with_entities(db.func.max(Submission.completed)) .all() ) return len(executions) / len(users) * 100 diff --git a/backend/functions/execution.py b/backend/functions/execution.py index 3b68911..ea89510 100644 --- a/backend/functions/execution.py +++ b/backend/functions/execution.py @@ -3,12 +3,19 @@ from typing import Tuple import requests +from backend.functions.helpers import convert_to_dict from backend.logger import logger from backend.conf.config import cfg -from backend.functions.database import db_get_venjix_execution, db_update_venjix_execution +from backend.functions.database import ( + db_get_exercise_by_id, + db_get_submission_by_execution_uuid, + db_update_venjix_execution, +) -def call_venjix(username: str, script: str, execution_uuid: str) -> Tuple[bool, bool]: +def call_venjix(exercise_id: str, username: str, callback_url: str, execution_uuid: str) -> Tuple[bool, bool]: + script_response = None + script = db_get_exercise_by_id(exercise_id).script_name try: response = requests.post( # TODO: Remove verify line @@ -19,7 +26,7 @@ def call_venjix(username: str, script: str, execution_uuid: str) -> Tuple[bool, { "script": script, "user_id": username, - "callback": f"/callback/{execution_uuid}", + "callback": f"{callback_url}/{execution_uuid}", } ), ) @@ -28,19 +35,27 @@ def call_venjix(username: str, script: str, execution_uuid: str) -> Tuple[bool, if response.status_code != 200: connected = False executed = False - msg = f"{response.status_code}: {resp['response']}" + status_msg = f"{response.status_code}: {resp['response']}" else: connected = True executed = bool(resp["response"] == "script started") - msg = resp.get("msg") or None + status_msg = resp.get("status_msg") or None + script_response = resp.get("script_response") or None except Exception as connection_exception: - logger.exception(connection_exception) + logger.error(connection_exception) connected = False executed = False - msg = "Connection failed" + status_msg = "connection failed" - db_update_venjix_execution(execution_uuid, connection_failed=(not connected), msg=msg) + updates = { + "execution_uuid": execution_uuid, + "executed": executed, + "status_msg": status_msg, + "script_response": script_response, + } + + db_update_venjix_execution(updates) return connected, executed @@ -50,19 +65,16 @@ def wait_for_venjix_response(execution_uuid: str) -> dict: time.sleep(0.5) try: - execution = db_get_venjix_execution(execution_uuid) - if execution["response_timestamp"] or execution["connection_failed"]: - if execution["connection_failed"]: - execution["executed"] = False - execution["msg"] = "Connection failed." - else: - # Get error msg - error = json.loads(execution["response_content"]).get("stderr") - execution["executed"] = bool(not error) - # Apply error msg to msg if none given - execution["msg"] = execution["msg"] or error + submission = db_get_submission_by_execution_uuid(execution_uuid) + submission = convert_to_dict(submission) + if not submission["executed"]: + submission["status_msg"] = ( + submission["status_msg"] or json.loads(submission["response_content"]).get("stderr") or "connection failed" + ) + return submission - return execution + if submission["response_timestamp"]: + return submission except Exception as e: logger.exception(e) diff --git a/backend/functions/helpers.py b/backend/functions/helpers.py index b78716d..84a4274 100644 --- a/backend/functions/helpers.py +++ b/backend/functions/helpers.py @@ -42,7 +42,7 @@ def extract_history(executions): "start_time": utc_to_local(execution.get("execution_timestamp"), date=True), "response_time": utc_to_local(execution.get("response_timestamp"), date=False), "completed": bool(execution.get("completed")), - "msg": execution.get("msg"), + "status_msg": execution.get("status_msg"), "partial": bool(execution.get("partial")), } for i, execution in enumerate(executions) @@ -100,6 +100,7 @@ def sse_create_and_publish( recipients=None, positions=None, question=None, + timer=None, ) -> bool: # Import from backend.classes.SSE import SSE_Event, sse @@ -114,6 +115,11 @@ def sse_create_and_publish( message = f"

New Comment

User: {user.name}
Page: {page}" recipients = [admin_user.id for admin_user in db_get_admin_users()] + if _type == "timer": + event = "timer" + message = f"{json.dumps(timer)}" + recipients = [admin_user.id for admin_user in db_get_admin_users()] + if _type == "content": message = """

@@ -129,8 +135,9 @@ def sse_create_and_publish( new_event = SSE_Event(event=event, _type=_type, message=message, question=question, recipients=recipients, positions=positions) - # Create Database entry - db_create_notification(new_event) + if event != "questionnaire" and event != "timer": + # Create Database entry + db_create_notification(new_event) # Notify Users sse.publish(new_event) diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py index fbaa982..ad71003 100644 --- a/backend/routes/__init__.py +++ b/backend/routes/__init__.py @@ -11,3 +11,4 @@ from backend.routes.setup import setup_api from backend.routes.users import users_api from backend.routes.pages import pages_api +from backend.routes.results import results_api diff --git a/backend/routes/cache.py b/backend/routes/cache.py index d1c32e8..1b48e61 100644 --- a/backend/routes/cache.py +++ b/backend/routes/cache.py @@ -19,20 +19,20 @@ def putCache(): new_cache_entry = { "user_id": current_user.id, - "global_exercise_id": data.get("global_exercise_id"), + "exercise_id": data.get("global_exercise_id"), "form_data": json.dumps(data.get("form_data")), } - db_create_or_update(Cache, ["user_id", "global_exercise_id"], new_cache_entry) + db_create_or_update(Cache, ["user_id", "exercise_id"], new_cache_entry) return jsonify(updated=True), 200 # Post new comment -@cache_api.route("/cache/", methods=["GET"]) +@cache_api.route("/cache/", methods=["GET"]) @jwt_required() -def getCache(global_exercise_id): - if cache := db_get_cache_by_ids(current_user.id, global_exercise_id): +def getCache(exercise_id): + if cache := db_get_cache_by_ids(current_user.id, exercise_id): return jsonify(form_data=cache.form_data), 200 else: return jsonify(form_data=None), 200 diff --git a/backend/routes/callback.py b/backend/routes/callback.py index ae5f301..e7250e6 100644 --- a/backend/routes/callback.py +++ b/backend/routes/callback.py @@ -1,7 +1,12 @@ from datetime import datetime, timezone from flask import Blueprint, json, jsonify, request -from backend.functions.database import db_update_venjix_execution +from backend.functions.database import ( + db_get_exercise_by_id, + db_get_submission_by_execution_uuid, + db_update_venjix_execution, +) +from backend.functions.helpers import sse_create_and_publish from backend.logger import logger callback_api = Blueprint("callback_api", __name__) @@ -11,14 +16,23 @@ def callback(execution_uuid): try: resp = request.get_json() - db_update_venjix_execution( - execution_uuid, - response_timestamp=datetime.now(timezone.utc), - response_content=json.dumps(resp), - completed=bool(resp.get("returncode") == 0), - msg=resp.get("msg") or None, - partial=resp.get("partial") or False, - ) + updates = { + "execution_uuid": execution_uuid, + "response_timestamp": datetime.now(timezone.utc), + "response_content": json.dumps(resp), + "completed": bool(resp.get("returncode") == 0), + "status_msg": resp.get("status_msg") or None, + "script_response": resp.get("script_response") or None, + "partial": resp.get("partial") or False, + } + + logger.info(f"Callback for { execution_uuid } received") + db_update_venjix_execution(updates) + + submission = db_get_submission_by_execution_uuid(execution_uuid) + + sse_create_and_publish(_type="submission", user=submission.user, exercise=submission.exercise) + return jsonify(success=True), 200 except Exception as e: diff --git a/backend/routes/executions.py b/backend/routes/executions.py index e401512..740a125 100644 --- a/backend/routes/executions.py +++ b/backend/routes/executions.py @@ -2,24 +2,28 @@ import uuid from backend.classes.SubmissionResponse import SubmissionResponse -from flask import Blueprint, jsonify, request, send_from_directory -from flask_jwt_extended import get_jwt_identity, jwt_required, current_user +from flask import Blueprint, jsonify, request, send_from_directory, make_response +from flask_jwt_extended import jwt_required, current_user from backend.jwt_manager import admin_required, only_self_or_admin from backend.logger import logger from backend.functions.database import ( - db_create_execution, + db_create_submission, db_create_file, - db_create_venjix_execution, db_get_current_submissions, + db_get_current_submissions, + db_get_exercise_by_id, db_get_running_executions_by_name, db_get_all_exercises, db_get_all_users, db_get_completed_state, - db_get_executions_by_user_exercise, - db_get_exercise_by_global_exercise_id, + db_get_submission_by_exercise_id, + db_get_submissions_by_user_exercise, db_get_exercise_groups, db_get_exercises_by_group, + db_get_submissions_by_user_exercise, + db_get_time, db_get_user_by_id, + db_set_time, ) from backend.functions.execution import ( call_venjix, @@ -35,24 +39,115 @@ executions_api = Blueprint("executions_api", __name__) -@executions_api.route("/execution/", methods=["POST"]) -@jwt_required() -def postExecution(exercise_type): - username = get_jwt_identity() - execution_uuid = f"{str(username)}_{uuid.uuid4().int & (1 << 64) - 1}" +@executions_api.route("/submissions", methods=["GET"]) +@admin_required() +def getAllSubmissions(): + submissions = [] + + for user in db_get_all_users(): + executions = {"user_id": user.id, "username": user.name} + for exercise in db_get_all_exercises(): + # completed_state = [state[0] for state in db_get_completed_state(user.id, exercise.id)] + _submissions = db_get_submissions_by_user_exercise(user.id, exercise.id) + execution_ids = [execution.get("id") for execution in convert_to_dict(_submissions)] + executions[exercise.id] = { + # "completed": int(any(completed_state)) if completed_state else -1, + "completed": int(_submissions[0].completed) if _submissions and _submissions[0] else 0, + "executed": int(_submissions[0].executed) if _submissions and _submissions[0] else 0, + "executions": execution_ids, + } + submissions.append(executions) + + return jsonify(submissions=submissions) - response = {"uuid": execution_uuid, "connected": False, "executed": False} + +@executions_api.route("/submissions/form/", methods=["POST"]) +@jwt_required() +def postFormSubmission(exercise_id): + response = SubmissionResponse() data = request.get_json() + if db_create_submission("form", exercise_id, current_user.id, data=data): + response.executed = True + response.completed = True + + sse_create_and_publish(_type="submission", user=current_user, exercise=db_get_exercise_by_id(exercise_id)) + + return jsonify(response.__dict__) + + +@executions_api.route("/submissions/script/", methods=["POST"]) +@jwt_required() +def postScriptSubmission(exercise_id): + execution_uuid = f"{str(current_user.name)}_{uuid.uuid4().int & (1 << 64) - 1}" + response = SubmissionResponse(uuid=execution_uuid) + callback_url = f"{request.headers.get('Referer').split('/statics')[0]}/callback" - if db_create_execution(exercise_type, data, username, execution_uuid): - if exercise_type == "script": - response["connected"], response["executed"] = call_venjix(username, data["script"], execution_uuid) - if exercise_type == "form": - response["connected"] = True - response["executed"] = True + if db_create_submission("script", exercise_id, current_user.id, execution_uuid=execution_uuid): + response.connected, response.executed = call_venjix(exercise_id, current_user.name, callback_url, execution_uuid) + + return jsonify(response.__dict__) + + +@executions_api.route("/submissions/", methods=["GET"]) +@jwt_required() +def getExerciseSubmissions(exercise_id): + response = None + + if submissions := db_get_current_submissions(current_user.id, exercise_id): + response = SubmissionResponse() + response.update(convert_to_dict(submissions)) + response = response.__dict__ - sse_create_and_publish(_type="submission", user=current_user, exercise=db_get_exercise_by_global_exercise_id(data.get("name"))) + return jsonify(response) + + +@executions_api.route("/submissions//", methods=["GET"]) +@only_self_or_admin() +def getExerciseSubmissionsByUser(user_id, exercise_id): + exercise = db_get_exercise_by_id(exercise_id) + db_submissions = db_get_submissions_by_user_exercise(user_id, exercise.id) + + return jsonify( + exercise_name=exercise.exercise_name, user_name=db_get_user_by_id(user_id).name, submissions=convert_to_dict(db_submissions) + ) + + +@executions_api.route("/executions/", methods=["GET"]) +@jwt_required() +def getExecutions(exercise_id): + response = None + + if executions := db_get_current_submissions(current_user.id, exercise_id): + response = SubmissionResponse() + response.update(convert_to_dict(executions)) + response = response.__dict__ + + return jsonify(response) + + +@executions_api.route("/executions/state/", methods=["GET"]) +@jwt_required() +def getExecutionState(execution_uuid): + response = SubmissionResponse() + venjix_response = wait_for_venjix_response(execution_uuid) + response.update(venjix_response) + response = response.__dict__ + + return jsonify(response) + + +@executions_api.route("/executions/active/", methods=["GET"]) +@jwt_required() +def getActiveExecutionsState(script_name): + response = None + + if running_execution := db_get_running_executions_by_name(current_user.id, script_name): + response = SubmissionResponse() + execution_uuid = running_execution[0].get("execution_uuid") + venjix_response = wait_for_venjix_response(execution_uuid) + response.update(venjix_response) + response = response.__dict__ return jsonify(response) @@ -85,19 +180,19 @@ def uploadFile(): # Check if 'file' is in the request.files dictionary, else return error message if "file" not in request.files: - response.msg = "File missing" + response.status_msg = "File missing" return jsonify(response.__dict__) # If filename is empty, return error message file = request.files["file"] if file.filename == "": - response.msg = "No file selected" + response.status_msg = "No file selected" return jsonify(response.__dict__) # Generate a new file name and check if the file type is allowed, else return error message filename = f"{current_user.name}_{secure_filename(file.filename)}" if not allowed_file(filename): - response.msg = "File type not allowed" + response.status_msg = "File type not allowed" return jsonify(response.__dict__) try: @@ -108,13 +203,13 @@ def uploadFile(): # Update the response object with success status and other details response.completed = True response.executed = True - response.msg = "File successfully uploaded" + response.status_msg = "File successfully uploaded" response.filename = filename # Catch any server errors and return error message except Exception as e: logger.exception(f"ERROR: {e}") - response.msg = "Server error" + response.status_msg = "Server error" # Return the JSON representation of the response object return jsonify(response.__dict__) @@ -126,99 +221,26 @@ def downloadFile(filename): return send_from_directory(os.path.join(os.getcwd(), cfg.upload_folder), filename) -@executions_api.route("/submissions", methods=["GET"]) +@executions_api.route("/time", methods=["GET"]) @admin_required() -def getAllSubmissions(): - submissions = [] +def getTime(): + if stored_time := db_get_time(): + return jsonify(convert_to_dict(stored_time)) + return jsonify({}) - for user in db_get_all_users(): - executions = {"user_id": user.id, "username": user.name} - for exercise in db_get_all_exercises(): - completed_state = [state[0] for state in db_get_completed_state(user.id, exercise.id)] - execution_ids = [ - execution.get("execution_uuid") for execution in convert_to_dict(db_get_executions_by_user_exercise(user.id, exercise.id)) - ] - executions[exercise.global_exercise_id] = { - "completed": int(any(completed_state)) if completed_state else -1, - "executions": execution_ids, - } - submissions.append(executions) - - return jsonify(submissions=submissions) - - -@executions_api.route("/submissions/form/", methods=["POST"]) -@jwt_required() -def postFormExercise(global_exercise_id): - response = SubmissionResponse() - - data = request.get_json() - if db_create_execution("form", global_exercise_id, data, current_user.id, None): - response.executed = True - response.completed = True - - sse_create_and_publish(_type="submission", user=current_user, exercise=db_get_exercise_by_global_exercise_id(global_exercise_id)) - - return jsonify(response.__dict__) - - -@executions_api.route("/submissions/", methods=["GET"]) -@jwt_required() -def getExerciseSubmissions(global_exercise_id): - response = None - - if executions := db_get_current_submissions(current_user.id, global_exercise_id): - response = SubmissionResponse() - response.update(convert_to_dict(executions)) - response = response.__dict__ - - return jsonify(response) +@executions_api.route("/time/", methods=["POST"]) +@admin_required() +def setTimer(action): -@executions_api.route("/submissions//", methods=["GET"]) -@only_self_or_admin() -def getExerciseSubmissionsByUser(user_id, global_exercise_id): - exercise = db_get_exercise_by_global_exercise_id(global_exercise_id) - db_submissions = db_get_executions_by_user_exercise(user_id, exercise.id) - - return jsonify( - exercise_name=exercise.exercise_name, user_name=db_get_user_by_id(user_id).name, submissions=convert_to_dict(db_submissions) - ) - - -@executions_api.route("/executions/", methods=["POST"]) -@jwt_required() -def runExecution(script_name): - execution_uuid = f"{str(current_user.name)}_{uuid.uuid4().int & (1 << 64) - 1}" - response = SubmissionResponse(uuid=execution_uuid) - - if db_create_venjix_execution(execution_uuid, current_user.id, script_name): - response.connected, response.executed = call_venjix(current_user.name, script_name, execution_uuid) - - return jsonify(response.__dict__) - - -@executions_api.route("/executions/", methods=["GET"]) -@jwt_required() -def getExecutionState(execution_uuid): - response = SubmissionResponse() - venjix_response = wait_for_venjix_response(execution_uuid) - response.update(venjix_response) - response = response.__dict__ - - return jsonify(response) - + offset = 0 -@executions_api.route("/executions/active/", methods=["GET"]) -@jwt_required() -def getActiveExecutionsState(script_name): - response = None + if request.is_json: + offset = (request.get_json()).get("offset", 0) - if running_execution := db_get_running_executions_by_name(current_user.id, script_name): - response = SubmissionResponse() - execution_uuid = running_execution[0].get("execution_uuid") - venjix_response = wait_for_venjix_response(execution_uuid) - response.update(venjix_response) - response = response.__dict__ + if timer_event := db_set_time(action, offset): + sse_create_and_publish(_type="timer", timer=convert_to_dict(timer_event)) + return jsonify(updated=True) - return jsonify(response) + else: + return jsonify(updated=False) diff --git a/backend/routes/questionnaires.py b/backend/routes/questionnaires.py index 7ed8de6..8132a2c 100644 --- a/backend/routes/questionnaires.py +++ b/backend/routes/questionnaires.py @@ -5,11 +5,11 @@ from backend.functions.database import ( db_activate_questioniare_question, db_create_questionnaire_answer, + db_get_participants_userids, db_get_questionnaire_question_answers_by_user, - db_get_all_userids, db_get_grouped_questionnaires, - db_get_questionnaire_question_by_global_question_id, - db_get_questionnaire_results_by_global_question_id, + db_get_questionnaire_question_by_id, + db_get_questionnaire_results_by_question_id, db_get_admin_users, ) from backend.functions.helpers import sse_create_and_publish @@ -31,14 +31,18 @@ def getQuestions(): active_questions = [] + # Avoid promting admins + if current_user.role == "instructor": + return jsonify(questions=[]), 200 + # Filter for active questions for questionnaire in grouped_questionnaires: for question in questionnaire.get("questions"): - question["global_questionnaire_id"] = questionnaire.get("global_questionnaire_id") + question["questionnaire_id"] = questionnaire.get("questionnaire_id") question["page_title"] = questionnaire.get("page_title") if question.get("active"): # Check if user has already answerd the questionnaire - answers = db_get_questionnaire_question_answers_by_user(question["global_question_id"], current_user.id) + answers = db_get_questionnaire_question_answers_by_user(question["id"], current_user.id) if not len(answers): active_questions.append(question) @@ -46,11 +50,11 @@ def getQuestions(): # Activate Question -@questionnaires_api.route("/questionnaires/questions/", methods=["PUT"]) +@questionnaires_api.route("/questionnaires/questions/", methods=["PUT"]) @admin_required() -def activateQuestion(global_question_id): - if question := db_activate_questioniare_question(global_question_id=global_question_id): - user_list = db_get_all_userids() +def activateQuestion(question_id): + if question := db_activate_questioniare_question(question_id=question_id): + user_list = db_get_participants_userids() sse_create_and_publish( event="questionnaire", @@ -63,18 +67,17 @@ def activateQuestion(global_question_id): return jsonify(success=False), 500 -@questionnaires_api.route("/questionnaires/questions/", methods=["POST"]) +@questionnaires_api.route("/questionnaires/questions/", methods=["POST"]) @jwt_required() -def submitQuestion(global_question_id): +def submitQuestion(question_id): data = request.get_json() answers = data.get("answers") - if db_create_questionnaire_answer(global_question_id=global_question_id, answers=answers, user_id=current_user.id): + if db_create_questionnaire_answer(question_id=question_id, answers=answers, user_id=current_user.id): # Send SSE event - sse_create_and_publish( event="questionnaireSubmission", - question=global_question_id, + question=question_id, recipients=[admin_user.id for admin_user in db_get_admin_users()], ) @@ -83,10 +86,10 @@ def submitQuestion(global_question_id): return jsonify(success=False), 500 -@questionnaires_api.route("/questionnaires/questions/", methods=["GET"]) +@questionnaires_api.route("/questionnaires/questions/", methods=["GET"]) @admin_required() -def getQuestionnaireResults(global_question_id): - labels, results = db_get_questionnaire_results_by_global_question_id(global_question_id) - question = db_get_questionnaire_question_by_global_question_id(global_question_id).question +def getQuestionnaireResults(question_id): + labels, results = db_get_questionnaire_results_by_question_id(question_id) + question = db_get_questionnaire_question_by_id(question_id).question return jsonify(question=question, labels=labels, results=results), 200 diff --git a/backend/routes/results.py b/backend/routes/results.py new file mode 100644 index 0000000..b40b178 --- /dev/null +++ b/backend/routes/results.py @@ -0,0 +1,52 @@ +from flask import Blueprint, make_response +from backend.jwt_manager import admin_required +from backend.logger import logger +from backend.functions.database import ( + db_get_all_exercises, + db_get_all_users, + db_get_submissions_by_user_exercise, +) + +results_api = Blueprint("results_api", __name__) + + +@results_api.route("/md_results", methods=["GET"]) +@admin_required() +def getResults(): + + results_md = "" + import json + + for exercise in db_get_all_exercises(): + + results_md += f"# {exercise.page_title}\n" + results_md += f"## {exercise.exercise_name}\n" + + for user in db_get_all_users(): + _submissions = db_get_submissions_by_user_exercise(user.id, exercise.id) + + if _submissions and _submissions[0]: + results_md += f"### { user.name }\n" + + submission_content = _submissions[0].form_data + submission_content = json.loads(submission_content) + + for input_group, input_fields in submission_content.items(): + if input_fields: + results_md += f"#### {input_group}\n" + results_md += "| Label | Content |\n" + results_md += "| ---- | ---- |\n" + for input_label, input_field in input_fields.items(): + if "divider" in input_label: + results_md += f"| ---- | ---- |\n" + else: + if isinstance(input_field, str): + input_field = input_field.replace("\n", "
") + results_md += f"| {input_label.replace('_', ' ')} | { input_field } |\n" + + results_md += "---\n" + + response = make_response(results_md) + response.headers["Content-Disposition"] = "attachment; filename=sample.md" + response.mimetype = "text/markdown" + return response diff --git a/backend/routes/setup.py b/backend/routes/setup.py index 435210a..4c9002e 100644 --- a/backend/routes/setup.py +++ b/backend/routes/setup.py @@ -38,12 +38,12 @@ def getSidebar(): usergroups = db_get_usergroups_by_user(current_user) if "admins" in usergroups: - tabs.append(Tab(name="admin", _type="admin").__dict__) + tabs.append(Tab(name="admin", _type="admin", user_role=current_user.role).__dict__) landingpage = "admin" for tab_id, tab_details in cfg.tabs.items(): if "all" in tab_details.get("show", []) or any(group in usergroups for group in tab_details.get("show", [])): - tabs.append(Tab(name=tab_id, **tab_details).__dict__) + tabs.append(Tab(name=tab_id, **tab_details, user_role=current_user.role).__dict__) if vnc_clients := cfg.users.get(current_user.name).get("vnc_clients"): multiple = len(vnc_clients) > 1 diff --git a/dev-backend.sh b/dev-backend.sh index f502b0d..a143326 100644 --- a/dev-backend.sh +++ b/dev-backend.sh @@ -1,3 +1,36 @@ #!/usr/bin/env bash + +cleanup() { + echo "Stopping processes..." + kill -TERM "$gunicorn_pid" "$yarn_pid" + wait + echo "Processes stopped." + exit +} + +trap cleanup INT + +cd frontend +yarn dev & +yarn_pid=$! +cd .. + +python -m venv venv +source venv/bin/activate + +while [ "$#" -gt 0 ]; do + case "$1" in + -init) + pip install -e . + esac +done + mkdir /tmp/learners -gunicorn backend:app --worker-class gevent --bind unix:/tmp/learners/learners.sock + +gunicorn backend:app --worker-class gevent --bind unix:/tmp/learners/learners.sock & +gunicorn_pid=$! + + +wait -n + +cleanup \ No newline at end of file diff --git a/frontend/src/components/LoginForm.vue b/frontend/src/components/LoginForm.vue index 910ec4e..8a81075 100644 --- a/frontend/src/components/LoginForm.vue +++ b/frontend/src/components/LoginForm.vue @@ -96,6 +96,7 @@ import { store } from "@/store"; import axios from "axios"; import { setStyles } from "@/helpers"; import Loader from "@/components/sub-components/Loader.vue"; +import { jwtDecode } from "jwt-js-decode"; export default { name: "LoginForm", @@ -141,6 +142,7 @@ export default { store.dispatch("setJwt", jwt); axios.defaults.headers.common["Authorization"] = "Bearer " + jwt; store.dispatch("getTabsFromServer"); + if (jwtDecode(jwt).payload.admin) this.landingpage = "admin"; this.$router.push(`/#${this.landingpage}`); } else { store.dispatch("unsetJwt"); diff --git a/frontend/src/components/Mainpage.vue b/frontend/src/components/Mainpage.vue index df419d9..be6950f 100644 --- a/frontend/src/components/Mainpage.vue +++ b/frontend/src/components/Mainpage.vue @@ -19,7 +19,11 @@ :currentView="currentView" @loaded="iframeLoaded" /> - + @@ -34,6 +38,9 @@ import { ITabObject } from "@/types"; import { jwtDecode } from "jwt-js-decode"; import { store } from "@/store"; import { setStyles, initSSE, initVisibility } from "@/helpers"; +import axios from "axios"; +import { VueCookies } from "vue-cookies"; +import { inject } from "vue"; // TODO: Add UserArea export default { @@ -48,10 +55,11 @@ export default { return { notificationClosed: false, questionnaireClosed: false, - sse_error: true, + sse_error: false, evtSource: EventSource as any, serverEvent: Object as any, iframes: [] as any, + intervalTracker: null as any | null, }; }, props: { @@ -64,15 +72,25 @@ export default { }, computed: { admin() { - const jwt = jwtDecode(store.getters.getJwt); - return jwt.payload.admin; + if (store.getters.getJwt) { + const jwt = jwtDecode(store.getters.getJwt); + return jwt.payload.admin; + } else { + return false; + } }, filteredTabs() { const tabsList = this.tabs || []; return tabsList.filter((tab) => tab._type != "admin"); }, + toggleNotifications() { + return store.getters.getShowNotifications; + }, + currentNotificationIndex() { + const index = store.getters.getCurrentNotificationIndex; + if (index) this.notificationClosed = false; + }, showNotifications() { - this.notificationClosed = false; return ( store.getters.getShowNotifications && store.getters.getNotifications.length > 0 && @@ -98,25 +116,32 @@ export default { let attemptCount = 1; const connectToStream = (context) => { - const jwt = store.getters.getJwt; - - const backend = store.getters.getBackendUrl; - context.evtSource = new EventSource(`${backend}/stream?jwt=${jwt}`); - - context.evtSource.onopen = function () { - console.log("Connected to SSE source."); - context.sse_error = false; - initSSE(context); - }; - - context.evtSource.onerror = function () { - console.log("Unable to establish SSE connection."); - context.sse_error = true; - setTimeout(() => { - attemptCount++; - connectToStream(context); - }, 5000); - }; + if (attemptCount > 2) { + console.log( + "Unable to establish SSE connection. Using fallback polling function." + ); + this.streamFallbackMethod(); + } else { + const jwt = store.getters.getJwt; + + const backend = store.getters.getBackendUrl; + context.evtSource = new EventSource(`${backend}/stream?jwt=${jwt}`); + + context.evtSource.onopen = function () { + console.log("Connected to SSE source."); + context.sse_error = false; + initSSE(context); + }; + + context.evtSource.onerror = function () { + console.log("Unable to establish SSE connection."); + context.sse_error = true; + this.intervalTracker = setTimeout(() => { + attemptCount++; + connectToStream(context); + }, 5000); + }; + } }; connectToStream(ctx); @@ -142,11 +167,42 @@ export default { this.iframes = iframes_list; return iframes_list; }, + streamFallbackMethod() { + const gatherUpdates = () => { + // Get full list of notifications from server + store.dispatch("getNotificationsFromServer"); + + // Get full list of questionnaires from server + store.dispatch("getQuestionnairesFromServer"); + + // Trigger visibility control + initVisibility(this.iFramesGather()); + + // Call setTimeout again to repeat after 10 seconds + this.intervalTracker = setTimeout(gatherUpdates, 15000); + }; + + clearInterval(this.intervalTracker); + + // Initial call to start the loop + gatherUpdates(); + }, }, async beforeMount() { setStyles(this); + clearInterval(this.intervalTracker); }, mounted() { + // Verify local jwt token + if (!store.getters.getJwt) { + const $cookies = inject("$cookies"); + $cookies?.remove("jwt_cookie"); + store.dispatch("resetTabs"); + store.dispatch("unsetJwt"); + axios.defaults.headers.common["Authorization"] = ""; + this.$router.push("/login"); + } + this.startSseSession(this); // Get full list of notifications from server @@ -163,10 +219,21 @@ export default { }, beforeUnmount() { this.closeSSE(); + clearInterval(this.intervalTracker); }, beforeDestroy() { window.removeEventListener("message", this.iFrameHandle); }, + watch: { + toggleNotifications: { + handler(new_state, old_state) { + if (new_state === true || old_state === undefined) { + this.notificationClosed = false; + } + }, + immediate: true, + }, + }, }; diff --git a/frontend/src/components/admin/AdminArea.vue b/frontend/src/components/admin/AdminArea.vue index e6b60f0..2ba47d4 100644 --- a/frontend/src/components/admin/AdminArea.vue +++ b/frontend/src/components/admin/AdminArea.vue @@ -19,7 +19,7 @@ > Submissions Overview Exercises - Pages + Visibility Notifications Questionnaire Feedback @@ -39,9 +39,10 @@ class="tab-container" /> - - + @@ -74,7 +75,7 @@ + + diff --git a/frontend/src/components/sub-components/Questionnaire.vue b/frontend/src/components/sub-components/Questionnaire.vue index d76cb8f..51d5166 100644 --- a/frontend/src/components/sub-components/Questionnaire.vue +++ b/frontend/src/components/sub-components/Questionnaire.vue @@ -9,7 +9,7 @@ -

Q{{ currentQuestionnaire?.id }}

+

Quiz

@@ -57,6 +57,16 @@ > submit + + dismiss +
@@ -97,15 +107,12 @@ export default { async submitHandler() { if (this.selectedAnswers === undefined) return; const response = await axios.post( - `questionnaires/questions/${this.currentQuestionnaire?.global_question_id}`, + `questionnaires/questions/${this.currentQuestionnaire?.id}`, { answers: this.selectedAnswers, } ); - store.dispatch( - "removeQuestionnaire", - this.currentQuestionnaire?.global_question_id - ); + store.dispatch("removeQuestionnaire", this.currentQuestionnaire?.id); }, triggerAnimation() { this.contentChanging = true; @@ -113,6 +120,9 @@ export default { this.contentChanging = false; }, 600); }, + dismissHandler() { + store.dispatch("removeQuestionnaire", this.currentQuestionnaire?.id); + }, }, watch: { currentQuestionnaire: function (newVal, oldVal) { diff --git a/frontend/src/components/sub-components/ScenarioTimer.vue b/frontend/src/components/sub-components/ScenarioTimer.vue new file mode 100644 index 0000000..5a96e18 --- /dev/null +++ b/frontend/src/components/sub-components/ScenarioTimer.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/frontend/src/helpers/index.ts b/frontend/src/helpers/index.ts index 83ac39b..b839c2e 100644 --- a/frontend/src/helpers/index.ts +++ b/frontend/src/helpers/index.ts @@ -80,8 +80,7 @@ export const extractQuestionnaires = (responseData) => { multiple: newQuestionnaire?.multiple, answers: JSON.parse(newQuestionnaire?.answer_options), language: newQuestionnaire?.language, - global_question_id: newQuestionnaire?.global_question_id, - global_questionnaire_id: newQuestionnaire?.global_questionnaire_id, + questionnaire_id: newQuestionnaire?.questionnaire_id, page_title: newQuestionnaire?.page_title, }; }); @@ -92,10 +91,7 @@ export const extractQuestionnaires = (responseData) => { multiple: (JSON.parse(responseData)?.question).multiple, answers: JSON.parse((JSON.parse(responseData)?.question).answer_options), language: (JSON.parse(responseData)?.question).language, - global_question_id: - (JSON.parse(responseData)?.question).global_question_id, - global_questionnaire_id: - (JSON.parse(responseData)?.question).global_questionnaire_id, + questionnaire_id: (JSON.parse(responseData)?.question).questionnaire_id, page_title: (JSON.parse(responseData)?.question).page_title, }; } @@ -250,13 +246,18 @@ export const initVisibility = async (iframe_list) => { data: pages, }; - try { - _iframe.contentWindow.postMessage( - functionCall, - new URL(_iframe.src).origin - ); - } catch (error) { - console.log(error); + const current_location = document.location.origin; + const iframe_location = new URL(_iframe.src).origin; + + if (current_location == iframe_location) { + try { + _iframe.contentWindow.postMessage( + functionCall, + new URL(_iframe.src).origin + ); + } catch (error) { + console.log(error); + } } }); }; @@ -268,10 +269,20 @@ export const sseHandlerContent = (ctx, event) => { function: "visibility", data: JSON.parse(event.data).message, }; - _iframe.contentWindow.postMessage( - functionCall, - new URL(_iframe.src).origin - ); + + const current_location = document.location.origin; + const iframe_location = new URL(_iframe.src).origin; + + if (current_location == iframe_location) { + try { + _iframe.contentWindow.postMessage( + functionCall, + new URL(_iframe.src).origin + ); + } catch (error) { + console.log(error); + } + } }); }; @@ -294,6 +305,13 @@ export const sseHandlerNotification = (ctx, event) => { } }; +export const sseHandlerTimer = (ctx, event) => { + ctx.notificationClosed = false; + let message_json = JSON.parse(event.data)?.message; + let timer = JSON.parse(message_json); + store.dispatch("setTimer", timer); +}; + export const sseHandlerQuestionnaire = (ctx, event) => { ctx.questionnaireClosed = false; const newQuestionnaire = extractQuestionnaires(event.data); @@ -307,12 +325,17 @@ export const sseHandlerQuestionnaireSubmission = (ctx, event) => { store.dispatch("setAdminForceReload", "questionnaire"); }; export const initSSE = (ctx) => { + ctx.sse_error = false; + ctx.evtSource.addEventListener("content", (event) => sseHandlerContent(ctx, event) ); ctx.evtSource.addEventListener("notification", (event) => sseHandlerNotification(ctx, event) ); + ctx.evtSource.addEventListener("timer", (event) => + sseHandlerTimer(ctx, event) + ); ctx.evtSource.addEventListener("questionnaire", (event) => sseHandlerQuestionnaire(ctx, event) ); @@ -328,6 +351,9 @@ export const initSSE = (ctx) => { ctx.evtSource.removeEventListener("notification", (event) => sseHandlerNotification(ctx, event) ); + ctx.evtSource.removeEventListener("timer", (event) => + sseHandlerTimer(ctx, event) + ); ctx.evtSource.removeEventListener("questionnaire", (event) => sseHandlerQuestionnaire(ctx, event) ); diff --git a/frontend/src/plugins/index.ts b/frontend/src/plugins/index.ts index f2586b7..80633b5 100644 --- a/frontend/src/plugins/index.ts +++ b/frontend/src/plugins/index.ts @@ -2,6 +2,7 @@ import { loadFonts } from "./webfontloader"; import vuetify from "./vuetify"; import router from "../router"; +import VueCookies from "vue-cookies"; // Types import type { App } from "vue"; @@ -10,4 +11,5 @@ export function registerPlugins(app: App) { loadFonts(); app.use(vuetify); app.use(router); + app.use(VueCookies); } diff --git a/frontend/src/plugins/webfontloader.ts b/frontend/src/plugins/webfontloader.ts index fed11cf..c66505b 100644 --- a/frontend/src/plugins/webfontloader.ts +++ b/frontend/src/plugins/webfontloader.ts @@ -14,6 +14,7 @@ export async function loadFonts() { families: [ "Rubik:100,300,400,500,700,900&display=swap", "Montserrat:100,300,400,500,700,900&display=swap", + "Roboto+Mono:400,500,700&display=swap", ], }, }); diff --git a/frontend/src/scss/main.scss b/frontend/src/scss/main.scss index 26533f0..e7b37e5 100644 --- a/frontend/src/scss/main.scss +++ b/frontend/src/scss/main.scss @@ -218,6 +218,10 @@ button.v-btn.resume-btn span { overflow-y: auto; } +.v-snackbar__content { + margin: auto; +} + @media (min-width: 2560px) { .v-main .v-container { max-width: 1800px !important; diff --git a/frontend/src/store/actions.ts b/frontend/src/store/actions.ts index a43f8b6..24efe6a 100644 --- a/frontend/src/store/actions.ts +++ b/frontend/src/store/actions.ts @@ -29,6 +29,10 @@ export default { commit("SET_BACKEND_URL", backendUrl); }, + // Timer + setTimer: ({ commit }: { commit: Commit }, timer: any) => + commit("SET_TIMER", timer), + // Tabs async getTabsFromServer({ commit }) { await axios @@ -112,9 +116,9 @@ export default { removeQuestionnaire: ( { commit }: { commit: Commit }, - global_question_id: Number + question_id: Number ) => { - commit("REMOVE_QUESTIONNAIRE", global_question_id); + commit("REMOVE_QUESTIONNAIRE", question_id); commit("SET_CURRENT_QUESTIONNAIRE_INDEX_TO_LAST"); }, diff --git a/frontend/src/store/getters.ts b/frontend/src/store/getters.ts index daa2a15..0412ffc 100644 --- a/frontend/src/store/getters.ts +++ b/frontend/src/store/getters.ts @@ -8,6 +8,9 @@ export default { getCurrentView: (state) => state.currentView || "", getTheme: (state) => state.theme, + // Timer + getTimer: (state) => state.timer, + // Admin View getAdminForceReload: (state) => (tab) => { return state.adminForceReload[tab]; diff --git a/frontend/src/store/mutations.ts b/frontend/src/store/mutations.ts index a5c707b..99bfae2 100644 --- a/frontend/src/store/mutations.ts +++ b/frontend/src/store/mutations.ts @@ -18,6 +18,8 @@ export default { }, SET_THEME: (state: { theme: any }, theme: any) => (state.theme = theme), + SET_TIMER: (state: { timer: any }, timer: any) => (state.timer = timer), + SET_CURRENT_VIEW: (state: { currentView: string }, currentView: string) => (state.currentView = currentView), @@ -73,12 +75,9 @@ export default { if (state.questionnaires.length) state.showQuestionnaires = true; }, - REMOVE_QUESTIONNAIRE: ( - state: { questionnaires: any }, - global_question_id: Number - ) => + REMOVE_QUESTIONNAIRE: (state: { questionnaires: any }, question_id: String) => (state.questionnaires = state.questionnaires.filter( - (q) => q.global_question_id != global_question_id + (q) => q.id != question_id )), SET_SHOW_QUESTIONNAIRE_STATE: ( @@ -91,7 +90,7 @@ export default { questionnaires: any; showQuestionnaires: boolean; }) => { - state.currentQuestionnaireIndex = state.questionnaires.length - 1; + state.currentQuestionnaireIndex = 0; if (state.questionnaires.length) { state.showQuestionnaires = true; } else { @@ -101,4 +100,5 @@ export default { SET_QUESTIONNAIRES: (state: { questionnaires: any }, payload: any) => (state.questionnaires = payload), + }; diff --git a/frontend/src/store/state.ts b/frontend/src/store/state.ts index 8962714..6b87eb3 100644 --- a/frontend/src/store/state.ts +++ b/frontend/src/store/state.ts @@ -24,6 +24,9 @@ export default { currentQuestionnaireIndex: 0, showQuestionnaires: true, + // Timer + timer: null, + // Admin Reloads adminForceReload: { submissions: false, @@ -31,5 +34,6 @@ export default { notifications: false, feedback: false, questionnaire: false, + timer: false, }, }; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6e53b32..c6cb68d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -9,13 +9,12 @@ export interface ITabObject { } export interface IExerciseObject { - id: number; + id: string; exercise_type: string; page_title: string; root_weight: number; child_weight: number; local_exercise_id: number; - global_exercise_id: string; exercise_name: string; parent_page_title: string; parent_weight: number; @@ -37,7 +36,7 @@ export interface IQuestionnaireQuestionObject { multiple: boolean; language: string; answers: Array; - global_question_id: string; - global_questionnaire_id: string; + question_id: string; + questionnaire_id: string; page_title: string; }