diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b7e5c..1dd01e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.8.1 + +- Улучшены доступность, фильтры, состояния и адаптивность административного каталога. + ## 0.7.8 - В колонке учёной степени отображаются все найденные степени без лишнего текста. diff --git a/README.md b/README.md index f55c69f..a2c4eba 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; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `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 51a8dd8..b7a045e 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -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; } @@ -161,8 +183,9 @@ border-radius: 6px; } -.button { - padding: 10px 14px; +.button { + min-height: 40px; + padding: 10px 14px; border: 0; border-radius: 6px; color: #ffffff; @@ -178,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 { @@ -426,25 +462,25 @@ gap: 12px; } -.progress-panel__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.progress-panel__actions { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.button--secondary { - color: #0f766e; - background: #ccfbf1; -} - -.progress-panel__body { +.progress-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.progress-panel__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.button--secondary { + color: #0f766e; + background: #ccfbf1; +} + +.progress-panel__body { display: grid; gap: 10px; } @@ -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; @@ -640,16 +737,37 @@ border-radius: 6px; } -.columns-modal__checkbox { - width: 16px; - height: 16px; -} +.columns-modal__checkbox { + width: 16px; + height: 16px; +} + +.columns-modal__presets { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 18px; + padding: 0; + border: 0; +} + +.columns-modal__preset-label { + color: #4b5563; + font-size: 13px; + font-weight: 700; +} @media (max-width: 920px) { .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; + } } diff --git a/app/static/admin.js b/app/static/admin.js index dc5b1d6..d4ffe05 100644 --- a/app/static/admin.js +++ b/app/static/admin.js @@ -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(); })(); diff --git a/app/templates/base.html b/app/templates/base.html index 8ebbe07..c7c70e7 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -7,18 +7,19 @@ + Перейти к содержимому

MIEM Employees

-
+
{% block content %}{% endblock %}