Merge pull request 'fix: complete admin catalog UX and recovery' (#38) from feature/admin-ux-hardening into main

Reviewed-on: #38
This commit was merged in pull request #38.
This commit is contained in:
2026-09-11 09:50:03 +00:00
17 changed files with 616 additions and 178 deletions

View File

@@ -1,5 +1,9 @@
# Changelog # Changelog
## 0.8.1
- Улучшены доступность, фильтры, состояния и адаптивность административного каталога.
## 0.7.8 ## 0.7.8
- В колонке учёной степени отображаются все найденные степени без лишнего текста. - В колонке учёной степени отображаются все найденные степени без лишнего текста.

View File

@@ -43,9 +43,15 @@ uvicorn app.main:app --reload
В админке доступны: В админке доступны:
- `Dashboard`: общая статистика, последний добавленный сотрудник, прогресс текущего/последнего парсинга и ручной запуск. - «Обзор»: статистика, последний добавленный сотрудник, прогресс парсинга и ручной запуск.
- `Directory`: настраиваемая таблица сотрудников с фильтрами, сортировкой, пагинацией и выбором колонок. - «Сотрудники»: поиск, фильтры, сортировка, пагинация и выбор колонок. По умолчанию показаны ФИО, статус, должности, дата последнего обнаружения и внешний профиль. В диалоге колонок доступны наборы «Проверка», «Контакты» и «Все поля»; ранее сохранённый выбор сохраняется.
- `Runs`: история запусков, ошибки и progress bar. - «Запуски»: история обходов, ошибки и доступный индикатор прогресса.
Все фильтры применяются кнопкой «Применить фильтры» с переходом на первую страницу. «Сбросить» очищает условия; при устаревшем номере страницы каталог возвращает первую. Подписи полей показывают выбранные условия, над таблицей указан диапазон результатов. Пояснения дат и статусов находятся под фильтрами.
Каталог поддерживает навигацию с клавиатуры и системный диалог колонок с Escape и возвратом фокуса. На узких экранах таблица прокручивается внутри страницы, колонка ФИО закреплена. Используется системный шрифт без внешних загрузок.
Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.
## Docker Compose ## Docker Compose
@@ -56,7 +62,9 @@ docker compose up -d --build --remove-orphans
По умолчанию: По умолчанию:
- API и админка: `http://localhost:8000` - API и админка: `http://localhost:8000`
- Postgres: `localhost:5432` - PostgreSQL: `postgres:5432` внутри сети Compose; порт на хост не опубликован.
Compose запускает `api`, `worker` и `postgres`. API привязан к localhost; для внешнего доступа нужен настроенный reverse proxy. MCP удалён, endpoint `/mcp` отсутствует; REST API данных требует сессию администратора.
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например `crawl_runs.skipped_count`. SQL-миграции для ручного применения лежат в `migrations/`. Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например `crawl_runs.skipped_count`. SQL-миграции для ручного применения лежат в `migrations/`.
@@ -71,8 +79,6 @@ docker compose up -d --build --remove-orphans
`employee_publications` содержит `publication_id`, название, год, тип публикации, язык, статус, ссылку на карточку HSE Publications, DOI, внешние/document-ссылки, citation text, аннотацию, описание, авторов, raw JSON ответа `searchPubs` и `source_hash` для безопасного повторного upsert. Уникальность поддерживается по `(employee_id, publication_id)` и `(employee_id, source_hash)`, поэтому повторный crawl не должен создавать дубликаты. `employee_publications` содержит `publication_id`, название, год, тип публикации, язык, статус, ссылку на карточку HSE Publications, DOI, внешние/document-ссылки, citation text, аннотацию, описание, авторов, raw JSON ответа `searchPubs` и `source_hash` для безопасного повторного upsert. Уникальность поддерживается по `(employee_id, publication_id)` и `(employee_id, source_hash)`, поэтому повторный crawl не должен создавать дубликаты.
`list_employee_publications` сначала читает `employee_publications`; если детальных строк еще нет, возвращает старые публикации из `current_data`.
Новости сотрудников также хранятся в двух видах: Новости сотрудников также хранятся в двух видах:
- краткий список остается внутри `employees.current_data.sections[].news_links`; - краткий список остается внутри `employees.current_data.sections[].news_links`;
@@ -116,4 +122,15 @@ docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
docker compose down docker compose down
``` ```
Версия сервиса: `0.7.7`. Админка всегда показывает версии backend и frontend в footer. ## Проверки
После установки зависимостей из `requirements.txt` выполните:
```bash
python -m pytest -q
node --check app/static/admin.js
```
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
Версия сервиса: `0.8.1`. Админка всегда показывает версии backend и frontend в footer.

View File

@@ -2,6 +2,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import desc, func, select from sqlalchemy import desc, func, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import Settings, get_settings from app.config import Settings, get_settings
@@ -93,19 +94,23 @@ def directory(
parsed_started_to = _parse_date(started_to) parsed_started_to = _parse_date(started_to)
parsed_has_email = None if has_email in (None, "") else has_email == "true" parsed_has_email = None if has_email in (None, "") else has_email == "true"
parsed_has_academic_degree = None if has_academic_degree in (None, "") else has_academic_degree == "true" parsed_has_academic_degree = None if has_academic_degree in (None, "") else has_academic_degree == "true"
page = list_employees_page( try:
db, page = list_employees_page(
status=status, db,
q=q, status=status,
started_from=parsed_started_from, q=q,
started_to=parsed_started_to, started_from=parsed_started_from,
has_email=parsed_has_email, started_to=parsed_started_to,
has_academic_degree=parsed_has_academic_degree, has_email=parsed_has_email,
sort=sort, has_academic_degree=parsed_has_academic_degree,
direction=direction, sort=sort,
limit=limit, direction=direction,
offset=offset, limit=limit,
) offset=offset,
)
except SQLAlchemyError:
db.rollback()
return _render(request, "directory_error.html", {}, status_code=503)
return _render( return _render(
request, request,
"directory.html", "directory.html",
@@ -121,7 +126,7 @@ def directory(
"sort": sort, "sort": sort,
"direction": direction, "direction": direction,
"limit": page["limit"], "limit": page["limit"],
"offset": offset, "offset": page["offset"],
}, },
}, },
) )

View File

@@ -132,6 +132,8 @@ def list_employees_page(
has_academic_degree=has_academic_degree, has_academic_degree=has_academic_degree,
) )
total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0 total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0
if offset >= total:
offset = 0
sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name) sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name)
order = desc(sort_column) if direction == "desc" else sort_column order = desc(sort_column) if direction == "desc" else sort_column
employees = db.scalars(base_stmt.order_by(order).limit(limit).offset(offset)).all() employees = db.scalars(base_stmt.order_by(order).limit(limit).offset(offset)).all()

View File

@@ -5,7 +5,22 @@
flex-direction: column; flex-direction: column;
color: #1f2937; color: #1f2937;
background: #f6f7f9; background: #f6f7f9;
font-family: Arial, sans-serif; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.admin__skip-link {
position: absolute;
top: 8px;
left: 8px;
z-index: 100;
padding: 8px 12px;
color: #ffffff;
background: #0f766e;
transform: translateY(-150%);
}
.admin__skip-link:focus {
transform: translateY(0);
} }
.admin__header { .admin__header {
@@ -16,6 +31,7 @@
padding: 18px 32px; padding: 18px 32px;
background: #ffffff; background: #ffffff;
border-bottom: 1px solid #d9dee7; border-bottom: 1px solid #d9dee7;
flex-wrap: wrap;
} }
.admin__brand { .admin__brand {
@@ -40,6 +56,16 @@
font-weight: 700; font-weight: 700;
} }
.admin__link[aria-current="page"] {
text-decoration: underline;
text-underline-offset: 4px;
}
:is(a, button, input, select, summary):focus-visible {
outline: 3px solid #0f766e;
outline-offset: 2px;
}
.admin__main { .admin__main {
flex: 1; flex: 1;
width: min(1180px, calc(100% - 32px)); width: min(1180px, calc(100% - 32px));
@@ -107,10 +133,6 @@
border-collapse: collapse; border-collapse: collapse;
} }
.table__row {
cursor: pointer;
}
.table__row:hover { .table__row:hover {
background: #f0fdfa; background: #f0fdfa;
} }
@@ -161,8 +183,9 @@
border-radius: 6px; border-radius: 6px;
} }
.button { .button {
padding: 10px 14px; min-height: 40px;
padding: 10px 14px;
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
color: #ffffff; color: #ffffff;
@@ -178,6 +201,19 @@
.button--compact { .button--compact {
padding: 8px 12px; padding: 8px 12px;
}
.button:hover {
filter: brightness(0.9);
}
.button:active {
filter: brightness(0.8);
}
.button:disabled {
cursor: wait;
opacity: 0.65;
} }
.code { .code {
@@ -426,25 +462,25 @@
gap: 12px; gap: 12px;
} }
.progress-panel__header { .progress-panel__header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
} }
.progress-panel__actions { .progress-panel__actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.button--secondary { .button--secondary {
color: #0f766e; color: #0f766e;
background: #ccfbf1; background: #ccfbf1;
} }
.progress-panel__body { .progress-panel__body {
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
@@ -467,6 +503,12 @@
color: #6b7280; color: #6b7280;
} }
.progress-panel__error {
margin: 0;
color: #991b1b;
font-weight: 700;
}
.progress-bar { .progress-bar {
height: 12px; height: 12px;
overflow: hidden; overflow: hidden;
@@ -478,7 +520,6 @@
height: 100%; height: 100%;
width: 0; width: 0;
background: #0f766e; background: #0f766e;
transition: width 0.25s ease;
} }
.directory { .directory {
@@ -505,7 +546,7 @@
.directory__filters { .directory__filters {
display: grid; display: grid;
grid-template-columns: minmax(220px, 1.7fr) repeat(6, minmax(120px, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px; gap: 10px;
padding: 16px; padding: 16px;
background: #ffffff; background: #ffffff;
@@ -513,13 +554,56 @@
border-radius: 8px; border-radius: 8px;
} }
.directory__filter-group {
display: grid;
grid-template-columns: 1fr;
align-content: start;
gap: 8px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.directory__filter-group--search {
grid-template-columns: 1fr;
}
.directory__filter-legend {
grid-column: 1 / -1;
margin-bottom: 2px;
color: #374151;
font-size: 12px;
font-weight: 700;
}
.directory__field {
display: grid;
gap: 5px;
min-width: 0;
color: #4b5563;
font-size: 12px;
font-weight: 700;
}
.directory__input { .directory__input {
font: inherit;
font-size: 16px;
font-weight: 400;
min-width: 0; min-width: 0;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid #cbd5e1; border: 1px solid #cbd5e1;
border-radius: 6px; border-radius: 6px;
} }
.directory__filter-actions {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 8px;
grid-column: 1 / -1;
}
.directory__table-wrap { .directory__table-wrap {
overflow-x: auto; overflow-x: auto;
background: #ffffff; background: #ffffff;
@@ -539,9 +623,18 @@
font-weight: 700; font-weight: 700;
} }
.directory__help {
color: #4b5563;
line-height: 1.5;
}
.directory__help summary {
cursor: pointer;
}
.directory-table { .directory-table {
width: 100%; width: 100%;
min-width: 1120px; min-width: 640px;
border-collapse: collapse; border-collapse: collapse;
} }
@@ -556,14 +649,19 @@
} }
.directory-table__cell { .directory-table__cell {
overflow-wrap: anywhere;
max-width: 280px; max-width: 280px;
padding: 12px 10px; padding: 12px 10px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
vertical-align: top; vertical-align: top;
}
.directory-table .badge {
white-space: nowrap;
} }
.directory-table__row { .directory-table__row {
cursor: pointer; cursor: default;
} }
.directory-table__row:hover { .directory-table__row:hover {
@@ -576,33 +674,32 @@
text-align: center; text-align: center;
} }
.directory-table__empty span,
.directory-table__empty a {
display: block;
margin-top: 8px;
}
.directory-table__cell--hidden, .directory-table__cell--hidden,
.directory-table__head--hidden { .directory-table__head--hidden {
display: none; display: none;
} }
.columns-modal { .columns-modal {
position: fixed; width: min(620px, calc(100% - 40px));
inset: 0; max-height: min(720px, calc(100vh - 40px));
z-index: 50; margin: auto;
display: grid; padding: 0;
place-items: center; border: 0;
padding: 20px; background: transparent;
} }
.columns-modal[hidden] { .columns-modal::backdrop {
display: none;
}
.columns-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(17, 24, 39, 0.54); background: rgba(17, 24, 39, 0.54);
} }
.columns-modal__panel { .columns-modal__panel {
position: relative; position: relative;
width: min(620px, 100%);
max-height: min(720px, calc(100vh - 40px)); max-height: min(720px, calc(100vh - 40px));
overflow: auto; overflow: auto;
padding: 20px; padding: 20px;
@@ -640,16 +737,37 @@
border-radius: 6px; border-radius: 6px;
} }
.columns-modal__checkbox { .columns-modal__checkbox {
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
.columns-modal__presets {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 18px;
padding: 0;
border: 0;
}
.columns-modal__preset-label {
color: #4b5563;
font-size: 13px;
font-weight: 700;
}
@media (max-width: 920px) { @media (max-width: 920px) {
.directory__filters { .directory__filters {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.directory__filter-group--search,
.directory__filter-actions {
grid-column: 1 / -1;
}
.progress-panel__header, .progress-panel__header,
.directory__header, .directory__header,
.employee-card__header { .employee-card__header {
@@ -659,7 +777,40 @@
} }
@media (max-width: 620px) { @media (max-width: 620px) {
.directory-table [data-column="full_name"] {
position: sticky;
left: 0;
z-index: 1;
min-width: 130px;
max-width: 160px;
background: #ffffff;
}
.admin__header {
align-items: flex-start;
padding: 16px;
}
.admin__nav {
width: 100%;
flex-wrap: wrap;
}
.admin__main {
width: min(100% - 24px, 1180px);
margin: 20px auto;
}
.directory__filters { .directory__filters {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.directory__filter-group,
.directory__filter-group--search,
.directory__filter-actions {
grid-column: 1;
}
.directory__field {
font-size: 13px;
}
} }

View File

@@ -3,14 +3,15 @@
"full_name", "full_name",
"status", "status",
"positions", "positions",
"hse_start_year",
"email",
"academic_degree",
"last_seen_at", "last_seen_at",
"dismissed_at",
"profile", "profile",
]; ];
const storageKey = "miem.directory.columns"; const storageKey = "miem.directory.columns";
const columnPresets = {
review: ["full_name", "status", "academic_degree", "last_seen_at", "profile"],
contacts: ["full_name", "status", "email", "phone", "address", "profile"],
full: ["full_name", "status", "positions", "hse_start_year", "email", "phone", "address", "academic_degree", "publications_count", "courses_count", "news_count", "first_seen_at", "last_seen_at", "dismissed_at", "profile"],
};
function readColumns() { function readColumns() {
try { try {
@@ -40,18 +41,33 @@
if (!document.querySelector("[data-directory-table]")) return; if (!document.querySelector("[data-directory-table]")) return;
let columns = readColumns(); let columns = readColumns();
const modal = document.querySelector("[data-columns-modal]"); const modal = document.querySelector("[data-columns-modal]");
const trigger = document.querySelector("[data-columns-open]");
applyColumns(columns); applyColumns(columns);
document.querySelectorAll("[data-columns-open]").forEach((button) => { trigger.addEventListener("click", () => {
button.addEventListener("click", () => { modal.showModal();
modal.hidden = false; trigger.setAttribute("aria-expanded", "true");
}); modal.querySelector("input")?.focus();
}); });
document.querySelectorAll("[data-columns-close]").forEach((button) => { document.querySelectorAll("[data-columns-close]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
modal.hidden = true; modal.close();
}); });
}); });
document.querySelectorAll("[data-columns-preset]").forEach((button) => {
button.addEventListener("click", () => {
columns = columnPresets[button.dataset.columnsPreset];
writeColumns(columns);
applyColumns(columns);
});
});
modal.addEventListener("click", (event) => {
if (event.target === modal) modal.close();
});
modal.addEventListener("close", () => {
trigger.setAttribute("aria-expanded", "false");
trigger.focus();
});
document.querySelectorAll("[data-column-toggle]").forEach((checkbox) => { document.querySelectorAll("[data-column-toggle]").forEach((checkbox) => {
checkbox.addEventListener("change", () => { checkbox.addEventListener("change", () => {
columns = Array.from(document.querySelectorAll("[data-column-toggle]:checked")).map((item) => item.value); columns = Array.from(document.querySelectorAll("[data-column-toggle]:checked")).map((item) => item.value);
@@ -62,28 +78,14 @@
}); });
} }
function setupClickableRows() {
const openRow = (row) => {
window.location.href = row.dataset.rowHref;
};
document.querySelectorAll("[data-row-href]").forEach((row) => {
row.addEventListener("click", (event) => {
if (event.target.closest("a, button, input, select, label")) return;
openRow(row);
});
row.addEventListener("keydown", (event) => {
if (!["Enter", " "].includes(event.key)) return;
if (event.target.closest("a, button, input, select, label")) return;
event.preventDefault();
openRow(row);
});
});
}
function setupProgress() { function setupProgress() {
const panel = document.querySelector("[data-progress-panel]"); const panel = document.querySelector("[data-progress-panel]");
if (!panel) return; if (!panel) return;
const state = document.createElement("p");
state.className = "progress-panel__empty";
state.setAttribute("role", "status");
panel.append(state);
let pending = false;
const update = (run) => { const update = (run) => {
if (!run) return; if (!run) return;
@@ -94,6 +96,7 @@
const errors = document.querySelector("[data-progress-errors]"); const errors = document.querySelector("[data-progress-errors]");
const fill = document.querySelector("[data-progress-fill]"); const fill = document.querySelector("[data-progress-fill]");
const percent = document.querySelector("[data-progress-percent]"); const percent = document.querySelector("[data-progress-percent]");
const error = document.querySelector("[data-progress-error]");
if (status) status.textContent = run.status_display || run.status; if (status) status.textContent = run.status_display || run.status;
if (processed) processed.textContent = run.processed_count; if (processed) processed.textContent = run.processed_count;
if (found) found.textContent = run.found_count; if (found) found.textContent = run.found_count;
@@ -101,21 +104,44 @@
if (errors) errors.textContent = run.error_count; if (errors) errors.textContent = run.error_count;
if (fill) fill.style.width = `${run.progress_percent}%`; if (fill) fill.style.width = `${run.progress_percent}%`;
if (percent) percent.textContent = run.progress_percent; if (percent) percent.textContent = run.progress_percent;
if (fill) {
fill.parentElement.setAttribute("aria-valuenow", run.progress_percent);
fill.parentElement.setAttribute("aria-valuetext", `${run.progress_percent}%`);
}
if (error) error.hidden = true;
}; };
const poll = async () => { const poll = async () => {
if (pending) return true;
pending = true;
state.textContent = "Обновляем прогресс…";
panel.setAttribute("aria-busy", "true");
try { try {
const response = await fetch("/api/crawl-runs/latest", { credentials: "same-origin" }); const response = await fetch("/api/crawl-runs/latest", {
if (!response.ok) return false; credentials: "same-origin",
signal: AbortSignal.timeout(15000),
});
if (!response.ok) throw new Error("progress request failed");
const data = await response.json(); const data = await response.json();
const run = data.running || data.latest; const run = data.running || data.latest;
update(run); update(run);
state.textContent = run ? "Прогресс обновлён" : "Запусков пока нет. Запустите парсинг.";
const error = document.querySelector("[data-progress-error]");
if (error) error.hidden = true;
return Boolean(data.running); return Boolean(data.running);
} catch (_error) { } catch (_error) {
return false; const error = document.querySelector("[data-progress-error]");
if (error) error.hidden = false;
state.textContent = "Показаны последние полученные данные. Прогресс может быть устаревшим.";
return true;
} finally {
pending = false;
panel.setAttribute("aria-busy", "false");
} }
}; };
document.querySelectorAll("[data-progress-retry]").forEach((button) => button.addEventListener("click", poll));
const interval = window.setInterval(async () => { const interval = window.setInterval(async () => {
const keepGoing = await poll(); const keepGoing = await poll();
if (!keepGoing) window.clearInterval(interval); if (!keepGoing) window.clearInterval(interval);
@@ -123,6 +149,5 @@
} }
setupColumns(); setupColumns();
setupClickableRows();
setupProgress(); setupProgress();
})(); })();

View File

@@ -7,18 +7,19 @@
<link rel="stylesheet" href="/static/admin.css"> <link rel="stylesheet" href="/static/admin.css">
</head> </head>
<body class="admin"> <body class="admin">
<a class="admin__skip-link" href="#main-content">Перейти к содержимому</a>
<header class="admin__header"> <header class="admin__header">
<h1 class="admin__brand"><a class="admin__brand-link" href="/admin">MIEM Employees</a></h1> <h1 class="admin__brand"><a class="admin__brand-link" href="/admin">MIEM Employees</a></h1>
<nav class="admin__nav"> <nav class="admin__nav">
<a class="admin__link" href="/admin">Обзор</a> <a class="admin__link" href="/admin"{% if request.url.path == "/admin" %} aria-current="page"{% endif %}>Обзор</a>
<a class="admin__link" href="/admin/directory">Сотрудники</a> <a class="admin__link" href="/admin/directory"{% if request.url.path == "/admin/directory" %} aria-current="page"{% endif %}>Сотрудники</a>
<a class="admin__link" href="/admin/runs">Запуски</a> <a class="admin__link" href="/admin/runs"{% if request.url.path.startswith("/admin/runs") %} aria-current="page"{% endif %}>Запуски</a>
<form method="post" action="/admin/logout"> <form method="post" action="/admin/logout">
<button class="button button--ghost" type="submit">Выйти</button> <button class="button button--ghost" type="submit">Выйти</button>
</form> </form>
</nav> </nav>
</header> </header>
<main class="admin__main"> <main class="admin__main" id="main-content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<footer class="admin__footer"> <footer class="admin__footer">

View File

@@ -28,15 +28,15 @@
</section> </section>
<section class="panel progress-panel" data-progress-panel> <section class="panel progress-panel" data-progress-panel>
<div class="progress-panel__header"> <div class="progress-panel__header">
<h2 class="panel__title">Прогресс парсинга</h2> <h2 class="panel__title">Прогресс парсинга</h2>
<div class="progress-panel__actions"> <div class="progress-panel__actions">
<form method="post" action="/admin/crawl-now"> <form method="post" action="/admin/crawl-now">
<button class="button" type="submit">Запустить парсинг</button> <button class="button" type="submit">Запустить парсинг</button>
</form> </form>
<form method="post" action="/admin/dismissed/refresh"> <form method="post" action="/admin/dismissed/refresh">
<button class="button button--secondary" type="submit">Проверить уволенных</button> <button class="button button--secondary" type="submit">Проверить уволенных</button>
</form> </form>
</div> </div>
</div> </div>
{% set run = counts.current_running_run or latest_run %} {% set run = counts.current_running_run or latest_run %}
<div class="progress-panel__body" data-progress-body> <div class="progress-panel__body" data-progress-body>
@@ -46,10 +46,11 @@
<span>без изменений: <span data-progress-skipped>{{ run.skipped_count if run else 0 }}</span></span> <span>без изменений: <span data-progress-skipped>{{ run.skipped_count if run else 0 }}</span></span>
<span>ошибок: <span data-progress-errors>{{ run.error_count if run else 0 }}</span></span> <span>ошибок: <span data-progress-errors>{{ run.error_count if run else 0 }}</span></span>
</div> </div>
<div class="progress-bar" aria-label="Parsing progress"> <div class="progress-bar" role="progressbar" aria-label="Прогресс парсинга" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ run.progress_percent if run else 0 }}" aria-valuetext="{{ run.progress_percent if run else 0 }}%">
<div class="progress-bar__fill" data-progress-fill style="width: {{ run.progress_percent if run else 0 }}%"></div> <div class="progress-bar__fill" data-progress-fill style="width: {{ run.progress_percent if run else 0 }}%"></div>
</div> </div>
<div class="progress-panel__percent"><span data-progress-percent>{{ run.progress_percent if run else 0 }}</span>%</div> <div class="progress-panel__percent"><span data-progress-percent>{{ run.progress_percent if run else 0 }}</span>%</div>
<p class="progress-panel__error" data-progress-error role="status" hidden>Не удалось обновить прогресс. <button class="button button--ghost button--compact" type="button" data-progress-retry>Повторить</button></p>
</div> </div>
</section> </section>
<section class="panel"> <section class="panel">
@@ -58,7 +59,7 @@
<thead><tr><th class="table__head">ID</th><th class="table__head">Статус</th><th class="table__head">Обработано</th><th class="table__head">Без изменений</th><th class="table__head">Ошибки</th><th class="table__head">Старт</th></tr></thead> <thead><tr><th class="table__head">ID</th><th class="table__head">Статус</th><th class="table__head">Обработано</th><th class="table__head">Без изменений</th><th class="table__head">Ошибки</th><th class="table__head">Старт</th></tr></thead>
<tbody> <tbody>
{% for run in runs %} {% for run in runs %}
<tr class="table__row" onclick="window.location.href='/admin/runs/{{ run.id }}'" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); window.location.href='/admin/runs/{{ run.id }}'; }" role="link" tabindex="0"><td class="table__cell">{{ run.id }}</td><td class="table__cell">{{ run.status_display }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.skipped_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.started_display }}</td></tr> <tr class="table__row"><td class="table__cell"><a class="admin__link" href="/admin/runs/{{ run.id }}">{{ run.id }}</a></td><td class="table__cell">{{ run.status_display }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.skipped_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.started_display }}</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -5,49 +5,70 @@
<div class="directory__header"> <div class="directory__header">
<div> <div>
<h2 class="directory__title">Сотрудники</h2> <h2 class="directory__title">Сотрудники</h2>
<p class="directory__summary">Найдено: {{ page.total }}</p> {% set range_start = page.offset + 1 if page.total else 0 %}
{% set range_end = [page.offset + page.employees|length, page.total]|min %}
<p class="directory__summary" role="status">Показаны {{ range_start }}{{ range_end }} из {{ page.total }}</p>
</div> </div>
<button class="button" type="button" data-columns-open>Колонки</button> <button class="button" type="button" data-columns-open aria-controls="columns-dialog" aria-expanded="false">Настроить колонки</button>
</div> </div>
<form class="directory__filters" method="get" action="/admin/directory"> <form class="directory__filters" method="get" action="/admin/directory">
<input class="directory__input" name="q" value="{{ filters.q }}" placeholder="ФИО или ссылка"> <fieldset class="directory__filter-group directory__filter-group--search">
<select class="directory__input" name="status"> <legend class="directory__filter-legend">Поиск</legend>
<label class="directory__field">ФИО или ссылка<input class="directory__input" name="q" value="{{ filters.q }}" placeholder="Например, Иванов или hse.ru"></label>
</fieldset>
<fieldset class="directory__filter-group">
<legend class="directory__filter-legend">Статус и данные</legend>
<label class="directory__field">Статус<select class="directory__input" name="status">
<option value="" {% if not filters.status %}selected{% endif %}>Все статусы</option> <option value="" {% if not filters.status %}selected{% endif %}>Все статусы</option>
<option value="active" {% if filters.status == "active" %}selected{% endif %}>Работает</option> <option value="active" {% if filters.status == "active" %}selected{% endif %}>Работает</option>
<option value="verification_required" {% if filters.status == "verification_required" %}selected{% endif %}>Требует проверки</option> <option value="verification_required" {% if filters.status == "verification_required" %}selected{% endif %}>Требует проверки</option>
<option value="dismissed" {% if filters.status == "dismissed" %}selected{% endif %}>Уволен</option> <option value="dismissed" {% if filters.status == "dismissed" %}selected{% endif %}>Уволен</option>
</select> </select></label>
<select class="directory__input" name="has_email"> <label class="directory__field">Email<select class="directory__input" name="has_email">
<option value="" {% if not filters.has_email %}selected{% endif %}>Любой email</option> <option value="" {% if not filters.has_email %}selected{% endif %}>Любой email</option>
<option value="true" {% if filters.has_email == "true" %}selected{% endif %}>Есть email</option> <option value="true" {% if filters.has_email == "true" %}selected{% endif %}>Есть email</option>
<option value="false" {% if filters.has_email == "false" %}selected{% endif %}>Нет email</option> <option value="false" {% if filters.has_email == "false" %}selected{% endif %}>Нет email</option>
</select> </select></label>
<select class="directory__input" name="has_academic_degree" aria-label="Учёная степень"> <label class="directory__field">Учёная степень<select class="directory__input" name="has_academic_degree">
<option value="" {% if not filters.has_academic_degree %}selected{% endif %}>Любая учёная степень</option> <option value="" {% if not filters.has_academic_degree %}selected{% endif %}>Любая учёная степень</option>
<option value="true" {% if filters.has_academic_degree == "true" %}selected{% endif %}>Есть учёная степень</option> <option value="true" {% if filters.has_academic_degree == "true" %}selected{% endif %}>Есть учёная степень</option>
<option value="false" {% if filters.has_academic_degree == "false" %}selected{% endif %}>Нет учёной степени</option> <option value="false" {% if filters.has_academic_degree == "false" %}selected{% endif %}>Нет учёной степени</option>
</select> </select></label>
<input class="directory__input" type="date" name="started_from" value="{{ filters.started_from }}" aria-label="Впервые найден с"> </fieldset>
<input class="directory__input" type="date" name="started_to" value="{{ filters.started_to }}" aria-label="Впервые найден по"> <fieldset class="directory__filter-group">
<select class="directory__input" name="sort"> <legend class="directory__filter-legend">Период обнаружения</legend>
<label class="directory__field">От<input class="directory__input" type="date" name="started_from" value="{{ filters.started_from }}"></label>
<label class="directory__field">До<input class="directory__input" type="date" name="started_to" value="{{ filters.started_to }}"></label>
</fieldset>
<fieldset class="directory__filter-group">
<legend class="directory__filter-legend">Сортировка и страница</legend>
<label class="directory__field">Сортировать по<select class="directory__input" name="sort">
{% for value, label in [("full_name", "ФИО"), ("status", "Статус"), ("hse_start_year", "Год начала"), ("first_seen_at", "Впервые найден"), ("last_seen_at", "Последний раз найден"), ("dismissed_at", "Дата увольнения")] %} {% for value, label in [("full_name", "ФИО"), ("status", "Статус"), ("hse_start_year", "Год начала"), ("first_seen_at", "Впервые найден"), ("last_seen_at", "Последний раз найден"), ("dismissed_at", "Дата увольнения")] %}
<option value="{{ value }}" {% if filters.sort == value %}selected{% endif %}>Сортировка: {{ label }}</option> <option value="{{ value }}" {% if filters.sort == value %}selected{% endif %}>{{ label }}</option>
{% endfor %} {% endfor %}
</select> </select></label>
<select class="directory__input" name="direction"> <label class="directory__field">Направление<select class="directory__input" name="direction">
<option value="asc" {% if filters.direction == "asc" %}selected{% endif %}>По возрастанию</option> <option value="asc" {% if filters.direction == "asc" %}selected{% endif %}>По возрастанию</option>
<option value="desc" {% if filters.direction == "desc" %}selected{% endif %}>По убыванию</option> <option value="desc" {% if filters.direction == "desc" %}selected{% endif %}>По убыванию</option>
</select> </select></label>
<select class="directory__input" name="limit" onchange="this.form.offset.value = 0; this.form.submit()"> <label class="directory__field">Сотрудников на странице<select class="directory__input" name="limit">
{% for value in [25, 50, 100] %} {% for value in [25, 50, 100] %}
<option value="{{ value }}" {% if filters.limit == value %}selected{% endif %}>На странице: {{ value }}</option> <option value="{{ value }}" {% if filters.limit == value %}selected{% endif %}>{{ value }}</option>
{% endfor %} {% endfor %}
</select> </select></label>
<input type="hidden" name="offset" value="{{ filters.offset }}"> </fieldset>
<button class="button" type="submit">Применить</button> <div class="directory__filter-actions">
<button class="button" type="submit">Применить фильтры</button>
<a class="button button--ghost" href="/admin/directory">Сбросить</a>
</div>
</form> </form>
<details class="directory__help">
<summary>Что означают даты и статусы</summary>
<p>«Год начала» — начало работы в ВШЭ. «Впервые найден» и «Последний раз найден» — даты обнаружения сотрудника в списке источника, а не даты приёма и увольнения. «Требует проверки» означает, что профиль недоступен и увольнение ещё не подтверждено.</p>
</details>
<div class="directory__table-wrap"> <div class="directory__table-wrap">
<table class="directory-table" data-directory-table> <table class="directory-table" data-directory-table>
<thead> <thead>
@@ -71,9 +92,9 @@
</thead> </thead>
<tbody> <tbody>
{% for employee in page.employees %} {% for employee in page.employees %}
<tr class="directory-table__row" data-row-href="/admin/employees/{{ employee.id }}"> <tr class="directory-table__row">
<td class="directory-table__cell" data-column="full_name">{{ employee.full_name or "Без имени" }}</td> <td class="directory-table__cell" data-column="full_name"><a class="admin__link" href="/admin/employees/{{ employee.id }}">{{ employee.full_name or "Без имени" }}</a></td>
<td class="directory-table__cell" data-column="status"><span class="badge {% if employee.status == "dismissed" %}badge--dismissed{% endif %}">{{ employee.status_display }}</span></td> <td class="directory-table__cell" data-column="status"><span class="badge {% if employee.status == "dismissed" %}badge--dismissed{% elif employee.status == "verification_required" %}badge--verification{% endif %}">{{ employee.status_display }}</span></td>
<td class="directory-table__cell" data-column="positions">{{ employee.positions_text }}</td> <td class="directory-table__cell" data-column="positions">{{ employee.positions_text }}</td>
<td class="directory-table__cell" data-column="hse_start_year">{{ employee.hse_start_year or "" }}</td> <td class="directory-table__cell" data-column="hse_start_year">{{ employee.hse_start_year or "" }}</td>
<td class="directory-table__cell" data-column="email">{{ employee.email_text }}</td> <td class="directory-table__cell" data-column="email">{{ employee.email_text }}</td>
@@ -86,10 +107,18 @@
<td class="directory-table__cell" data-column="first_seen_at">{{ employee.first_seen_display }}</td> <td class="directory-table__cell" data-column="first_seen_at">{{ employee.first_seen_display }}</td>
<td class="directory-table__cell" data-column="last_seen_at">{{ employee.last_seen_display }}</td> <td class="directory-table__cell" data-column="last_seen_at">{{ employee.last_seen_display }}</td>
<td class="directory-table__cell" data-column="dismissed_at">{{ employee.dismissed_display }}</td> <td class="directory-table__cell" data-column="dismissed_at">{{ employee.dismissed_display }}</td>
<td class="directory-table__cell" data-column="profile"><a class="admin__link" href="{{ employee.canonical_url }}">Открыть</a></td> <td class="directory-table__cell" data-column="profile"><a class="admin__link" href="{{ employee.canonical_url }}" target="_blank" rel="noopener">Внешний профиль</a></td>
</tr> </tr>
{% else %} {% else %}
<tr><td class="directory-table__empty" colspan="15">По этим фильтрам сотрудники не найдены.</td></tr> <tr><td class="directory-table__empty" colspan="15">
<strong>Сотрудники не найдены</strong>
{% if filters.q or filters.status or filters.has_email or filters.has_academic_degree or filters.started_from or filters.started_to %}
<span>Попробуйте изменить запрос или сбросить фильтры.</span>
<a class="admin__link" href="/admin/directory">Сбросить фильтры</a>
{% else %}
<span>Запустите парсинг, чтобы загрузить сотрудников.</span>
{% endif %}
</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -108,11 +137,10 @@
</div> </div>
</section> </section>
<div class="columns-modal" data-columns-modal hidden> <dialog class="columns-modal" id="columns-dialog" data-columns-modal aria-labelledby="columns-dialog-title">
<div class="columns-modal__backdrop" data-columns-close></div> <section class="columns-modal__panel">
<section class="columns-modal__panel" aria-label="Настройка колонок">
<div class="columns-modal__header"> <div class="columns-modal__header">
<h3 class="columns-modal__title">Отображаемые колонки</h3> <h3 class="columns-modal__title" id="columns-dialog-title">Отображаемые колонки</h3>
<button class="button button--ghost" type="button" data-columns-close>Закрыть</button> <button class="button button--ghost" type="button" data-columns-close>Закрыть</button>
</div> </div>
<div class="columns-modal__grid"> <div class="columns-modal__grid">
@@ -120,8 +148,14 @@
<label class="columns-modal__option"><input class="columns-modal__checkbox" type="checkbox" value="{{ key }}" data-column-toggle> {{ label }}</label> <label class="columns-modal__option"><input class="columns-modal__checkbox" type="checkbox" value="{{ key }}" data-column-toggle> {{ label }}</label>
{% endfor %} {% endfor %}
</div> </div>
<fieldset class="columns-modal__presets">
<legend class="columns-modal__preset-label">Быстрый набор</legend>
<button class="button button--ghost button--compact" type="button" data-columns-preset="review">Проверка</button>
<button class="button button--ghost button--compact" type="button" data-columns-preset="contacts">Контакты</button>
<button class="button button--ghost button--compact" type="button" data-columns-preset="full">Все поля</button>
</fieldset>
</section> </section>
</div> </dialog>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/admin.js"></script> <script src="/static/admin.js"></script>

View File

@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Каталог недоступен · MIEM Employees{% endblock %}
{% block content %}
<section class="panel">
<h2 class="panel__title">Не удалось загрузить сотрудников</h2>
<p>База данных временно недоступна. Повторите запрос; выбранные фильтры сохранятся.</p>
<a class="button" href="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}">Повторить загрузку</a>
</section>
{% endblock %}

View File

@@ -17,10 +17,11 @@
<span>без изменений: <span data-progress-skipped>{{ run.skipped_count }}</span></span> <span>без изменений: <span data-progress-skipped>{{ run.skipped_count }}</span></span>
<span>ошибок: <span data-progress-errors>{{ run.error_count }}</span></span> <span>ошибок: <span data-progress-errors>{{ run.error_count }}</span></span>
</div> </div>
<div class="progress-bar" aria-label="Parsing progress"> <div class="progress-bar" role="progressbar" aria-label="Прогресс парсинга" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ percent }}" aria-valuetext="{{ percent }}%">
<div class="progress-bar__fill" data-progress-fill style="width: {{ percent }}%"></div> <div class="progress-bar__fill" data-progress-fill style="width: {{ percent }}%"></div>
</div> </div>
<div class="progress-panel__percent"><span data-progress-percent>{{ percent }}</span>%</div> <div class="progress-panel__percent"><span data-progress-percent>{{ percent }}</span>%</div>
<p class="progress-panel__error" data-progress-error role="status" hidden>Не удалось обновить прогресс. <button class="button button--ghost button--compact" type="button" data-progress-retry>Повторить</button></p>
</div> </div>
{% else %} {% else %}
<div class="progress-panel" data-progress-panel> <div class="progress-panel" data-progress-panel>
@@ -30,17 +31,18 @@
<span>без изменений: <span data-progress-skipped>0</span></span> <span>без изменений: <span data-progress-skipped>0</span></span>
<span>ошибок: <span data-progress-errors>0</span></span> <span>ошибок: <span data-progress-errors>0</span></span>
</div> </div>
<div class="progress-bar" aria-label="Parsing progress"> <div class="progress-bar" role="progressbar" aria-label="Прогресс парсинга" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="0%">
<div class="progress-bar__fill" data-progress-fill style="width: 0%"></div> <div class="progress-bar__fill" data-progress-fill style="width: 0%"></div>
</div> </div>
<div class="progress-panel__percent"><span data-progress-percent>0</span>%</div> <div class="progress-panel__percent"><span data-progress-percent>0</span>%</div>
<p class="progress-panel__error" data-progress-error role="status" hidden>Не удалось обновить прогресс. <button class="button button--ghost button--compact" type="button" data-progress-retry>Повторить</button></p>
</div> </div>
{% endif %} {% endif %}
<table class="table"> <table class="table">
<thead><tr><th class="table__head">ID</th><th class="table__head">Статус</th><th class="table__head">Найдено</th><th class="table__head">Обработано</th><th class="table__head">Без изменений</th><th class="table__head">Новые</th><th class="table__head">Ошибки</th><th class="table__head">Уволены</th><th class="table__head">Старт</th></tr></thead> <thead><tr><th class="table__head">ID</th><th class="table__head">Статус</th><th class="table__head">Найдено</th><th class="table__head">Обработано</th><th class="table__head">Без изменений</th><th class="table__head">Новые</th><th class="table__head">Ошибки</th><th class="table__head">Уволены</th><th class="table__head">Старт</th></tr></thead>
<tbody> <tbody>
{% for run in runs %} {% for run in runs %}
<tr class="table__row" onclick="window.location.href='/admin/runs/{{ run.id }}'" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); window.location.href='/admin/runs/{{ run.id }}'; }" role="link" tabindex="0"><td class="table__cell">{{ run.id }}</td><td class="table__cell">{{ run.status_display }}</td><td class="table__cell">{{ run.found_count }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.skipped_count }}</td><td class="table__cell">{{ run.new_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.dismissed_count }}</td><td class="table__cell">{{ run.started_display }}</td></tr> <tr class="table__row"><td class="table__cell"><a class="admin__link" href="/admin/runs/{{ run.id }}">{{ run.id }}</a></td><td class="table__cell">{{ run.status_display }}</td><td class="table__cell">{{ run.found_count }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.skipped_count }}</td><td class="table__cell">{{ run.new_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.dismissed_count }}</td><td class="table__cell">{{ run.started_display }}</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -1,3 +1,3 @@
APP_VERSION = "0.7.8" APP_VERSION = "0.8.1"
FRONTEND_VERSION = "0.7.8" FRONTEND_VERSION = "0.8.1"
BACKEND_VERSION = "0.7.8" BACKEND_VERSION = "0.8.1"

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "miem-workers" name = "miem-workers"
version = "0.7.8" version = "0.8.1"
description = "MIEM employees parser, admin API, and web admin" description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

126
tests/browser_admin.py Normal file
View File

@@ -0,0 +1,126 @@
"""Run from the repository root: python tests/browser_admin.py.
Requires Playwright and Chromium. Uses in-memory data and intercepted requests;
never contacts the production service. Optional BROWSER_EXECUTABLE selects an
already installed Chromium instead of the Playwright download.
"""
import os
from pathlib import Path
import sys
from tempfile import mkdtemp
from urllib.parse import urlsplit
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from fastapi.testclient import TestClient
from playwright.sync_api import expect, sync_playwright
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.config import Settings, get_settings
from app.db import Base, get_db
from app.main import app
from app.models import Employee
from app.security import SESSION_COOKIE, sign_session
def main():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
with factory() as db:
db.add_all(Employee(profile_key=f"staff:{i}", canonical_url=f"https://www.hse.ru/staff/{i}",
full_name=f"Сотрудник {i:02d}", current_data={"positions": ["Преподаватель"]})
for i in range(30))
db.commit()
def database():
with factory() as db:
yield db
settings = Settings(_env_file=None, session_secret="browser-test-secret")
app.dependency_overrides[get_db] = database
app.dependency_overrides[get_settings] = lambda: settings
client = TestClient(app)
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
output = Path(mkdtemp(prefix="miem-admin-browser-"))
pending = []
def serve(route):
url = urlsplit(route.request.url)
if url.path == "/api/crawl-runs/latest":
pending.append(route)
return
response = client.get(url.path + ("?" + url.query if url.query else ""))
route.fulfill(status=response.status_code, body=response.content,
content_type=response.headers.get("content-type", "text/plain"))
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(executable_path=os.getenv("BROWSER_EXECUTABLE"))
page = browser.new_page(viewport={"width": 1440, "height": 1000})
page.route("**/*", serve)
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
page.goto("http://miem.test/admin/directory?limit=25&offset=25")
expect(page.get_by_role("status")).to_have_text("Показаны 2630 из 30")
assert page.locator("th:visible").count() == 5
assert page.locator(".directory__table-wrap").evaluate("e => e.scrollWidth <= e.clientWidth")
page.get_by_label("ФИО или ссылка").fill("Сотрудник 00")
page.get_by_role("button", name="Применить фильтры").click()
expect(page.get_by_role("status")).to_have_text("Показаны 11 из 1")
assert "offset=" not in page.url
trigger = page.get_by_role("button", name="Настроить колонки")
trigger.focus()
page.keyboard.press("Enter")
dialog = page.get_by_role("dialog", name="Отображаемые колонки")
expect(dialog).to_be_visible()
for _ in range(22):
page.keyboard.press("Tab")
# Native dialogs may let Tab reach browser chrome, never background controls.
assert dialog.evaluate("e => document.activeElement === document.body || e.contains(document.activeElement)")
page.keyboard.press("Escape")
expect(trigger).to_be_focused()
trigger.click()
page.get_by_role("button", name="Контакты", exact=True).click()
page.keyboard.press("Escape")
page.reload()
expect(page.locator('th[data-column="email"]')).to_be_visible()
page.evaluate("localStorage.clear()")
page.goto("http://miem.test/admin/directory")
page.screenshot(path=str(output / "desktop.png"), full_page=True)
page.set_viewport_size({"width": 390, "height": 844})
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth")
assert page.locator('th[data-column="full_name"]').evaluate("e => getComputedStyle(e).position") == "sticky"
page.screenshot(path=str(output / "mobile.png"), full_page=True)
trigger.click()
expect(dialog).to_be_visible()
assert dialog.evaluate("e => e.getBoundingClientRect().right <= innerWidth")
page.screenshot(path=str(output / "mobile-dialog.png"), full_page=True)
page.keyboard.press("Escape")
page.get_by_label("ФИО или ссылка").fill("Нет совпадений")
page.get_by_role("button", name="Применить фильтры").click()
expect(page.get_by_text("Сотрудники не найдены", exact=True)).to_be_visible()
expect(page.get_by_role("link", name="Сбросить фильтры", exact=True)).to_be_visible()
page.goto("http://miem.test/admin")
page.wait_for_function("document.querySelector('[data-progress-panel]').getAttribute('aria-busy') === 'true'")
pending.pop(0).fulfill(status=503, body="unavailable")
expect(page.locator("[data-progress-error]")).to_be_visible()
expect(page.get_by_text("Показаны последние полученные данные.", exact=False)).to_be_visible()
page.get_by_role("button", name="Повторить").click()
page.wait_for_timeout(100)
pending.pop(0).fulfill(json={"running": None, "latest": None})
expect(page.locator("[data-progress-error]")).to_be_hidden()
expect(page.get_by_text("Запусков пока нет. Запустите парсинг.", exact=True)).to_be_visible()
assert not errors, errors
browser.close()
finally:
app.dependency_overrides.clear()
client.close()
engine.dispose()
print(f"Browser checks passed; screenshots: {output}")
if __name__ == "__main__":
main()

View File

@@ -268,6 +268,18 @@ def test_list_employees_page_filters_sorts_and_paginates(db_session):
assert page["limit"] == 50 assert page["limit"] == 50
def test_catalog_recovers_from_out_of_range_offset(db_session):
db_session.add(Employee(profile_key="staff:page", canonical_url="https://www.hse.ru/staff/page", full_name="Page Person"))
db_session.commit()
page = list_employees_page(db_session, q="Page", offset=100, limit=25)
assert (page["offset"], page["page"], page["total"]) == (0, 1, 1)
assert len(page["employees"]) == 1
empty = list_employees_page(db_session, q="missing", offset=100)
assert empty["offset"] == 0
assert empty["employees"] == []
def test_stats_payload_uses_latest_run_new_count(db_session): def test_stats_payload_uses_latest_run_new_count(db_session):
db_session.add( db_session.add(
Employee( Employee(

View File

@@ -17,11 +17,11 @@ def test_directory_template_is_russian_and_uses_display_dates():
template = Path("app/templates/directory.html").read_text(encoding="utf-8") template = Path("app/templates/directory.html").read_text(encoding="utf-8")
assert "Сотрудники" in template assert "Сотрудники" in template
assert "Колонки" in template assert "колонки" in template
assert "Применить" in template assert "Применить фильтры" in template
assert "На странице: {{ value }}" in template assert "Сотрудников на странице" in template
assert "{% for value in [25, 50, 100] %}" in template assert "{% for value in [25, 50, 100] %}" in template
assert "Найдено:" in template assert "Показаны" in template
assert "Новости" in template assert "Новости" in template
assert "Есть учёная степень" in template assert "Есть учёная степень" in template
assert 'data-column="academic_degree"' in template assert 'data-column="academic_degree"' in template
@@ -29,7 +29,17 @@ def test_directory_template_is_russian_and_uses_display_dates():
assert "employee.first_seen_display" in template assert "employee.first_seen_display" in template
assert "employee.last_seen_display" in template assert "employee.last_seen_display" in template
assert "employee.dismissed_display" in template assert "employee.dismissed_display" in template
assert "verification_required" in template assert "verification_required" in template
assert '<label class="directory__field">Статус' in template
assert 'id="columns-dialog"' in template
assert 'aria-controls="columns-dialog"' in template
assert 'aria-labelledby="columns-dialog-title"' in template
assert "Сбросить фильтры" in template
assert 'data-columns-preset="review"' in template
assert '<dialog class="columns-modal"' in template
assert 'id="columns-dialog"' in template
assert 'aria-controls="columns-dialog"' in template
assert "Сбросить фильтры" in template
assert "Directory" not in template assert "Directory" not in template
assert "employees found" not in template assert "employees found" not in template
@@ -50,12 +60,9 @@ def test_dashboard_limits_latest_runs_to_five():
def test_runs_template_links_to_run_detail(): def test_runs_template_links_to_run_detail():
template = Path("app/templates/runs.html").read_text(encoding="utf-8") template = Path("app/templates/runs.html").read_text(encoding="utf-8")
assert 'onclick="window.location.href=\'/admin/runs/{{ run.id }}\'"' in template assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' in template
assert "onkeydown=\"if (event.key === 'Enter' || event.key === ' ')" in template assert "onclick=" not in template
assert 'role="link"' in template assert 'role="link"' not in template
assert 'tabindex="0"' in template
assert 'data-row-href="/admin/runs/{{ run.id }}"' not in template
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' not in template
def test_run_detail_template_extends_base_and_shows_change_groups(): def test_run_detail_template_extends_base_and_shows_change_groups():
@@ -86,22 +93,36 @@ def test_dashboard_has_dismissed_status_refresh_action():
assert 'action="/admin/dismissed/refresh"' in template assert 'action="/admin/dismissed/refresh"' in template
assert "Проверить уволенных" in template assert "Проверить уволенных" in template
def test_progress_template_has_accessible_progressbar():
for name in ("dashboard.html", "runs.html"):
template = Path(f"app/templates/{name}").read_text(encoding="utf-8")
assert 'role="progressbar"' in template
assert 'aria-valuemin="0"' in template
assert 'aria-valuemax="100"' in template
assert "Прогресс парсинга" in template
def test_dashboard_latest_run_rows_link_to_run_detail(): def test_dashboard_latest_run_rows_link_to_run_detail():
template = Path("app/templates/dashboard.html").read_text(encoding="utf-8") template = Path("app/templates/dashboard.html").read_text(encoding="utf-8")
assert 'onclick="window.location.href=\'/admin/runs/{{ run.id }}\'"' in template assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' in template
assert "onkeydown=\"if (event.key === 'Enter' || event.key === ' ')" in template assert "onclick=" not in template
assert 'role="link"' in template assert 'role="link"' not in template
assert 'tabindex="0"' in template
assert 'data-row-href="/admin/runs/{{ run.id }}"' not in template
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' not in template
def test_admin_js_supports_keyboard_activation_for_clickable_rows(): def test_admin_js_uses_native_dialog_for_column_settings():
source = Path("app/static/admin.js").read_text(encoding="utf-8") source = Path("app/static/admin.js").read_text(encoding="utf-8")
assert 'addEventListener("keydown"' in source assert "showModal()" in source
assert '"Enter"' in source assert "modal.close()" in source
assert '" "' in source assert 'setAttribute("aria-expanded", "true")' in source
assert "data-progress-retry" in source
def test_base_template_has_skip_link_and_current_navigation_state():
template = Path("app/templates/base.html").read_text(encoding="utf-8")
assert 'href="#main-content"' in template
assert 'aria-current="page"' in template

View File

@@ -3,6 +3,7 @@ from types import SimpleNamespace
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select from sqlalchemy import create_engine, select
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@@ -17,7 +18,7 @@ def test_health_returns_versions():
response = TestClient(app).get("/api/health") response = TestClient(app).get("/api/health")
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["backend_version"] == "0.7.7" assert response.json()["backend_version"] == "0.8.1"
def test_mcp_endpoint_is_removed(): def test_mcp_endpoint_is_removed():
@@ -81,6 +82,14 @@ def test_api_employees_and_stats_require_admin_session():
assert stats.json()["new_in_last_run"] == 1 assert stats.json()["new_in_last_run"] == 1
assert run_details.status_code == 200 assert run_details.status_code == 200
assert run_details.json()["changes"]["new"][0]["full_name"] == "Alpha Person" assert run_details.json()["changes"]["new"][0]["full_name"] == "Alpha Person"
directory = client.get("/admin/directory?q=Alpha&offset=100")
assert directory.status_code == 200
assert "Показаны 11 из 1" in directory.text
assert 'name="offset"' not in directory.text
assert "Alpha Person" in directory.text
empty = client.get("/admin/directory?q=missing&offset=100")
assert "Показаны 00 из 0" in empty.text
assert "Сбросить фильтры" in empty.text
app.dependency_overrides.clear() app.dependency_overrides.clear()
@@ -128,3 +137,22 @@ def test_admin_refresh_employee_route_updates_only_requested_employee(monkeypatc
assert response.headers["location"] == f"/admin/employees/{employee_id}?refresh_status=success" assert response.headers["location"] == f"/admin/employees/{employee_id}?refresh_status=success"
assert calls == [(employee_id, settings)] assert calls == [(employee_id, settings)]
app.dependency_overrides.clear() app.dependency_overrides.clear()
def test_directory_database_error_has_retry_and_preserves_filters(db_session, monkeypatch):
settings = Settings(_env_file=None, session_secret="test-session-secret")
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[get_settings] = lambda: settings
def unavailable(*args, **kwargs):
raise OperationalError("SELECT", {}, Exception("private database details"))
monkeypatch.setattr("app.admin.list_employees_page", unavailable)
try:
client = TestClient(app)
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
response = client.get("/admin/directory?q=Alice")
assert response.status_code == 503
assert 'href="/admin/directory?q=Alice"' in response.text
assert "Повторить загрузку" in response.text
assert "private database details" not in response.text
finally:
app.dependency_overrides.clear()