diff --git a/README.md b/README.md
index f55c69f..8d885f2 100644
--- a/README.md
+++ b/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, без подключения к рабочей базе. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
+
+Версия сервиса: `0.8.1`. Админка всегда показывает версии backend и frontend в footer.
diff --git a/app/admin.py b/app/admin.py
index 8cacf37..68b070e 100644
--- a/app/admin.py
+++ b/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"],
},
},
)
diff --git a/app/services/admin_data.py b/app/services/admin_data.py
index 6464859..228b7de 100644
--- a/app/services/admin_data.py
+++ b/app/services/admin_data.py
@@ -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()
diff --git a/app/static/admin.css b/app/static/admin.css
index d1c5ea2..b7a045e 100644
--- a/app/static/admin.css
+++ b/app/static/admin.css
@@ -61,8 +61,8 @@
text-underline-offset: 4px;
}
-:is(a, button, input, select):focus-visible {
- outline: 3px solid #14b8a6;
+:is(a, button, input, select, summary):focus-visible {
+ outline: 3px solid #0f766e;
outline-offset: 2px;
}
@@ -133,10 +133,6 @@
border-collapse: collapse;
}
-.table__row {
- cursor: pointer;
-}
-
.table__row:hover {
background: #f0fdfa;
}
@@ -205,6 +201,19 @@
.button--compact {
padding: 8px 12px;
+}
+
+.button:hover {
+ filter: brightness(0.9);
+}
+
+.button:active {
+ filter: brightness(0.8);
+}
+
+.button:disabled {
+ cursor: wait;
+ opacity: 0.65;
}
.code {
@@ -537,7 +546,7 @@
.directory__filters {
display: grid;
- grid-template-columns: 1.1fr 2fr 1.25fr 2fr;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
padding: 16px;
background: #ffffff;
@@ -547,7 +556,7 @@
.directory__filter-group {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
+ grid-template-columns: 1fr;
align-content: start;
gap: 8px;
min-width: 0;
@@ -578,6 +587,9 @@
}
.directory__input {
+ font: inherit;
+ font-size: 16px;
+ font-weight: 400;
min-width: 0;
padding: 10px 12px;
border: 1px solid #cbd5e1;
@@ -611,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: 960px;
+ min-width: 640px;
border-collapse: collapse;
}
@@ -628,10 +649,15 @@
}
.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 {
@@ -751,6 +777,14 @@
}
@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;
@@ -780,11 +814,3 @@
font-size: 13px;
}
}
-
-@media (prefers-reduced-motion: no-preference) {
- .progress-bar__fill {
- transition-property: width;
- transition-duration: 0.25s;
- transition-timing-function: ease;
- }
-}
diff --git a/app/static/admin.js b/app/static/admin.js
index 2eecdae..d4ffe05 100644
--- a/app/static/admin.js
+++ b/app/static/admin.js
@@ -3,11 +3,7 @@
"full_name",
"status",
"positions",
- "hse_start_year",
- "email",
- "academic_degree",
"last_seen_at",
- "dismissed_at",
"profile",
];
const storageKey = "miem.directory.columns";
@@ -85,6 +81,11 @@
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;
@@ -111,17 +112,31 @@
};
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" });
+ 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) {
const error = document.querySelector("[data-progress-error]");
if (error) error.hidden = false;
+ state.textContent = "Показаны последние полученные данные. Прогресс может быть устаревшим.";
return true;
+ } finally {
+ pending = false;
+ panel.setAttribute("aria-busy", "false");
}
};
diff --git a/app/templates/directory.html b/app/templates/directory.html
index 5eb6da5..30c9f45 100644
--- a/app/templates/directory.html
+++ b/app/templates/directory.html
@@ -45,7 +45,7 @@
Сортировка и страница
Сортировать по
{% for value, label in [("full_name", "ФИО"), ("status", "Статус"), ("hse_start_year", "Год начала"), ("first_seen_at", "Впервые найден"), ("last_seen_at", "Последний раз найден"), ("dismissed_at", "Дата увольнения")] %}
- Сортировка: {{ label }}
+ {{ label }}
{% endfor %}
Направление
@@ -54,17 +54,21 @@
Сотрудников на странице
{% for value in [25, 50, 100] %}
- На странице: {{ value }}
+ {{ value }}
{% endfor %}
-
+
+ Что означают даты и статусы
+ «Год начала» — начало работы в ВШЭ. «Впервые найден» и «Последний раз найден» — даты обнаружения сотрудника в списке источника, а не даты приёма и увольнения. «Требует проверки» означает, что профиль недоступен и увольнение ещё не подтверждено.
+
+
diff --git a/app/templates/directory_error.html b/app/templates/directory_error.html
new file mode 100644
index 0000000..e4f83e4
--- /dev/null
+++ b/app/templates/directory_error.html
@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+{% block title %}Каталог недоступен · MIEM Employees{% endblock %}
+{% block content %}
+
+ Не удалось загрузить сотрудников
+ База данных временно недоступна. Повторите запрос; выбранные фильтры сохранятся.
+ Повторить загрузку
+
+{% endblock %}
diff --git a/app/version.py b/app/version.py
index 6df5956..b797d9d 100644
--- a/app/version.py
+++ b/app/version.py
@@ -1,3 +1,3 @@
-APP_VERSION = "0.8.0"
-FRONTEND_VERSION = "0.8.0"
-BACKEND_VERSION = "0.8.0"
+APP_VERSION = "0.8.1"
+FRONTEND_VERSION = "0.8.1"
+BACKEND_VERSION = "0.8.1"
diff --git a/pyproject.toml b/pyproject.toml
index f22eb95..4bcfbab 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
-version = "0.8.0"
+version = "0.8.1"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
diff --git a/tests/browser_admin.py b/tests/browser_admin.py
new file mode 100644
index 0000000..15e6d73
--- /dev/null
+++ b/tests/browser_admin.py
@@ -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()
diff --git a/tests/test_admin_data.py b/tests/test_admin_data.py
index 48eead4..88efa3e 100644
--- a/tests/test_admin_data.py
+++ b/tests/test_admin_data.py
@@ -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(
diff --git a/tests/test_admin_templates.py b/tests/test_admin_templates.py
index 1c78268..c5c1672 100644
--- a/tests/test_admin_templates.py
+++ b/tests/test_admin_templates.py
@@ -19,7 +19,7 @@ def test_directory_template_is_russian_and_uses_display_dates():
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 "Показаны" in template
assert "Новости" in template
diff --git a/tests/test_api.py b/tests/test_api.py
index f95b1cd..212f52b 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -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.8.0"
+ 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()