upd 30.05.2026

pull/9/head
Andrey Karpachevskiy 4 weeks ago
parent 2cff9522e6
commit 1088278eeb

@ -0,0 +1,5 @@
{
"quiz_length": 30,
"topics": null,
"test_time": 40
}

@ -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: "Анализ по темам" });
}

@ -7,11 +7,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
<!-- <link rel="stylesheet" href="https://unpkg.com/awsm.css/dist/awsm.min.css"> -->
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/spcss@0.9.0"> -->
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css"> -->
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/spcss@0.9.0"> -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css">
<!-- <link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura.css" type="text/css"> -->
<!-- <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"> -->
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.classless.min.css"> -->
<script src="main.js"></script>
<style>
label {
@ -43,8 +42,19 @@
</p>
<button id="end-quiz">Завершить тест для всех 😇</button>
<br>
<button id="check-questions">Проверить вопросы 🧐</button>
</main>
<form>
<button formaction="/check_questions">Проверить вопросы 🧐</button>
</form>
<p>
<button onclick="window.location.href='settings.html'">⚙️ Настройки теста</button>
</p>
<p>
<button onclick = "window.location.href = 'monitor.html'">Монитор</button>
</p>
</main>
<hr>
<footer>

@ -1,3 +1,4 @@
// REPLACE localhost WITH ACTUAL HOST IP
document.addEventListener("DOMContentLoaded", function () {
// console.log("ok")
var students_selector = document.getElementById("students-selector")
@ -83,10 +84,29 @@ document.addEventListener("DOMContentLoaded", function () {
document.getElementById("header").innerHTML = `<h1>Тестирование</h1><p>${students_selector.options[students_selector.selectedIndex].text}</p>`
document.getElementById("main").innerHTML = questions_html
var testTime = quiz.test_time || 15;
var startTime = new Date();
var endTime = startTime.getTime() + testTime * 60 * 1000;
var time = document.createElement("div");
document.getElementById("main").appendChild(time);
var timeLeft = new Date();
function showTime() {
timeLeft = new Date();
timeLeft = endTime - timeLeft.getTime();
time.innerHTML = Math.floor(timeLeft / 1000 / 60) + ":";
if (Math.floor(timeLeft / 1000) % 60 < 10) { time.innerHTML += "0" }
time.innerHTML += (Math.floor(timeLeft / 1000) % 60);
if (time.innerHTML == "0:00") {
endTest();
// clearInterval(testTiming);
}
}
var testTiming = setInterval(showTime, 200);
var button = document.createElement('button');
button.style.margin = "20px"
button.innerHTML = 'Сдать тест';
button.onclick = function () {
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) {
@ -113,8 +133,10 @@ document.addEventListener("DOMContentLoaded", function () {
// },
body: JSON.stringify(quiz)
})
document.getElementById("main").innerHTML = "<p>Тестирование окончено</p>"
};
clearInterval(testTiming);
document.getElementById("main").innerHTML = "<p>Тестирование окончено</p>";
}
button.onclick = endTest;
// where do we want to have the button to appear?
// you can append it to another element just by doing something like
// document.getElementById('foobutton').appendChild(button);
@ -131,10 +153,4 @@ document.addEventListener("DOMContentLoaded", function () {
.then(r => r.text())
.then(text => window.alert(text))
})
document.getElementById("check-questions").addEventListener("click", function() {
fetch('/check_questions')
.then(r => r.text())
.then(text => window.alert(text))
})
})

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

@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Настройки теста</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css">
</head>
<body>
<script>
let password = prompt("Введите пароль для доступа к настройкам:");
if (!password) {
alert("Пароль не введён. Доступ запрещён.");
document.body.innerHTML = "<h2>Доступ запрещён.</h2>";
throw new Error("Пароль не введён");
}
</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>Выберите темы:</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) {
alert("Неверный пароль");
document.body.innerHTML = "<h2>Доступ запрещён.</h2>";
throw new Error("Неверный пароль");
}
savedConfig = cfg;
document.getElementById("quiz-length").value = cfg.quiz_length;
document.getElementById("test-time").value = cfg.test_time || 15;
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 || 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;
fetch("/set_config?password=" + encodeURIComponent(password), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quiz_length, topics, test_time })
})
.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>

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

Binary file not shown.

@ -1,4 +1,4 @@
import uvicorn
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, log_level="info")
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

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

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