pull_request 14/07/2026

pull/9/head
Andrey Karpachevskiy 1 month ago
parent 1088278eeb
commit 73b9296bbe

@ -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": [
"Прибор для измерения расстояний",
"Прибор для измерения углов"
]
}
]
}

@ -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": "Бургеркинг Кабачок"
}
]
}

@ -0,0 +1,34 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Аналитика тестирования</title>
<link rel="stylesheet" href="teacher.css?v=20260610-2">
<script defer src="analytics.js?v=20260610-3"></script>
</head>
<body>
<header>
<div><h1>Аналитика тестирования</h1><p class="stat">Статистика по всем сохранённым попыткам · <span id="updated">обновление...</span></p></div>
<nav><a href="teacher.html">Страница преподавателя</a> · <a href="results.html">Неправильные ответы</a></nav>
</header>
<main>
<div id="message" class="message">Загрузка аналитики...</div>
<section id="analytics" hidden>
<div class="metrics" id="metrics"></div>
<div class="analytics-grid">
<section class="card"><h2>Распределение результатов</h2><div id="distribution" class="bar-chart"></div></section>
<section class="card"><h2>Самые сложные темы</h2><div id="topics" class="bar-chart"></div></section>
</div>
<section class="card analytics-section">
<h2>Самые сложные вопросы</h2>
<div class="table-wrap"><table><thead><tr><th>Вопрос</th><th>Тема</th><th>Ответов</th><th>Правильно</th><th>Ошибок</th></tr></thead><tbody id="questions"></tbody></table></div>
</section>
<section class="card analytics-section">
<h2>Результаты студентов</h2>
<div class="table-wrap"><table><thead><tr><th>Студент</th><th>Результат</th><th>Правильно</th><th>Дата</th></tr></thead><tbody id="students"></tbody></table></div>
</section>
</section>
</main>
</body>
</html>

@ -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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[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 `<article class="metric"><span>${label}</span><strong>${value}</strong><small>${hint}</small></article>`;
}
function bar(label, value, detail, danger=false) {
return `<div class="bar-row"><div class="bar-label"><span>${escapeHtml(label)}</span><strong>${detail}</strong></div><div class="bar-track"><div class="bar-fill${danger?" danger":""}" style="width:${Math.max(2,value)}%"></div></div></div>`;
}
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("") : '<p class="empty">Нет данных</p>';
$("questions").innerHTML = data.questions.length ? data.questions.slice(0, 30).map(item => `<tr><td>${escapeHtml(item.question)}</td><td>${escapeHtml(item.topic)}</td><td>${item.attempts}</td><td class="success">${item.accuracy}%</td><td class="fail">${item.incorrect}</td></tr>`).join("") : '<tr><td colspan="5" class="empty">Нет данных</td></tr>';
$("students").innerHTML = data.students.length ? data.students.map(item => `<tr><td>${escapeHtml(item.student)}</td><td class="${item.percent>=50?"success":"fail"}">${item.percent}%</td><td>${item.correct} из ${item.total}</td><td>${formatDate(item.end_time)}</td></tr>`).join("") : '<tr><td colspan="4" class="empty">Нет данных</td></tr>';
}
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);

@ -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; }
}

@ -1,66 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
<!-- <link rel="stylesheet" href="https://unpkg.com/awsm.css/dist/awsm.min.css"> -->
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/spcss@0.9.0"> -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css">
<!-- <link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura.css" type="text/css"> -->
<!-- <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"> -->
<script src="main.js"></script>
<style>
label {
margin-right: 10px
/* interval after answer option */
}
footer {
text-align: center;
}
</style>
<title>Тестирование</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="app.css?v=20260609-1">
<script src="main.js?v=20260621-2"></script>
<title>Тестирование</title>
</head>
<body>
<header id="header">
<h1>Тестирование</h1>
<p id="host-ip">Тест работает по адресу</p>
<p>Получите тест</p>
</header>
<main id="main">
<p>Найдите себя и нажмите получить тест:</p>
<p>
<select id="students-selector">
<option value="" disabled selected>ФИО</option>
</select>
<button id="get-quiz" disabled>Получить тест</button>
</p>
<button id="end-quiz">Завершить тест для всех 😇</button>
<br>
<form>
<button formaction="/check_questions">Проверить вопросы 🧐</button>
</form>
<p>
<button onclick="window.location.href='settings.html'">⚙️ Настройки теста</button>
</p>
<p>
<button onclick = "window.location.href = 'monitor.html'">Монитор</button>
</p>
</main>
<hr>
<footer>
<kbd>кафедра картографии и геоинформатики c 💖 для 👩‍🎓</kbd>
</footer>
<header id="header">
<h1>Тестирование</h1>
<p id="host-ip">Тест работает по адресу</p>
<p>Получите тест</p>
</header>
<main id="main">
<p>Найдите себя и нажмите «Получить тест»:</p>
<p>
<select id="students-selector">
<option value="" disabled selected>ФИО</option>
</select>
<button id="get-quiz" disabled>Получить тест</button>
</p>
<p><button id="teacher-page">Страница преподавателя</button></p>
</main>
<dialog id="teacher-login">
<form id="teacher-login-form">
<h2>Вход для преподавателя</h2>
<label for="teacher-password">Пароль</label>
<input id="teacher-password" type="password" autocomplete="current-password" required>
<p id="teacher-login-error" class="error"></p>
<div class="dialog-actions">
<button type="submit">Войти</button>
<button type="button" id="teacher-login-cancel">Отмена</button>
</div>
</form>
</dialog>
<hr>
<footer><kbd>кафедра картографии и геоинформатики с 💖 для 👩‍🎓</kbd></footer>
</body>
</html>
</html>

@ -1,156 +1,267 @@
// 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 += `<option value=${student.id}>${student.name}</option>`
})
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 = "<form id='form' onkeydown='return event.keyCode != 13;'>"
questions.forEach(q => {
// console.log(q)
if (q.is_multiple) {
let options_div = ""
q.options.forEach(o => {
options_div += `<label for="${o}${q.id}"><input type="checkbox" id="${o}${q.id}" name="${q.id}" value="${o}">${o}</label>`
})
const question_div =
`<fieldset>
<legend>Выберите ответ:</legend>
${options_div}
</fieldset>`
questions_html +=
`<article>
<h3>${q.question}</h3>
${q.picture ? `<img src='${q.picture}'>` : ""}
${question_div}
</article>`
} else if (q.options) {
let options_div = ""
q.options.forEach(o => {
options_div += `<label for="${o}${q.id}"><input type="radio" id="${o}${q.id}" name="${q.id}" value="${o}">${o}</label>`
})
const question_div =
`<fieldset>
<legend>Выберите ответ:</legend>
${options_div}
</fieldset>`
questions_html +=
`<article>
<h3>${q.question}</h3>
${q.picture ? `<img src='${q.picture}'>` : ""}
${question_div}
</article>`
} else {
const question_div = `<input type="text" autocomplete="off" id="${q.id}" name="${q.id}">`
questions_html +=
`<article>
<h3>${q.question}</h3>
${q.picture ? `<img src='${q.picture}'>` : ""}
${question_div}
</article>`
}
})
// console.log(questions_html)
questions_html += "</form>"
document.getElementById("header").innerHTML = `<h1>Тестирование</h1><p>${students_selector.options[students_selector.selectedIndex].text}</p>`
document.getElementById("main").innerHTML = questions_html
var testTime = quiz.test_time || 15;
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) + ":";
if (Math.floor(timeLeft / 1000) % 60 < 10) { time.innerHTML += "0" }
time.innerHTML += (Math.floor(timeLeft / 1000) % 60);
if (time.innerHTML == "0:00") {
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)
})
clearInterval(testTiming);
document.getElementById("main").innerHTML = "<p>Тестирование окончено</p>";
}
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))
})
})
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 = `<h1>Тестирование</h1><p>${escapeHtml(quiz.student)}</p>`;
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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[char]));
}

@ -1,124 +1,23 @@
<!DOCTYPE html>
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>Мониторинг студентов</title>
<style>
body { font-family: sans-serif; margin: 2rem; }
h2 { margin-top: 3rem; }
table { border-collapse: collapse; width: 100%; margin-bottom: 2rem; }
th, td { border: 1px solid #aaa; padding: 0.5rem; text-align: left; }
th { background-color: #eee; }
.success { color: green; }
.fail { color: red; }
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Монитор тестирования</title>
<link rel="stylesheet" href="teacher.css?v=20260616-1">
<script defer src="monitor.js?v=20260616-1"></script>
</head>
<body onload = "getData()">
<h1>Прогресс студентов</h1>
<h2>✅ Завершённые тесты</h2>
<table id="results-table">
<thead>
<tr>
<th>Студент</th>
<th>Правильных (%)</th>
<th>Начало</th>
<th>Окончание</th>
</tr>
</thead>
<tbody id="results-rows">
<tr><td colspan="4">Загрузка...</td></tr>
</tbody>
</table>
<!-- <h2>🕐 Текущий прогресс</h2> -->
<!-- <table id="progress-table"> -->
<!-- <thead> -->
<!-- <tr> -->
<!-- <th>Студент</th> -->
<!-- <th>Ответов</th> -->
<!-- <th>Всего</th> -->
<!-- <th>Начало</th> -->
<!-- </tr> -->
<!-- </thead> -->
<!-- <tbody> -->
<!-- <tr><td colspan="4">Загрузка...</td></tr> -->
<!-- </tbody> -->
<!-- </table> -->
<script>
function getData() {
var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200 && this.responseText != "") {
// document.getElementById("results-rows").innerHTML = "<tr><td>" + this.responseText.slice(0, -2).replace(/,/g, "</td><td>").replace(/\n/g, "</td></tr><tr><td>");
const risp = this.responseText.slice(0, -2).split("\n");
document.getElementById("results-rows").innerHTML = "";
for (var i = 0; i < risp.length; i++) {
const row = risp[i].split(",");
row[2] = row[2].slice(8, 10) + "." + row[2].slice(5, 7) + "." + row[2].slice(0, 4) + " " + row[2].slice(11, 19).replace(/-/g, ":");
var text1 = "";
text1 = "<tr><td>" + row[0] + "</td>";
text1 += "<td>" + row[1] + "</td><td></td>";
text1 += "<td>" + row[2] + "</td></tr>";
document.getElementById("results-rows").innerHTML += text1;
}
}
};
xmlhttp.open("GET", "summary.txt", true);
xmlhttp.send();
}
setInterval(getData, 5000);
</script>
<script>
// async function fetchAll() {
// await fetch('/current_results')
// .then(r => r.json())
// .then(data => {
// const tbody = document.querySelector("#results-table tbody");
// tbody.innerHTML = "";
// if (data.length === 0) {
// tbody.innerHTML = "<tr><td colspan='4'>Нет завершённых тестов</td></tr>";
// return;
// }
// data.forEach(item => {
// const row = document.createElement("tr");
// row.innerHTML = `
// <td>${item.student}</td>
// <td class="${item.correct_percent >= 50 ? 'success' : 'fail'}">${item.correct_percent}</td>
// <td>${item.start}</td>
// <td>${item.end}</td>
// `;
// tbody.appendChild(row);
// });
// });
// await fetch('/current_progress')
// .then(r => r.json())
// .then(data => {
// const tbody = document.querySelector("#progress-table tbody");
// tbody.innerHTML = "";
// if (data.length === 0) {
// tbody.innerHTML = "<tr><td colspan='4'>Нет активных студентов</td></tr>";
// return;
// }
// data.forEach(item => {
// const row = document.createElement("tr");
// row.innerHTML = `
// <td>${item.student}</td>
// <td>${item.answered}</td>
// <td>${item.total}</td>
// <td>${item.start}</td>
// `;
// tbody.appendChild(row);
// });
// });
// }
// fetchAll();
// setInterval(fetchAll, 10000);
</script>
<body>
<header><div><h1>Монитор тестирования</h1><p class="stat" id="updated"></p></div><nav><a href="teacher.html">Страница преподавателя</a></nav></header>
<main>
<section class="card">
<h2>Сейчас проходят тест <span class="badge" id="active-count">0</span></h2>
<div class="table-wrap"><table><thead><tr><th>Студент</th><th>Начало</th><th>Заполнено</th><th>Правильно</th><th>Неправильно</th><th>Осталось</th><th>Связь</th></tr></thead><tbody id="active-rows"><tr><td colspan="7">Загрузка...</td></tr></tbody></table></div>
</section>
<section class="card monitor-section">
<h2>Завершённые тесты</h2>
<div class="table-wrap"><table><thead><tr><th>Студент</th><th>Результат</th><th>Оценка</th><th>Окончание</th></tr></thead><tbody id="completed-rows"><tr><td colspan="4">Загрузка...</td></tr></tbody></table></div>
</section>
</main>
</body>
</html>

@ -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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[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 => `<tr><td><strong>${escapeHtml(attempt.student)}</strong></td><td>${formatDate(attempt.start_time)}</td><td>${attempt.answered} из ${attempt.total}</td><td class="success">${attempt.correct}</td><td class="fail">${attempt.incorrect}</td><td>${formatRemaining(attempt)}</td><td class="success">на связи</td></tr>`).join("") : '<tr><td colspan="7" class="empty">Сейчас никто не проходит тест.</td></tr>';
$("completed-rows").innerHTML = completed.length ? completed.map(result => {
const grade = getGrade(result.correct_percent, gradeThresholds);
return `<tr><td>${escapeHtml(result.student)}</td><td>${result.correct_percent}%</td><td><span class="grade-badge grade-${grade}">${grade}</span></td><td>${formatDate(result.end_time)}</td></tr>`;
}).join("") : '<tr><td colspan="4" class="empty">Завершённых тестов пока нет.</td></tr>';
} 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;
}

@ -0,0 +1,29 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Редактор вопросов</title><link rel="stylesheet" href="teacher.css?v=20260608-1"><script defer src="questions.js?v=20260608-1"></script>
</head>
<body>
<header><div><h1>Редактор вопросов</h1><p class="stat" id="count"></p></div><nav><a href="teacher.html">Страница преподавателя</a> · <a href="settings.html">Настройки</a> · <a href="results.html">Неправильные ответы</a></nav></header>
<main>
<div class="toolbar"><label>Поиск <input id="search" placeholder="Текст вопроса, тема или автор"></label><button id="new-question">Добавить вопрос</button></div>
<div class="grid">
<section class="card"><div class="list" id="question-list"><div class="empty">Загрузка вопросов...</div></div></section>
<section class="card">
<h2 id="form-title">Новый вопрос</h2>
<form id="question-form"><div class="form-grid">
<label class="wide">Текст вопроса<textarea id="question" required></textarea></label>
<label>Тема<input id="topic" list="topics"></label><label>Автор<input id="author"></label>
<label class="wide enabled-control"><input id="enabled" type="checkbox" checked> Вопрос включён и участвует в тестировании</label>
<label class="wide">Ссылка на изображение<input id="picture" placeholder="pictures/example.png"></label>
<label class="wide">Тип ответа<select id="answer-type"><option value="text">Текстовый ответ</option><option value="single">Один вариант</option><option value="multiple">Несколько вариантов</option></select></label>
<label class="wide" id="text-answer-wrap">Правильный ответ<input id="text-answer"></label>
<div class="wide" id="options-wrap" hidden><strong>Варианты ответа</strong><div id="options"></div><button type="button" class="secondary" id="add-option">Добавить вариант</button></div>
</div><p id="message" class="message"></p><div class="actions"><button type="submit">Сохранить</button><button type="button" class="danger" id="delete-question" hidden>Удалить</button></div></form>
</section>
</div>
<datalist id="topics"></datalist>
</main>
</body>
</html>

@ -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='<div class="empty">Вопросы не найдены</div>';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();

@ -0,0 +1,8 @@
<!doctype html>
<html lang="ru">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Неправильные ответы</title><link rel="stylesheet" href="teacher.css?v=20260621-1"><script defer src="results.js?v=20260621-1"></script></head>
<body>
<header><div><h1>Неправильные ответы</h1><p class="stat" id="summary"></p></div><nav><a href="teacher.html">Страница преподавателя</a> · <a href="questions.html">Редактор вопросов</a> · <a href="monitor.html">Монитор</a></nav></header>
<main><div class="toolbar"><label>Студент <input id="student-filter" placeholder="Введите имя"></label><label>Тема <select id="topic-filter"><option value="">Все темы</option></select></label><button id="reload">Обновить</button></div><div id="message" class="message"></div><section id="results"></section></main>
</body>
</html>

@ -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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[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='<div class="card empty">По выбранным фильтрам неправильных ответов нет.</div>';return;}
filtered.forEach(result=>{const card=document.createElement("details");card.className="card result-card";card.innerHTML=`<summary class="result-head"><span><strong>${escapeHtml(result.student)}</strong><br><small>${escapeHtml(formatDate(result.end_time))} · ${result.correct}/${result.total} правильных</small></span><span class="badge">${result.incorrect.length} ошибок · ${result.correct_percent}%</span></summary>`;result.incorrect.forEach(q=>{const block=document.createElement("div");block.className="incorrect";block.innerHTML=`<strong>${escapeHtml(q.question)}</strong><p class="stat">${escapeHtml(q.topic||"Без темы")}</p><div class="answers"><div class="given"><b>Ответ студента</b><br>${escapeHtml(formatAnswer(q.student_answer))}</div><div class="correct"><b>Правильный ответ</b><br>${escapeHtml(formatAnswer(q.correct_answer))}</div></div>`;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();

@ -3,15 +3,15 @@
<head>
<meta charset="UTF-8">
<title>Настройки теста</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css">
<link rel="stylesheet" href="app.css?v=20260609-1">
</head>
<body>
<p><a href="teacher.html">Страница преподавателя</a> · <a href="questions.html">Редактор вопросов</a> · <a href="results.html">Неправильные ответы студентов</a></p>
<script>
let password = prompt("Введите пароль для доступа к настройкам:");
const password = sessionStorage.getItem("teacherPassword") || "";
if (!password) {
alert("Пароль не введён. Доступ запрещён.");
document.body.innerHTML = "<h2>Доступ запрещён.</h2>";
throw new Error("Пароль не введён");
window.location.replace("index.html");
}
</script>
@ -23,6 +23,20 @@
<label for="test-time">Время теста (мин):</label>
<input type="number" id="test-time" name="test_time" min="1" max="300">
<label for="activity-timeout">Тайм-аут активности студента (сек):</label>
<input type="number" id="activity-timeout" name="activity_timeout" min="15" max="3600">
<small>Если от студента нет обновлений дольше этого времени, он исчезает из списка активных.</small>
<h2>Градации оценок</h2>
<label for="grade-3">Оценка 3 с процента:</label>
<input type="number" id="grade-3" min="0" max="100">
<label for="grade-4">Оценка 4 с процента:</label>
<input type="number" id="grade-4" min="0" max="100">
<label for="grade-5">Оценка 5 с процента:</label>
<input type="number" id="grade-5" min="0" max="100">
<label>Выберите темы:</label>
<div id="topics-container"></div>
@ -55,13 +69,18 @@
.then(r => r.json())
.then(cfg => {
if (cfg.error) {
alert("Неверный пароль");
document.body.innerHTML = "<h2>Доступ запрещён.</h2>";
throw new Error("Неверный пароль");
sessionStorage.removeItem("teacherPassword");
window.location.replace("index.html");
throw new Error("Доступ запрещён");
}
savedConfig = cfg;
document.getElementById("quiz-length").value = cfg.quiz_length;
document.getElementById("test-time").value = cfg.test_time || 15;
document.getElementById("activity-timeout").value = cfg.activity_timeout || 30;
const gradeThresholds = cfg.grade_thresholds || {"3": 52, "4": 68, "5": 84};
document.getElementById("grade-3").value = gradeThresholds["3"];
document.getElementById("grade-4").value = gradeThresholds["4"];
document.getElementById("grade-5").value = gradeThresholds["5"];
return fetch("/get_topics?password=" + encodeURIComponent(password));
})
@ -77,7 +96,7 @@
checkbox.value = topic;
checkbox.id = id;
if (!savedConfig.topics || savedConfig.topics.includes(topic)) {
if (savedConfig.topics == null || savedConfig.topics.includes(topic)) {
checkbox.checked = true;
}
@ -96,11 +115,17 @@
const checked = Array.from(document.querySelectorAll("input[name='topics']:checked"));
const topics = checked.map(c => c.value);
const test_time = document.getElementById("test-time").value;
const activity_timeout = document.getElementById("activity-timeout").value;
const grade_thresholds = {
"3": document.getElementById("grade-3").value,
"4": document.getElementById("grade-4").value,
"5": document.getElementById("grade-5").value
};
fetch("/set_config?password=" + encodeURIComponent(password), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quiz_length, topics, test_time })
body: JSON.stringify({ quiz_length, topics, test_time, activity_timeout, grade_thresholds })
})
.then(r => r.text())
.then(alert);
@ -127,4 +152,4 @@
});
</script>
</body>
</html>
</html>

@ -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

@ -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; } }

@ -0,0 +1,33 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Страница преподавателя</title>
<link rel="stylesheet" href="teacher.css?v=20260607-2">
<script defer src="teacher.js?v=20260607-2"></script>
</head>
<body>
<header>
<div>
<h1>Страница преподавателя</h1>
<p class="stat">Управление тестированием и разбор результатов</p>
</div>
<nav><a href="index.html">К тестированию</a></nav>
</header>
<main>
<div id="access-message" class="message">Проверка доступа...</div>
<section id="teacher-tools" class="tools-grid" hidden>
<a class="tool-card" href="settings.html"><h2>Настройки теста</h2><p>Количество вопросов, время, темы и список студентов.</p></a>
<a class="tool-card" href="monitor.html"><h2>Монитор</h2><p>Текущий список завершённых работ и результаты студентов.</p></a>
<a class="tool-card" href="questions.html"><h2>Редактор вопросов</h2><p>Добавление, поиск, изменение и удаление вопросов.</p></a>
<a class="tool-card" href="results.html"><h2>Неправильные ответы</h2><p>Разбор ошибок студентов с фильтрами по имени и теме.</p></a>
<a class="tool-card" href="analytics.html"><h2>Аналитика</h2><p>Средний балл, распределение результатов, сложные вопросы и темы.</p></a>
</section>
<section id="teacher-actions" class="card" hidden>
<h2>Управление тестированием</h2>
<button id="end-quiz" class="danger">Завершить тест для всех</button>
</section>
</main>
</body>
</html>

@ -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));
});

@ -1,4 +1,4 @@
from fastapi import FastAPI, Body, UploadFile
from fastapi import FastAPI, Body, UploadFile, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
import json
@ -9,6 +9,10 @@ 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
@ -23,7 +27,9 @@ def load_config():
return {
"quiz_length": QUIZ_LENGTH,
"topics": None,
"test_time": 15
"test_time": 15,
"activity_timeout": 30,
"grade_thresholds": {"3": 52, "4": 68, "5": 84}
}
@ -51,6 +57,101 @@ def backup_students_file():
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())
@ -196,6 +297,9 @@ def parse_students_csv(file_obj):
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)
@ -209,8 +313,8 @@ app.add_middleware(
with open(QUESTIONS_FILE, "r", encoding="UTF-8") as f:
questions_json = json.load(f)
all_questions = questions_json["questions"]
typed_questions = [dict(q, **{"is_multiple": isinstance(q["answer"], list)}) for q in all_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)
@ -218,6 +322,7 @@ with open(STUDENTS_FILE, "r", encoding="UTF-8") as f:
Path("answers").mkdir(parents=True, exist_ok=True)
Path("results").mkdir(parents=True, exist_ok=True)
active_attempts = {}
@app.get("/get_config")
@ -231,7 +336,177 @@ def get_config(password: str):
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")))
return sorted(set(q["topic"] for q in all_questions if q.get("topic") and q.get("enabled", True)))
@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.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": "049%", "count": sum(score < 50 for score in scores)},
{"label": "5069%", "count": sum(50 <= score < 70 for score in scores)},
{"label": "7084%", "count": sum(70 <= score < 85 for score in scores)},
{"label": "85100%", "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
}
@app.get("/students")
@ -239,6 +514,126 @@ def show_students():
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:
@ -246,8 +641,15 @@ async def set_config(password: str, data: dict = Body()):
config["quiz_length"] = int(data.get("quiz_length", QUIZ_LENGTH))
topics = data.get("topics")
config["topics"] = topics if topics else None
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 "Настройки сохранены!"
@ -289,27 +691,51 @@ def upload_students(password: str, file: UploadFile):
@app.get("/get_quiz")
def get_quiz(student_id, student: str):
if config["topics"]:
selected_questions = [q for q in typed_questions if q.get("topic") in config["topics"]]
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 = typed_questions
selected_questions = enabled_questions
if not selected_questions:
raise HTTPException(status_code=400, detail="Нет включённых вопросов для выбранных тем")
questions_for_student = random.sample(
selected_questions,
min(config["quiz_length"], len(selected_questions))
)
remove_keys = ["author", "answer"]
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
}
return {
"version": 1,
"attempt_id": uuid.uuid4().hex,
"student_id": student_id,
"student": student,
"wave": WAVE,
"start_time": datetime.now().isoformat(),
"start_time": start_time.isoformat(),
"server_time_ms": server_time_ms,
"end_time_ms": end_time_ms,
"questions": quiz_questions,
"test_time": load_config().get("test_time", 15)
"test_time": test_time
}
@ -334,23 +760,59 @@ def check_answers(student_answers: dict):
@app.post("/save_student_answers")
def send_student_answers(student_answers: str = Body()):
json_answers = json.loads(student_answers)
timestamp_str = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
json_answers["end_time"] = timestamp_str
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
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
}
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 = f'answers/{json_answers["student"]}_{json_answers["wave"]}_{timestamp_str}.json'
with open(path_to_answers, 'w', encoding='utf-8') as f:
json.dump(json_answers, f, ensure_ascii=False)
path_to_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 = f'results/{json_answers["student"]}_{json_answers["wave"]}_{timestamp_str}_{checked["correct_percent"]}.json'
path_to_summary = 'gui/summary.txt'
with open(path_to_results, 'w', encoding='utf-8') as f:
json.dump(checked, 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
}
with open(path_to_summary, 'a', encoding='utf-8') as f:
f.write(json_answers["student"] + ',' + str(checked["correct_percent"]) + ',' + timestamp_str + '\n')
return json_answers["student"]
@app.get("/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}
@app.get("/end_quiz")
@ -388,4 +850,4 @@ def show_host_ip():
app.mount("/pictures", StaticFiles(directory="pictures"), name="pictures")
app.mount("/", StaticFiles(directory="gui", html=True), name="gui")
app.mount("/", StaticFiles(directory="gui", html=True), name="gui")

@ -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
}
]
}

@ -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
pause

@ -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, reload=True)
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)

@ -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

@ -20,6 +20,10 @@
{
"id": 5,
"name": "Бургеркинг Кабачок"
},
{
"id": 6,
"name": "Бранденбург Кенигсберг"
}
]
}
Loading…
Cancel
Save