vibe code update 🌌 (#9)

master
Andrey Karpachevskiy 1 month ago committed by GitHub
parent 2cff9522e6
commit 75d5082e4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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,5 @@
{
"quiz_length": 30,
"topics": null,
"test_time": 40
}

@ -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,48 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Анализ результатов</title>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
<script defer src="analysis.js"></script>
<style>
body {
font-family: sans-serif;
background: #f8f9fa;
margin: 2rem;
}
h1 {
margin-bottom: 1rem;
}
.info {
margin: 1rem 0;
}
.dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 2rem;
}
.chart-box {
background: white;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.05);
}
</style>
</head>
<body>
<h1>Анализ результатов</h1>
<div class="info">
<input type="file" id="fileInput" multiple />
<span id="fileCount">Число файлов: 0</span><br/>
<span id="processedCount">Обработано файлов: 0</span>
</div>
<div class="dashboard">
<div id="chart1" class="chart-box"></div>
<div id="chart2" class="chart-box"></div>
<div id="chart3" class="chart-box"></div>
</div>
</body>
</html>

@ -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<br>");
}
// 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("<br>")
};
});
Plotly.newPlot(
"chart2",
[
{
x: xvals,
y: sortedErrors.map(x => x[1].length),
type: "bar",
marker: { color: colors2 },
customdata: customdata,
hovertemplate:
"<b>%{customdata.full}</b><br>%{customdata.lines}<extra></extra>"
}
],
{
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: "Анализ по темам" });
}

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

@ -1,140 +1,267 @@
document.addEventListener("DOMContentLoaded", function () { const ACTIVE_QUIZ_KEY = "activeQuiz";
// console.log("ok")
var students_selector = document.getElementById("students-selector") document.addEventListener("DOMContentLoaded", async function () {
var get_quiz_button = document.getElementById("get-quiz") const studentsSelector = document.getElementById("students-selector");
const getQuizButton = document.getElementById("get-quiz");
fetch("/hostip") const savedAttempt = loadAttempt();
.then(r => r.json())
.then(host_ip => document.getElementById("host-ip").innerText += ` ${host_ip}:8000`) if (savedAttempt && await isAttemptSubmitted(savedAttempt)) {
sessionStorage.removeItem(ACTIVE_QUIZ_KEY);
fetch("/students") loadStartPage(studentsSelector, getQuizButton);
.then(r => r.json()) } else if (savedAttempt) {
.then(students => { renderQuiz(savedAttempt);
students.forEach(student => { } else {
students_selector.innerHTML += `<option value=${student.id}>${student.name}</option>` loadStartPage(studentsSelector, getQuizButton);
}) }
students_selector.addEventListener("change", function (e) {
get_quiz_button.disabled = false setupTeacherLogin();
}, });
{ once: true }
) async function isAttemptSubmitted(attempt) {
}) const attemptId = attempt.quiz && attempt.quiz.attempt_id;
if (!attemptId) return false;
get_quiz_button.addEventListener("click", function () { try {
// console.log(students_selector.value) const response = await fetch(`/submission_status/${encodeURIComponent(attemptId)}`, { cache: "no-store" });
// console.log(students_selector.options[students_selector.selectedIndex].text) return response.ok && (await response.json()).submitted === true;
fetch('/get_quiz?' + new URLSearchParams({ } catch {
student_id: students_selector.value, return false;
student: students_selector.options[students_selector.selectedIndex].text }
})) }
.then(r => r.json())
.then(quiz => { function loadAttempt() {
// console.log(quiz) try {
// console.log(quiz.questions) const attempt = JSON.parse(sessionStorage.getItem(ACTIVE_QUIZ_KEY));
var questions = quiz.questions if (!attempt || !attempt.quiz || (!attempt.endTimeMs && !attempt.endTime)) return null;
attempt.endTimeMs = attempt.endTimeMs || attempt.endTime;
var questions_html = "<form id='form' onkeydown='return event.keyCode != 13;'>" attempt.serverOffsetMs = attempt.serverOffsetMs || 0;
questions.forEach(q => { return attempt;
// console.log(q) } catch {
if (q.is_multiple) { sessionStorage.removeItem(ACTIVE_QUIZ_KEY);
let options_div = "" return null;
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 = function saveAttempt(attempt) {
`<fieldset> sessionStorage.setItem(ACTIVE_QUIZ_KEY, JSON.stringify(attempt));
<legend>Выберите ответ:</legend> }
${options_div}
</fieldset>` function loadStartPage(studentsSelector, getQuizButton) {
questions_html += fetch("/hostip").then(r => r.json()).then(hostIp => {
`<article> document.getElementById("host-ip").textContent += ` ${hostIp}:8000`;
<h3>${q.question}</h3> });
${q.picture ? `<img src='${q.picture}'>` : ""}
${question_div} fetch("/students").then(r => r.json()).then(students => {
</article>` students.forEach(student => {
} else if (q.options) { const option = document.createElement("option");
let options_div = "" option.value = student.id;
q.options.forEach(o => { option.textContent = student.name;
options_div += `<label for="${o}${q.id}"><input type="radio" id="${o}${q.id}" name="${q.id}" value="${o}">${o}</label>` studentsSelector.appendChild(option);
}) });
const question_div = studentsSelector.addEventListener("change", () => { getQuizButton.disabled = false; });
`<fieldset> });
<legend>Выберите ответ:</legend>
${options_div} getQuizButton.addEventListener("click", function () {
</fieldset>` fetch("/get_quiz?" + new URLSearchParams({
questions_html += student_id: studentsSelector.value,
`<article> student: studentsSelector.options[studentsSelector.selectedIndex].text
<h3>${q.question}</h3> })).then(async response => {
${q.picture ? `<img src='${q.picture}'>` : ""} const data = await response.json();
${question_div} if (!response.ok) throw new Error(data.detail || "Не удалось получить тест");
</article>` return data;
} else { }).then(quiz => {
const question_div = `<input type="text" autocomplete="off" id="${q.id}" name="${q.id}">` const attempt = {
questions_html += quiz,
`<article> answers: {},
<h3>${q.question}</h3> endTimeMs: quiz.end_time_ms,
${q.picture ? `<img src='${q.picture}'>` : ""} serverOffsetMs: quiz.server_time_ms - Date.now()
${question_div} };
</article>` saveAttempt(attempt);
} renderQuiz(attempt);
}) }).catch(error => alert(error.message));
// 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 function renderQuiz(attempt) {
const { quiz } = attempt;
var button = document.createElement('button'); document.getElementById("header").innerHTML = `<h1>Тестирование</h1><p>${escapeHtml(quiz.student)}</p>`;
button.style.margin = "20px" const main = document.getElementById("main");
button.innerHTML = 'Сдать тест'; main.innerHTML = "";
button.onclick = function () {
// TODO: move this logic to the backend check_answers function const form = document.createElement("form");
// Populate quiz with empty answers (if no answer presented in select there'll be no property "answer" what could not be resolved in API) form.id = "form";
for (const question of quiz.questions) { form.addEventListener("keydown", event => {
question.is_multiple ? question.student_answer = [] : question.student_answer = "" if (event.key === "Enter") event.preventDefault();
} });
// Replace the empty answers with real answers quiz.questions.forEach(question => {
const form = document.getElementById('form'); const article = document.createElement("article");
const formData = new FormData(form); const title = document.createElement("h3");
for (const [key, value] of formData) { title.textContent = question.question;
// console.log(quiz) article.appendChild(title);
console.log(`${key}: ${value}\n`) // assume questions are in the same order - can it make code simplier? if (question.picture) {
const question = quiz.questions.find(q => q.id == key) const image = document.createElement("img");
question.is_multiple ? question.student_answer.push(value) : question.student_answer = value image.src = question.picture;
// quiz.questions.find(q => q.id == key).student_answer = value article.appendChild(image);
} }
console.log(quiz)
fetch('/save_student_answers', { if (question.options) {
method: 'POST', const fieldset = document.createElement("fieldset");
// mode: 'no-cors', const legend = document.createElement("legend");
// headers: { legend.textContent = "Выберите ответ:";
// 'Accept': 'text/plain', fieldset.appendChild(legend);
// 'Content-Type': 'text/plain' question.options.forEach((option, index) => {
// }, const label = document.createElement("label");
body: JSON.stringify(quiz) const input = document.createElement("input");
}) input.type = question.is_multiple ? "checkbox" : "radio";
document.getElementById("main").innerHTML = "<p>Тестирование окончено</p>" input.name = String(question.id);
}; input.value = option;
// where do we want to have the button to appear? input.id = `q-${question.id}-${index}`;
// you can append it to another element just by doing something like const saved = attempt.answers[String(question.id)];
// document.getElementById('foobutton').appendChild(button); input.checked = Array.isArray(saved) ? saved.includes(option) : saved === option;
document.getElementById("main").appendChild(button) label.htmlFor = input.id;
}) label.append(input, document.createTextNode(option));
}) fieldset.appendChild(label);
});
document.getElementById("end-quiz").addEventListener("click", function() { article.appendChild(fieldset);
let pass = window.prompt("Уважаемый преподаватель, введите пароль, чтобы завершить тестирование для всех", "Я здесь случайно") } else {
// console.log(pass) const input = document.createElement("input");
fetch('/end_quiz?' + new URLSearchParams({ input.type = "text";
password: pass input.autocomplete = "off";
})) input.name = String(question.id);
.then(r => r.text()) input.value = attempt.answers[String(question.id)] || "";
.then(text => window.alert(text)) article.appendChild(input);
}) }
form.appendChild(article);
document.getElementById("check-questions").addEventListener("click", function() { });
fetch('/check_questions')
.then(r => r.text()) form.addEventListener("input", () => persistAnswers(attempt, form));
.then(text => window.alert(text)) 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]));
}

@ -0,0 +1,23 @@
<!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=20260616-1">
<script defer src="monitor.js?v=20260616-1"></script>
</head>
<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();

@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Настройки теста</title>
<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>
const password = sessionStorage.getItem("teacherPassword") || "";
if (!password) {
window.location.replace("index.html");
}
</script>
<h1>Настройки теста</h1>
<form id="settings-form">
<label for="quiz-length">Количество вопросов:</label>
<input type="number" id="quiz-length" name="quiz_length" min="1" max="200">
<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>
<button type="submit">Сохранить настройки</button>
</form>
<hr>
<h2>Обновить список студентов</h2>
<p>Можно загрузить файл <code>.json</code> или <code>.csv</code>.</p>
<p>Поддерживаются форматы CSV:</p>
<ul>
<li>один столбец с ФИО;</li>
<li>столбец <code>name</code>;</li>
<li>столбцы <code>id</code> и <code>name</code>.</li>
</ul>
<p>Если готовите файл в Excel: сохраните его как <b>CSV UTF-8</b>.</p>
<form id="upload-form">
<input type="file" id="students-file" accept=".json,.csv" required>
<button type="submit">Загрузить файл</button>
</form>
<p><a href="index.html">Назад к тестированию</a></p>
<script>
let savedConfig = {};
fetch("/get_config?password=" + encodeURIComponent(password))
.then(r => r.json())
.then(cfg => {
if (cfg.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));
})
.then(r => r.json())
.then(topics => {
const container = document.getElementById("topics-container");
topics.forEach(topic => {
const id = `topic-${Math.random().toString(36).substr(2, 9)}`;
const label = document.createElement("label");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.name = "topics";
checkbox.value = topic;
checkbox.id = id;
if (savedConfig.topics == null || savedConfig.topics.includes(topic)) {
checkbox.checked = true;
}
label.htmlFor = id;
label.textContent = topic || "(без темы)";
const line = document.createElement("div");
line.appendChild(checkbox);
line.appendChild(label);
container.appendChild(line);
});
});
document.getElementById("settings-form").addEventListener("submit", function(e) {
e.preventDefault();
const quiz_length = document.getElementById("quiz-length").value;
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, activity_timeout, grade_thresholds })
})
.then(r => r.text())
.then(alert);
});
document.getElementById("upload-form").addEventListener("submit", function(e) {
e.preventDefault();
const fileInput = document.getElementById("students-file");
if (!fileInput.files.length) {
alert("Выберите файл.");
return;
}
const formData = new FormData();
formData.append("file", fileInput.files[0]);
fetch("/upload_students?password=" + encodeURIComponent(password), {
method: "POST",
body: formData
})
.then(r => r.text())
.then(alert);
});
</script>
</body>
</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,185 +1,853 @@
from fastapi import FastAPI, Body from fastapi import FastAPI, Body, UploadFile, HTTPException
from fastapi.middleware.cors import CORSMiddleware # CORS from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import json import json
import random import random
import shutil
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
import socket 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 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' должны быть числа.")
students_list.append({
"id": student_id,
"name": normalize_name(student_name)
})
# Вариант 2: заголовок name
elif "name" in first_row:
name_idx = first_row.index("name")
next_id = 1
for row in rows[1:]:
if len(row) <= name_idx:
continue
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()
origins = [ # CORS app = FastAPI(debug=True)
"*",
]
app.add_middleware( # CORS app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=origins, allow_origins=["*"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# open files once and use variables after
with open(QUESTIONS_FILE, "r", encoding="UTF-8") as f: with open(QUESTIONS_FILE, "r", encoding="UTF-8") as f:
questions_json = json.load(f) questions_json = json.load(f)
# TODO reassign questions id since mistakes are possible and checking is based on id all_questions = prepare_questions(questions_json["questions"])
all_questions = [{**q, "id": i} for i, q in enumerate(questions_json["questions"])] typed_questions = all_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
with open(STUDENTS_FILE, "r", encoding="UTF-8") as f: with open(STUDENTS_FILE, "r", encoding="UTF-8") as f:
students_json = json.load(f) students_json = json.load(f)
students = students_json["students"] students = students_json["students"]
# create folders if they don't exist
Path("answers").mkdir(parents=True, exist_ok=True) Path("answers").mkdir(parents=True, exist_ok=True)
Path("results").mkdir(parents=True, exist_ok=True) Path("results").mkdir(parents=True, exist_ok=True)
active_attempts = {}
@app.get("/get_config")
def get_config(password: str):
if password != END_TEST_PASSWORD:
return {"error": "Неверный пароль"}
return config
@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)))
@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)}
]
def check_answers(student_answers: dict): return {
checked_answers = student_answers "summary": {
for a in checked_answers["questions"]: "attempts": len(attempts),
question_id = a["id"] "students": len({attempt["student"] for attempt in attempts if attempt["student"]}),
a["correct_answer"] = next(question["answer"] for question in all_questions if question["id"] == question_id) # all_questions can be replaced with topic_questions "average_score": round(sum(scores) / len(scores), 1) if scores else 0,
if type(a["student_answer"]) is str and type(a["correct_answer"]) is str: "median_score": median_score,
a["is_correct"] = a["student_answer"].casefold() == a["correct_answer"].casefold() "pass_rate": round(sum(score >= 50 for score in scores) * 100 / len(scores)) if scores else 0
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"]) "distribution": distribution,
else: "students": students,
print("Unmatched types! Can't compare.") "questions": questions,
a["is_correct"] = False "topics": topics
}
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("/students")
def show_students():
return students
@app.get("/check_questions")
def check_questions():
"""Проверка вопросов на 1) наличие уникального идентификатора вопроса, 2) наличие правильного варианта ответа для вопросов с вариантами ответа
Returns: @app.post("/quiz_heartbeat")
Сообщение о корректности / некорректности вопросов def quiz_heartbeat(data: dict = Body()):
""" server_time_ms = round(time.time() * 1000)
unique_ids = [] student_id = str(data.get("student_id", ""))
errors = [] if not student_id:
for q in all_questions: return {"active": False, "server_time_ms": server_time_ms}
if q["id"] in unique_ids:
errors.append(f'"id": {q["id"]} — идентификатор не уникален') 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: else:
unique_ids.append(q["id"]) is_correct = False
if is_correct:
correct += 1
else:
incorrect += 1
if q.get("options") and not (set(q["answer"]).issubset(set(q["options"])) or q["answer"] in q["options"]): attempt["answered"] = answered
errors.append(f"В вопросе '{q['question']}' нет корректного варианта ответа") attempt["correct"] = correct
return errors if errors else "Вопросы в порядке!" 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.get("/hostip")
def show_host_ip():
"""Возвращает IP-адрес компьютера, на котором запущен сервер тестирования, — к этому IP-адресу нужно подключаться с компьютеров пользователей
Returns: @app.post("/upload_students")
IP-адрес def upload_students(password: str, file: UploadFile):
""" if password != END_TEST_PASSWORD:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) return "Неверный пароль"
s.settimeout(0)
try: try:
# doesn't even have to be reachable filename = (file.filename or "").lower()
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") global students
def show_students():
"""Список студентов if filename.endswith(".json"):
content = json.load(file.file)
if "students" not in content or not isinstance(content["students"], list):
raise ValueError("Неверный JSON-формат. Ожидается объект с полем 'students'.")
backup_students_file()
students = content["students"]
save_students_json(students)
return f"JSON загружен успешно. Студентов: {len(students)}"
elif filename.endswith(".csv"):
parsed_students = parse_students_csv(file.file)
backup_students_file()
students = parsed_students
save_students_json(students)
return f"CSV обработан успешно. Студентов: {len(students)}"
else:
return "Поддерживаются только файлы .json и .csv"
except Exception as e:
return f"Ошибка: {str(e)}"
Returns:
JSON со списком студентов
"""
return students
@app.get("/get_quiz") @app.get("/get_quiz")
def get_quiz(student_id, student: str): 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: questions_for_student = random.sample(
student_id (int): идентификатор студента selected_questions,
student (str): имя студента 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 { return {
"version": 1, "version": 1,
"attempt_id": uuid.uuid4().hex,
"student_id": student_id, "student_id": student_id,
"student": student, "student": student,
"wave": WAVE, # волна сдачи теста "wave": WAVE,
"start_time": datetime.now().strftime("%Y-%m-%dT%H-%M-%S"), "start_time": start_time.isoformat(),
"questions": questions_for_student "server_time_ms": server_time_ms,
"end_time_ms": end_time_ms,
"questions": quiz_questions,
"test_time": test_time
} }
@app.post("/save_student_answers")
def send_student_answers(student_answers: str = Body()):
"""Сохранить ответы студента как есть (./answers/...) и сохранить проверенные ответы студента (./results/...)
Args: def check_answers(student_answers: dict):
student_answers (str): JSON с ответами студента checked_answers = student_answers
for a in checked_answers["questions"]:
question_id = a["id"]
a["correct_answer"] = next(question["answer"] for question in all_questions if question["id"] == question_id)
Returns: if isinstance(a["student_answer"], str) and isinstance(a["correct_answer"], str):
str: имя студента a["is_correct"] = a["student_answer"].casefold() == a["correct_answer"].casefold()
""" elif isinstance(a["student_answer"], list) and isinstance(a["correct_answer"], list):
a["is_correct"] = set(a["student_answer"]) == set(a["correct_answer"])
else:
a["is_correct"] = False
checked_answers["correct"] = sum([a["is_correct"] for a in checked_answers["questions"]])
checked_answers["correct_percent"] = round(checked_answers["correct"] * 100 / len(checked_answers["questions"]))
return checked_answers
@app.post("/save_student_answers")
def send_student_answers(student_answers: str = Body()):
json_answers = json.loads(student_answers) json_answers = json.loads(student_answers)
timestamp_str = datetime.now().strftime("%Y-%m-%dT%H-%M-%S") attempt_id = str(json_answers.get("attempt_id") or "").strip()
json_answers["end_time"] = timestamp_str if not attempt_id:
path_to_answers = f'answers/{json_answers["student"]}_{json_answers["wave"]}_{timestamp_str}.json' identity = f'{json_answers.get("student_id", "")}|{json_answers.get("start_time", "")}|{json_answers.get("wave", "")}'
with open(path_to_answers, 'w', encoding='utf-8') as f: attempt_id = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32]
json.dump(json_answers, f, ensure_ascii=False) 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 = 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") @app.get("/end_quiz")
def end_test(password: str): def end_test(password: str):
"""Сохранить результаты из файлов JSON в папке ./results в файл CSV
Args:
password (str): пароль для "окончания теста"
"""
if password == END_TEST_PASSWORD: if password == END_TEST_PASSWORD:
csv_string = "student,percent,start,end\n" csv_string = "student,percent,start,end\n"
for file_in_results in Path("results").iterdir(): for file_in_results in Path("results").iterdir():
if file_in_results.is_file() and file_in_results.suffix == ".json": 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: with open(file_in_results, "r", encoding="UTF-8") as f:
content = json.load(f) content = json.load(f)
csv_string += f"{content['student']},{content['correct_percent']},{content['start_time']},{content['end_time']}\n" 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") timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
results_path = Path(f"results/results_{timestamp}.csv") results_path = Path(f"results/results_{timestamp}.csv")
with open(results_path, "w", encoding='utf-8') as f: with open(results_path, "w", encoding='utf-8') as f:
f.write(csv_string) f.write(csv_string)
return(f"Тестирование завершено. Сводные результаты сохранены в {results_path.resolve()}")
return f"Тестирование завершено. Сводные результаты сохранены в {results_path.resolve()}"
else: 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
app.mount("/pictures", StaticFiles(directory="pictures"), name="pictures")
app.mount("/", StaticFiles(directory="gui", html=True), name="gui")

@ -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 += `<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 = 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 = "<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))
})
})

@ -1,63 +1,70 @@
{ {
"version": 2, "version": 2,
"questions": [ "questions": [
{ {
"topic": "теодолит", "topic": "теодолит",
"author": "GT", "author": "GT",
"question": "Что такое теодолит?", "question": "Что такое теодолит?",
"picture": "pictures/teodolit.png", "picture": "pictures/teodolit.png",
"options": [ "options": [
"Прибор для измерения земли", "Прибор для измерения земли",
"Прибор для измерения углов", "Прибор для измерения углов",
"Прибор для измерения расстояний", "Прибор для измерения расстояний",
"Прибор для измерения высот" "Прибор для измерения высот"
], ],
"answer": "Прибор для измерения углов" "answer": "Прибор для измерения углов",
}, "id": 1
{ },
"topic": "геометрия", {
"author": "GT", "topic": "геометрия",
"question": "Сумма углов выпуклого пятиугольника составляет?", "author": "GT",
"answer": "540" "question": "Сумма углов выпуклого пятиугольника составляет?",
}, "answer": "540",
{ "id": 2
"topic": "нивелир", },
"author": "GT", {
"question": "Как называется прибор для измерения превышений?", "topic": "нивелир",
"answer": "нивелир" "author": "GT",
}, "question": "Как называется прибор для измерения превышений?",
{ "answer": "нивелир",
"topic": "теодолит", "id": 3
"author": "GT", },
"question": "Сколько винтов у теодолита?", {
"answer": "520" "topic": "теодолит",
}, "author": "GT",
{ "question": "Сколько винтов у теодолита?",
"topic": "нивелир", "answer": "520",
"author": "GT", "id": 4
"question": "Что такое нивелир?", },
"options": [ {
"Прибор для измерения земли", "id": 5,
"Прибор для измерения углов", "topic": "нивелир",
"Прибор для измерения расстояний", "author": "GT",
"Прибор для измерения высот" "question": "Что такое нивелир?",
], "answer": "Прибор для измерения превышений",
"answer": "Прибор для измерения углов" "options": [
}, "Прибор для измерения земли",
{ "Прибор для измерения углов",
"topic": "нивелир", "Прибор для измерения расстояний",
"author": "GT", "Прибор для измерения высот",
"question": "Что такое нивелир 2.0?", "Прибор для измерения превышений"
"options": [ ]
"Прибор для измерения земли", },
"Прибор для измерения углов", {
"Прибор для измерения расстояний", "topic": "нивелир",
"Прибор для измерения высот" "author": "GT",
], "question": "Что такое нивелир 2.0?",
"answer": [ "options": [
"Прибор для измерения расстояний", "Прибор для измерения земли",
"Прибор для измерения углов" "Прибор для измерения углов",
] "Прибор для измерения расстояний",
} "Прибор для измерения высот"
] ],
"answer": [
"Прибор для измерения расстояний",
"Прибор для измерения углов"
],
"id": 6
}
]
} }

@ -1,7 +1,8 @@
{ {
"version": 2, "version": 1,
"questions": [ "questions": [
{ {
"id": 1,
"topic": "теодолит", "topic": "теодолит",
"author": "GT", "author": "GT",
"question": "Что такое теодолит?", "question": "Что такое теодолит?",
@ -15,24 +16,28 @@
"answer": "Прибор для измерения" "answer": "Прибор для измерения"
}, },
{ {
"id": 1,
"topic": "геометрия", "topic": "геометрия",
"author": "GT", "author": "GT",
"question": "Сумма углов выпуклого пятиугольника составляет?", "question": "Сумма углов выпуклого пятиугольника составляет?",
"answer": "540" "answer": "540"
}, },
{ {
"id": 3,
"topic": "нивелир", "topic": "нивелир",
"author": "GT", "author": "GT",
"question": "Как называется прибор для измерения превышений?", "question": "Как называется прибор для измерения превышений?",
"answer": "нивелир" "answer": "нивелир"
}, },
{ {
"id": 4,
"topic": "теодолит", "topic": "теодолит",
"author": "GT", "author": "GT",
"question": "Сколько винтов у теодолита?", "question": "Сколько винтов у теодолита?",
"answer": "520" "answer": "520"
}, },
{ {
"id": 5,
"topic": "нивелир", "topic": "нивелир",
"author": "GT", "author": "GT",
"question": "Что такое нивелир?", "question": "Что такое нивелир?",
@ -45,6 +50,7 @@
"answer": "Прибор для измерения углов" "answer": "Прибор для измерения углов"
}, },
{ {
"id": 6,
"topic": "нивелир", "topic": "нивелир",
"author": "GT", "author": "GT",
"question": "Что такое нивелир 2.0?", "question": "Что такое нивелир 2.0?",

Binary file not shown.

@ -1,3 +1,5 @@
pip install -r requirements.txt @echo off
cd /d "%~dp0"
python -m pip install -r requirements.txt
python run.py python run.py
pause pause

@ -1,4 +1,28 @@
import uvicorn 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__": if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, log_level="info") 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)

@ -1,5 +1,5 @@
WAVE = 1 WAVE = 1
END_TEST_PASSWORD = "qwe" END_TEST_PASSWORD = "qwe"
QUIZ_LENGTH = 4 QUIZ_LENGTH = 50
QUESTIONS_FILE = "questions.json" QUESTIONS_FILE = "questions.json"
STUDENTS_FILE = "students.json" STUDENTS_FILE = "students.json"

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

@ -1,15 +1,29 @@
{ {
"version": 2, "version": 1,
"students": [ "students": [
{ {
"id": 1, "id": 1,
"name": "Екатерина Александровна Щербацкая", "name": "Будапешт Казантип"
"group": 209
}, },
{ {
"id": 2, "id": 2,
"name": "Екатерина Александровна Щербацкая", "name": "Боброслав Купидон"
"group": 208 },
{
"id": 3,
"name": "Барбарис Корвалол"
},
{
"id": 4,
"name": "Баттлфилд Когтевран"
},
{
"id": 5,
"name": "Бургеркинг Кабачок"
},
{
"id": 6,
"name": "Бранденбург Кенигсберг"
} }
] ]
} }
Loading…
Cancel
Save