From a82930027dfa36e0562c506f92e68e80a4517d9e Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 11 Sep 2026 11:25:59 +0300 Subject: [PATCH] fix: complete catalog pagination and recovery states --- README.md | 31 +++++-- app/admin.py | 33 ++++---- app/services/admin_data.py | 2 + app/static/admin.css | 60 ++++++++++---- app/static/admin.js | 25 ++++-- app/templates/directory.html | 10 ++- app/templates/directory_error.html | 9 +++ app/version.py | 6 +- pyproject.toml | 2 +- tests/browser_admin.py | 126 +++++++++++++++++++++++++++++ tests/test_admin_data.py | 12 +++ tests/test_admin_templates.py | 2 +- tests/test_api.py | 30 ++++++- 13 files changed, 296 insertions(+), 52 deletions(-) create mode 100644 app/templates/directory_error.html create mode 100644 tests/browser_admin.py 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 @@ Сортировка и страница -
Сбросить
+
+ Что означают даты и статусы +

«Год начала» — начало работы в ВШЭ. «Впервые найден» и «Последний раз найден» — даты обнаружения сотрудника в списке источника, а не даты приёма и увольнения. «Требует проверки» означает, что профиль недоступен и увольнение ещё не подтверждено.

+
+
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()