fix: complete admin catalog UX and recovery #38
@@ -1,5 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.1
|
||||
|
||||
- Улучшены доступность, фильтры, состояния и адаптивность административного каталога.
|
||||
|
||||
## 0.7.8
|
||||
|
||||
- В колонке учёной степени отображаются все найденные степени без лишнего текста.
|
||||
|
||||
31
README.md
31
README.md
@@ -43,9 +43,15 @@ uvicorn app.main:app --reload
|
||||
|
||||
В админке доступны:
|
||||
|
||||
- `Dashboard`: общая статистика, последний добавленный сотрудник, прогресс текущего/последнего парсинга и ручной запуск.
|
||||
- `Directory`: настраиваемая таблица сотрудников с фильтрами, сортировкой, пагинацией и выбором колонок.
|
||||
- `Runs`: история запусков, ошибки и progress bar.
|
||||
- «Обзор»: статистика, последний добавленный сотрудник, прогресс парсинга и ручной запуск.
|
||||
- «Сотрудники»: поиск, фильтры, сортировка, пагинация и выбор колонок. По умолчанию показаны ФИО, статус, должности, дата последнего обнаружения и внешний профиль. В диалоге колонок доступны наборы «Проверка», «Контакты» и «Все поля»; ранее сохранённый выбор сохраняется.
|
||||
- «Запуски»: история обходов, ошибки и доступный индикатор прогресса.
|
||||
|
||||
Все фильтры применяются кнопкой «Применить фильтры» с переходом на первую страницу. «Сбросить» очищает условия; при устаревшем номере страницы каталог возвращает первую. Подписи полей показывают выбранные условия, над таблицей указан диапазон результатов. Пояснения дат и статусов находятся под фильтрами.
|
||||
|
||||
Каталог поддерживает навигацию с клавиатуры и системный диалог колонок с Escape и возвратом фокуса. На узких экранах таблица прокручивается внутри страницы, колонка ФИО закреплена. Используется системный шрифт без внешних загрузок.
|
||||
|
||||
Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
@@ -56,7 +62,9 @@ docker compose up -d --build --remove-orphans
|
||||
По умолчанию:
|
||||
|
||||
- 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/`.
|
||||
|
||||
@@ -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 не должен создавать дубликаты.
|
||||
|
||||
`list_employee_publications` сначала читает `employee_publications`; если детальных строк еще нет, возвращает старые публикации из `current_data`.
|
||||
|
||||
Новости сотрудников также хранятся в двух видах:
|
||||
|
||||
- краткий список остается внутри `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
|
||||
```
|
||||
|
||||
Версия сервиса: `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.
|
||||
|
||||
33
app/admin.py
33
app/admin.py
@@ -2,6 +2,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
@@ -93,19 +94,23 @@ def directory(
|
||||
parsed_started_to = _parse_date(started_to)
|
||||
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"
|
||||
page = list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
q=q,
|
||||
started_from=parsed_started_from,
|
||||
started_to=parsed_started_to,
|
||||
has_email=parsed_has_email,
|
||||
has_academic_degree=parsed_has_academic_degree,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
try:
|
||||
page = list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
q=q,
|
||||
started_from=parsed_started_from,
|
||||
started_to=parsed_started_to,
|
||||
has_email=parsed_has_email,
|
||||
has_academic_degree=parsed_has_academic_degree,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _render(request, "directory_error.html", {}, status_code=503)
|
||||
return _render(
|
||||
request,
|
||||
"directory.html",
|
||||
@@ -121,7 +126,7 @@ def directory(
|
||||
"sort": sort,
|
||||
"direction": direction,
|
||||
"limit": page["limit"],
|
||||
"offset": offset,
|
||||
"offset": page["offset"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -132,6 +132,8 @@ def list_employees_page(
|
||||
has_academic_degree=has_academic_degree,
|
||||
)
|
||||
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)
|
||||
order = desc(sort_column) if direction == "desc" else sort_column
|
||||
employees = db.scalars(base_stmt.order_by(order).limit(limit).offset(offset)).all()
|
||||
|
||||
@@ -5,7 +5,22 @@
|
||||
flex-direction: column;
|
||||
color: #1f2937;
|
||||
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 {
|
||||
@@ -16,6 +31,7 @@
|
||||
padding: 18px 32px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #d9dee7;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin__brand {
|
||||
@@ -40,6 +56,16 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
@@ -107,10 +133,6 @@
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table__row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table__row:hover {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
@@ -162,6 +184,7 @@
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 40px;
|
||||
padding: 10px 14px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
@@ -180,6 +203,19 @@
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.code {
|
||||
overflow-x: auto;
|
||||
padding: 14px;
|
||||
@@ -467,6 +503,12 @@
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.progress-panel__error {
|
||||
margin: 0;
|
||||
color: #991b1b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
@@ -478,7 +520,6 @@
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: #0f766e;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.directory {
|
||||
@@ -505,7 +546,7 @@
|
||||
|
||||
.directory__filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.7fr) repeat(6, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
@@ -513,13 +554,56 @@
|
||||
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 {
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.directory__filter-actions {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.directory__table-wrap {
|
||||
overflow-x: auto;
|
||||
background: #ffffff;
|
||||
@@ -539,9 +623,18 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.directory__help {
|
||||
color: #4b5563;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.directory__help summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.directory-table {
|
||||
width: 100%;
|
||||
min-width: 1120px;
|
||||
min-width: 640px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
@@ -556,14 +649,19 @@
|
||||
}
|
||||
|
||||
.directory-table__cell {
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 280px;
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.directory-table .badge {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.directory-table__row {
|
||||
cursor: pointer;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.directory-table__row:hover {
|
||||
@@ -576,33 +674,32 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.directory-table__empty span,
|
||||
.directory-table__empty a {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.directory-table__cell--hidden,
|
||||
.directory-table__head--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.columns-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
width: min(620px, calc(100% - 40px));
|
||||
max-height: min(720px, calc(100vh - 40px));
|
||||
margin: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.columns-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.columns-modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
.columns-modal::backdrop {
|
||||
background: rgba(17, 24, 39, 0.54);
|
||||
}
|
||||
|
||||
.columns-modal__panel {
|
||||
position: relative;
|
||||
width: min(620px, 100%);
|
||||
max-height: min(720px, calc(100vh - 40px));
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
@@ -645,11 +742,32 @@
|
||||
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) {
|
||||
.directory__filters {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.directory__filter-group--search,
|
||||
.directory__filter-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.progress-panel__header,
|
||||
.directory__header,
|
||||
.employee-card__header {
|
||||
@@ -659,7 +777,40 @@
|
||||
}
|
||||
|
||||
@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 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.directory__filter-group,
|
||||
.directory__filter-group--search,
|
||||
.directory__filter-actions {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.directory__field {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
"full_name",
|
||||
"status",
|
||||
"positions",
|
||||
"hse_start_year",
|
||||
"email",
|
||||
"academic_degree",
|
||||
"last_seen_at",
|
||||
"dismissed_at",
|
||||
"profile",
|
||||
];
|
||||
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() {
|
||||
try {
|
||||
@@ -40,18 +41,33 @@
|
||||
if (!document.querySelector("[data-directory-table]")) return;
|
||||
let columns = readColumns();
|
||||
const modal = document.querySelector("[data-columns-modal]");
|
||||
const trigger = document.querySelector("[data-columns-open]");
|
||||
applyColumns(columns);
|
||||
|
||||
document.querySelectorAll("[data-columns-open]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
modal.hidden = false;
|
||||
});
|
||||
trigger.addEventListener("click", () => {
|
||||
modal.showModal();
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
modal.querySelector("input")?.focus();
|
||||
});
|
||||
document.querySelectorAll("[data-columns-close]").forEach((button) => {
|
||||
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) => {
|
||||
checkbox.addEventListener("change", () => {
|
||||
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() {
|
||||
const panel = document.querySelector("[data-progress-panel]");
|
||||
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) => {
|
||||
if (!run) return;
|
||||
@@ -94,6 +96,7 @@
|
||||
const errors = document.querySelector("[data-progress-errors]");
|
||||
const fill = document.querySelector("[data-progress-fill]");
|
||||
const percent = document.querySelector("[data-progress-percent]");
|
||||
const error = document.querySelector("[data-progress-error]");
|
||||
if (status) status.textContent = run.status_display || run.status;
|
||||
if (processed) processed.textContent = run.processed_count;
|
||||
if (found) found.textContent = run.found_count;
|
||||
@@ -101,21 +104,44 @@
|
||||
if (errors) errors.textContent = run.error_count;
|
||||
if (fill) fill.style.width = `${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 () => {
|
||||
if (pending) return true;
|
||||
pending = true;
|
||||
state.textContent = "Обновляем прогресс…";
|
||||
panel.setAttribute("aria-busy", "true");
|
||||
try {
|
||||
const response = await fetch("/api/crawl-runs/latest", { credentials: "same-origin" });
|
||||
if (!response.ok) return false;
|
||||
const response = await fetch("/api/crawl-runs/latest", {
|
||||
credentials: "same-origin",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) throw new Error("progress request failed");
|
||||
const data = await response.json();
|
||||
const run = data.running || data.latest;
|
||||
update(run);
|
||||
state.textContent = run ? "Прогресс обновлён" : "Запусков пока нет. Запустите парсинг.";
|
||||
const error = document.querySelector("[data-progress-error]");
|
||||
if (error) error.hidden = true;
|
||||
return Boolean(data.running);
|
||||
} 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 keepGoing = await poll();
|
||||
if (!keepGoing) window.clearInterval(interval);
|
||||
@@ -123,6 +149,5 @@
|
||||
}
|
||||
|
||||
setupColumns();
|
||||
setupClickableRows();
|
||||
setupProgress();
|
||||
})();
|
||||
|
||||
@@ -7,18 +7,19 @@
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
</head>
|
||||
<body class="admin">
|
||||
<a class="admin__skip-link" href="#main-content">Перейти к содержимому</a>
|
||||
<header class="admin__header">
|
||||
<h1 class="admin__brand"><a class="admin__brand-link" href="/admin">MIEM Employees</a></h1>
|
||||
<nav class="admin__nav">
|
||||
<a class="admin__link" href="/admin">Обзор</a>
|
||||
<a class="admin__link" href="/admin/directory">Сотрудники</a>
|
||||
<a class="admin__link" href="/admin/runs">Запуски</a>
|
||||
<a class="admin__link" href="/admin"{% if request.url.path == "/admin" %} aria-current="page"{% endif %}>Обзор</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"{% if request.url.path.startswith("/admin/runs") %} aria-current="page"{% endif %}>Запуски</a>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button class="button button--ghost" type="submit">Выйти</button>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="admin__main">
|
||||
<main class="admin__main" id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="admin__footer">
|
||||
|
||||
@@ -46,10 +46,11 @@
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</section>
|
||||
<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>
|
||||
<tbody>
|
||||
{% 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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -5,49 +5,70 @@
|
||||
<div class="directory__header">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<form class="directory__filters" method="get" action="/admin/directory">
|
||||
<input class="directory__input" name="q" value="{{ filters.q }}" placeholder="ФИО или ссылка">
|
||||
<select class="directory__input" name="status">
|
||||
<fieldset class="directory__filter-group directory__filter-group--search">
|
||||
<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="active" {% if filters.status == "active" %}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>
|
||||
</select>
|
||||
<select class="directory__input" name="has_email">
|
||||
</select></label>
|
||||
<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="true" {% if filters.has_email == "true" %}selected{% endif %}>Есть email</option>
|
||||
<option value="false" {% if filters.has_email == "false" %}selected{% endif %}>Нет email</option>
|
||||
</select>
|
||||
<select class="directory__input" name="has_academic_degree" aria-label="Учёная степень">
|
||||
</select></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="true" {% if filters.has_academic_degree == "true" %}selected{% endif %}>Есть учёная степень</option>
|
||||
<option value="false" {% if filters.has_academic_degree == "false" %}selected{% endif %}>Нет учёной степени</option>
|
||||
</select>
|
||||
<input class="directory__input" type="date" name="started_from" value="{{ filters.started_from }}" aria-label="Впервые найден с">
|
||||
<input class="directory__input" type="date" name="started_to" value="{{ filters.started_to }}" aria-label="Впервые найден по">
|
||||
<select class="directory__input" name="sort">
|
||||
</select></label>
|
||||
</fieldset>
|
||||
<fieldset class="directory__filter-group">
|
||||
<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", "Дата увольнения")] %}
|
||||
<option value="{{ value }}" {% if filters.sort == value %}selected{% endif %}>Сортировка: {{ label }}</option>
|
||||
<option value="{{ value }}" {% if filters.sort == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select class="directory__input" name="direction">
|
||||
</select></label>
|
||||
<label class="directory__field">Направление<select class="directory__input" name="direction">
|
||||
<option value="asc" {% if filters.direction == "asc" %}selected{% endif %}>По возрастанию</option>
|
||||
<option value="desc" {% if filters.direction == "desc" %}selected{% endif %}>По убыванию</option>
|
||||
</select>
|
||||
<select class="directory__input" name="limit" onchange="this.form.offset.value = 0; this.form.submit()">
|
||||
</select></label>
|
||||
<label class="directory__field">Сотрудников на странице<select class="directory__input" name="limit">
|
||||
{% 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 %}
|
||||
</select>
|
||||
<input type="hidden" name="offset" value="{{ filters.offset }}">
|
||||
<button class="button" type="submit">Применить</button>
|
||||
</select></label>
|
||||
</fieldset>
|
||||
<div class="directory__filter-actions">
|
||||
<button class="button" type="submit">Применить фильтры</button>
|
||||
<a class="button button--ghost" href="/admin/directory">Сбросить</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<details class="directory__help">
|
||||
<summary>Что означают даты и статусы</summary>
|
||||
<p>«Год начала» — начало работы в ВШЭ. «Впервые найден» и «Последний раз найден» — даты обнаружения сотрудника в списке источника, а не даты приёма и увольнения. «Требует проверки» означает, что профиль недоступен и увольнение ещё не подтверждено.</p>
|
||||
</details>
|
||||
|
||||
<div class="directory__table-wrap">
|
||||
<table class="directory-table" data-directory-table>
|
||||
<thead>
|
||||
@@ -71,9 +92,9 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for employee in page.employees %}
|
||||
<tr class="directory-table__row" data-row-href="/admin/employees/{{ employee.id }}">
|
||||
<td class="directory-table__cell" data-column="full_name">{{ employee.full_name or "Без имени" }}</td>
|
||||
<td class="directory-table__cell" data-column="status"><span class="badge {% if employee.status == "dismissed" %}badge--dismissed{% endif %}">{{ employee.status_display }}</span></td>
|
||||
<tr class="directory-table__row">
|
||||
<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{% 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="hse_start_year">{{ employee.hse_start_year or "" }}</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="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="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>
|
||||
{% 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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -108,11 +137,10 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="columns-modal" data-columns-modal hidden>
|
||||
<div class="columns-modal__backdrop" data-columns-close></div>
|
||||
<section class="columns-modal__panel" aria-label="Настройка колонок">
|
||||
<dialog class="columns-modal" id="columns-dialog" data-columns-modal aria-labelledby="columns-dialog-title">
|
||||
<section class="columns-modal__panel">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
{% endfor %}
|
||||
</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>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/admin.js"></script>
|
||||
|
||||
9
app/templates/directory_error.html
Normal file
9
app/templates/directory_error.html
Normal 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 %}
|
||||
@@ -17,10 +17,11 @@
|
||||
<span>без изменений: <span data-progress-skipped>{{ run.skipped_count }}</span></span>
|
||||
<span>ошибок: <span data-progress-errors>{{ run.error_count }}</span></span>
|
||||
</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>
|
||||
<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>
|
||||
{% else %}
|
||||
<div class="progress-panel" data-progress-panel>
|
||||
@@ -30,17 +31,18 @@
|
||||
<span>без изменений: <span data-progress-skipped>0</span></span>
|
||||
<span>ошибок: <span data-progress-errors>0</span></span>
|
||||
</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>
|
||||
<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>
|
||||
{% endif %}
|
||||
<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>
|
||||
<tbody>
|
||||
{% 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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.7.8"
|
||||
FRONTEND_VERSION = "0.7.8"
|
||||
BACKEND_VERSION = "0.7.8"
|
||||
APP_VERSION = "0.8.1"
|
||||
FRONTEND_VERSION = "0.8.1"
|
||||
BACKEND_VERSION = "0.8.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.7.8"
|
||||
version = "0.8.1"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
126
tests/browser_admin.py
Normal file
126
tests/browser_admin.py
Normal 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("Показаны 26–30 из 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("Показаны 1–1 из 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()
|
||||
@@ -268,6 +268,18 @@ def test_list_employees_page_filters_sorts_and_paginates(db_session):
|
||||
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):
|
||||
db_session.add(
|
||||
Employee(
|
||||
|
||||
@@ -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")
|
||||
|
||||
assert "Сотрудники" in template
|
||||
assert "Колонки" in template
|
||||
assert "Применить" in template
|
||||
assert "На странице: {{ value }}" in template
|
||||
assert "колонки" in template
|
||||
assert "Применить фильтры" in template
|
||||
assert "Сотрудников на странице" in template
|
||||
assert "{% for value in [25, 50, 100] %}" in template
|
||||
assert "Найдено:" in template
|
||||
assert "Показаны" in template
|
||||
assert "Новости" in template
|
||||
assert "Есть учёная степень" in template
|
||||
assert 'data-column="academic_degree"' in template
|
||||
@@ -30,6 +30,16 @@ def test_directory_template_is_russian_and_uses_display_dates():
|
||||
assert "employee.last_seen_display" in template
|
||||
assert "employee.dismissed_display" 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 "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():
|
||||
template = Path("app/templates/runs.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'onclick="window.location.href=\'/admin/runs/{{ run.id }}\'"' in template
|
||||
assert "onkeydown=\"if (event.key === 'Enter' || event.key === ' ')" in template
|
||||
assert 'role="link"' 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
|
||||
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' in template
|
||||
assert "onclick=" not in template
|
||||
assert 'role="link"' not in template
|
||||
|
||||
|
||||
def test_run_detail_template_extends_base_and_shows_change_groups():
|
||||
@@ -88,20 +95,34 @@ def test_dashboard_has_dismissed_status_refresh_action():
|
||||
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():
|
||||
template = Path("app/templates/dashboard.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'onclick="window.location.href=\'/admin/runs/{{ run.id }}\'"' in template
|
||||
assert "onkeydown=\"if (event.key === 'Enter' || event.key === ' ')" in template
|
||||
assert 'role="link"' 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
|
||||
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' in template
|
||||
assert "onclick=" not in template
|
||||
assert 'role="link"' 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")
|
||||
|
||||
assert 'addEventListener("keydown"' in source
|
||||
assert '"Enter"' in source
|
||||
assert '" "' in source
|
||||
assert "showModal()" in source
|
||||
assert "modal.close()" 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
|
||||
|
||||
@@ -3,6 +3,7 @@ from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -17,7 +18,7 @@ def test_health_returns_versions():
|
||||
response = TestClient(app).get("/api/health")
|
||||
|
||||
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():
|
||||
@@ -81,6 +82,14 @@ def test_api_employees_and_stats_require_admin_session():
|
||||
assert stats.json()["new_in_last_run"] == 1
|
||||
assert run_details.status_code == 200
|
||||
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 "Показаны 1–1 из 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 "Показаны 0–0 из 0" in empty.text
|
||||
assert "Сбросить фильтры" in empty.text
|
||||
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 calls == [(employee_id, settings)]
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user