v4
This commit is contained in:
14
src/db.js
14
src/db.js
@@ -75,6 +75,20 @@ async function initDb() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS exam_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
student_id INTEGER NOT NULL,
|
||||||
|
subject_code TEXT NOT NULL,
|
||||||
|
field_name TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
source TEXT DEFAULT 'manual'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
db.run('INSERT OR IGNORE INTO subjects (code, name, is_mandatory) VALUES (?, ?, 1)', ['01', 'Русский язык']);
|
db.run('INSERT OR IGNORE INTO subjects (code, name, is_mandatory) VALUES (?, ?, 1)', ['01', 'Русский язык']);
|
||||||
db.run('INSERT OR IGNORE INTO subjects (code, name, is_mandatory) VALUES (?, ?, 1)', ['02', 'Математика']);
|
db.run('INSERT OR IGNORE INTO subjects (code, name, is_mandatory) VALUES (?, ?, 1)', ['02', 'Математика']);
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ router.post('/import', upload.single('xlsfile'), (req, res) => {
|
|||||||
if (!existing || existing.grade === null) code = '02';
|
if (!existing || existing.grade === null) code = '02';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oldRow = dbGet("SELECT primary_score, grade FROM exam_results WHERE student_id = ? AND subject_code = ?", [sid, code]);
|
||||||
|
const changedBy = (req.session && req.session.user) ? req.session.user.login : 'import';
|
||||||
|
|
||||||
|
if (!oldRow) {
|
||||||
|
if (r.primary_score !== null) logHistory(sid, code, 'primary_score', null, String(r.primary_score), changedBy, 'import');
|
||||||
|
if (r.grade !== null) logHistory(sid, code, 'grade', null, String(r.grade), changedBy, 'import');
|
||||||
|
} else {
|
||||||
|
if (String(oldRow.primary_score) !== String(r.primary_score)) logHistory(sid, code, 'primary_score', oldRow.primary_score, r.primary_score, changedBy, 'import');
|
||||||
|
if (String(oldRow.grade) !== String(r.grade)) logHistory(sid, code, 'grade', oldRow.grade, r.grade, changedBy, 'import');
|
||||||
|
}
|
||||||
|
|
||||||
dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [sid, code, r.primary_score, r.grade]);
|
dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [sid, code, r.primary_score, r.grade]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,19 +165,46 @@ router.get('/students/:id', (req, res) => {
|
|||||||
ORDER BY s.is_mandatory DESC, s.name
|
ORDER BY s.is_mandatory DESC, s.name
|
||||||
`, [req.params.id]);
|
`, [req.params.id]);
|
||||||
|
|
||||||
res.json({ student, results });
|
const history = dbAll(`
|
||||||
|
SELECT h.*, s.name as subject_name
|
||||||
|
FROM exam_history h
|
||||||
|
LEFT JOIN subjects s ON s.code = h.subject_code
|
||||||
|
WHERE h.student_id = ?
|
||||||
|
ORDER BY h.changed_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`, [req.params.id]);
|
||||||
|
|
||||||
|
res.json({ student, results, history });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/students/:id/mandatory', (req, res) => {
|
router.put('/students/:id/mandatory', (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { rus_score, rus_grade, math_score, math_grade } = req.body;
|
const { rus_score, rus_grade, math_score, math_grade } = req.body;
|
||||||
|
const changedBy = (req.session && req.session.user) ? req.session.user.login : 'unknown';
|
||||||
|
|
||||||
dbBegin();
|
dbBegin();
|
||||||
|
|
||||||
|
const updateSubject = (code, score, grade) => {
|
||||||
|
const oldRow = dbGet("SELECT primary_score, grade FROM exam_results WHERE student_id = ? AND subject_code = ?", [id, code]);
|
||||||
|
|
||||||
|
if (!oldRow) {
|
||||||
|
if (score !== undefined && score !== null && score !== '') logHistory(id, code, 'primary_score', null, String(score), changedBy, 'manual');
|
||||||
|
if (grade !== undefined && grade !== null && grade !== '') logHistory(id, code, 'grade', null, String(grade), changedBy, 'manual');
|
||||||
|
} else {
|
||||||
|
const newScore = (score !== undefined && score !== '' && score !== null) ? score : null;
|
||||||
|
const newGrade = (grade !== undefined && grade !== '' && grade !== null) ? grade : null;
|
||||||
|
if (String(oldRow.primary_score) !== String(newScore)) logHistory(id, code, 'primary_score', oldRow.primary_score, newScore, changedBy, 'manual');
|
||||||
|
if (String(oldRow.grade) !== String(newGrade)) logHistory(id, code, 'grade', oldRow.grade, newGrade, changedBy, 'manual');
|
||||||
|
}
|
||||||
|
|
||||||
|
dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [id, code, score || null, grade || null]);
|
||||||
|
};
|
||||||
|
|
||||||
if (rus_score !== undefined || rus_grade !== undefined) {
|
if (rus_score !== undefined || rus_grade !== undefined) {
|
||||||
dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [id, '01', rus_score || null, rus_grade || null]);
|
updateSubject('01', rus_score, rus_grade);
|
||||||
}
|
}
|
||||||
if (math_score !== undefined || math_grade !== undefined) {
|
if (math_score !== undefined || math_grade !== undefined) {
|
||||||
dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [id, '02', math_score || null, math_grade || null]);
|
updateSubject('02', math_score, math_grade);
|
||||||
}
|
}
|
||||||
dbCommit();
|
dbCommit();
|
||||||
saveDb();
|
saveDb();
|
||||||
@@ -187,4 +225,12 @@ router.delete('/data', (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function logHistory(studentId, subjectCode, fieldName, oldValue, newValue, changedBy, source) {
|
||||||
|
if (String(oldValue) === String(newValue)) return;
|
||||||
|
dbRun(
|
||||||
|
'INSERT INTO exam_history (student_id, subject_code, field_name, old_value, new_value, changed_by, source, changed_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime(\'now\'))',
|
||||||
|
[studentId, subjectCode, fieldName, oldValue !== null && oldValue !== undefined ? String(oldValue) : null, newValue !== null && newValue !== undefined ? String(newValue) : null, changedBy || 'unknown', source || 'manual']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { dbGet, dbAll } = require('../db');
|
const { dbGet, dbAll } = require('../db');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const requireAuth = (req, res, next) => {
|
const requireAuth = (req, res, next) => {
|
||||||
@@ -34,11 +33,38 @@ router.get('/student/:id', requireAuth, (req, res) => {
|
|||||||
const rusResult = mandatoryResults.find(r => r.subject_code === '01') || null;
|
const rusResult = mandatoryResults.find(r => r.subject_code === '01') || null;
|
||||||
const mathResult = mandatoryResults.find(r => r.subject_code === '02') || null;
|
const mathResult = mandatoryResults.find(r => r.subject_code === '02') || null;
|
||||||
|
|
||||||
|
const history = dbAll(`
|
||||||
|
SELECT h.*, s.name as subject_name
|
||||||
|
FROM exam_history h
|
||||||
|
LEFT JOIN subjects s ON s.code = h.subject_code
|
||||||
|
WHERE h.student_id = ?
|
||||||
|
ORDER BY h.changed_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`, [req.params.id]);
|
||||||
|
|
||||||
|
// Group history by timestamp+user+source
|
||||||
|
const groupedHistory = [];
|
||||||
|
let currentGroup = null;
|
||||||
|
for (const row of history) {
|
||||||
|
const key = `${row.changed_at}|${row.changed_by}|${row.source}`;
|
||||||
|
if (!currentGroup || currentGroup.key !== key) {
|
||||||
|
currentGroup = { key, changed_at: row.changed_at, changed_by: row.changed_by, source: row.source, changes: [] };
|
||||||
|
groupedHistory.push(currentGroup);
|
||||||
|
}
|
||||||
|
currentGroup.changes.push({
|
||||||
|
subject_name: row.subject_name || row.subject_code,
|
||||||
|
field_name: row.field_name === 'primary_score' ? 'первичный балл' : 'оценка',
|
||||||
|
old_value: row.old_value !== null ? row.old_value : '—',
|
||||||
|
new_value: row.new_value !== null ? row.new_value : '—'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.render('student', {
|
res.render('student', {
|
||||||
student,
|
student,
|
||||||
electiveResults,
|
electiveResults,
|
||||||
rusResult,
|
rusResult,
|
||||||
mathResult,
|
mathResult,
|
||||||
|
groupedHistory,
|
||||||
user: req.session.user
|
user: req.session.user
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -232,4 +258,18 @@ router.get('/batch-print', requireAuth, (req, res) => {
|
|||||||
res.render('batch-certificate', { students: studentData });
|
res.render('batch-certificate', { students: studentData });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/history', requireAuth, (req, res) => {
|
||||||
|
const rows = dbAll(`
|
||||||
|
SELECT h.*, s.name as subject_name,
|
||||||
|
st.last_name, st.first_name, st.patronymic, st.class, st.id as student_id
|
||||||
|
FROM exam_history h
|
||||||
|
LEFT JOIN subjects s ON s.code = h.subject_code
|
||||||
|
LEFT JOIN students st ON st.id = h.student_id
|
||||||
|
ORDER BY h.changed_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.render('history', { rows, user: req.session.user });
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
76
views/history.ejs
Normal file
76
views/history.ejs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>История изменений — Справки</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="btn">← К списку</a>
|
||||||
|
<span class="user-bar"><%= user ? user.name : '' %></span>
|
||||||
|
<button id="logout-btn" class="btn btn-logout">Выйти</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>История изменений</h1>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Ученик</th>
|
||||||
|
<th>Класс</th>
|
||||||
|
<th>Предмет</th>
|
||||||
|
<th>Поле</th>
|
||||||
|
<th>Было</th>
|
||||||
|
<th>Стало</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Источник</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% rows.forEach(r => { %>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:12px;white-space:nowrap"><%= r.changed_at %></td>
|
||||||
|
<td><a href="/student/<%= r.student_id %>"><%= r.last_name %> <%= r.first_name %> <%= r.patronymic %></a></td>
|
||||||
|
<td><%= r.class %></td>
|
||||||
|
<td><%= r.subject_name || r.subject_code %></td>
|
||||||
|
<td><%= r.field_name === 'primary_score' ? 'балл' : 'оценка' %></td>
|
||||||
|
<td style="color:#999"><%= r.old_value !== null ? r.old_value : '—' %></td>
|
||||||
|
<td><strong><%= r.new_value !== null ? r.new_value : '—' %></strong></td>
|
||||||
|
<td style="font-size:12px"><%= r.changed_by || '—' %></td>
|
||||||
|
<td style="font-size:12px">
|
||||||
|
<% if (r.source === 'import') { %>импорт
|
||||||
|
<% } else if (r.source === 'manual') { %>вручную
|
||||||
|
<% } else { %><%= r.source %><% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
<% if (rows.length === 0) { %>
|
||||||
|
<tr><td colspan="9" style="text-align:center;color:#999;padding:20px">Нет записей</td></tr>
|
||||||
|
<% } %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.__CSRF_TOKEN = '<%= csrfToken %>';
|
||||||
|
const _origFetch = window.fetch.bind(window);
|
||||||
|
window.fetch = function(input, init = {}) {
|
||||||
|
init.credentials = 'include';
|
||||||
|
const method = (init.method || 'GET').toUpperCase();
|
||||||
|
if (['POST','PUT','PATCH','DELETE'].includes(method)) {
|
||||||
|
if (!init.headers) init.headers = {};
|
||||||
|
init.headers['X-CSRF-Token'] = window.__CSRF_TOKEN || '';
|
||||||
|
}
|
||||||
|
return _origFetch(input, init);
|
||||||
|
};
|
||||||
|
document.getElementById('logout-btn').addEventListener('click', async () => {
|
||||||
|
await fetch('/api/logout', { method: 'POST' });
|
||||||
|
window.location.href = '/login';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/" class="btn">← К списку</a>
|
<a href="/" class="btn">← К списку</a>
|
||||||
|
<a href="/history" class="btn" style="background:#5a3e85">История</a>
|
||||||
<span class="user-bar"><%= user ? user.name : '' %></span>
|
<span class="user-bar"><%= user ? user.name : '' %></span>
|
||||||
<button id="logout-btn" class="btn btn-logout">Выйти</button>
|
<button id="logout-btn" class="btn btn-logout">Выйти</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/" class="btn">Список учеников</a>
|
<a href="/" class="btn">Список учеников</a>
|
||||||
<a href="/import" class="btn btn-green">Импорт XLS</a>
|
<a href="/import" class="btn btn-green">Импорт XLS</a>
|
||||||
|
<a href="/history" class="btn" style="background:#5a3e85">История</a>
|
||||||
<span class="user-bar"><%= user ? user.name : '' %></span>
|
<span class="user-bar"><%= user ? user.name : '' %></span>
|
||||||
<button id="logout-btn" class="btn btn-logout">Выйти</button>
|
<button id="logout-btn" class="btn btn-logout">Выйти</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/" class="btn">← К списку</a>
|
<a href="/" class="btn">← К списку</a>
|
||||||
|
<a href="/history" class="btn" style="background:#5a3e85">История</a>
|
||||||
<span class="user-bar"><%= user ? user.name : '' %></span>
|
<span class="user-bar"><%= user ? user.name : '' %></span>
|
||||||
<button id="logout-btn" class="btn btn-logout">Выйти</button>
|
<button id="logout-btn" class="btn btn-logout">Выйти</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -100,6 +101,40 @@
|
|||||||
<button type="submit" class="btn btn-blue" style="margin-top:15px">Сформировать и распечатать</button>
|
<button type="submit" class="btn btn-blue" style="margin-top:15px">Сформировать и распечатать</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if (groupedHistory && groupedHistory.length > 0) { %>
|
||||||
|
<div class="card">
|
||||||
|
<h2>История изменений</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Источник</th>
|
||||||
|
<th>Изменения</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% groupedHistory.forEach(g => { %>
|
||||||
|
<tr>
|
||||||
|
<td style="white-space:nowrap;font-size:12px"><%= g.changed_at %></td>
|
||||||
|
<td style="font-size:12px"><%= g.changed_by %></td>
|
||||||
|
<td style="font-size:12px">
|
||||||
|
<% if (g.source === 'import') { %>XLS импорт
|
||||||
|
<% } else if (g.source === 'manual') { %>вручную
|
||||||
|
<% } else { %><%= g.source %><% } %>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px">
|
||||||
|
<% g.changes.forEach((c, ci) => { %>
|
||||||
|
<div><%= c.subject_name %>: <%= c.field_name %> <span style="color:#999"><%= c.old_value %></span> → <strong><%= c.new_value %></strong></div>
|
||||||
|
<% }) %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
Reference in New Issue
Block a user