From 75d5082e4b90bf76e7682653fb5be62def2c7d98 Mon Sep 17 00:00:00 2001 From: Andrey Karpachevskiy <35732974+IOWq750@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:52:07 +0300 Subject: [PATCH] =?UTF-8?q?vibe=20code=20update=20=F0=9F=8C=8C=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../questions_2026-06-08T11-03-15-431728.json | 63 ++ config.json | 5 + groups/students_00.json | 33 + gui/analysis.html | 48 + gui/analysis.js | 146 +++ gui/analytics.html | 34 + gui/analytics.js | 54 ++ gui/app.css | 79 ++ gui/index.html | 90 +- gui/main.js | 407 +++++--- gui/monitor.html | 23 + gui/monitor.js | 58 ++ gui/questions.html | 29 + gui/questions.js | 30 + gui/results.html | 8 + gui/results.js | 19 + gui/settings.html | 155 +++ gui/summary.txt | 4 + gui/teacher.css | 71 ++ gui/teacher.html | 33 + gui/teacher.js | 34 + main.py | 892 +++++++++++++++--- main_.js | 153 +++ questions.json | 129 +-- questions_invalid.json | 8 +- requirements.txt | Bin 70 -> 32 bytes run.cmd | 6 +- run.py | 26 +- settings.py | 4 +- stop.cmd | 15 + students.json | 24 +- 31 files changed, 2304 insertions(+), 376 deletions(-) create mode 100644 backups/questions_2026-06-08T11-03-15-431728.json create mode 100644 config.json create mode 100644 groups/students_00.json create mode 100644 gui/analysis.html create mode 100644 gui/analysis.js create mode 100644 gui/analytics.html create mode 100644 gui/analytics.js create mode 100644 gui/app.css create mode 100644 gui/monitor.html create mode 100644 gui/monitor.js create mode 100644 gui/questions.html create mode 100644 gui/questions.js create mode 100644 gui/results.html create mode 100644 gui/results.js create mode 100644 gui/settings.html create mode 100644 gui/summary.txt create mode 100644 gui/teacher.css create mode 100644 gui/teacher.html create mode 100644 gui/teacher.js create mode 100644 main_.js create mode 100644 stop.cmd 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 = "
" - 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 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:

+ +

Если готовите файл в 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 = "
" + 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.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 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@9cCnul +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