diff --git a/backups/questions_2026-06-08T11-03-15-431728.json b/backups/questions_2026-06-08T11-03-15-431728.json
new file mode 100644
index 0000000..1c9957b
--- /dev/null
+++ b/backups/questions_2026-06-08T11-03-15-431728.json
@@ -0,0 +1,63 @@
+{
+ "version": 2,
+ "questions": [
+ {
+ "topic": "теодолит",
+ "author": "GT",
+ "question": "Что такое теодолит?",
+ "picture": "pictures/teodolit.png",
+ "options": [
+ "Прибор для измерения земли",
+ "Прибор для измерения углов",
+ "Прибор для измерения расстояний",
+ "Прибор для измерения высот"
+ ],
+ "answer": "Прибор для измерения углов"
+ },
+ {
+ "topic": "геометрия",
+ "author": "GT",
+ "question": "Сумма углов выпуклого пятиугольника составляет?",
+ "answer": "540"
+ },
+ {
+ "topic": "нивелир",
+ "author": "GT",
+ "question": "Как называется прибор для измерения превышений?",
+ "answer": "нивелир"
+ },
+ {
+ "topic": "теодолит",
+ "author": "GT",
+ "question": "Сколько винтов у теодолита?",
+ "answer": "520"
+ },
+ {
+ "topic": "нивелир",
+ "author": "GT",
+ "question": "Что такое нивелир?",
+ "options": [
+ "Прибор для измерения земли",
+ "Прибор для измерения превышений",
+ "Прибор для измерения расстояний",
+ "Прибор для измерения высот"
+ ],
+ "answer": "Прибор для измерения превышений"
+ },
+ {
+ "topic": "нивелир",
+ "author": "GT",
+ "question": "Что такое нивелир 2.0?",
+ "options": [
+ "Прибор для измерения земли",
+ "Прибор для измерения углов",
+ "Прибор для измерения расстояний",
+ "Прибор для измерения высот"
+ ],
+ "answer": [
+ "Прибор для измерения расстояний",
+ "Прибор для измерения углов"
+ ]
+ }
+ ]
+}
\ No newline at end of file
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/groups/students_00.json b/groups/students_00.json
new file mode 100644
index 0000000..bbeb68c
--- /dev/null
+++ b/groups/students_00.json
@@ -0,0 +1,33 @@
+{
+ "version": 1,
+ "students": [
+ {
+ "id": 1,
+ "name": "Бенедикт Кембербетч"
+ },
+ {
+ "id": 2,
+ "name": "Бранденбург Кенигсберг"
+ },
+ {
+ "id": 3,
+ "name": "Базилик Кибервотч"
+ },
+ {
+ "id": 4,
+ "name": "Будапешт Казантип"
+ },
+ {
+ "id": 5,
+ "name": "Барбарис Корвалол"
+ },
+ {
+ "id": 6,
+ "name": "Баттлфилд Когтевран"
+ },
+ {
+ "id": 7,
+ "name": "Бургеркинг Кабачок"
+ }
+ ]
+}
\ 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/analytics.html b/gui/analytics.html
new file mode 100644
index 0000000..bff6801
--- /dev/null
+++ b/gui/analytics.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+ Аналитика тестирования
+
+
+
+
+
+
+ Загрузка аналитики...
+
+
+
+
Распределение результатов
+
+
+
+ Самые сложные вопросы
+ | Вопрос | Тема | Ответов | Правильно | Ошибок |
|---|
+
+
+ Результаты студентов
+ | Студент | Результат | Правильно | Дата |
|---|
+
+
+
+
+
diff --git a/gui/analytics.js b/gui/analytics.js
new file mode 100644
index 0000000..bf9ca84
--- /dev/null
+++ b/gui/analytics.js
@@ -0,0 +1,54 @@
+const password = sessionStorage.getItem("teacherPassword") || "";
+const $ = id => document.getElementById(id);
+if (!password) window.location.replace("index.html");
+
+function escapeHtml(value) {
+ return String(value ?? "").replace(/[&<>"']/g, char => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[char]));
+}
+function formatDate(value) {
+ if (!value) return "Нет данных";
+ const normalized = value.replace(/T(\d{2})-(\d{2})-(\d{2})$/, "T$1:$2:$3");
+ const date = new Date(normalized);
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString("ru-RU");
+}
+function metric(label, value, hint) {
+ return `${label}${value}${hint}`;
+}
+function bar(label, value, detail, danger=false) {
+ return `${escapeHtml(label)}${detail}
`;
+}
+function renderAnalytics(data) {
+ $("message").textContent = "";
+ $("message").className = "message";
+ $("analytics").hidden = false;
+ $("updated").textContent = `обновлено ${new Date().toLocaleTimeString("ru-RU")}`;
+ const s = data.summary;
+ $("metrics").innerHTML = [
+ metric("Попыток", s.attempts, `${s.students} студентов`),
+ metric("Средний балл", `${s.average_score}%`, `медиана ${s.median_score}%`),
+ metric("Успешность", `${s.pass_rate}%`, "результат от 50%"),
+ metric("Вопросов в статистике", data.questions.length, `${data.topics.length} тем`)
+ ].join("");
+ const maxCount = Math.max(1, ...data.distribution.map(item => item.count));
+ $("distribution").innerHTML = data.distribution.map(item => bar(item.label, item.count * 100 / maxCount, `${item.count}`)).join("");
+ $("topics").innerHTML = data.topics.length ? data.topics.slice(0, 10).map(item => bar(item.topic, 100-item.accuracy, `${item.accuracy}% правильно`, true)).join("") : 'Нет данных
';
+ $("questions").innerHTML = data.questions.length ? data.questions.slice(0, 30).map(item => `| ${escapeHtml(item.question)} | ${escapeHtml(item.topic)} | ${item.attempts} | ${item.accuracy}% | ${item.incorrect} |
`).join("") : '| Нет данных |
';
+ $("students").innerHTML = data.students.length ? data.students.map(item => `| ${escapeHtml(item.student)} | ${item.percent}% | ${item.correct} из ${item.total} | ${formatDate(item.end_time)} |
`).join("") : '| Нет данных |
';
+}
+async function loadAnalytics() {
+ try {
+ const response = await fetch(`/teacher/analytics?password=${encodeURIComponent(password)}`, { cache: "no-store" });
+ if (!response.ok) {
+ sessionStorage.removeItem("teacherPassword");
+ window.location.replace("index.html");
+ return;
+ }
+ renderAnalytics(await response.json());
+ } catch {
+ $("message").textContent = "Не удалось обновить аналитику";
+ $("message").className = "message error";
+ }
+}
+
+loadAnalytics();
+setInterval(loadAnalytics, 10000);
diff --git a/gui/app.css b/gui/app.css
new file mode 100644
index 0000000..1837507
--- /dev/null
+++ b/gui/app.css
@@ -0,0 +1,79 @@
+:root {
+ color-scheme: dark;
+ --bg: #111827;
+ --surface: #1f2937;
+ --surface-hover: #273449;
+ --text: #f3f4f6;
+ --muted: #aeb8c8;
+ --line: #39475c;
+ --accent: #7c9cff;
+ --accent-hover: #95adff;
+ --danger: #ff8f8f;
+}
+* { box-sizing: border-box; }
+html { min-height: 100%; background: radial-gradient(circle at top, #202b42 0, var(--bg) 48%); }
+body {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 36px 24px;
+ color: var(--text);
+ background: transparent;
+ font: 17px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
+}
+header, main, footer { animation: appear .25s ease-out; }
+header { margin-bottom: 28px; }
+h1, h2, h3 { line-height: 1.2; color: #fff; }
+h1 { font-size: clamp(2rem, 5vw, 3.2rem); margin-bottom: 12px; }
+h2 { margin-top: 32px; }
+p { color: var(--muted); }
+a { color: var(--accent); text-decoration: none; }
+a:hover { color: var(--accent-hover); }
+button, input, select, textarea { font: inherit; transition: border-color .18s ease, background .18s ease, transform .18s ease, box-shadow .18s ease; }
+button {
+ border: 1px solid #7087d9;
+ border-radius: 10px;
+ padding: 10px 18px;
+ background: #536fc9;
+ color: white;
+ cursor: pointer;
+ box-shadow: 0 5px 16px rgba(0,0,0,.18);
+}
+button:hover:not(:disabled) { transform: translateY(-1px); background: #617ed8; box-shadow: 0 8px 22px rgba(0,0,0,.25); }
+button:disabled { opacity: .45; cursor: default; }
+input, select, textarea {
+ width: 100%;
+ max-width: 560px;
+ margin: 6px 0 16px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 10px 12px;
+ background: var(--surface);
+ color: var(--text);
+}
+input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124,156,255,.2); }
+label { display: block; margin-top: 14px; color: var(--text); }
+article, fieldset {
+ margin: 20px 0;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ padding: 20px;
+ background: rgba(31,41,55,.86);
+ box-shadow: 0 8px 28px rgba(0,0,0,.15);
+}
+fieldset label { display: flex; align-items: center; gap: 9px; }
+fieldset input[type=radio], fieldset input[type=checkbox], #topics-container input { width: auto; margin: 0; }
+#topics-container div { margin: 8px 0; }
+hr { border: 0; border-top: 1px solid var(--line); margin: 32px 0 20px; }
+footer { text-align: center; color: var(--muted); }
+kbd { border: 1px solid var(--line); border-radius: 8px; padding: 5px 9px; background: var(--surface); color: var(--muted); }
+dialog { border: 1px solid var(--line); border-radius: 14px; padding: 24px; min-width: min(420px, 90vw); background: var(--surface); color: var(--text); box-shadow: 0 24px 70px rgba(0,0,0,.55); }
+dialog::backdrop { background: rgba(3,7,18,.72); backdrop-filter: blur(3px); }
+dialog form { margin-bottom: 0; }
+.dialog-actions { display: flex; gap: 10px; margin-top: 16px; }
+.error { color: var(--danger); }
+small { color: var(--muted); }
+@keyframes appear { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
+@media (max-width: 650px) {
+ body { padding: 24px 16px; }
+ button { width: 100%; margin: 4px 0; }
+}
diff --git a/gui/index.html b/gui/index.html
index e61114c..fe4580a 100644
--- a/gui/index.html
+++ b/gui/index.html
@@ -1,56 +1,42 @@
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
- Тестирование
+
+
+
+
+
+ Тестирование
-
-
-
-
- Найдите себя и нажмите получить тест:
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ Найдите себя и нажмите «Получить тест»:
+
+
+
+
+
+
+
+
+
-
-
\ No newline at end of file
+
diff --git a/gui/main.js b/gui/main.js
index 7829398..39ba4b4 100644
--- a/gui/main.js
+++ b/gui/main.js
@@ -1,140 +1,267 @@
-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 = ""
- document.getElementById("header").innerHTML = `Тестирование
${students_selector.options[students_selector.selectedIndex].text}
`
- document.getElementById("main").innerHTML = questions_html
-
- var button = document.createElement('button');
- button.style.margin = "20px"
- button.innerHTML = 'Сдать тест';
- button.onclick = function () {
- // 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 = "Тестирование окончено
"
- };
- // 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))
- })
-
- document.getElementById("check-questions").addEventListener("click", function() {
- fetch('/check_questions')
- .then(r => r.text())
- .then(text => window.alert(text))
- })
-})
\ No newline at end of file
+const ACTIVE_QUIZ_KEY = "activeQuiz";
+
+document.addEventListener("DOMContentLoaded", async function () {
+ const studentsSelector = document.getElementById("students-selector");
+ const getQuizButton = document.getElementById("get-quiz");
+ const savedAttempt = loadAttempt();
+
+ if (savedAttempt && await isAttemptSubmitted(savedAttempt)) {
+ sessionStorage.removeItem(ACTIVE_QUIZ_KEY);
+ loadStartPage(studentsSelector, getQuizButton);
+ } else if (savedAttempt) {
+ renderQuiz(savedAttempt);
+ } else {
+ loadStartPage(studentsSelector, getQuizButton);
+ }
+
+ setupTeacherLogin();
+});
+
+async function isAttemptSubmitted(attempt) {
+ const attemptId = attempt.quiz && attempt.quiz.attempt_id;
+ if (!attemptId) return false;
+ try {
+ const response = await fetch(`/submission_status/${encodeURIComponent(attemptId)}`, { cache: "no-store" });
+ return response.ok && (await response.json()).submitted === true;
+ } catch {
+ return false;
+ }
+}
+
+function loadAttempt() {
+ try {
+ const attempt = JSON.parse(sessionStorage.getItem(ACTIVE_QUIZ_KEY));
+ if (!attempt || !attempt.quiz || (!attempt.endTimeMs && !attempt.endTime)) return null;
+ attempt.endTimeMs = attempt.endTimeMs || attempt.endTime;
+ attempt.serverOffsetMs = attempt.serverOffsetMs || 0;
+ return attempt;
+ } catch {
+ sessionStorage.removeItem(ACTIVE_QUIZ_KEY);
+ return null;
+ }
+}
+
+function saveAttempt(attempt) {
+ sessionStorage.setItem(ACTIVE_QUIZ_KEY, JSON.stringify(attempt));
+}
+
+function loadStartPage(studentsSelector, getQuizButton) {
+ fetch("/hostip").then(r => r.json()).then(hostIp => {
+ document.getElementById("host-ip").textContent += ` ${hostIp}:8000`;
+ });
+
+ fetch("/students").then(r => r.json()).then(students => {
+ students.forEach(student => {
+ const option = document.createElement("option");
+ option.value = student.id;
+ option.textContent = student.name;
+ studentsSelector.appendChild(option);
+ });
+ studentsSelector.addEventListener("change", () => { getQuizButton.disabled = false; });
+ });
+
+ getQuizButton.addEventListener("click", function () {
+ fetch("/get_quiz?" + new URLSearchParams({
+ student_id: studentsSelector.value,
+ student: studentsSelector.options[studentsSelector.selectedIndex].text
+ })).then(async response => {
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.detail || "Не удалось получить тест");
+ return data;
+ }).then(quiz => {
+ const attempt = {
+ quiz,
+ answers: {},
+ endTimeMs: quiz.end_time_ms,
+ serverOffsetMs: quiz.server_time_ms - Date.now()
+ };
+ saveAttempt(attempt);
+ renderQuiz(attempt);
+ }).catch(error => alert(error.message));
+ });
+}
+
+function renderQuiz(attempt) {
+ const { quiz } = attempt;
+ document.getElementById("header").innerHTML = `Тестирование
${escapeHtml(quiz.student)}
`;
+ const main = document.getElementById("main");
+ main.innerHTML = "";
+
+ const form = document.createElement("form");
+ form.id = "form";
+ form.addEventListener("keydown", event => {
+ if (event.key === "Enter") event.preventDefault();
+ });
+
+ quiz.questions.forEach(question => {
+ const article = document.createElement("article");
+ const title = document.createElement("h3");
+ title.textContent = question.question;
+ article.appendChild(title);
+ if (question.picture) {
+ const image = document.createElement("img");
+ image.src = question.picture;
+ article.appendChild(image);
+ }
+
+ if (question.options) {
+ const fieldset = document.createElement("fieldset");
+ const legend = document.createElement("legend");
+ legend.textContent = "Выберите ответ:";
+ fieldset.appendChild(legend);
+ question.options.forEach((option, index) => {
+ const label = document.createElement("label");
+ const input = document.createElement("input");
+ input.type = question.is_multiple ? "checkbox" : "radio";
+ input.name = String(question.id);
+ input.value = option;
+ input.id = `q-${question.id}-${index}`;
+ const saved = attempt.answers[String(question.id)];
+ input.checked = Array.isArray(saved) ? saved.includes(option) : saved === option;
+ label.htmlFor = input.id;
+ label.append(input, document.createTextNode(option));
+ fieldset.appendChild(label);
+ });
+ article.appendChild(fieldset);
+ } else {
+ const input = document.createElement("input");
+ input.type = "text";
+ input.autocomplete = "off";
+ input.name = String(question.id);
+ input.value = attempt.answers[String(question.id)] || "";
+ article.appendChild(input);
+ }
+ form.appendChild(article);
+ });
+
+ form.addEventListener("input", () => persistAnswers(attempt, form));
+ form.addEventListener("change", () => persistAnswers(attempt, form));
+ main.appendChild(form);
+
+ const timer = document.createElement("div");
+ main.appendChild(timer);
+ let submitted = false;
+ const showTime = () => {
+ const correctedNow = Date.now() + attempt.serverOffsetMs;
+ const seconds = Math.max(0, Math.ceil((attempt.endTimeMs - correctedNow) / 1000));
+ timer.textContent = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
+ if (seconds === 0 && !submitted) submitQuiz();
+ };
+ showTime();
+ const timerInterval = setInterval(showTime, 1000);
+
+ const sendHeartbeat = () => {
+ persistAnswers(attempt, form);
+ const requestStartedAt = Date.now();
+ fetch("/quiz_heartbeat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ student_id: quiz.student_id,
+ student: quiz.student,
+ start_time: quiz.start_time,
+ total: quiz.questions.length,
+ test_time: quiz.test_time,
+ end_time_ms: attempt.endTimeMs,
+ answers: answerList(attempt)
+ })
+ }).then(response => response.json()).then(status => {
+ const requestFinishedAt = Date.now();
+ if (status.server_time_ms) {
+ const localMidpoint = (requestStartedAt + requestFinishedAt) / 2;
+ attempt.serverOffsetMs = status.server_time_ms - localMidpoint;
+ }
+ if (status.end_time_ms) attempt.endTimeMs = status.end_time_ms;
+ saveAttempt(attempt);
+ if (status.expired && !submitted) submitQuiz();
+ }).catch(() => {});
+ };
+ sendHeartbeat();
+ const heartbeatInterval = setInterval(sendHeartbeat, 10000);
+
+ const submitButton = document.createElement("button");
+ submitButton.textContent = "Сдать тест";
+ submitButton.style.margin = "20px";
+ submitButton.type = "button";
+ submitButton.addEventListener("click", submitQuiz);
+ main.appendChild(submitButton);
+ if (attempt.submissionPending) {
+ setTimeout(submitQuiz, 0);
+ }
+
+ function submitQuiz() {
+ if (submitted) return;
+ submitted = true;
+ persistAnswers(attempt, form);
+ attempt.submissionPending = true;
+ saveAttempt(attempt);
+ quiz.questions.forEach(question => {
+ question.student_answer = attempt.answers[String(question.id)] ?? (question.is_multiple ? [] : "");
+ });
+ fetch("/save_student_answers", { method: "POST", body: JSON.stringify(quiz) }).then(async response => {
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}));
+ throw new Error(data.detail || "Не удалось сохранить результат");
+ }
+ sessionStorage.removeItem(ACTIVE_QUIZ_KEY);
+ clearInterval(timerInterval);
+ clearInterval(heartbeatInterval);
+ main.innerHTML = "";
+ const message = document.createElement("p");
+ message.textContent = "Тестирование окончено";
+ const nextButton = document.createElement("button");
+ nextButton.textContent = "Начать тест для следующего пользователя";
+ nextButton.addEventListener("click", () => window.location.reload());
+ main.append(message, nextButton);
+ }).catch(error => {
+ submitted = false;
+ alert(`${error.message}. Результат будет отправлен повторно после обновления страницы.`);
+ });
+ }
+}
+
+function persistAnswers(attempt, form) {
+ attempt.quiz.questions.forEach(question => {
+ const field = form.elements.namedItem(String(question.id));
+ const inputs = Array.from(field ? (field.length === undefined ? [field] : field) : []);
+ const selected = inputs.filter(input => ["radio", "checkbox"].includes(input.type) && input.checked).map(input => input.value);
+ const text = inputs.find(input => !["radio", "checkbox"].includes(input.type));
+ attempt.answers[String(question.id)] = question.is_multiple ? selected : (selected[0] || (text ? text.value : ""));
+ });
+ saveAttempt(attempt);
+}
+
+function answerList(attempt) {
+ return attempt.quiz.questions.map(question => ({
+ id: question.id,
+ answer: attempt.answers[String(question.id)] ?? (question.is_multiple ? [] : "")
+ }));
+}
+
+function setupTeacherLogin() {
+ const dialog = document.getElementById("teacher-login");
+ document.getElementById("teacher-page").addEventListener("click", function () {
+ document.getElementById("teacher-login-error").textContent = "";
+ dialog.showModal();
+ document.getElementById("teacher-password").focus();
+ });
+ document.getElementById("teacher-login-cancel").addEventListener("click", () => dialog.close());
+ document.getElementById("teacher-login-form").addEventListener("submit", function (event) {
+ event.preventDefault();
+ const password = document.getElementById("teacher-password").value;
+ fetch("/get_config?password=" + encodeURIComponent(password)).then(r => r.json()).then(data => {
+ if (data.error) {
+ document.getElementById("teacher-login-error").textContent = "Неверный пароль";
+ return;
+ }
+ sessionStorage.setItem("teacherPassword", password);
+ window.location.href = "teacher.html";
+ }).catch(() => {
+ document.getElementById("teacher-login-error").textContent = "Не удалось проверить пароль";
+ });
+ });
+}
+
+function escapeHtml(value) {
+ return String(value ?? "").replace(/[&<>"']/g, char => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[char]));
+}
diff --git a/gui/monitor.html b/gui/monitor.html
new file mode 100644
index 0000000..653daa6
--- /dev/null
+++ b/gui/monitor.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Монитор тестирования
+
+
+
+
+
+
+
+ Сейчас проходят тест 0
+ | Студент | Начало | Заполнено | Правильно | Неправильно | Осталось | Связь |
|---|
| Загрузка... |
+
+
+ Завершённые тесты
+ | Студент | Результат | Оценка | Окончание |
|---|
| Загрузка... |
+
+
+
+
diff --git a/gui/monitor.js b/gui/monitor.js
new file mode 100644
index 0000000..94f2623
--- /dev/null
+++ b/gui/monitor.js
@@ -0,0 +1,58 @@
+const password = sessionStorage.getItem("teacherPassword") || "";
+const $ = id => document.getElementById(id);
+if (!password) window.location.replace("index.html");
+
+function formatDate(value) {
+ if (!value) return "Нет данных";
+ const normalized = String(value).replace(/T(\d{2})-(\d{2})-(\d{2})$/, "T$1:$2:$3");
+ const date = new Date(normalized);
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString("ru-RU");
+}
+function formatRemaining(attempt) {
+ const seconds = Math.max(0, attempt.remaining_seconds || 0);
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
+}
+function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, char => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[char])); }
+
+async function loadMonitor() {
+ try {
+ const [activeResponse, completedResponse, configResponse] = await Promise.all([
+ fetch(`/teacher/active_attempts?password=${encodeURIComponent(password)}`),
+ fetch(`/teacher/results?password=${encodeURIComponent(password)}`),
+ fetch(`/get_config?password=${encodeURIComponent(password)}`)
+ ]);
+ if (!activeResponse.ok || !completedResponse.ok || !configResponse.ok) {
+ sessionStorage.removeItem("teacherPassword");
+ window.location.replace("index.html");
+ return;
+ }
+ const active = await activeResponse.json();
+ const completed = await completedResponse.json();
+ const config = await configResponse.json();
+ if (config.error) {
+ sessionStorage.removeItem("teacherPassword");
+ window.location.replace("index.html");
+ return;
+ }
+ const gradeThresholds = config.grade_thresholds || {"3": 52, "4": 68, "5": 84};
+ $("active-count").textContent = active.length;
+ $("updated").textContent = `Обновлено: ${new Date().toLocaleTimeString("ru-RU")}`;
+ $("active-rows").innerHTML = active.length ? active.map(attempt => `| ${escapeHtml(attempt.student)} | ${formatDate(attempt.start_time)} | ${attempt.answered} из ${attempt.total} | ${attempt.correct} | ${attempt.incorrect} | ${formatRemaining(attempt)} | на связи |
`).join("") : '| Сейчас никто не проходит тест. |
';
+ $("completed-rows").innerHTML = completed.length ? completed.map(result => {
+ const grade = getGrade(result.correct_percent, gradeThresholds);
+ return `| ${escapeHtml(result.student)} | ${result.correct_percent}% | ${grade} | ${formatDate(result.end_time)} |
`;
+ }).join("") : '| Завершённых тестов пока нет. |
';
+ } catch (error) {
+ $("updated").textContent = "Не удалось обновить монитор";
+ }
+}
+loadMonitor();
+setInterval(loadMonitor, 5000);
+
+function getGrade(percent, thresholds) {
+ const score = Number(percent) || 0;
+ if (score >= Number(thresholds["5"])) return 5;
+ if (score >= Number(thresholds["4"])) return 4;
+ if (score >= Number(thresholds["3"])) return 3;
+ return 2;
+}
diff --git a/gui/questions.html b/gui/questions.html
new file mode 100644
index 0000000..af6cd2d
--- /dev/null
+++ b/gui/questions.html
@@ -0,0 +1,29 @@
+
+
+
+
+ Редактор вопросов
+
+
+
+
+
+
+
+
+
+
diff --git a/gui/questions.js b/gui/questions.js
new file mode 100644
index 0000000..d964d25
--- /dev/null
+++ b/gui/questions.js
@@ -0,0 +1,30 @@
+const password = sessionStorage.getItem("teacherPassword") || "";
+let questions = [], selectedId = null;
+const $ = id => document.getElementById(id);
+const api = path => `${path}${path.includes("?") ? "&" : "?"}password=${encodeURIComponent(password || "")}`;
+
+function showMessage(text, error=false) { $("message").textContent=text; $("message").className=error?"message error":"message"; }
+async function request(path, options) { const response=await fetch(api(path),options); const data=await response.json(); if(!response.ok) throw new Error(data.detail||"Ошибка запроса"); return data; }
+function answerType(q) { return Array.isArray(q.answer)?"multiple":q.options?"single":"text"; }
+function renderList() {
+ const query=$("search").value.trim().toLowerCase();
+ const filtered=questions.filter(q=>[q.question,q.topic,q.author].some(v=>(v||"").toLowerCase().includes(query)));
+ $("count").textContent=`Вопросов: ${questions.length} · включено: ${questions.filter(q=>q.enabled!==false).length}`; $("question-list").innerHTML="";
+ if(!filtered.length){$("question-list").innerHTML='Вопросы не найдены
';return;}
+ filtered.forEach(q=>{const button=document.createElement("button");button.type="button";button.className=`list-item${q.id===selectedId?" active":""}${q.enabled===false?" disabled-question":""}`;button.textContent=q.question;const meta=document.createElement("small");meta.textContent=`${q.enabled===false?"Выключен · ":""}${q.topic||"Без темы"} · ${answerType(q)==="text"?"текст":"варианты"}`;button.appendChild(meta);button.onclick=()=>editQuestion(q.id);$("question-list").appendChild(button);});
+}
+function addOption(value="",checked=false) {
+ const row=document.createElement("div");row.className="option-row";
+ const choice=document.createElement("input");choice.className="correct-option";choice.type=$("answer-type").value==="multiple"?"checkbox":"radio";choice.name="correct-option";choice.checked=checked;
+ const input=document.createElement("input");input.className="option-value";input.value=value;input.placeholder="Вариант ответа";input.required=true;
+ const remove=document.createElement("button");remove.type="button";remove.className="danger";remove.textContent="×";remove.onclick=()=>row.remove();
+ row.append(choice,input,remove);$("options").appendChild(row);
+}
+function updateType(clear=true){const isText=$("answer-type").value==="text";$("text-answer-wrap").hidden=!isText;$("options-wrap").hidden=isText;if(!isText&&clear){$("options").innerHTML="";addOption();addOption();}}
+function resetForm(){selectedId=null;$("question-form").reset();$("enabled").checked=true;$("options").innerHTML="";$("form-title").textContent="Новый вопрос";$("delete-question").hidden=true;updateType(false);showMessage("");renderList();$("question").focus();}
+function editQuestion(id){const q=questions.find(item=>item.id===id);selectedId=id;$("form-title").textContent=`Вопрос №${id}`;$("question").value=q.question||"";$("topic").value=q.topic||"";$("author").value=q.author||"";$("picture").value=q.picture||"";$("enabled").checked=q.enabled!==false;$("answer-type").value=answerType(q);$("text-answer").value=answerType(q)==="text"?q.answer:"";$("options").innerHTML="";(q.options||[]).forEach(option=>addOption(option,Array.isArray(q.answer)?q.answer.includes(option):q.answer===option));updateType(false);$("delete-question").hidden=false;showMessage("");renderList();}
+function formData(){const type=$("answer-type").value,rows=[...document.querySelectorAll(".option-row")],options=rows.map(r=>r.querySelector(".option-value").value.trim()).filter(Boolean);let answer=$("text-answer").value.trim();if(type==="single")answer=rows.find(r=>r.querySelector(".correct-option").checked)?.querySelector(".option-value").value.trim()||"";if(type==="multiple")answer=rows.filter(r=>r.querySelector(".correct-option").checked).map(r=>r.querySelector(".option-value").value.trim());return{question:$("question").value,topic:$("topic").value,author:$("author").value,picture:$("picture").value,enabled:$("enabled").checked,answer_type:type,options,answer};}
+async function loadQuestions(){if(!password){window.location.replace("index.html");return;}try{questions=await request("/teacher/questions");$("topics").replaceChildren(...[...new Set(questions.map(q=>q.topic).filter(Boolean))].map(t=>{const option=document.createElement("option");option.value=t;return option;}));renderList();showMessage("");}catch(error){sessionStorage.removeItem("teacherPassword");window.location.replace("index.html");}}
+$("question-form").onsubmit=async event=>{event.preventDefault();try{const path=selectedId?`/teacher/questions/${selectedId}`:"/teacher/questions";await request(path,{method:selectedId?"PUT":"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(formData())});await loadQuestions();resetForm();showMessage("Вопрос сохранён");}catch(error){showMessage(error.message,true);}};
+$("delete-question").onclick=async()=>{if(!selectedId||!confirm("Удалить этот вопрос?"))return;try{await request(`/teacher/questions/${selectedId}`,{method:"DELETE"});await loadQuestions();resetForm();showMessage("Вопрос удалён");}catch(error){showMessage(error.message,true);}};
+$("answer-type").onchange=()=>updateType(true);$("add-option").onclick=()=>addOption();$("new-question").onclick=resetForm;$("search").oninput=renderList;loadQuestions();
diff --git a/gui/results.html b/gui/results.html
new file mode 100644
index 0000000..7921a8c
--- /dev/null
+++ b/gui/results.html
@@ -0,0 +1,8 @@
+
+
+Неправильные ответы
+
+
+
+
+
diff --git a/gui/results.js b/gui/results.js
new file mode 100644
index 0000000..a080be0
--- /dev/null
+++ b/gui/results.js
@@ -0,0 +1,19 @@
+const password=sessionStorage.getItem("teacherPassword")||"";let results=[];
+const $=id=>document.getElementById(id);
+function formatAnswer(value){if(Array.isArray(value))return value.length?value.join(", "):"Нет ответа";return value||"Нет ответа";}
+function formatDate(value){
+ if(!value)return "Дата неизвестна";
+ const normalized=String(value).replace(/T(\d{2})-(\d{2})-(\d{2})$/,"T$1:$2:$3");
+ const date=new Date(normalized);
+ return Number.isNaN(date.getTime())?value:date.toLocaleString("ru-RU");
+}
+function escapeHtml(value){return String(value??"").replace(/[&<>"']/g,char=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[char]));}
+function render(){
+ const student=$("student-filter").value.trim().toLowerCase(),topic=$("topic-filter").value;
+ const filtered=results.filter(r=>(r.student||"").toLowerCase().includes(student)).map(r=>({...r,incorrect:r.incorrect.filter(q=>!topic||q.topic===topic)})).filter(r=>r.incorrect.length);
+ $("summary").textContent=`Работ с ошибками: ${filtered.length} · Ошибок: ${filtered.reduce((sum,r)=>sum+r.incorrect.length,0)}`;$("results").innerHTML="";
+ if(!filtered.length){$("results").innerHTML='По выбранным фильтрам неправильных ответов нет.
';return;}
+ filtered.forEach(result=>{const card=document.createElement("details");card.className="card result-card";card.innerHTML=`${escapeHtml(result.student)}
${escapeHtml(formatDate(result.end_time))} · ${result.correct}/${result.total} правильных${result.incorrect.length} ошибок · ${result.correct_percent}%`;result.incorrect.forEach(q=>{const block=document.createElement("div");block.className="incorrect";block.innerHTML=`${escapeHtml(q.question)}${escapeHtml(q.topic||"Без темы")}
Ответ студента
${escapeHtml(formatAnswer(q.student_answer))}
Правильный ответ
${escapeHtml(formatAnswer(q.correct_answer))}
`;card.appendChild(block);});$("results").appendChild(card);});
+}
+async function loadResults(){if(!password){window.location.replace("index.html");return;}$("message").textContent="Загрузка...";const response=await fetch(`/teacher/results?password=${encodeURIComponent(password||"")}`),data=await response.json();if(!response.ok){sessionStorage.removeItem("teacherPassword");window.location.replace("index.html");return;}results=data;const topics=[...new Set(results.flatMap(r=>r.incorrect.map(q=>q.topic)).filter(Boolean))].sort(),all=document.createElement("option");all.value="";all.textContent="Все темы";$("topic-filter").replaceChildren(all,...topics.map(t=>{const option=document.createElement("option");option.value=t;option.textContent=t;return option;}));$("message").textContent="";$("message").className="message";render();}
+$("student-filter").oninput=render;$("topic-filter").onchange=render;$("reload").onclick=loadResults;loadResults();
diff --git a/gui/settings.html b/gui/settings.html
new file mode 100644
index 0000000..92b0ae7
--- /dev/null
+++ b/gui/settings.html
@@ -0,0 +1,155 @@
+
+
+
+
+ Настройки теста
+
+
+
+ Страница преподавателя · Редактор вопросов · Неправильные ответы студентов
+
+
+
+ Настройки теста
+
+
+
+
+ Обновить список студентов
+ Можно загрузить файл .json или .csv.
+ Поддерживаются форматы CSV:
+
+ - один столбец с ФИО;
+ - столбец
name;
+ - столбцы
id и name.
+
+ Если готовите файл в Excel: сохраните его как CSV UTF-8.
+
+
+
+ Назад к тестированию
+
+
+
+
diff --git a/gui/summary.txt b/gui/summary.txt
new file mode 100644
index 0000000..b5cda70
--- /dev/null
+++ b/gui/summary.txt
@@ -0,0 +1,4 @@
+Барбарис Корвалол,33,2026-06-08T11-01-04
+Боброслав Купидон,50,2026-06-08T11-06-39
+Будапешт Казантип,0,2026-06-21T13-56-22
+Боброслав Купидон,0,2026-06-21T13-56-50
diff --git a/gui/teacher.css b/gui/teacher.css
new file mode 100644
index 0000000..8e9c1b0
--- /dev/null
+++ b/gui/teacher.css
@@ -0,0 +1,71 @@
+:root { color-scheme: light; --accent:#3157c8; --danger:#b42318; --muted:#667085; --line:#d0d5dd; --surface:#fff; --background:#f5f7fb; }
+* { box-sizing:border-box; }
+body { margin:0; background:var(--background); color:#101828; font-family:system-ui,-apple-system,"Segoe UI",sans-serif; }
+header, main { max-width:1180px; margin:auto; padding:24px; }
+header { display:flex; justify-content:space-between; align-items:center; gap:16px; }
+h1, h2, p { margin-top:0; }
+a { color:var(--accent); }
+button, input, select, textarea { font:inherit; }
+button { border:0; border-radius:8px; padding:10px 16px; background:var(--accent); color:white; cursor:pointer; }
+button.secondary { background:#e8edff; color:#243b80; }
+button.danger { background:#fee4e2; color:var(--danger); }
+input, select, textarea { width:100%; border:1px solid var(--line); border-radius:8px; padding:10px 12px; background:white; }
+textarea { min-height:92px; resize:vertical; }
+label { display:grid; gap:6px; color:#344054; font-weight:600; }
+.card { border:1px solid #e4e7ec; border-radius:12px; padding:20px; background:var(--surface); box-shadow:0 3px 12px rgba(16,24,40,.05); }
+.toolbar { display:flex; align-items:end; gap:12px; margin-bottom:20px; }
+.toolbar > label { flex:1; }
+.grid { display:grid; grid-template-columns:minmax(300px,1fr) minmax(380px,1.3fr); gap:20px; }
+.list { display:grid; gap:10px; max-height:72vh; overflow:auto; padding-right:4px; }
+.list-item { text-align:left; background:white; color:#101828; border:1px solid var(--line); }
+.list-item.active { border-color:var(--accent); box-shadow:0 0 0 2px #dbe4ff; }
+.list-item.disabled-question { opacity:.58; border-style:dashed; }
+.list-item small { display:block; margin-top:5px; color:var(--muted); }
+.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
+.wide { grid-column:1/-1; }
+.actions { display:flex; gap:10px; margin-top:18px; }
+.option-row { display:grid; grid-template-columns:auto 1fr auto; gap:8px; align-items:center; margin-top:8px; }
+.option-row input[type=radio], .option-row input[type=checkbox] { width:auto; }
+.enabled-control { display:flex; align-items:center; gap:10px; }
+.enabled-control input { width:auto; }
+.empty, .message, .stat { color:var(--muted); }
+.empty, .message { padding:16px 0; }
+.error { color:var(--danger); }
+.result-card { margin-bottom:14px; }
+.result-head { display:flex; justify-content:space-between; gap:12px; cursor:pointer; }
+.badge { border-radius:999px; padding:4px 9px; background:#eef2ff; color:#3538cd; font-weight:700; white-space:nowrap; }
+.incorrect { border-top:1px solid #eaecf0; margin-top:14px; padding-top:14px; }
+.answers { display:grid; grid-template-columns:1fr 1fr; gap:10px; font-size:14px; }
+.answers div { padding:10px; border-radius:8px; background:#f9fafb; }
+.answers .given { background:#fff1f0; }
+.answers .correct { background:#ecfdf3; }
+.tools-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:20px; }
+.tools-grid + .card { margin-top:20px; }
+.tool-card { display:block; min-height:170px; border:1px solid #e4e7ec; border-radius:14px; padding:24px; background:white; color:#101828; text-decoration:none; box-shadow:0 3px 12px rgba(16,24,40,.05); transition:transform .15s ease,box-shadow .15s ease,border-color .15s ease; }
+.tool-card:hover { transform:translateY(-2px); border-color:var(--accent); box-shadow:0 8px 22px rgba(16,24,40,.1); }
+.tool-card h2 { color:var(--accent); }
+.tool-card p { color:var(--muted); line-height:1.5; }
+.monitor-section { margin-top:20px; }
+.table-wrap { overflow-x:auto; }
+table { width:100%; border-collapse:collapse; }
+th,td { padding:12px; border-bottom:1px solid #eaecf0; text-align:left; }
+.success { color:#067647; }
+.fail { color:#b42318; }
+.grade-badge { display:inline-flex; align-items:center; justify-content:center; min-width:34px; border-radius:999px; padding:4px 10px; color:white; font-weight:700; }
+.grade-2 { background:#b42318; }
+.grade-3 { background:#f79009; }
+.grade-4 { background:#1570ef; }
+.grade-5 { background:#067647; }
+.metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; margin-bottom:20px; }
+.metric { border:1px solid #e4e7ec; border-radius:12px; padding:18px; background:var(--surface); box-shadow:0 3px 12px rgba(16,24,40,.05); }
+.metric span,.metric small { display:block; color:var(--muted); }
+.metric strong { display:block; margin:5px 0; color:var(--accent); font-size:28px; }
+.analytics-grid { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
+.analytics-section { margin-top:20px; }
+.bar-row { margin:14px 0; }
+.bar-label { display:flex; justify-content:space-between; gap:12px; margin-bottom:5px; }
+.bar-label span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.bar-track { height:10px; border-radius:999px; overflow:hidden; background:#eef2f6; }
+.bar-fill { height:100%; border-radius:999px; background:var(--accent); }
+.bar-fill.danger { background:#f97066; }
+@media (max-width:800px) { header,.toolbar,.result-head { align-items:stretch; flex-direction:column; } .grid,.form-grid,.answers,.analytics-grid,.metrics { grid-template-columns:1fr; } }
diff --git a/gui/teacher.html b/gui/teacher.html
new file mode 100644
index 0000000..9e2f0f8
--- /dev/null
+++ b/gui/teacher.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+ Страница преподавателя
+
+
+
+
+
+
+ Проверка доступа...
+
+
+ Управление тестированием
+
+
+
+
+
diff --git a/gui/teacher.js b/gui/teacher.js
new file mode 100644
index 0000000..92d6131
--- /dev/null
+++ b/gui/teacher.js
@@ -0,0 +1,34 @@
+const password = sessionStorage.getItem("teacherPassword") || "";
+const message = document.getElementById("access-message");
+const tools = document.getElementById("teacher-tools");
+const actions = document.getElementById("teacher-actions");
+
+if (!password) {
+ message.textContent = "Доступ не подтверждён. Вернитесь на главную страницу и войдите как преподаватель.";
+ message.className = "message error";
+} else {
+ fetch("/get_config?password=" + encodeURIComponent(password))
+ .then(response => response.json())
+ .then(data => {
+ if (data.error) {
+ sessionStorage.removeItem("teacherPassword");
+ throw new Error("Неверный пароль");
+ }
+ message.textContent = "";
+ tools.hidden = false;
+ actions.hidden = false;
+ })
+ .catch(error => {
+ message.textContent = error.message || "Не удалось проверить доступ";
+ message.className = "message error";
+ });
+}
+
+document.getElementById("end-quiz").addEventListener("click", function() {
+ if (!confirm("Завершить тестирование для всех студентов?")) {
+ return;
+ }
+ fetch("/end_quiz?password=" + encodeURIComponent(password))
+ .then(response => response.json())
+ .then(text => alert(text));
+});
diff --git a/main.py b/main.py
index 9d9b4a0..7af4801 100644
--- a/main.py
+++ b/main.py
@@ -1,185 +1,853 @@
-from fastapi import FastAPI, Body
-from fastapi.middleware.cors import CORSMiddleware # CORS
+from fastapi import FastAPI, Body, UploadFile, HTTPException
+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
+import time
+import hashlib
+import threading
+import uuid
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,
+ "activity_timeout": 30,
+ "grade_thresholds": {"3": 52, "4": 68, "5": 84}
+ }
+
+
+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 backup_questions_file():
+ path = Path(QUESTIONS_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-%f")
+ shutil.copy2(path, backup_dir / f"questions_{timestamp}.json")
+
+
+def require_teacher(password: str):
+ if password != END_TEST_PASSWORD:
+ raise HTTPException(status_code=403, detail="Неверный пароль")
+
+
+def prepare_questions(questions: list) -> list:
+ existing_ids = {
+ q["id"] for q in questions
+ if isinstance(q.get("id"), int) and not isinstance(q.get("id"), bool)
+ }
+ next_id = max(existing_ids, default=0) + 1
+ used_ids = set()
+ prepared = []
+
+ for question in questions:
+ item = dict(question)
+ if (
+ not isinstance(item.get("id"), int)
+ or isinstance(item.get("id"), bool)
+ or item["id"] in used_ids
+ ):
+ while next_id in used_ids:
+ next_id += 1
+ item["id"] = next_id
+ next_id += 1
+ used_ids.add(item["id"])
+ item["is_multiple"] = isinstance(item.get("answer"), list)
+ item["enabled"] = item.get("enabled", True) is not False
+ prepared.append(item)
+ return prepared
+
+
+def save_questions():
+ data = {
+ "version": questions_json.get("version", 2),
+ "questions": [
+ {key: value for key, value in q.items() if key != "is_multiple"}
+ for q in all_questions
+ ]
+ }
+ with open(QUESTIONS_FILE, "w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+
+
+def validate_question(data: dict, question_id=None) -> dict:
+ question = str(data.get("question", "")).strip()
+ if not question:
+ raise HTTPException(status_code=400, detail="Введите текст вопроса")
+
+ answer_type = data.get("answer_type", "text")
+ options = [str(x).strip() for x in data.get("options", []) if str(x).strip()]
+ raw_answer = data.get("answer", "")
+
+ if answer_type == "multiple":
+ answer = [str(x).strip() for x in raw_answer if str(x).strip()] if isinstance(raw_answer, list) else []
+ if len(options) < 2 or not answer:
+ raise HTTPException(status_code=400, detail="Добавьте варианты и отметьте правильные ответы")
+ if any(value not in options for value in answer):
+ raise HTTPException(status_code=400, detail="Правильные ответы должны входить в список вариантов")
+ elif answer_type == "single":
+ answer = str(raw_answer).strip()
+ if len(options) < 2 or answer not in options:
+ raise HTTPException(status_code=400, detail="Выберите правильный ответ из списка вариантов")
+ else:
+ answer = str(raw_answer).strip()
+ options = []
+ if not answer:
+ raise HTTPException(status_code=400, detail="Введите правильный ответ")
+
+ result = {
+ "id": question_id,
+ "topic": str(data.get("topic", "")).strip(),
+ "author": str(data.get("author", "")).strip(),
+ "question": question,
+ "answer": answer,
+ "enabled": data.get("enabled", True) is not False,
+ "is_multiple": isinstance(answer, list)
+ }
+ picture = str(data.get("picture", "")).strip()
+ if picture:
+ result["picture"] = picture
+ if options:
+ result["options"] = options
+ return result
+
+
+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' должны быть числа.")
-origins = [ # CORS
- "*",
-]
+ students_list.append({
+ "id": student_id,
+ "name": normalize_name(student_name)
+ })
-app.add_middleware( # CORS
+ # Вариант 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
+
+ student_name = row[name_idx]
+ if not str(student_name).strip():
+ continue
+
+ 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()
+config.setdefault("activity_timeout", 30)
+config.setdefault("grade_thresholds", {"3": 52, "4": 68, "5": 84})
+submission_lock = threading.Lock()
+
+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 = prepare_questions(questions_json["questions"])
+ typed_questions = 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)
+active_attempts = {}
-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") and q.get("enabled", True)))
- 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("/teacher/questions")
+def get_teacher_questions(password: str):
+ require_teacher(password)
+ return sorted(all_questions, key=lambda q: (q.get("topic", "").casefold(), q["id"]))
-@app.get("/hostip")
-def show_host_ip():
- """Возвращает IP-адрес компьютера, на котором запущен сервер тестирования, — к этому IP-адресу нужно подключаться с компьютеров пользователей
+@app.post("/teacher/questions")
+def create_question(password: str, data: dict = Body()):
+ require_teacher(password)
+ next_id = max((q["id"] for q in all_questions), default=0) + 1
+ question = validate_question(data, next_id)
+ backup_questions_file()
+ all_questions.append(question)
+ save_questions()
+ return question
+
+
+@app.put("/teacher/questions/{question_id}")
+def update_question(question_id: int, password: str, data: dict = Body()):
+ require_teacher(password)
+ index = next((i for i, q in enumerate(all_questions) if q["id"] == question_id), None)
+ if index is None:
+ raise HTTPException(status_code=404, detail="Вопрос не найден")
+ question = validate_question(data, question_id)
+ backup_questions_file()
+ all_questions[index] = question
+ save_questions()
+ return question
+
+
+@app.delete("/teacher/questions/{question_id}")
+def delete_question(question_id: int, password: str):
+ require_teacher(password)
+ index = next((i for i, q in enumerate(all_questions) if q["id"] == question_id), None)
+ if index is None:
+ raise HTTPException(status_code=404, detail="Вопрос не найден")
+ backup_questions_file()
+ deleted = all_questions.pop(index)
+ save_questions()
+ return {"deleted": deleted["id"]}
+
+
+@app.get("/teacher/results")
+def get_teacher_results(password: str):
+ require_teacher(password)
+ results = []
+ for path in Path("results").glob("*.json"):
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ content = json.load(f)
+ incorrect = [
+ {
+ "id": q.get("id"),
+ "topic": q.get("topic", ""),
+ "question": q.get("question", ""),
+ "student_answer": q.get("student_answer"),
+ "correct_answer": q.get("correct_answer")
+ }
+ for q in content.get("questions", [])
+ if not q.get("is_correct", False)
+ ]
+ results.append({
+ "student": content.get("student", ""),
+ "student_id": content.get("student_id"),
+ "correct": content.get("correct", 0),
+ "total": len(content.get("questions", [])),
+ "correct_percent": content.get("correct_percent", 0),
+ "start_time": content.get("start_time"),
+ "end_time": content.get("end_time"),
+ "incorrect": incorrect
+ })
+ except (OSError, json.JSONDecodeError):
+ continue
+ return sorted(results, key=lambda x: x.get("end_time") or "", reverse=True)
+
+
+@app.get("/teacher/analytics")
+def get_teacher_analytics(password: str):
+ require_teacher(password)
+ attempts = []
+ question_stats = {}
+ topic_stats = {}
+
+ for path in Path("results").glob("*.json"):
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ result = json.load(f)
+ except (OSError, json.JSONDecodeError):
+ continue
+
+ questions = result.get("questions", [])
+ if not questions:
+ continue
+ attempts.append({
+ "student": result.get("student", ""),
+ "percent": result.get("correct_percent", 0),
+ "correct": result.get("correct", 0),
+ "total": len(questions),
+ "end_time": result.get("end_time")
+ })
+
+ for question in questions:
+ question_id = str(question.get("id", question.get("question", "")))
+ topic = question.get("topic") or "Без темы"
+ q_stat = question_stats.setdefault(question_id, {
+ "id": question.get("id"),
+ "question": question.get("question", ""),
+ "topic": topic,
+ "attempts": 0,
+ "correct": 0
+ })
+ t_stat = topic_stats.setdefault(topic, {
+ "topic": topic,
+ "attempts": 0,
+ "correct": 0
+ })
+ q_stat["attempts"] += 1
+ t_stat["attempts"] += 1
+ if question.get("is_correct", False):
+ q_stat["correct"] += 1
+ t_stat["correct"] += 1
+
+ scores = [attempt["percent"] for attempt in attempts]
+ sorted_scores = sorted(scores)
+ median_score = (
+ sorted_scores[len(sorted_scores) // 2]
+ if len(sorted_scores) % 2 == 1
+ else round(sum(sorted_scores[len(sorted_scores) // 2 - 1:len(sorted_scores) // 2 + 1]) / 2, 1)
+ ) if sorted_scores else 0
+
+ def with_accuracy(stat):
+ attempts_count = stat["attempts"]
+ accuracy = round(stat["correct"] * 100 / attempts_count) if attempts_count else 0
+ return {
+ **stat,
+ "incorrect": attempts_count - stat["correct"],
+ "accuracy": accuracy
+ }
+
+ questions = sorted(
+ (with_accuracy(stat) for stat in question_stats.values()),
+ key=lambda stat: (stat["accuracy"], -stat["attempts"], stat["question"])
+ )
+ topics = sorted(
+ (with_accuracy(stat) for stat in topic_stats.values()),
+ key=lambda stat: (stat["accuracy"], -stat["attempts"], stat["topic"])
+ )
+ students = sorted(attempts, key=lambda attempt: (-attempt["percent"], attempt["student"]))
+ distribution = [
+ {"label": "0–49%", "count": sum(score < 50 for score in scores)},
+ {"label": "50–69%", "count": sum(50 <= score < 70 for score in scores)},
+ {"label": "70–84%", "count": sum(70 <= score < 85 for score in scores)},
+ {"label": "85–100%", "count": sum(score >= 85 for score in scores)}
+ ]
+
+ return {
+ "summary": {
+ "attempts": len(attempts),
+ "students": len({attempt["student"] for attempt in attempts if attempt["student"]}),
+ "average_score": round(sum(scores) / len(scores), 1) if scores else 0,
+ "median_score": median_score,
+ "pass_rate": round(sum(score >= 50 for score in scores) * 100 / len(scores)) if scores else 0
+ },
+ "distribution": distribution,
+ "students": students,
+ "questions": questions,
+ "topics": topics
+ }
- 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
@app.get("/students")
def show_students():
- """Список студентов
-
- Returns:
- JSON со списком студентов
- """
return students
+
+@app.post("/quiz_heartbeat")
+def quiz_heartbeat(data: dict = Body()):
+ server_time_ms = round(time.time() * 1000)
+ student_id = str(data.get("student_id", ""))
+ if not student_id:
+ return {"active": False, "server_time_ms": server_time_ms}
+
+ if student_id not in active_attempts:
+ if not data.get("student") or not data.get("start_time") or not data.get("total"):
+ return {"active": False}
+ try:
+ start_time = datetime.fromisoformat(data.get("start_time", ""))
+ except (TypeError, ValueError):
+ start_time = datetime.now()
+ test_time = max(1, int(data.get("test_time", 15)))
+ maximum_end_time_ms = round(start_time.timestamp() * 1000) + test_time * 60_000
+ proposed_end_time_ms = int(data.get("end_time_ms") or maximum_end_time_ms)
+ active_attempts[student_id] = {
+ "student_id": data.get("student_id"),
+ "student": str(data.get("student", "")),
+ "start_time": start_time,
+ "last_seen": datetime.now(),
+ "answered": 0,
+ "correct": 0,
+ "incorrect": 0,
+ "total": max(0, int(data.get("total", 0))),
+ "test_time": test_time,
+ "end_time_ms": min(proposed_end_time_ms, maximum_end_time_ms)
+ }
+
+ attempt = active_attempts[student_id]
+ attempt.setdefault(
+ "end_time_ms",
+ round(attempt["start_time"].timestamp() * 1000) + attempt["test_time"] * 60_000
+ )
+ if server_time_ms >= attempt["end_time_ms"]:
+ active_attempts.pop(student_id, None)
+ return {
+ "active": False,
+ "expired": True,
+ "server_time_ms": server_time_ms,
+ "end_time_ms": attempt["end_time_ms"]
+ }
+
+ attempt["last_seen"] = datetime.now()
+ current_answers = data.get("answers", [])
+ correct = 0
+ incorrect = 0
+ answered = 0
+ for current in current_answers:
+ question = next((q for q in all_questions if q["id"] == current.get("id")), None)
+ if not question:
+ continue
+ student_answer = current.get("answer")
+ is_answered = (
+ bool(student_answer)
+ if isinstance(student_answer, list)
+ else bool(str(student_answer or "").strip())
+ )
+ if not is_answered:
+ continue
+ answered += 1
+ if isinstance(student_answer, str) and isinstance(question["answer"], str):
+ is_correct = student_answer.casefold() == question["answer"].casefold()
+ elif isinstance(student_answer, list) and isinstance(question["answer"], list):
+ is_correct = set(student_answer) == set(question["answer"])
+ else:
+ is_correct = False
+ if is_correct:
+ correct += 1
+ else:
+ incorrect += 1
+
+ attempt["answered"] = answered
+ attempt["correct"] = correct
+ attempt["incorrect"] = incorrect
+ return {
+ "active": True,
+ "expired": False,
+ "server_time_ms": server_time_ms,
+ "end_time_ms": attempt["end_time_ms"]
+ }
+
+
+@app.get("/teacher/active_attempts")
+def get_active_attempts(password: str):
+ require_teacher(password)
+ now = datetime.now()
+ now_ms = round(time.time() * 1000)
+ expired = [
+ student_id for student_id, attempt in active_attempts.items()
+ if (now - attempt["last_seen"]).total_seconds() > config.get("activity_timeout", 30)
+ or now_ms >= attempt.get(
+ "end_time_ms",
+ round(attempt["start_time"].timestamp() * 1000) + attempt["test_time"] * 60_000
+ )
+ ]
+ for student_id in expired:
+ active_attempts.pop(student_id, None)
+
+ return [
+ {
+ "student_id": attempt["student_id"],
+ "student": attempt["student"],
+ "start_time": attempt["start_time"].isoformat(),
+ "last_seen": attempt["last_seen"].isoformat(),
+ "answered": attempt["answered"],
+ "correct": attempt["correct"],
+ "incorrect": attempt["incorrect"],
+ "total": attempt["total"],
+ "test_time": attempt["test_time"],
+ "remaining_seconds": max(0, (attempt.get(
+ "end_time_ms",
+ round(attempt["start_time"].timestamp() * 1000) + attempt["test_time"] * 60_000
+ ) - now_ms) // 1000)
+ }
+ for attempt in sorted(active_attempts.values(), key=lambda item: item["start_time"])
+ ]
+
+
+@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 is not None else None
+ config["test_time"] = int(data.get("test_time", 15))
+ config["activity_timeout"] = max(15, min(3600, int(data.get("activity_timeout") or 30)))
+ grade_thresholds = data.get("grade_thresholds") or {}
+ config["grade_thresholds"] = {
+ "3": max(0, min(100, int(grade_thresholds.get("3", 52)))),
+ "4": max(0, min(100, int(grade_thresholds.get("4", 68)))),
+ "5": max(0, min(100, int(grade_thresholds.get("5", 84))))
+ }
+ save_config(config)
+ return "Настройки сохранены!"
+
+
+@app.post("/upload_students")
+def upload_students(password: str, file: UploadFile):
+ if password != END_TEST_PASSWORD:
+ return "Неверный пароль"
+
+ try:
+ filename = (file.filename or "").lower()
+
+ 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)}"
+
+
@app.get("/get_quiz")
def get_quiz(student_id, student: str):
- """Получить JSON с тестом
+ enabled_questions = [q for q in typed_questions if q.get("enabled", True)]
+ if config.get("topics") is not None:
+ selected_questions = [q for q in enabled_questions if q.get("topic") in config["topics"]]
+ else:
+ selected_questions = enabled_questions
+
+ if not selected_questions:
+ raise HTTPException(status_code=400, detail="Нет включённых вопросов для выбранных тем")
- Args:
- student_id (int): идентификатор студента
- student (str): имя студента
+ questions_for_student = random.sample(
+ selected_questions,
+ min(config["quiz_length"], len(selected_questions))
+ )
+
+ remove_keys = ["author", "answer", "enabled"]
+ quiz_questions = [{key: value for key, value in q.items() if key not in remove_keys} for q in questions_for_student]
+
+ start_time = datetime.now()
+ test_time = load_config().get("test_time", 15)
+ server_time_ms = round(time.time() * 1000)
+ end_time_ms = server_time_ms + test_time * 60_000
+ active_attempts[str(student_id)] = {
+ "student_id": student_id,
+ "student": student,
+ "start_time": start_time,
+ "last_seen": start_time,
+ "answered": 0,
+ "correct": 0,
+ "incorrect": 0,
+ "total": len(quiz_questions),
+ "test_time": test_time,
+ "end_time_ms": end_time_ms
+ }
- 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,
+ "attempt_id": uuid.uuid4().hex,
"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": start_time.isoformat(),
+ "server_time_ms": server_time_ms,
+ "end_time_ms": end_time_ms,
+ "questions": quiz_questions,
+ "test_time": test_time
}
+
+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)
+
+ 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()):
- """Сохранить ответы студента как есть (./answers/...) и сохранить проверенные ответы студента (./results/...)
+ json_answers = json.loads(student_answers)
+ attempt_id = str(json_answers.get("attempt_id") or "").strip()
+ if not attempt_id:
+ identity = f'{json_answers.get("student_id", "")}|{json_answers.get("start_time", "")}|{json_answers.get("wave", "")}'
+ attempt_id = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32]
+ json_answers["attempt_id"] = attempt_id
- Args:
- student_answers (str): JSON с ответами студента
+ with submission_lock:
+ existing = next(Path("results").glob(f"*_{attempt_id}_*.json"), None)
+ if existing:
+ with open(existing, "r", encoding="utf-8") as f:
+ content = json.load(f)
+ active_attempts.pop(str(json_answers.get("student_id", "")), None)
+ return {
+ "student": content.get("student", json_answers.get("student", "")),
+ "attempt_id": attempt_id,
+ "already_saved": True
+ }
- Returns:
- str: имя студента
- """
- 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)
+ active_attempts.pop(str(json_answers.get("student_id", "")), None)
+ timestamp_str = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
+ json_answers["end_time"] = timestamp_str
+ safe_student = "".join(
+ char if char not in '<>:"/\\|?*' else "_"
+ for char in str(json_answers.get("student", "student"))
+ )
+
+ path_to_answers = Path("answers") / f"{safe_student}_{json_answers['wave']}_{attempt_id}.json"
+ with open(path_to_answers, "w", encoding="utf-8") as f:
+ json.dump(json_answers, f, ensure_ascii=False)
+
+ checked = check_answers(json_answers)
+ path_to_results = Path("results") / (
+ f"{safe_student}_{json_answers['wave']}_{attempt_id}_{checked['correct_percent']}.json"
+ )
+ with open(path_to_results, "w", encoding="utf-8") as f:
+ json.dump(checked, f, ensure_ascii=False)
+
+ with open("gui/summary.txt", "a", encoding="utf-8") as f:
+ f.write(json_answers["student"] + "," + str(checked["correct_percent"]) + "," + timestamp_str + "\n")
+
+ return {
+ "student": json_answers["student"],
+ "attempt_id": attempt_id,
+ "already_saved": False
+ }
+
+
+@app.get("/submission_status/{attempt_id}")
+def submission_status(attempt_id: str):
+ safe_attempt_id = "".join(char for char in attempt_id if char.isalnum() or char in "-_")
+ if not safe_attempt_id or safe_attempt_id != attempt_id:
+ raise HTTPException(status_code=400, detail="Некорректный идентификатор попытки")
+ return {"submitted": next(Path("results").glob(f"*_{safe_attempt_id}_*.json"), None) is not None}
- path_to_results = f'results/{json_answers["student"]}_{json_answers["wave"]}_{timestamp_str}.json'
- 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?
- 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")
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 = ""
+ 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.json b/questions.json
index 9e9b22a..ab752ec 100644
--- a/questions.json
+++ b/questions.json
@@ -1,63 +1,70 @@
{
- "version": 2,
- "questions": [
- {
- "topic": "теодолит",
- "author": "GT",
- "question": "Что такое теодолит?",
- "picture": "pictures/teodolit.png",
- "options": [
- "Прибор для измерения земли",
- "Прибор для измерения углов",
- "Прибор для измерения расстояний",
- "Прибор для измерения высот"
- ],
- "answer": "Прибор для измерения углов"
- },
- {
- "topic": "геометрия",
- "author": "GT",
- "question": "Сумма углов выпуклого пятиугольника составляет?",
- "answer": "540"
- },
- {
- "topic": "нивелир",
- "author": "GT",
- "question": "Как называется прибор для измерения превышений?",
- "answer": "нивелир"
- },
- {
- "topic": "теодолит",
- "author": "GT",
- "question": "Сколько винтов у теодолита?",
- "answer": "520"
- },
- {
- "topic": "нивелир",
- "author": "GT",
- "question": "Что такое нивелир?",
- "options": [
- "Прибор для измерения земли",
- "Прибор для измерения углов",
- "Прибор для измерения расстояний",
- "Прибор для измерения высот"
- ],
- "answer": "Прибор для измерения углов"
- },
- {
- "topic": "нивелир",
- "author": "GT",
- "question": "Что такое нивелир 2.0?",
- "options": [
- "Прибор для измерения земли",
- "Прибор для измерения углов",
- "Прибор для измерения расстояний",
- "Прибор для измерения высот"
- ],
- "answer": [
- "Прибор для измерения расстояний",
- "Прибор для измерения углов"
- ]
- }
- ]
+ "version": 2,
+ "questions": [
+ {
+ "topic": "теодолит",
+ "author": "GT",
+ "question": "Что такое теодолит?",
+ "picture": "pictures/teodolit.png",
+ "options": [
+ "Прибор для измерения земли",
+ "Прибор для измерения углов",
+ "Прибор для измерения расстояний",
+ "Прибор для измерения высот"
+ ],
+ "answer": "Прибор для измерения углов",
+ "id": 1
+ },
+ {
+ "topic": "геометрия",
+ "author": "GT",
+ "question": "Сумма углов выпуклого пятиугольника составляет?",
+ "answer": "540",
+ "id": 2
+ },
+ {
+ "topic": "нивелир",
+ "author": "GT",
+ "question": "Как называется прибор для измерения превышений?",
+ "answer": "нивелир",
+ "id": 3
+ },
+ {
+ "topic": "теодолит",
+ "author": "GT",
+ "question": "Сколько винтов у теодолита?",
+ "answer": "520",
+ "id": 4
+ },
+ {
+ "id": 5,
+ "topic": "нивелир",
+ "author": "GT",
+ "question": "Что такое нивелир?",
+ "answer": "Прибор для измерения превышений",
+ "options": [
+ "Прибор для измерения земли",
+ "Прибор для измерения углов",
+ "Прибор для измерения расстояний",
+ "Прибор для измерения высот",
+ "Прибор для измерения превышений"
+ ]
+ },
+ {
+ "topic": "нивелир",
+ "author": "GT",
+ "question": "Что такое нивелир 2.0?",
+ "options": [
+ "Прибор для измерения земли",
+ "Прибор для измерения углов",
+ "Прибор для измерения расстояний",
+ "Прибор для измерения высот"
+ ],
+ "answer": [
+ "Прибор для измерения расстояний",
+ "Прибор для измерения углов"
+ ],
+ "id": 6
+ }
+ ]
}
\ 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 8c6a77e..6d7503c 100644
Binary files a/requirements.txt and b/requirements.txt differ
diff --git a/run.cmd b/run.cmd
index 958905b..6e74fa8 100644
--- a/run.cmd
+++ b/run.cmd
@@ -1,3 +1,5 @@
-pip install -r requirements.txt
+@echo off
+cd /d "%~dp0"
+python -m pip install -r requirements.txt
python run.py
-pause
\ No newline at end of file
+pause
diff --git a/run.py b/run.py
index b0b931d..17ef1bc 100644
--- a/run.py
+++ b/run.py
@@ -1,4 +1,28 @@
import uvicorn
+import socket
+from urllib.request import urlopen
+from urllib.error import URLError
+
+
+def port_is_busy(host="127.0.0.1", port=8000):
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as connection:
+ connection.settimeout(1)
+ return connection.connect_ex((host, port)) == 0
+
+
+def quiz_is_running():
+ try:
+ with urlopen("http://127.0.0.1:8000/", timeout=2) as response:
+ return response.status == 200
+ except (OSError, URLError):
+ return False
if __name__ == "__main__":
- uvicorn.run("main:app", host="0.0.0.0", port=8000, log_level="info")
\ No newline at end of file
+ if port_is_busy():
+ if quiz_is_running():
+ print("Приложение уже запущено: http://localhost:8000/")
+ else:
+ print("Порт 8000 занят другим процессом. Освободите порт и повторите запуск.")
+ raise SystemExit(0)
+
+ uvicorn.run("main:app", host="0.0.0.0", port=8000)
diff --git a/settings.py b/settings.py
index 7dbc377..af75c49 100644
--- a/settings.py
+++ b/settings.py
@@ -1,5 +1,5 @@
WAVE = 1
END_TEST_PASSWORD = "qwe"
-QUIZ_LENGTH = 4
+QUIZ_LENGTH = 50
QUESTIONS_FILE = "questions.json"
-STUDENTS_FILE = "students.json"
\ No newline at end of file
+STUDENTS_FILE = "students.json"
diff --git a/stop.cmd b/stop.cmd
new file mode 100644
index 0000000..89d9a0e
--- /dev/null
+++ b/stop.cmd
@@ -0,0 +1,15 @@
+@echo off
+chcp 65001 >nul
+cd /d "%~dp0"
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$listeners = Get-NetTCPConnection -LocalPort 8000 -State Listen -ErrorAction SilentlyContinue; if (-not $listeners) { exit 2 }; $listeners | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction Stop }"
+
+if errorlevel 2 (
+ echo Сервер не запущен.
+) else if errorlevel 1 (
+ echo Не удалось остановить сервер.
+) else (
+ echo Сервер остановлен.
+)
+
+pause
diff --git a/students.json b/students.json
index 07c26f7..f613a1d 100644
--- a/students.json
+++ b/students.json
@@ -1,15 +1,29 @@
{
- "version": 2,
+ "version": 1,
"students": [
{
"id": 1,
- "name": "Екатерина Александровна Щербацкая",
- "group": 209
+ "name": "Будапешт Казантип"
},
{
"id": 2,
- "name": "Екатерина Александровна Щербацкая",
- "group": 208
+ "name": "Боброслав Купидон"
+ },
+ {
+ "id": 3,
+ "name": "Барбарис Корвалол"
+ },
+ {
+ "id": 4,
+ "name": "Баттлфилд Когтевран"
+ },
+ {
+ "id": 5,
+ "name": "Бургеркинг Кабачок"
+ },
+ {
+ "id": 6,
+ "name": "Бранденбург Кенигсберг"
}
]
}
\ No newline at end of file