From 1088278eebb44f3adf3276b77ec36a8e6bfda12a Mon Sep 17 00:00:00 2001 From: Andrey Karpachevskiy Date: Sat, 30 May 2026 15:35:57 +0300 Subject: [PATCH] upd 30.05.2026 --- config.json | 5 + gui/analysis.html | 48 +++++ gui/analysis.js | 146 ++++++++++++++ gui/index.html | 20 +- gui/main.js | 34 +++- gui/monitor.html | 124 ++++++++++++ gui/settings.html | 130 +++++++++++++ main.py | 420 ++++++++++++++++++++++++++++++----------- main_.js | 153 +++++++++++++++ questions_invalid.json | 8 +- requirements.txt | Bin 70 -> 32 bytes run.py | 2 +- settings.py | 4 +- students.json | 20 +- 14 files changed, 984 insertions(+), 130 deletions(-) create mode 100644 config.json create mode 100644 gui/analysis.html create mode 100644 gui/analysis.js create mode 100644 gui/monitor.html create mode 100644 gui/settings.html create mode 100644 main_.js diff --git a/config.json b/config.json new file mode 100644 index 0000000..a345290 --- /dev/null +++ b/config.json @@ -0,0 +1,5 @@ +{ + "quiz_length": 30, + "topics": null, + "test_time": 40 +} \ No newline at end of file diff --git a/gui/analysis.html b/gui/analysis.html new file mode 100644 index 0000000..0bfcb59 --- /dev/null +++ b/gui/analysis.html @@ -0,0 +1,48 @@ + + + + + + + Анализ результатов + + + + + +

Анализ результатов

+
+ + Число файлов: 0
+ Обработано файлов: 0 +
+
+
+
+
+
+ + diff --git a/gui/analysis.js b/gui/analysis.js new file mode 100644 index 0000000..21e4b5d --- /dev/null +++ b/gui/analysis.js @@ -0,0 +1,146 @@ +document.getElementById('fileInput').addEventListener('change', handleFiles); + +async function handleFiles(event) { + const files = event.target.files; + document.getElementById('fileCount').textContent = `Число файлов: ${files.length}`; + let processed = 0; + + const questionErrors = {}; + const topicStats = {}; + const studentScores = []; + + for (const file of files) { + const text = await file.text(); + const json = JSON.parse(text); + const student = json.student; + const questions = json.questions; + let correct = 0; + + for (const q of questions) { + const topic = q.topic || "Без темы"; + const questionText = q.question; + const isCorrect = q.is_correct; + + if (isCorrect) correct++; + else { + if (!questionErrors[questionText]) questionErrors[questionText] = []; + questionErrors[questionText].push({ + student, + given: q.student_answer, + correct: q.correct_answer, + topic + }); + } + + if (!topicStats[topic]) topicStats[topic] = 0; + topicStats[topic]++; + } + + studentScores.push({ student, correct }); + processed++; + document.getElementById('processedCount').textContent = `Обработано файлов: ${processed}`; + } + + drawCharts(studentScores, questionErrors, topicStats); +} + +function truncate(text, len = 35) { + return text.length > len ? text.slice(0, len) + "…" : text; +} + +function surname(fullname) { + return fullname.split(" ")[0]; +} + +// Wrap long hover text +function wrapText(str, width = 60) { + return str.replace(new RegExp(`(.{${width}})`, "g"), "$1
"); +} + +// Generate stable color from topic name +function hashColor(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + let color = "#"; + for (let i = 0; i < 3; i++) { + const value = (hash >> (i * 8)) & 0xff; + color += ("00" + value.toString(16)).slice(-2); + } + return color; +} + +function drawCharts(scores, errors, topics) { + // === Chart 1 === + scores.sort((a, b) => b.correct - a.correct); + Plotly.newPlot("chart1", [{ + x: scores.map(x => x.student), + y: scores.map(x => x.correct), + type: "bar", + marker: { color: "lightblue" } + }], { title: "Рейтинг студентов" }); + + // === Chart 2 === + const sortedErrors = Object.entries(errors) + .sort((a, b) => b[1].length - a[1].length) + .slice(0, 50); + + const fullQuestions = sortedErrors.map(x => x[0]); + const shortLabels = fullQuestions.map(q => truncate(q)); + + const xvals = fullQuestions.map((_, i) => i); + + const colors2 = sortedErrors.map(([qText, entries]) => { + const topic = entries[0].topic || "Без темы"; + return hashColor(topic); + }); + + const customdata = sortedErrors.map(([qText, entries]) => { + return { + full: wrapText(qText, 60), + lines: entries.map(d => `${surname(d.student)}: "${d.given}"`).join("
") + }; + }); + + Plotly.newPlot( + "chart2", + [ + { + x: xvals, + y: sortedErrors.map(x => x[1].length), + type: "bar", + marker: { color: colors2 }, + customdata: customdata, + hovertemplate: + "%{customdata.full}
%{customdata.lines}" + } + ], + { + title: "Наиболее частые ошибки", + xaxis: { + tickmode: "array", + tickvals: xvals, + ticktext: shortLabels + }, + hoverlabel: { + namelength: -1, + bgcolor: "rgba(255,255,255,0.95)", + bordercolor: "#333", + font: { size: 14 } + } + } + ); + + // === Chart 3 === + const topicsList = Object.keys(topics); + const topicsValues = Object.values(topics); + const colors3 = topicsList.map(t => hashColor(t)); + + Plotly.newPlot("chart3", [{ + x: topicsList, + y: topicsValues, + type: "bar", + marker: { color: colors3 } + }], { title: "Анализ по темам" }); +} diff --git a/gui/index.html b/gui/index.html index e61114c..600bfc7 100644 --- a/gui/index.html +++ b/gui/index.html @@ -7,11 +7,10 @@ - - + + - + + +

Прогресс студентов

+ +

✅ Завершённые тесты

+ + + + + + + + + + + + +
СтудентПравильных (%)НачалоОкончание
Загрузка...
+ + + + + + + + + + + + + + + + + + + + + diff --git a/gui/settings.html b/gui/settings.html new file mode 100644 index 0000000..09d2371 --- /dev/null +++ b/gui/settings.html @@ -0,0 +1,130 @@ + + + + + Настройки теста + + + + + +

Настройки теста

+
+ + + + + + + +
+ + +
+ +
+ +

Обновить список студентов

+

Можно загрузить файл .json или .csv.

+

Поддерживаются форматы CSV:

+ +

Если готовите файл в Excel: сохраните его как CSV UTF-8.

+ +
+ + +
+ +

Назад к тестированию

+ + + + \ No newline at end of file diff --git a/main.py b/main.py index 9d9b4a0..41c8bbc 100644 --- a/main.py +++ b/main.py @@ -1,185 +1,391 @@ -from fastapi import FastAPI, Body -from fastapi.middleware.cors import CORSMiddleware # CORS +from fastapi import FastAPI, Body, UploadFile +from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles import json import random +import shutil from pathlib import Path from datetime import datetime import socket +import csv +import io from settings import WAVE, END_TEST_PASSWORD, QUIZ_LENGTH, QUESTIONS_FILE, STUDENTS_FILE -app = FastAPI(debug=True) +CONFIG_FILE = "config.json" + + +def load_config(): + if Path(CONFIG_FILE).exists(): + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + return json.load(f) + else: + return { + "quiz_length": QUIZ_LENGTH, + "topics": None, + "test_time": 15 + } + + +def save_config(cfg): + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) + + +def save_students_json(students_list: list): + data = { + "version": 1, + "students": students_list + } + with open(STUDENTS_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def backup_students_file(): + path = Path(STUDENTS_FILE) + if path.exists(): + backup_dir = Path("backups") + backup_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S") + backup_path = backup_dir / f"students_{timestamp}.json" + shutil.copy2(path, backup_path) + + +def normalize_name(name: str) -> str: + return " ".join(str(name).strip().split()) + + +def parse_students_csv(file_obj): + raw = file_obj.read() + + # пробуем utf-8-sig, потом utf-8, потом cp1251 + text = None + for encoding in ("utf-8-sig", "utf-8", "cp1251"): + try: + text = raw.decode(encoding) + break + except UnicodeDecodeError: + continue + + if text is None: + raise ValueError("Не удалось прочитать CSV. Сохраните файл как CSV UTF-8.") + + # пытаемся определить разделитель + sample = text[:2048] + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;") + delimiter = dialect.delimiter + except Exception: + delimiter = ";" + + reader = csv.reader(io.StringIO(text), delimiter=delimiter) + rows = [row for row in reader if row and any(str(cell).strip() for cell in row)] + + if not rows: + raise ValueError("CSV-файл пуст.") + + first_row = [str(cell).strip().lower() for cell in rows[0]] + students_list = [] + + # Вариант 1: заголовки id,name + if "id" in first_row and "name" in first_row: + id_idx = first_row.index("id") + name_idx = first_row.index("name") + + for row in rows[1:]: + if len(row) <= max(id_idx, name_idx): + continue + + student_id = row[id_idx] + student_name = row[name_idx] + + if not str(student_name).strip(): + continue + + try: + student_id = int(str(student_id).strip()) + except Exception: + raise ValueError("В колонке 'id' должны быть числа.") + + students_list.append({ + "id": student_id, + "name": normalize_name(student_name) + }) + + # Вариант 2: заголовок name + elif "name" in first_row: + name_idx = first_row.index("name") + next_id = 1 + + for row in rows[1:]: + if len(row) <= name_idx: + continue -origins = [ # CORS - "*", -] + student_name = row[name_idx] + if not str(student_name).strip(): + continue -app.add_middleware( # CORS + students_list.append({ + "id": next_id, + "name": normalize_name(student_name) + }) + next_id += 1 + + # Вариант 3: заголовок ФИО + elif len(first_row) >= 1 and first_row[0] in {"фио", "ф.и.о.", "student", "student_name"}: + next_id = 1 + for row in rows[1:]: + if not row: + continue + + student_name = row[0] + if not str(student_name).strip(): + continue + + students_list.append({ + "id": next_id, + "name": normalize_name(student_name) + }) + next_id += 1 + + # Вариант 4: просто один столбец без заголовка + elif all(len(row) == 1 for row in rows): + next_id = 1 + for row in rows: + student_name = row[0] + if not str(student_name).strip(): + continue + + students_list.append({ + "id": next_id, + "name": normalize_name(student_name) + }) + next_id += 1 + + else: + raise ValueError( + "Не удалось распознать формат CSV. Используйте один из вариантов: " + "1) колонки id и name; " + "2) колонка name; " + "3) колонка ФИО; " + "4) один столбец с ФИО." + ) + + if not students_list: + raise ValueError("Не найдено ни одного студента.") + + # проверка дублей по ФИО + seen_names = set() + duplicate_names = set() + for s in students_list: + key = s["name"].casefold() + if key in seen_names: + duplicate_names.add(s["name"]) + seen_names.add(key) + + if duplicate_names: + dupes = ", ".join(sorted(duplicate_names)) + raise ValueError(f"В списке есть дубли ФИО: {dupes}") + + # проверка дублей id + ids = [s["id"] for s in students_list] + if len(ids) != len(set(ids)): + raise ValueError("В списке есть повторяющиеся id.") + + return students_list + + +config = load_config() + +app = FastAPI(debug=True) + +app.add_middleware( CORSMiddleware, - allow_origins=origins, + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -# open files once and use variables after with open(QUESTIONS_FILE, "r", encoding="UTF-8") as f: questions_json = json.load(f) - # TODO reassign questions id since mistakes are possible and checking is based on id - all_questions = [{**q, "id": i} for i, q in enumerate(questions_json["questions"])] - # if you want to use topics 1) filter all questions with topics 2) use topic questions to construct quiz_questions (when removing service keys from all_questions dict) - # topics = ["теодолит"] # move topics to VARIABLES - # topics_questions = [q for q in all_questions if q.get("topic") in topics] - typed_questions = [dict(q, **{"is_multiple": True}) if type(q["answer"]) is list else dict(q, **{"is_multiple": False}) for q in all_questions] - # print(typed_questions) - remove_keys = ["author", "answer", "topic"] - quiz_questions = [{key: value for key, value in q.items() if key not in remove_keys} for q in typed_questions] # all_questions can be replaced with topic_questions + all_questions = questions_json["questions"] + typed_questions = [dict(q, **{"is_multiple": isinstance(q["answer"], list)}) for q in all_questions] with open(STUDENTS_FILE, "r", encoding="UTF-8") as f: students_json = json.load(f) students = students_json["students"] -# create folders if they don't exist Path("answers").mkdir(parents=True, exist_ok=True) Path("results").mkdir(parents=True, exist_ok=True) -def check_answers(student_answers: dict): - checked_answers = student_answers - for a in checked_answers["questions"]: - question_id = a["id"] - a["correct_answer"] = next(question["answer"] for question in all_questions if question["id"] == question_id) # all_questions can be replaced with topic_questions - if type(a["student_answer"]) is str and type(a["correct_answer"]) is str: - a["is_correct"] = a["student_answer"].casefold() == a["correct_answer"].casefold() - elif type(a["student_answer"]) is list and type(a["correct_answer"]) is list: - a["is_correct"] = set(a["student_answer"]) == set(a["correct_answer"]) - else: - print("Unmatched types! Can't compare.") - a["is_correct"] = False - checked_answers["correct"] = sum([a["is_correct"] for a in checked_answers["questions"]]) - checked_answers["correct_percent"] = round(checked_answers["correct"] * 100 / len(checked_answers["questions"])) - return checked_answers +@app.get("/get_config") +def get_config(password: str): + if password != END_TEST_PASSWORD: + return {"error": "Неверный пароль"} + return config -@app.get("/check_questions") -def check_questions(): - """Проверка вопросов на 1) наличие уникального идентификатора вопроса, 2) наличие правильного варианта ответа для вопросов с вариантами ответа +@app.get("/get_topics") +def get_topics(password: str): + if password != END_TEST_PASSWORD: + return [] + return sorted(set(q["topic"] for q in all_questions if q.get("topic"))) - Returns: - Сообщение о корректности / некорректности вопросов - """ - unique_ids = [] - errors = [] - for q in all_questions: - if q["id"] in unique_ids: - errors.append(f'"id": {q["id"]} — идентификатор не уникален') - else: - unique_ids.append(q["id"]) - - if q.get("options") and not (set(q["answer"]).issubset(set(q["options"])) or q["answer"] in q["options"]): - errors.append(f"В вопросе '{q['question']}' нет корректного варианта ответа") - return errors if errors else "Вопросы в порядке!" +@app.get("/students") +def show_students(): + return students -@app.get("/hostip") -def show_host_ip(): - """Возвращает IP-адрес компьютера, на котором запущен сервер тестирования, — к этому IP-адресу нужно подключаться с компьютеров пользователей +@app.post("/set_config") +async def set_config(password: str, data: dict = Body()): + if password != END_TEST_PASSWORD: + return "Неверный пароль" + + config["quiz_length"] = int(data.get("quiz_length", QUIZ_LENGTH)) + topics = data.get("topics") + config["topics"] = topics if topics else None + config["test_time"] = int(data.get("test_time", 15)) + save_config(config) + return "Настройки сохранены!" + + +@app.post("/upload_students") +def upload_students(password: str, file: UploadFile): + if password != END_TEST_PASSWORD: + return "Неверный пароль" - Returns: - IP-адрес - """ - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.settimeout(0) try: - # doesn't even have to be reachable - s.connect(('10.254.254.254', 1)) - ip = s.getsockname()[0] - except Exception: - ip = '127.0.0.1' - finally: - s.close() - return ip + filename = (file.filename or "").lower() -@app.get("/students") -def show_students(): - """Список студентов + global students + + if filename.endswith(".json"): + content = json.load(file.file) + if "students" not in content or not isinstance(content["students"], list): + raise ValueError("Неверный JSON-формат. Ожидается объект с полем 'students'.") + + backup_students_file() + students = content["students"] + save_students_json(students) + return f"JSON загружен успешно. Студентов: {len(students)}" + + elif filename.endswith(".csv"): + parsed_students = parse_students_csv(file.file) + + backup_students_file() + students = parsed_students + save_students_json(students) + return f"CSV обработан успешно. Студентов: {len(students)}" + + else: + return "Поддерживаются только файлы .json и .csv" + + except Exception as e: + return f"Ошибка: {str(e)}" - Returns: - JSON со списком студентов - """ - return students @app.get("/get_quiz") def get_quiz(student_id, student: str): - """Получить JSON с тестом + if config["topics"]: + selected_questions = [q for q in typed_questions if q.get("topic") in config["topics"]] + else: + selected_questions = typed_questions + + questions_for_student = random.sample( + selected_questions, + min(config["quiz_length"], len(selected_questions)) + ) - Args: - student_id (int): идентификатор студента - student (str): имя студента + remove_keys = ["author", "answer"] + quiz_questions = [{key: value for key, value in q.items() if key not in remove_keys} for q in questions_for_student] - Returns: - obj: JSON с тестом - """ - quiz_length = QUIZ_LENGTH - questions_for_student = random.sample(quiz_questions, len(quiz_questions))[:quiz_length] # random order and only first n questions return { "version": 1, "student_id": student_id, "student": student, - "wave": WAVE, # волна сдачи теста - "start_time": datetime.now().strftime("%Y-%m-%dT%H-%M-%S"), - "questions": questions_for_student + "wave": WAVE, + "start_time": datetime.now().isoformat(), + "questions": quiz_questions, + "test_time": load_config().get("test_time", 15) } -@app.post("/save_student_answers") -def send_student_answers(student_answers: str = Body()): - """Сохранить ответы студента как есть (./answers/...) и сохранить проверенные ответы студента (./results/...) - Args: - student_answers (str): JSON с ответами студента +def check_answers(student_answers: dict): + checked_answers = student_answers + for a in checked_answers["questions"]: + question_id = a["id"] + a["correct_answer"] = next(question["answer"] for question in all_questions if question["id"] == question_id) - Returns: - str: имя студента - """ + if isinstance(a["student_answer"], str) and isinstance(a["correct_answer"], str): + a["is_correct"] = a["student_answer"].casefold() == a["correct_answer"].casefold() + elif isinstance(a["student_answer"], list) and isinstance(a["correct_answer"], list): + a["is_correct"] = set(a["student_answer"]) == set(a["correct_answer"]) + else: + a["is_correct"] = False + + checked_answers["correct"] = sum([a["is_correct"] for a in checked_answers["questions"]]) + checked_answers["correct_percent"] = round(checked_answers["correct"] * 100 / len(checked_answers["questions"])) + return checked_answers + + +@app.post("/save_student_answers") +def send_student_answers(student_answers: str = Body()): json_answers = json.loads(student_answers) timestamp_str = datetime.now().strftime("%Y-%m-%dT%H-%M-%S") json_answers["end_time"] = timestamp_str + path_to_answers = f'answers/{json_answers["student"]}_{json_answers["wave"]}_{timestamp_str}.json' with open(path_to_answers, 'w', encoding='utf-8') as f: json.dump(json_answers, f, ensure_ascii=False) - path_to_results = f'results/{json_answers["student"]}_{json_answers["wave"]}_{timestamp_str}.json' + checked = check_answers(json_answers) + path_to_results = f'results/{json_answers["student"]}_{json_answers["wave"]}_{timestamp_str}_{checked["correct_percent"]}.json' + path_to_summary = 'gui/summary.txt' with open(path_to_results, 'w', encoding='utf-8') as f: - json.dump(check_answers(json_answers), f, ensure_ascii=False) # TODO move checking to background? + json.dump(checked, f, ensure_ascii=False) + + with open(path_to_summary, 'a', encoding='utf-8') as f: + f.write(json_answers["student"] + ',' + str(checked["correct_percent"]) + ',' + timestamp_str + '\n') + return json_answers["student"] + @app.get("/end_quiz") def end_test(password: str): - """Сохранить результаты из файлов JSON в папке ./results в файл CSV - - Args: - password (str): пароль для "окончания теста" - """ if password == END_TEST_PASSWORD: csv_string = "student,percent,start,end\n" for file_in_results in Path("results").iterdir(): if file_in_results.is_file() and file_in_results.suffix == ".json": - # print(file_in_results) with open(file_in_results, "r", encoding="UTF-8") as f: content = json.load(f) csv_string += f"{content['student']},{content['correct_percent']},{content['start_time']},{content['end_time']}\n" + timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S") results_path = Path(f"results/results_{timestamp}.csv") with open(results_path, "w", encoding='utf-8') as f: f.write(csv_string) - return(f"Тестирование завершено. Сводные результаты сохранены в {results_path.resolve()}") + + return f"Тестирование завершено. Сводные результаты сохранены в {results_path.resolve()}" else: - return("Неверный пароль") + return "Неверный пароль" -app.mount("/pictures", StaticFiles(directory="pictures"), name="pictures") -app.mount("/", StaticFiles(directory="gui", html = True), name="gui") # must be after all since root route will fill all empty routes + +@app.get("/hostip") +def show_host_ip(): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0) + try: + s.connect(('10.254.254.254', 1)) + ip = s.getsockname()[0] + except Exception: + ip = '127.0.0.1' + finally: + s.close() + return ip - \ No newline at end of file +app.mount("/pictures", StaticFiles(directory="pictures"), name="pictures") +app.mount("/", StaticFiles(directory="gui", html=True), name="gui") \ No newline at end of file diff --git a/main_.js b/main_.js new file mode 100644 index 0000000..5f81e4e --- /dev/null +++ b/main_.js @@ -0,0 +1,153 @@ +// REPLACE localhost WITH ACTUAL HOST IP +document.addEventListener("DOMContentLoaded", function () { + // console.log("ok") + var students_selector = document.getElementById("students-selector") + var get_quiz_button = document.getElementById("get-quiz") + + fetch("/hostip") + .then(r => r.json()) + .then(host_ip => document.getElementById("host-ip").innerText += ` ${host_ip}:8000`) + + fetch("/students") + .then(r => r.json()) + .then(students => { + students.forEach(student => { + students_selector.innerHTML += `` + }) + students_selector.addEventListener("change", function (e) { + get_quiz_button.disabled = false + }, + { once: true } + ) + }) + + get_quiz_button.addEventListener("click", function () { + // console.log(students_selector.value) + // console.log(students_selector.options[students_selector.selectedIndex].text) + fetch('/get_quiz?' + new URLSearchParams({ + student_id: students_selector.value, + student: students_selector.options[students_selector.selectedIndex].text + })) + .then(r => r.json()) + .then(quiz => { + // console.log(quiz) + // console.log(quiz.questions) + var questions = quiz.questions + + var questions_html = "
" + questions.forEach(q => { + // console.log(q) + if (q.is_multiple) { + let options_div = "" + q.options.forEach(o => { + options_div += `` + }) + const question_div = + `
+ Выберите ответ: + ${options_div} +
` + questions_html += + `
+

${q.question}

+ ${q.picture ? `` : ""} + ${question_div} +
` + } else if (q.options) { + let options_div = "" + q.options.forEach(o => { + options_div += `` + }) + const question_div = + `
+ Выберите ответ: + ${options_div} +
` + questions_html += + `
+

${q.question}

+ ${q.picture ? `` : ""} + ${question_div} +
` + } else { + const question_div = `` + questions_html += + `
+

${q.question}

+ ${q.picture ? `` : ""} + ${question_div} +
` + } + }) + // console.log(questions_html) + questions_html += "
" + document.getElementById("header").innerHTML = `

Тестирование

${students_selector.options[students_selector.selectedIndex].text}

` + document.getElementById("main").innerHTML = questions_html + + var testTime = 2; + var startTime = new Date(); + var endTime = startTime.getTime() + testTime * 60 * 1000; + var time = document.createElement("div"); + document.getElementById("main").appendChild(time); + var timeLeft = new Date(); + function showTime() { + timeLeft = new Date(); + timeLeft = endTime - timeLeft.getTime(); + time.innerHTML = Math.floor(timeLeft / 1000 / 60) + ":" + (Math.floor(timeLeft / 1000) % 60); + if (time.innerHTML == "0:0") { + endTest(); + clearInterval(testTiming); + } + } + var testTiming = setInterval(showTime, 200); + + var button = document.createElement('button'); + button.style.margin = "20px" + button.innerHTML = 'Сдать тест'; + function endTest() { + // TODO: move this logic to the backend check_answers function + // Populate quiz with empty answers (if no answer presented in select there'll be no property "answer" what could not be resolved in API) + for (const question of quiz.questions) { + question.is_multiple ? question.student_answer = [] : question.student_answer = "" + } + + // Replace the empty answers with real answers + const form = document.getElementById('form'); + const formData = new FormData(form); + for (const [key, value] of formData) { + // console.log(quiz) + console.log(`${key}: ${value}\n`) // assume questions are in the same order - can it make code simplier? + const question = quiz.questions.find(q => q.id == key) + question.is_multiple ? question.student_answer.push(value) : question.student_answer = value + // quiz.questions.find(q => q.id == key).student_answer = value + } + console.log(quiz) + fetch('/save_student_answers', { + method: 'POST', + // mode: 'no-cors', + // headers: { + // 'Accept': 'text/plain', + // 'Content-Type': 'text/plain' + // }, + body: JSON.stringify(quiz) + }) + document.getElementById("main").innerHTML = "

Тестирование окончено

" + } + button.onclick = endTest; + // where do we want to have the button to appear? + // you can append it to another element just by doing something like + // document.getElementById('foobutton').appendChild(button); + document.getElementById("main").appendChild(button) + }) + }) + + document.getElementById("end-quiz").addEventListener("click", function() { + let pass = window.prompt("Уважаемый преподаватель, введите пароль, чтобы завершить тестирование для всех", "Я здесь случайно") + // console.log(pass) + fetch('/end_quiz?' + new URLSearchParams({ + password: pass + })) + .then(r => r.text()) + .then(text => window.alert(text)) + }) +}) \ No newline at end of file diff --git a/questions_invalid.json b/questions_invalid.json index 23e2000..c714d13 100644 --- a/questions_invalid.json +++ b/questions_invalid.json @@ -1,7 +1,8 @@ { - "version": 2, + "version": 1, "questions": [ { + "id": 1, "topic": "теодолит", "author": "GT", "question": "Что такое теодолит?", @@ -15,24 +16,28 @@ "answer": "Прибор для измерения" }, { + "id": 1, "topic": "геометрия", "author": "GT", "question": "Сумма углов выпуклого пятиугольника составляет?", "answer": "540" }, { + "id": 3, "topic": "нивелир", "author": "GT", "question": "Как называется прибор для измерения превышений?", "answer": "нивелир" }, { + "id": 4, "topic": "теодолит", "author": "GT", "question": "Сколько винтов у теодолита?", "answer": "520" }, { + "id": 5, "topic": "нивелир", "author": "GT", "question": "Что такое нивелир?", @@ -45,6 +50,7 @@ "answer": "Прибор для измерения углов" }, { + "id": 6, "topic": "нивелир", "author": "GT", "question": "Что такое нивелир 2.0?", diff --git a/requirements.txt b/requirements.txt index 8c6a77e57778720562acdab23ba92b03af58d51c..6d7503ce382eaccf599b5f0393cc88780c3a2f40 100644 GIT binary patch literal 32 ncmYdGEG|hb$mA+5%S_HM%Ht}iEXl~v)6Ff-DakBIEGhv2&SVU< literal 70 zcmezWFO4CQp_rirNER?;GS~v40fQcc1%oM&G-Tjq;9@9cC