Compare commits

...
Author SHA1 Message Date
codex 25eb6481f7 fix: address publication import review findings
CI / test (pull_request) Successful in 9m10s
CI / deploy (pull_request) Has been skipped
2026-10-05 12:12:35 +03:00
codex 72afb31049 fix: preserve imported publication enrichment during crawls
CI / test (pull_request) Successful in 9m11s
CI / deploy (pull_request) Has been skipped
2026-10-05 11:57:44 +03:00
codex 05a62d18e6 feat: prepare safe publication CSV enrichment import 2026-10-05 11:43:14 +03:00
admin 111ee15289 Merge pull request 'Предварительный список кандидатов в диссертационный совет' (#53) from codex/miem_workers:feature/council-candidates into main
CI / test (push) Successful in 9m5s
CI / deploy (push) Failing after 7s
Reviewed-on: #53
Reviewed-by: agent <agent@vakanaut.ru>
2026-09-30 08:31:44 +00:00
codex cb15d11df4 feat: add preliminary council candidate view
CI / test (pull_request) Has been cancelled
CI / deploy (pull_request) Has been cancelled
2026-09-30 11:11:47 +03:00
admin 07bd42927b Merge pull request 'fix: run backup installer through sh' (#52) from fix/deploy-cron-installer-permission into main
CI / test (push) Successful in 9m11s
CI / deploy (push) Successful in 18s
Reviewed-on: #52
2026-09-17 10:12:16 +00:00
agent e49bcf6ba6 test: update deploy version expectation
CI / test (pull_request) Successful in 9m13s
CI / deploy (pull_request) Has been skipped
2026-09-17 12:05:55 +03:00
agent 4cf6cba77c fix: run backup installer through sh
CI / test (pull_request) Failing after 4m45s
CI / deploy (pull_request) Has been skipped
2026-09-17 12:04:16 +03:00
admin 9fc7de113a Merge pull request 'fix: ограничить PostgreSQL backup одним еженедельным dump' (#51) from fix/backup-retention into main
CI / test (push) Successful in 9m14s
CI / deploy (push) Failing after 10s
Reviewed-on: #51
2026-09-17 07:09:23 +00:00
agent 69c1dd52cf fix: report scheduled backup failures
CI / test (pull_request) Successful in 9m12s
CI / deploy (pull_request) Has been skipped
2026-09-15 18:53:06 +03:00
agent b933f95302 fix: finalize weekly postgres backup retention
CI / test (pull_request) Successful in 9m11s
CI / deploy (pull_request) Has been skipped
2026-09-15 18:50:22 +03:00
agent 384cd550d5 fix: schedule weekly full postgres backup
CI / test (pull_request) Failing after 4m45s
CI / deploy (pull_request) Has been skipped
2026-09-15 18:45:27 +03:00
admin 5c3883beca Merge pull request 'fix: verify deployed runtime version' (#50) from fix/verify-production-release into main
CI / test (push) Successful in 9m10s
CI / deploy (push) Successful in 1m0s
Reviewed-on: #50
2026-09-15 15:13:55 +00:00
agent 144e62a35e fix: validate deploy version input
CI / test (pull_request) Successful in 9m11s
CI / deploy (pull_request) Has been skipped
2026-09-15 17:59:23 +03:00
agent 8102556862 fix: bust admin stylesheet cache
CI / test (pull_request) Has been cancelled
CI / deploy (pull_request) Has been cancelled
2026-09-15 17:57:59 +03:00
agent 711c6ae7b0 fix: verify deployed runtime version
CI / deploy (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
2026-09-15 17:54:18 +03:00
admin 580df29849 Merge pull request 'fix: restart production containers automatically' (#49) from fix/restart-containers into main
CI / test (push) Successful in 9m11s
CI / deploy (push) Successful in 1m1s
Reviewed-on: #49
2026-09-15 14:34:15 +00:00
agent 186f2d0804 chore: align package version and changelog
CI / test (pull_request) Has been cancelled
CI / deploy (pull_request) Has been cancelled
2026-09-15 17:29:50 +03:00
agent e4e64b92c1 fix: restart production containers automatically
CI / deploy (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
2026-09-15 17:27:27 +03:00
admin aa43da2857 Merge pull request 'test: update health version expectation' (#48) from fix/update-version-test into main
CI / test (push) Successful in 9m16s
CI / deploy (push) Successful in 1m0s
Reviewed-on: #48
2026-09-15 13:40:20 +00:00
agent bee3dc9848 test: update health version expectation
CI / test (pull_request) Successful in 9m11s
CI / deploy (pull_request) Has been skipped
2026-09-15 16:18:36 +03:00
admin d95133e257 Merge pull request 'fix: align directory filter actions' (#47) from fix/directory-filter-action-alignment into main
CI / test (push) Failing after 4m46s
CI / deploy (push) Has been skipped
Reviewed-on: #47
2026-09-15 12:57:53 +00:00
agent 2bf6ff570c fix: align directory filter actions
CI / deploy (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
2026-09-15 15:55:47 +03:00
admin e58156fbeb Merge pull request 'fix: retain only latest production backup' (#46) from fix/retain-single-db-backup into main
CI / deploy (push) Has been cancelled
CI / test (push) Has been cancelled
Reviewed-on: #46
2026-09-15 12:51:30 +00:00
agent ddb3ad1a72 fix: retain only latest production backup
CI / test (pull_request) Failing after 4m46s
CI / deploy (pull_request) Has been skipped
2026-09-15 15:49:21 +03:00
admin f6a01500d7 Merge pull request 'fix: upload deployment archive to user home' (#44) from fix/cd-release-upload-path into main
CI / test (push) Successful in 9m19s
CI / deploy (push) Failing after 15s
Reviewed-on: #44
2026-09-14 14:33:14 +00:00
agent ec7aec310a fix: stage deploy archive in user home
CI / test (pull_request) Successful in 9m22s
CI / deploy (pull_request) Has been skipped
2026-09-14 17:23:29 +03:00
admin 0f91ed0d8f Merge pull request 'fix: repair admin directory filter layout' (#43) from fix/admin-directory-filters into main
CI / test (push) Successful in 9m23s
CI / deploy (push) Failing after 14s
Reviewed-on: #43
2026-09-14 13:58:41 +00:00
agent 1e9cc50a73 fix: keep filter select values readable
CI / test (pull_request) Successful in 9m20s
CI / deploy (pull_request) Has been skipped
2026-09-14 15:28:02 +03:00
agent 10d2cf0101 fix: repair admin directory filter layout
CI / test (pull_request) Successful in 9m21s
CI / deploy (pull_request) Has been skipped
2026-09-14 15:24:41 +03:00
admin 44949996bc Merge pull request 'fix: configure dismissal safeguards and remove obsolete endpoint references' (#42) from refactor/remove-mcp-mentions into main
CI / test (push) Successful in 9m20s
CI / deploy (push) Failing after 42s
Reviewed-on: #42
2026-09-14 10:26:12 +00:00
admin baad49b976 docs: record dismissal config fix
CI / test (pull_request) Successful in 9m19s
CI / deploy (pull_request) Has been skipped
2026-09-14 12:53:01 +03:00
admin ceca2017cb fix: wire dismissal safety settings
CI / test (pull_request) Successful in 9m20s
CI / deploy (pull_request) Has been skipped
2026-09-14 12:48:47 +03:00
admin 83e726efe5 docs: align service version in README
CI / test (pull_request) Successful in 9m18s
CI / deploy (pull_request) Has been skipped
2026-09-14 12:41:04 +03:00
admin 7d6d8759a5 refactor: remove obsolete endpoint references
CI / test (pull_request) Successful in 9m23s
CI / deploy (pull_request) Has been skipped
2026-09-14 12:39:06 +03:00
admin 5d89ae3891 Merge pull request 'ci: add protected production deploy workflow' (#40) from chore/production-cd into main
CI / test (push) Successful in 12m58s
CI / deploy (push) Successful in 1m18s
Reviewed-on: #40
2026-09-11 12:17:48 +00:00
admin 880ac6c53e ci: gate production deploy on tests
CI / test (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
2026-09-11 13:26:59 +03:00
admin 2d19eef9c2 ci: add protected production deploy workflow
CI / test (pull_request) Failing after 1s
2026-09-11 13:16:43 +03:00
admin a1d60984e3 Merge pull request 'ci: run tests on main and pull requests' (#39) from chore/ci-cd-production into main
CI / test (push) Has been cancelled
Reviewed-on: #39
2026-09-11 10:06:21 +00:00
admin d5f41e015f ci: run tests on pushes and pull requests
CI / test (pull_request) Failing after 12s
2026-09-11 13:04:54 +03:00
admin e1ba77c337 Merge pull request 'fix: complete admin catalog UX and recovery' (#38) from feature/admin-ux-hardening into main
Reviewed-on: #38
2026-09-11 09:50:03 +00:00
admin a989c5c53c docs: align version and test instructions 2026-09-11 12:35:02 +03:00
admin a82930027d fix: complete catalog pagination and recovery states 2026-09-11 11:25:59 +03:00
admin 78493b9248 fix: complete admin UX review findings 2026-08-28 16:01:00 +03:00
admin 5d3fb68c2f fix: harden admin UX and accessibility 2026-08-28 12:11:52 +03:00
admin dc060985a3 Merge pull request 'fix: display all academic degrees' (#36) from fix/academic-degree-display into main
Reviewed-on: #36
2026-08-27 14:34:17 +00:00
admin 1d8a854a12 chore: align package version 2026-08-27 17:32:04 +03:00
admin d07711f665 fix: display all academic degrees 2026-08-27 17:29:16 +03:00
admin cf810f9cad Merge pull request 'refactor: remove unused data integration' (#35) from fix/remove-mcp into main
Reviewed-on: #35
2026-08-27 14:12:37 +00:00
38 changed files with 4014 additions and 219 deletions

No files matched your search

+2
View File
@@ -10,6 +10,8 @@ CRAWL_LIMIT=
REQUEST_TIMEOUT=30
REQUEST_DELAY_SECONDS=1
PARSER_USE_PLAYWRIGHT=false
DISMISSAL_CONFIRMATION_RUNS=3
MAX_AUTO_DISMISSALS_PER_RUN=25
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
+104
View File
@@ -0,0 +1,104 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
env:
DATABASE_URL: sqlite:///./ci.db
PYTHONUNBUFFERED: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: python -m pip install --upgrade pip
- run: python -m pip install -r requirements.txt
- run: python -m pytest -q
- run: node --check app/static/admin.js
- run: sh -n infra/postgres-backup.sh
- run: sh -n infra/install-postgres-backup-cron.sh
deploy:
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
needs: test
runs-on: ubuntu-latest
concurrency:
group: production-deploy
cancel-in-progress: false
env:
PROD_PATH: /srv/miem_workers
RELEASE_NAME: miem-workers-release-${{ github.run_id }}.tar
steps:
- uses: actions/checkout@v4
- name: Prepare release
id: prepare
shell: bash
run: |
set -euo pipefail
backend_version="$(sed -n 's/^BACKEND_VERSION = "\(.*\)"$/\1/p' app/version.py)"
case "$backend_version" in
''|*[!0-9.]*) exit 1 ;;
esac
echo "backend_version=$backend_version" >> "$GITHUB_OUTPUT"
tar --exclude=.git --exclude=.env -cf "$RUNNER_TEMP/$RELEASE_NAME" .
printf '%s\n' "$PROD_SSH_KEY" > "$RUNNER_TEMP/deploy_key"
printf '%s\n' "$PROD_KNOWN_HOSTS" > "$RUNNER_TEMP/known_hosts"
chmod 600 "$RUNNER_TEMP/deploy_key" "$RUNNER_TEMP/known_hosts"
env:
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
PROD_KNOWN_HOSTS: ${{ secrets.PROD_KNOWN_HOSTS }}
- name: Upload release
shell: bash
run: |
set -euo pipefail
scp -i "$RUNNER_TEMP/deploy_key" \
-o BatchMode=yes \
-o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" \
"$RUNNER_TEMP/$RELEASE_NAME" \
"$PROD_USER@$PROD_HOST:$RELEASE_NAME"
env:
PROD_HOST: ${{ secrets.PROD_HOST }}
PROD_USER: ${{ secrets.PROD_USER }}
- name: Deploy and verify
shell: bash
run: |
set -euo pipefail
ssh -i "$RUNNER_TEMP/deploy_key" \
-o BatchMode=yes \
-o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" \
"$PROD_USER@$PROD_HOST" \
"PROD_PATH='$PROD_PATH' RELEASE_NAME='$RELEASE_NAME' EXPECTED_BACKEND_VERSION='$EXPECTED_BACKEND_VERSION' bash -s" <<'REMOTE'
set -euo pipefail
cd "$PROD_PATH"
release="$HOME/$RELEASE_NAME"
test -f "$release"
archive_version="$(tar -xOf "$release" ./app/version.py | sed -n 's/^BACKEND_VERSION = "\(.*\)"$/\1/p')"
test "$archive_version" = "$EXPECTED_BACKEND_VERSION"
docker run --rm \
-v "$release:/src/release.tar:ro" \
-v "$PROD_PATH":/dst \
alpine:3.20 sh -c 'tar -xf /src/release.tar -C /dst'
sh /srv/miem_workers/infra/install-postgres-backup-cron.sh
docker compose up -d --build
runtime_version="$(docker compose exec -T api python -c 'from app.version import BACKEND_VERSION; print(BACKEND_VERSION)')"
test "$runtime_version" = "$EXPECTED_BACKEND_VERSION"
health="$(curl --fail --silent --show-error http://127.0.0.1:8000/api/health)"
case "$health" in
*"\"status\":\"ok\""*"\"backend_version\":\"$EXPECTED_BACKEND_VERSION\""*) ;;
*) echo "Unexpected health response: $health" >&2; exit 1 ;;
esac
docker compose ps
rm -f "$release"
REMOTE
env:
PROD_HOST: ${{ secrets.PROD_HOST }}
PROD_USER: ${{ secrets.PROD_USER }}
EXPECTED_BACKEND_VERSION: ${{ steps.prepare.outputs.backend_version }}
+62
View File
@@ -1,5 +1,67 @@
# Changelog
## 0.8.15
- Исправлен production deploy: cron installer запускается через `sh` и не требует executable-бита.
## 0.8.14
- Production CD больше не создаёт backup при каждом deploy; еженедельная задача
сохраняет один полный PostgreSQL backup с атомарной заменой; ошибки и
пропущенные из-за lock запуски пишутся в system log.
## 0.8.12
- Передача версии в production deploy ограничена безопасным форматом.
## 0.8.11
- CSS-ассеты админки получают версию в URL и обновляются после релиза без ручной очистки кэша браузера.
## 0.8.10
- Production deploy проверяет совпадение версии release-архива и запущенного API.
## 0.8.9
- Контейнеры API, worker и PostgreSQL автоматически перезапускаются после остановки или перезагрузки хоста.
## 0.8.8
- Исправлено вертикальное выравнивание кнопки и ссылки в действиях фильтров справочника.
## 0.8.7
- Production CD оставляет только последний PostgreSQL backup после успешного деплоя.
## 0.8.6
- Исправлена загрузка release-архива production CD в домашний каталог deploy-пользователя.
## 0.8.5
- Исправлена адаптивная компоновка фильтров справочника и скрытие skip-link до клавиатурного фокуса.
## 0.8.4
- Подключены к конфигурации ограничения массового автоматического увольнения, используемые плановым обходом.
## 0.8.3
- Удалены устаревшие пояснение о недоступном endpoint и проверка его отсутствия.
## 0.8.2
- Добавлен production CD workflow с backup PostgreSQL и проверкой healthcheck после обновления.
## 0.8.1
- Улучшены доступность, фильтры, состояния и адаптивность административного каталога.
## 0.7.8
- В колонке учёной степени отображаются все найденные степени без лишнего текста.
## 0.7.7
- Удалена неиспользуемая интеграция обмена данными и связанная документация и тесты.
+43 -14
View File
@@ -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. REST API данных требует сессию администратора.
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например `crawl_runs.skipped_count`. SQL-миграции для ручного применения лежат в `migrations/`.
@@ -69,16 +77,20 @@ docker compose up -d --build --remove-orphans
- краткий список остается внутри `employees.current_data.sections[].publications` для обратной совместимости;
- детальные записи сохраняются в `employee_publications` и связываются с сотрудником через `employee_id`.
`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`.
Новости сотрудников также хранятся в двух видах:
`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 не должен создавать дубликаты.
Новости сотрудников также хранятся в двух видах:
- краткий список остается внутри `employees.current_data.sections[].news_links`;
- нормализованные карточки из вкладки «В новостях» сохраняются в `employee_news_links`.
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
## Кандидаты в диссертационный совет
Ссылка в админке открывает `/admin/council`. Страница использует действующих сотрудников и докторские степени из их профилей, а публикации — из предоставленной выгрузки `app/data/council_articles.csv`. Публикации сопоставляются только по полному ФИО при единственном совпадении; повторный DOI (или одинаковые название и год при отсутствии DOI) учитывается один раз. Порог — 10 предварительно подходящих публикаций за скользящие пять лет от текущей даты по Москве.
Список **предварительный**: в CSV указаны год и квартиль Scopus/WoS, но нет точной даты, типа материала, журнала, источника квартиля, подтверждения К1 и RSCI. На границах периода год не позволяет проверить точную дату. Статьи с Q1/Q2 учитываются с пометкой источника; отсутствие кандидата не доказывает недостаточную публикационную активность. Для подтверждённого отбора нужны проверка журналов и дат и дополнительные источники публикаций. Выгрузка поставляется вместе с релизом и обновляется отдельным изменением репозитория.
## Парсинг
@@ -113,7 +125,24 @@ curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=.
docker compose logs -f api
docker compose logs -f worker
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
docker compose down
```
Версия сервиса: `0.7.7`. Админка всегда показывает версии backend и frontend в footer.
docker compose down
```
Production deploy выполняется job `deploy` workflow `CI` после успешного `test` на push в `main` или вручную. Для него нужны Actions secrets `PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY` и `PROD_KNOWN_HOSTS`; `.env` и ключи в репозиторий не добавляются. PostgreSQL backup выполняется на VPS раз в неделю user cron deploy-пользователя через `infra/postgres-backup.sh`: `pg_dumpall` сохраняет весь кластер в `~/backups/postgres/postgres-latest.sql`. Новый dump сначала создаётся во временный файл и заменяет старый только после успешного завершения; предыдущие dump-файлы `miem_workers` удаляются после успешного создания нового. Одновременно выполняется только один production deploy.
## Проверки
После установки зависимостей из `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.10.2`. Админка всегда показывает версии backend и frontend в footer.
## Разовый импорт публикаций
Инструкция, минимальные права временной роли и ограниченный SSH-запуск: [docs/publication-import.md](docs/publication-import.md). По умолчанию импортер выполняет dry-run; production-применение требует отдельного решения владельца.
+38 -21
View File
@@ -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
@@ -16,8 +17,9 @@ from app.services.admin_data import (
run_payload,
stats_payload,
)
from app.services.crawl_control import get_running_run, run_crawl_if_idle
from app.services.crawl_control import get_running_run, run_crawl_if_idle
from app.services.crawler import refresh_dismissed_status, refresh_employee
from app.services.council import council_candidates
from app.version import BACKEND_VERSION, FRONTEND_VERSION
router = APIRouter(prefix="/admin")
@@ -72,8 +74,8 @@ def employees(
return RedirectResponse("/admin/directory", status_code=303)
@router.get("/directory", response_class=HTMLResponse)
def directory(
@router.get("/directory", response_class=HTMLResponse)
def directory(
request: Request,
status: str | None = None,
q: str | None = None,
@@ -93,19 +95,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,13 +127,24 @@ def directory(
"sort": sort,
"direction": direction,
"limit": page["limit"],
"offset": offset,
"offset": page["offset"],
},
},
)
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
)
@router.get("/council", response_class=HTMLResponse)
def council(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
require_admin(request, settings)
try:
result = council_candidates(db)
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council.html", {"error": True}, status_code=503)
return _render(request, "council.html", {"error": False, **result})
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
def employee_detail(
employee_id: int,
request: Request,
+2
View File
@@ -13,6 +13,8 @@ class Settings(BaseSettings):
request_timeout: int = 30
request_delay_seconds: float = 1.0
parser_use_playwright: bool = False
dismissal_confirmation_runs: int = Field(default=3, ge=1)
max_auto_dismissals_per_run: int = Field(default=25, ge=1)
admin_username: str = "admin"
admin_password: str = "admin"
File diff suppressed because it is too large. Load diff
+14 -4
View File
@@ -2,13 +2,16 @@ import re
from typing import Any
_PATTERN = re.compile(r"\b(?:кандидат|доктор)\s+[\w\s-]{0,80}?\s+наук\b|\bph\.?\s*d\.?\b", re.IGNORECASE)
_PATTERN = re.compile(r"\b(?:кандидат|доктор)(?:\s+[\w-]+){0,12}\s+наук\b|\bph\.?\s*d\.?(?!\w)", re.IGNORECASE)
def academic_degrees(data: dict[str, Any] | None) -> list[str]:
degrees = []
seen = set()
for section in (data or {}).get("sections") or []:
if not isinstance(section, dict) or not re.search(r"уч[её]н.*степен|academic degree", str(section.get("title") or ""), re.IGNORECASE):
if not isinstance(section, dict) or not re.search(
r"образован|степен|academic degree|education", str(section.get("title") or ""), re.IGNORECASE
):
continue
values = [
*(entry.get("text") for entry in section.get("year_entries") or [] if isinstance(entry, dict)),
@@ -16,8 +19,15 @@ def academic_degrees(data: dict[str, Any] | None) -> list[str]:
*(section.get("items") or []),
section.get("raw_text"),
]
table = section.get("table") or {}
for row in table.get("rows") or []:
if isinstance(row, dict):
values.extend(row.get("cells") or [])
for value in values:
text = str(value or "").strip()
if text and _PATTERN.search(text) and text not in degrees:
degrees.append(text)
for match in _PATTERN.finditer(text):
degree = match.group(0).strip()
if degree.casefold() not in seen:
seen.add(degree.casefold())
degrees.append(degree)
return degrees
+2
View File
@@ -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()
+117
View File
@@ -0,0 +1,117 @@
"""Preliminary council candidates from the supplied publication export."""
import calendar
import csv
import re
from collections import Counter, defaultdict
from datetime import date, datetime
from functools import lru_cache
from pathlib import Path
from urllib.parse import quote
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Employee
from app.services.academic_degrees import academic_degrees
EXPORT = Path(__file__).resolve().parents[1] / "data" / "council_articles.csv"
DOCTOR = re.compile(r"^доктор\b.+\bнаук$", re.IGNORECASE)
DOI = re.compile(r"10\.\d{4,9}/\S+", re.IGNORECASE)
def _name(value: str) -> str:
return " ".join(value.casefold().replace("ё", "е").split())
def _doi(value: str) -> str:
value = value.strip().lower()
for prefix in ("https://doi.org/", "http://doi.org/", "doi:"):
if value.startswith(prefix):
value = value[len(prefix):]
return value
def period(today: date) -> tuple[date, date]:
year = today.year - 5
return date(year, today.month, min(today.day, calendar.monthrange(year, today.month)[1])), today
@lru_cache(maxsize=1)
def _articles_by_author() -> dict[str, list[dict]]:
articles = defaultdict(list)
with EXPORT.open(encoding="utf-8-sig", newline="") as source:
for row_number, row in enumerate(csv.DictReader(source, delimiter=";"), 2):
sources = []
for label, quartile, identifier in (
("Scopus", "Квартиль scopus", "EID (Scopus)"),
("Web of Science", "Квартиль wos", "UT (WoS)"),
):
value = (row[quartile] or "").strip().upper()
if value in {"Q1", "Q2"}:
sources.append({"name": label, "quartile": value, "identifier": (row[identifier] or "").strip()})
if not sources:
continue
try:
year = int(row["Год"])
except (TypeError, ValueError):
continue
title = (row["Название публикации"] or "").strip()
if not title:
continue
doi = _doi(row["DOI"] or "")
article = {
"title": title,
"year": year,
"doi": doi,
"doi_url": f"https://doi.org/{quote(doi, safe='/')}" if DOI.fullmatch(doi) else None,
"sources": sources,
"row_number": row_number,
}
for author in set(_name(value) for value in (row["Авторы НИУ ВШЭ"] or "").split(";")) - {""}:
articles[author].append(article)
return articles
def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]:
unique = {}
for article in articles:
if not start.year <= article["year"] <= end.year:
continue
key = article["doi"] or (_name(article["title"]), article["year"])
if key not in unique:
unique[key] = {**article, "sources": list(article["sources"])}
else:
known = {item["name"] for item in unique[key]["sources"]}
unique[key]["sources"].extend(
item for item in article["sources"] if item["name"] not in known
)
return sorted(unique.values(), key=lambda item: (-len(item["sources"]), -item["year"], item["title"].casefold()))
def council_candidates(db: Session, today: date | None = None) -> dict:
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
start, end = period(today)
employees = db.scalars(select(Employee).where(Employee.status == "active")).all()
name_counts = Counter(_name(employee.full_name or "") for employee in employees)
by_author = _articles_by_author()
candidates = []
for employee in employees:
author = _name(employee.full_name or "")
if not author or name_counts[author] != 1:
continue
try:
degrees = academic_degrees(employee.current_data if isinstance(employee.current_data, dict) else {})
except (AttributeError, TypeError):
continue
degree = next((value for value in degrees if DOCTOR.fullmatch(value)), None)
if not degree:
continue
articles = _unique_articles(by_author.get(author, []), start, end)
if len(articles) >= 10:
candidates.append({"id": employee.id, "name": employee.full_name, "degree": degree,
"count": len(articles), "articles": articles})
candidates.sort(key=lambda item: (-item["count"], item["name"].casefold()))
return {"candidates": candidates, "start": start, "end": end, "total": len(candidates)}
+60 -10
View File
@@ -2,7 +2,8 @@ import gzip
import hashlib
import json
import re
import time
import time
import unicodedata
from datetime import datetime, timezone
import requests
@@ -398,8 +399,11 @@ def _employee_publications_table_exists(db: Session) -> bool:
return inspect(db.connection()).has_table(EmployeePublication.__tablename__)
def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -> None:
publications = _publication_payloads(parsed)
def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -> None:
publications = _publication_payloads(parsed)
imported = [item for item in db.scalars(select(EmployeePublication).where(
EmployeePublication.employee_id == employee.id
)).all() if _csv_enrichment(item) is not None]
seen_hashes = set()
for publication in publications:
source_hash = _publication_hash(publication)
@@ -420,8 +424,10 @@ def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -
EmployeePublication.source_hash == source_hash,
)
)
if not existing:
existing = EmployeePublication(employee_id=employee.id, source_hash=source_hash, title=_publication_title(publication))
if not existing:
existing = _match_imported_publication(imported, publication)
if not existing:
existing = EmployeePublication(employee_id=employee.id, source_hash=source_hash, title=_publication_title(publication))
db.add(existing)
_apply_publication(existing, publication, source_hash)
@@ -432,8 +438,39 @@ def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -
EmployeePublication.source_hash.not_in(seen_hashes),
)
).all()
for item in stale:
db.delete(item)
for item in stale:
if _csv_enrichment(item) is None:
db.delete(item)
def _csv_enrichment(publication: EmployeePublication) -> dict | None:
raw = publication.raw_data
metadata = raw.get("csv_enrichment") if isinstance(raw, dict) else None
return metadata if isinstance(metadata, dict) else None
def _match_imported_publication(imported: list[EmployeePublication], publication: dict) -> EmployeePublication | None:
def doi(value):
return re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", (value or "").strip(), flags=re.I).lower()
def title(value):
return " ".join(unicodedata.normalize("NFKC", value or "").casefold().replace("ё", "е").split())
identifier = doi(publication.get("doi_url"))
year = _int_or_none(publication.get("year"))
name = title(publication.get("title") or publication.get("text"))
matches = [item for item in imported if
(identifier and doi(item.doi_url) == identifier) or
(name and title(item.title) == name and item.year in (None, year))]
if len(matches) > 1:
raise ValueError("Ambiguous imported publication; synchronization rolled back")
if not matches:
return None
match = matches[0]
if ((identifier and doi(match.doi_url) and identifier != doi(match.doi_url)) or
(year and match.year and year != match.year)):
raise ValueError("Conflicting imported publication; synchronization rolled back")
return match
def _publication_payloads(parsed: dict) -> list[dict]:
@@ -447,8 +484,16 @@ def _publication_payloads(parsed: dict) -> list[dict]:
return publications
def _apply_publication(target: EmployeePublication, publication: dict, source_hash: str) -> None:
target.publication_id = _clean_optional(publication.get("publication_id") or publication.get("id"))
def _apply_publication(target: EmployeePublication, publication: dict, source_hash: str) -> None:
enrichment = _csv_enrichment(target)
publication_id = _clean_optional(publication.get("publication_id") or publication.get("id"))
if enrichment is not None and target.publication_id and publication_id and target.publication_id != publication_id:
raise ValueError(
f"Ambiguous HSE IDs for CSV publication {target.id}: "
f"{target.publication_id} != {publication_id}; publication synchronization rolled back"
)
preserved = {field: getattr(target, field) for field in ("title", "year", "doi_url")}
target.publication_id = (publication_id or target.publication_id) if enrichment is not None else publication_id
target.title = _publication_title(publication)
target.year = _int_or_none(publication.get("year"))
target.publication_type = _clean_optional(publication.get("publication_type") or publication.get("type"))
@@ -462,7 +507,12 @@ def _apply_publication(target: EmployeePublication, publication: dict, source_ha
target.annotation = publication.get("annotation") if isinstance(publication.get("annotation"), dict) else None
target.description = publication.get("description") if isinstance(publication.get("description"), dict) else None
target.authors = publication.get("authors") if isinstance(publication.get("authors"), list) else None
target.raw_data = publication.get("raw_data") if isinstance(publication.get("raw_data"), dict) else publication
target.raw_data = publication.get("raw_data") if isinstance(publication.get("raw_data"), dict) else publication
if enrichment is not None:
target.raw_data = {**target.raw_data, "csv_enrichment": enrichment}
for field in ("title", "year", "doi_url"):
if not getattr(target, field) or (field == "title" and not (publication.get("title") or publication.get("text"))):
setattr(target, field, preserved[field])
target.source_hash = source_hash
+281 -53
View File
@@ -5,9 +5,23 @@
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: -999px;
z-index: 100;
padding: 8px 12px;
color: #ffffff;
background: #0f766e;
}
.admin__skip-link:focus-visible {
left: 8px;
}
.admin__header {
display: flex;
align-items: center;
@@ -16,6 +30,7 @@
padding: 18px 32px;
background: #ffffff;
border-bottom: 1px solid #d9dee7;
flex-wrap: wrap;
}
.admin__brand {
@@ -40,6 +55,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 +132,6 @@
border-collapse: collapse;
}
.table__row {
cursor: pointer;
}
.table__row:hover {
background: #f0fdfa;
}
@@ -161,8 +182,10 @@
border-radius: 6px;
}
.button {
padding: 10px 14px;
.button {
box-sizing: border-box;
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 {
@@ -503,9 +544,9 @@
color: #6b7280;
}
.directory__filters {
display: grid;
grid-template-columns: minmax(220px, 1.7fr) repeat(6, minmax(120px, 1fr));
.directory__filters {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 2fr) minmax(0, 1.25fr) minmax(0, 2fr);
gap: 10px;
padding: 16px;
background: #ffffff;
@@ -513,13 +554,58 @@
border-radius: 8px;
}
.directory__input {
.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 {
box-sizing: border-box;
width: 100%;
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 +625,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 +651,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 +676,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 +739,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 {
@@ -658,8 +778,116 @@
}
}
@media (max-width: 620px) {
@media (max-width: 620px) {
.directory-table [data-column="full_name"] {
position: sticky;
left: 0;
z-index: 1;
min-width: 130px;
max-width: 160px;
background: #ffffff;
}
.admin__header {
align-items: flex-start;
padding: 16px;
}
.admin__nav {
width: 100%;
flex-wrap: wrap;
}
.admin__main {
width: min(100% - 24px, 1180px);
margin: 20px auto;
}
.directory__filters {
grid-template-columns: 1fr;
}
}
.directory__filter-group,
.directory__filter-group--search,
.directory__filter-actions {
grid-column: 1;
}
.directory__field {
font-size: 13px;
}
}
.council {
display: grid;
gap: 24px;
color: #25313a;
}
.council__hero {
display: flex;
align-items: end;
justify-content: space-between;
gap: 32px;
padding: clamp(24px, 4vw, 48px);
color: #f9f6f0;
background: #263640;
border-radius: 16px;
}
.council__hero-copy { max-width: 680px; }
.council__title { margin: 0; font-family: Georgia, serif; font-size: clamp(29px, 4vw, 48px); line-height: 1.12; letter-spacing: -0.025em; }
.council__intro { max-width: 60ch; margin: 16px 0 0; color: #e3e8e5; line-height: 1.55; }
.council__total { margin: 0; min-width: 160px; color: #e3e8e5; font-size: 14px; line-height: 1.4; }
.council__total strong { display: block; color: #f6d49b; font-size: 42px; font-variant-numeric: tabular-nums; line-height: 1; }
.council__criteria {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
padding-block: 6px 22px;
border-bottom: 1px solid #cdd4d5;
}
.council__criteria p { display: grid; align-content: start; gap: 5px; margin: 0; }
.council__criteria strong { font-size: 18px; font-variant-numeric: tabular-nums; }
.council__criteria span { color: #4c5b64; font-size: 13px; }
.council__notice { padding: 16px 20px; background: #f6efe2; border-radius: 10px; line-height: 1.55; }
.council__notice strong { color: #593b20; }
.council__list-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.council__list-heading h3 { margin: 0; font-family: Georgia, serif; font-size: 27px; }
.council__list-heading p { margin: 0; color: #4c5b64; font-size: 14px; }
.council__list { display: grid; gap: 10px; }
.council__candidate { overflow: hidden; background: #fff; border: 1px solid #d6dddc; border-radius: 12px; }
.council__candidate[open] { border-color: #8fa8a5; }
.council__candidate-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 20px; min-height: 76px; padding: 14px 20px; cursor: pointer; list-style: none; }
.council__candidate-summary::-webkit-details-marker { display: none; }
.council__candidate-summary:hover { background: #f3f7f5; }
.council__candidate-identity { display: grid; gap: 4px; min-width: 0; }
.council__candidate-identity strong { font-size: 17px; overflow-wrap: anywhere; }
.council__candidate-identity small, .council__candidate-count small { color: #4c5b64; font-size: 12px; }
.council__candidate-count { display: grid; gap: 2px; text-align: end; }
.council__candidate-count strong { font-size: 25px; font-variant-numeric: tabular-nums; line-height: 1; }
.council__candidate-cue { width: 9px; height: 9px; border-inline-end: 2px solid #345e59; border-bottom: 2px solid #345e59; transform: rotate(45deg); }
.council__candidate[open] .council__candidate-cue { transform: rotate(225deg); }
.council__candidate-body { padding: 20px; border-top: 1px solid #e4e9e8; }
.council__articles { display: grid; gap: 0; margin: 16px 0 0; padding-inline-start: 22px; }
.council__article { padding: 14px 4px; border-top: 1px solid #e7ebea; }
.council__article::marker { color: #5e746f; font-variant-numeric: tabular-nums; }
.council__article-heading { display: flex; justify-content: space-between; gap: 16px; line-height: 1.45; }
.council__article-heading strong { font-weight: 600; overflow-wrap: anywhere; }
.council__article-heading span { color: #4c5b64; font-variant-numeric: tabular-nums; }
.council__article-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; margin-top: 9px; color: #4c5b64; font-size: 12px; }
.council__source { padding: 4px 8px; color: #244b46; background: #e7f0eb; border-radius: 4px; font-weight: 700; overflow-wrap: anywhere; }
.council__message { padding: 28px; background: #fff; border-radius: 12px; }
.council__message h3, .council__message h4 { margin: 0 0 8px; font-size: 19px; }
.council__message p { margin: 0; color: #4c5b64; line-height: 1.5; }
@media (max-width: 700px) {
.council__hero, .council__list-heading { align-items: start; flex-direction: column; }
.council__total strong { display: inline; margin-inline-end: 8px; font-size: 28px; }
.council__criteria { grid-template-columns: 1fr; gap: 14px; }
.council__candidate-summary { grid-template-columns: minmax(0, 1fr) auto 14px; gap: 10px; padding: 14px; }
.council__candidate-count small { max-width: 72px; }
.council__candidate-body { padding: 14px; }
}
+57 -32
View File
@@ -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 -5
View File
@@ -4,21 +4,23 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}MIEM Employees{% endblock %}</title>
<link rel="stylesheet" href="/static/admin.css">
<link rel="stylesheet" href="/static/admin.css?v={{ frontend_version }}">
</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/council"{% if request.url.path == "/admin/council" %} 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">
+72
View File
@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}Кандидаты в диссертационный совет · MIEM Employees{% endblock %}
{% block content %}
<section class="council">
<header class="council__hero">
<div class="council__hero-copy">
<h2 class="council__title">Кандидаты в диссертационный совет</h2>
<p class="council__intro">Сотрудники с докторской степенью в профиле и публикациями с отметками Q1 или Q2 в имеющейся выгрузке. Список помогает начать проверку состава комиссии.</p>
</div>
{% if not error %}<p class="council__total"><strong>{{ total }}</strong> предварительно подходят</p>{% endif %}
</header>
{% if error %}
<div class="council__message" role="alert">
<h3>Не удалось загрузить список</h3>
<p>Проверьте доступность базы сотрудников и файла публикаций, затем обновите страницу.</p>
</div>
{% else %}
<div class="council__criteria" aria-label="Правила предварительного отбора">
<p><strong>10 и более</strong><span>различных публикаций</span></p>
<p><strong>{{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}</strong><span>скользящие пять лет</span></p>
<p><strong>К1 · RSCI · Q1/Q2</strong><span>итоговые основания отбора</span></p>
</div>
<div class="council__notice" role="note">
<strong>Предварительный список.</strong> Сейчас учитываются только отметки Q1/Q2 в CSV для Scopus и Web of Science. В нём нет названия журнала, точной даты статьи, типа материала и подтверждения К1 или RSCI. Год на границе периода и принадлежность к журналу нужно проверить. Сотрудники с публикациями только в К1 или RSCI могут отсутствовать.
</div>
<div class="council__list-heading">
<h3>Сотрудники</h3>
<p>Откройте строку, чтобы увидеть публикации и основания.</p>
</div>
{% if candidates %}
<div class="council__list">
{% for candidate in candidates %}
<details class="council__candidate">
<summary class="council__candidate-summary">
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}</small></span>
<span class="council__candidate-count"><strong>{{ candidate.count }}</strong><small>публикаций по выгрузке</small></span>
<span class="council__candidate-cue" aria-hidden="true"></span>
</summary>
<div class="council__candidate-body">
<a class="admin__link" href="/admin/employees/{{ candidate.id }}">Открыть профиль сотрудника</a>
<ol class="council__articles">
{% for article in candidate.articles %}
<li class="council__article">
<div class="council__article-heading">
<strong>{{ article.title }}</strong>
<span>{{ article.year }}</span>
</div>
<div class="council__article-meta">
{% for source in article.sources %}
<span class="council__source">{{ source.name }} {{ source.quartile }}{% if source.identifier %} · {{ source.identifier }}{% else %} · ID не указан{% endif %}</span>
{% endfor %}
{% if article.doi_url %}<a class="admin__link" href="{{ article.doi_url }}" target="_blank" rel="noopener noreferrer">DOI</a>{% endif %}
<span>Строка {{ article.row_number }} в CSV</span>
</div>
</li>
{% endfor %}
</ol>
</div>
</details>
{% endfor %}
</div>
{% else %}
<div class="council__message">
<h4>Кандидаты пока не найдены</h4>
<p>По доступной выгрузке нет действующих сотрудников с докторской степенью, указанной в профиле, и десятью предварительно подходящими публикациями. Это не означает, что таких сотрудников нет.</p>
</div>
{% endif %}
{% endif %}
</section>
{% endblock %}
+12 -11
View File
@@ -28,15 +28,15 @@
</section>
<section class="panel progress-panel" data-progress-panel>
<div class="progress-panel__header">
<h2 class="panel__title">Прогресс парсинга</h2>
<div class="progress-panel__actions">
<form method="post" action="/admin/crawl-now">
<button class="button" type="submit">Запустить парсинг</button>
</form>
<form method="post" action="/admin/dismissed/refresh">
<button class="button button--secondary" type="submit">Проверить уволенных</button>
</form>
</div>
<h2 class="panel__title">Прогресс парсинга</h2>
<div class="progress-panel__actions">
<form method="post" action="/admin/crawl-now">
<button class="button" type="submit">Запустить парсинг</button>
</form>
<form method="post" action="/admin/dismissed/refresh">
<button class="button button--secondary" type="submit">Проверить уволенных</button>
</form>
</div>
</div>
{% set run = counts.current_running_run or latest_run %}
<div class="progress-panel__body" data-progress-body>
@@ -46,10 +46,11 @@
<span>без изменений: <span data-progress-skipped>{{ run.skipped_count if run else 0 }}</span></span>
<span>ошибок: <span data-progress-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>
+65 -31
View File
@@ -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
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Каталог недоступен · MIEM Employees{% endblock %}
{% block content %}
<section class="panel">
<h2 class="panel__title">Не удалось загрузить сотрудников</h2>
<p>База данных временно недоступна. Повторите запрос; выбранные фильтры сохранятся.</p>
<a class="button" href="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}">Повторить загрузку</a>
</section>
{% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Вход · MIEM Employees</title>
<link rel="stylesheet" href="/static/admin.css">
<link rel="stylesheet" href="/static/admin.css?v={{ frontend_version }}">
</head>
<body class="admin">
<main class="admin__main">
+5 -3
View File
@@ -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>
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "0.7.7"
FRONTEND_VERSION = "0.7.7"
BACKEND_VERSION = "0.7.7"
APP_VERSION = "0.10.2"
FRONTEND_VERSION = "0.10.2"
BACKEND_VERSION = "0.10.2"
+3
View File
@@ -1,6 +1,7 @@
services:
postgres:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_DB: ${POSTGRES_DB:-miem_workers}
POSTGRES_USER: ${POSTGRES_USER:-miem}
@@ -15,6 +16,7 @@ services:
api:
build: .
restart: always
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
env_file: .env
environment:
@@ -27,6 +29,7 @@ services:
worker:
build: .
restart: always
command: python -m app.worker
env_file: .env
environment:
+312
View File
@@ -0,0 +1,312 @@
# Разовый импорт публикаций
Инструмент: `scripts/import_publications.py`. Он не импортирует приложение, не читает
`.env`, не вызывает `init_db`, не создаёт таблиц и не удаляет записи. Подключение
берётся только из libpq environment/service, настроенных владельцем. Production в
рамках подготовки не проверялся и не изменялся.
Локальные проверки: синтетические сотрудники, повторный запуск, дубликаты,
неоднозначности, конфликты DOI/года, сохранение заполненных значений и
транзакционный путь на тестовом соединении (read-only / rollback при второй
ошибочной вставке / успешный commit). Прогон на настоящем PostgreSQL и проверка
grants не выполнялись; первый owner-run dry-run с read-only ролью обязателен.
## Вход и сопоставление
Файл владельца `Статьи.csv`: UTF-8 с возможным BOM, разделитель `;`, 1797 записей,
SHA256 `1f236be56d99677b1403df29a4c1d3a7e6d10cb90bd355d678201a027921b0ce`.
Обязательные колонки: `Название публикации`, `Год`, `DOI`, `Авторы НИУ ВШЭ`.
Номер строки в отчёте — номер CSV-записи с заголовком, а не физической строки.
Сотрудник сопоставляется по полному ФИО: Unicode NFKC, регистр, пробелы и ё/е.
Учитываются все существующие статусы; новые сотрудники не создаются. Ноль
совпадений → `employee_not_found`, несколько → `ambiguous_employee`. Инициалы,
переводы, перестановки слов и приблизительные совпадения не используются.
Публикация сопоставляется **внутри конкретного сотрудника** по нормализованному DOI
или полному названию и году. Отсутствующий год БД допускает заполнение при
совпадении названия. Если эти способы указывают на разные записи, импорт
пропускается (`ambiguous_publication`). Разные заполненные DOI или годы —
`doi_conflict` / `year_conflict`. Существующие дубликаты не объединяются и не
удаляются. Одна статья у разных сотрудников — предусмотренные моделью авторские
связи, а не дубли внутри карточки.
Повтор CSV внутри сотрудника не создаёт записи. Каждая поздняя строка дополняет
ещё пустые метаданные; первый непустой источник имеет приоритет, в том числе
ранее сохранённое значение БД. Повтор без изменений — `csv_duplicate`, дополнение
запланированной вставки — `merged_csv_duplicate`. При конфликте непустых
метаданных первое значение сохраняется; остальные пустые поля всё равно
дополняются. Отчёт содержит `metadata_conflict_first_value_kept`, список
`conflict_fields` (только названия полей, без значений) и сводный счётчик
`metadata_conflicts`. Конфликт DOI/года идентификации по-прежнему пропускает
всю связь. Если строка без DOI была запланирована первой, следующая строка с
DOI может дополнить ту же вставку. При повторном запуске уже заполненные записи дают
`already_present`. Хеш новой записи детерминирован по сотруднику и DOI либо
названию/году. `source_hash` существующих записей не меняется.
Пустые ячейки ничего не стирают. Пустые название/авторы, некорректный год
(допустимо 1000–9999) или DOI пропускаются как `invalid_fields`. Повреждённая
структура CSV прерывает весь запуск до подключения. Несовместимый JSON БД
пропускается (`unsupported_raw_data`, `unsupported_csv_metadata`).
## Таблицы, столбцы и права
| Объект | Операция / столбцы |
|---|---|
| `public.employees` | Только SELECT: `id`, `full_name` |
| `public.employee_publications` | SELECT: `id`, `employee_id`, `title`, `year`, `doi_url`, `raw_data` |
| Новая публикация | INSERT: `employee_id`, `title`, `year`, `doi_url` при наличии, `raw_data`, `source_hash`, `created_at`, `updated_at` |
| Существующая публикация | UPDATE: только пустые `title`, `year`, `doi_url`; `raw_data`; служебный `updated_at` |
| Sequence идентификатора публикации | USAGE для `nextval`; имя определяется `pg_get_serial_sequence`, обычно `public.employee_publications_id_seq` |
Остальные таблицы и sequences не затрагиваются. В `raw_data` сохраняются прежние
ключи; добавляется/дополняется `csv_enrichment` только отсутствующими или пустыми
значениями. Уже заполненные CSV-метаданные не заменяются. Поля `authors`,
`publication_id`, тип материала, язык, аннотация, ссылки HSE и статус не меняются.
CSV содержит **только авторов НИУ ВШЭ**, поэтому он не считается полным составом
авторов и не заменяет `authors`. Идентификаторы Scopus/WoS, квартиль и остальные
непустые исходные ячейки сохраняются в `raw_data.csv_enrichment` без интерпретации.
Карточки `employees.current_data` и snapshots не изменяются; отчёт и данные
таблицы не означают обновления всех представлений интерфейса.
Минимальные собственные grants роли: CONNECT к БД, USAGE схемы public,
перечисленные SELECT/INSERT/UPDATE по столбцам, USAGE найденной sequence.
Не нужны DELETE, TRUNCATE, CREATE, ALTER, ownership, superuser, доступ к прочим
таблицам и sequence UPDATE/setval. Роль новая, без членства в других ролях.
Права PUBLIC всё равно наследуются: владелец должен отдельно проверить их;
этот набор команд не меняет общие права PUBLIC и не гарантирует изоляцию при
уже существующих широких общих grants. В частности TEMP может быть выдан PUBLIC.
## Применение и ограничения
Без `--apply` используется PostgreSQL `SERIALIZABLE READ ONLY`, без INSERT,
UPDATE и nextval, с rollback. С `--apply` весь план применяется одной транзакцией
SERIALIZABLE. Advisory lock предотвращает одновременное применение этим
импортером. Таймаут запроса — 60 секунд, ожидания блокировок — 5 секунд.
Ошибка записи/конфликт сериализации откатывает все изменения таблиц; автоматических
повторов нет. Sequence может получить пропуски при откате — это нормальное
поведение PostgreSQL. Изменение входного файла во время запуска прерывает импорт.
JSON-отчёт содержит количества insert/update/skip, причины и пары
`номер CSV-записи — employee_id`, без ФИО, названий статей и секретов. Счётчики
относятся к авторским связям, а не уникальным статьям. Консоль выводит только
сводку. Отчёт сначала пишется в `.pending` до commit, после успеха переименовывается.
Если остался `.pending`, следующий запуск блокируется: сначала проверьте исход
транзакции. Отчёт `.pending` не доказывает commit; ошибка переименования после
commit также возможна. Безопасный повторный dry-run позволяет проверить состояние.
При необходимости владелец перемещает `.pending` в архив перед повтором.
**До применения нужен свежий backup БД.** Во время импорта владелец должен
исключить конкурентные записи публикаций на время транзакции, согласовав окно
работы; эта подготовка не отключает crawler. Не выполняйте production apply до
независимого ревью и развёртывания исправления crawler из этого PR.
До исправления `_upsert_employee` запускал синхронизацию публикаций даже для
неизменённой карточки. При пустом списке публикаций или отсутствии таблицы
синхронизация пропускалась. При непустом списке `_sync_employee_publications`
удалял все записи сотрудника, чей `source_hash` отсутствовал среди хешей ответа
ВШЭ, включая CSV-вставки. Найденные по `publication_id`/хешу записи полностью
получали новый `raw_data`, поэтому теряли CSV-метаданные даже без удаления.
Исправление сохраняет записи с dict `raw_data.csv_enrichment` при очистке
устаревших публикаций. При обновлении сохраняется этот ключ и заполненные
title/year/doi_url, если ВШЭ вернул пустые значения. Непустые значения ВШЭ
продолжают обновлять канонические поля, а исходные CSV-значения остаются в
метаданных. Если статья впервые появилась у ВШЭ, crawler сопоставляет её с
импортированной записью по DOI или названию/году до новой вставки, сохраняет id
и добавляет HSE publication_id. Конфликт/неоднозначность откатывает весь savepoint
синхронизации публикаций и регистрирует CrawlError; карточка сотрудника продолжает
обрабатываться. Удаление обычных устаревших HSE-записей остаётся прежним. Здесь
не приведены команды рестарта или отключения сервисов.
Уже установленный HSE publication_id CSV-записи нельзя заменить другим HSE ID,
даже когда DOI/название/год совпадают. Это проверяется перед изменением полей
при любом пути сопоставления (ID, source_hash, CSV-поля). Поэтому две HSE-статьи
не могут по очереди перезаписать одну CSV-запись: весь savepoint откатывается
независимо от порядка статей. Отсутствующий ID во входе не очищает существующую
CSV-привязку. Отчёт обхода (`errors` в деталях запуска) содержит CrawlError с
идентификатором записи БД и конфликтующими HSE ID, без ФИО и названий статей.
## Команды владельцу (не выполнялись)
Ниже — подготовка разового доступа после ревью. Выполняет владелец на сервере.
Рабочая директория — проверенная копия этого PR; файлы программы и CSV должны
быть защищены от записи временным SSH-пользователем. Пароли/ключи не включать
в репозиторий или чат. Имена контейнеров соответствуют текущему Compose-проекту;
владелец проверяет их локально перед выполнением.
Сначала резервная копия в приватный каталог владельца:
```sh
install -d -m 700 /var/backups/miem-publication-import
docker exec miem_workers-postgres-1 pg_dump -U miem -d miem_workers -Fc -f /tmp/publications-before.dump
docker cp miem_workers-postgres-1:/tmp/publications-before.dump /var/backups/miem-publication-import/before.dump
chmod 600 /var/backups/miem-publication-import/before.dump
```
При повторной операции используйте новый путь backup; не перезаписывайте нужную
предыдущую копию. Проверьте exit codes и архив `pg_restore --list` до выдачи write.
Откройте psql владельцем БД:
```sh
docker exec -it miem_workers-postgres-1 psql -U miem -d miem_workers
```
Выполните SQL (без пароля в SQL/истории):
```sql
\set ON_ERROR_STOP on
SELECT format('CREATE ROLE miem_publication_import LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 1 VALID UNTIL %L', CURRENT_TIMESTAMP + interval '24 hours') \gexec
\password miem_publication_import
GRANT CONNECT ON DATABASE miem_workers TO miem_publication_import;
GRANT USAGE ON SCHEMA public TO miem_publication_import;
GRANT SELECT (id, full_name) ON public.employees TO miem_publication_import;
GRANT SELECT (id, employee_id, title, year, doi_url, raw_data) ON public.employee_publications TO miem_publication_import;
-- Сначала выдаются только read-права для dry-run.
SELECT pg_get_serial_sequence('public.employee_publications', 'id');
SELECT has_schema_privilege('miem_publication_import', 'public', 'CREATE') AS unexpected_create,
has_table_privilege('miem_publication_import', 'public.employees', 'INSERT') AS unexpected_employee_insert,
has_table_privilege('miem_publication_import', 'public.employee_publications', 'DELETE') AS unexpected_delete;
```
Три проверки должны вернуть false. Не выдавайте write-права при неожиданном
доступе. Если sequence не найдена, выясните фактический механизм id перед импортом.
Срок роли ограничивает парольную авторизацию, но не заменяет отзыв доступа и
завершение открытых сессий.
После принятого dry-run, непосредственно перед разрешённым apply:
```sql
GRANT INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at) ON public.employee_publications TO miem_publication_import;
GRANT UPDATE (title, year, doi_url, raw_data, updated_at) ON public.employee_publications TO miem_publication_import;
SELECT format('GRANT USAGE ON SEQUENCE %s TO miem_publication_import', pg_get_serial_sequence('public.employee_publications', 'id')) \gexec
```
Подготовьте внутри API-контейнера защищённые файлы libpq `pg_service.conf` и
`pgpass` из окружения/секрет-хранилища владельца: service
`miem_publication_import`, БД `miem_workers`, пользователь
`miem_publication_import`. Используйте внутренний PostgreSQL endpoint Compose,
порт 5432; не публикуйте порт БД. Поле password хранится только в `pgpass`,
доступном UID 65534 с mode 600. Не копируйте `.env` приложения. Этот шаг
зависит от секрет-хранилища владельца; универсальной команды получения секрета нет.
Установите файлы (команды root; каталог контейнера не должен быть volume с
непроверенными файлами). `Статьи.csv` расположен рядом с каталогом репозитория:
```sh
docker exec miem_workers-api-1 mkdir -p /opt/miem-import /tmp/miem-import
docker cp scripts/import_publications.py miem_workers-api-1:/opt/miem-import/import_publications.py
docker cp ../Статьи.csv miem_workers-api-1:/opt/miem-import/articles.csv
docker exec miem_workers-api-1 chown -R root:root /opt/miem-import
docker exec miem_workers-api-1 chmod 755 /opt/miem-import
docker exec miem_workers-api-1 chmod 644 /opt/miem-import/import_publications.py /opt/miem-import/articles.csv
docker exec miem_workers-api-1 chown 65534:65534 /opt/miem-import/pg_service.conf /opt/miem-import/pgpass /tmp/miem-import
docker exec miem_workers-api-1 chmod 600 /opt/miem-import/pg_service.conf /opt/miem-import/pgpass
docker exec miem_workers-api-1 chmod 700 /tmp/miem-import
docker exec miem_workers-api-1 sha256sum /opt/miem-import/articles.csv
install -m 755 -o root -g root scripts/import-publications-run /usr/local/sbin/miem-publication-import
install -m 755 -o root -g root scripts/import-publications-ssh /usr/local/sbin/miem-publication-import-ssh
useradd --create-home --shell /bin/sh miem-pub-import
install -d -m 700 -o miem-pub-import -g miem-pub-import /home/miem-pub-import/.ssh
```
Проверьте SHA256 CSV по указанному выше значению и script по проверенной копии PR.
Секретные файлы должны существовать перед командами chown/chmod. Root-владелец
каталога не позволяет UID 65534 заменить программу/CSV; этот UID может писать
только отчёт и свои файлы конфигурации. Не предоставляйте SSH-пользователю группу
docker или общий sudo. Каталоги и executables выше должны быть root-owned и
недоступны для записи этому пользователю. Убедитесь, что абсолютный путь Docker
на сервере — `/usr/bin/docker`.
Создайте `/etc/sudoers.d/miem-publication-import` с mode 440:
```sudoers
miem-pub-import ALL=(root) NOPASSWD: /usr/local/sbin/miem-publication-import dry-run, /usr/local/sbin/miem-publication-import apply
```
```sh
visudo -cf /etc/sudoers.d/miem-publication-import
```
В `/home/miem-pub-import/.ssh/authorized_keys` добавьте **только публичный** ключ
владельца с ограничениями (замените `<PUBLIC_KEY>` самим публичным ключом):
```text
restrict,command="/usr/local/sbin/miem-publication-import-ssh" <PUBLIC_KEY>
```
```sh
chown root:root /home/miem-pub-import/.ssh/authorized_keys
chmod 644 /home/miem-pub-import/.ssh/authorized_keys
chown root:root /home/miem-pub-import /home/miem-pub-import/.ssh
chmod 755 /home/miem-pub-import
chmod 755 /home/miem-pub-import/.ssh
```
Root-владение home/.ssh предотвращает замену ограниченного authorized_keys.
Убедитесь, что SSH не разрешает этому пользователю другие ключи, пароль,
keyboard-interactive, пользовательские environment/rc или общий shell.
При необходимости задайте в sshd `Match User miem-pub-import` с
`AuthenticationMethods publickey`, `PasswordAuthentication no`,
`KbdInteractiveAuthentication no`, `DisableForwarding yes`, `PermitTTY no`,
`PermitUserRC no` и указанным ForceCommand. Проверка/reload SSH зависит от
конфигурации сервера; перед reload сохраните действующую сессию владельца.
С клиентской машины параметры хоста/ключа берутся из окружения владельца:
```sh
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications dry-run'
```
После проверки сводки, выдачи write-grants и отдельного решения владельца:
```sh
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications apply'
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications dry-run'
```
Повторный dry-run должен показать ноль insert/update. При concurrent changes
сводки могут различаться; без production-проверки фактические количества неизвестны.
Владелец забирает отчёты без предоставления SCP/general shell временному ключу:
```sh
docker cp miem_workers-api-1:/tmp/miem-import/apply.json ./publication-import-apply.json
docker cp miem_workers-api-1:/tmp/miem-import/dry-run.json ./publication-import-dry-run.json
```
## Отзыв доступа
Владелец прекращает новые SSH-входы: перемещает временный authorized_keys в свой
закрытый архив и удаляет только этот sudoers-файл. Не удаляйте данные БД или volumes.
```sh
mv /home/miem-pub-import/.ssh/authorized_keys /var/backups/miem-publication-import/authorized_keys.disabled
rm /etc/sudoers.d/miem-publication-import
```
В psql владельцем:
```sql
ALTER ROLE miem_publication_import NOLOGIN;
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'miem_publication_import' AND pid <> pg_backend_pid();
REVOKE ALL PRIVILEGES ON DATABASE miem_workers FROM miem_publication_import;
REVOKE ALL PRIVILEGES ON SCHEMA public FROM miem_publication_import;
REVOKE SELECT (id, full_name) ON public.employees FROM miem_publication_import;
REVOKE SELECT (id, employee_id, title, year, doi_url, raw_data),
INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at),
UPDATE (title, year, doi_url, raw_data, updated_at)
ON public.employee_publications FROM miem_publication_import;
SELECT format('REVOKE ALL PRIVILEGES ON SEQUENCE %s FROM miem_publication_import', pg_get_serial_sequence('public.employee_publications', 'id')) \gexec
DROP ROLE miem_publication_import;
```
После сохранения отчётов и backup удалите только временные секреты подключения:
```sh
docker exec miem_workers-api-1 rm /opt/miem-import/pgpass /opt/miem-import/pg_service.conf
```
Эти действия не используют `DROP OWNED` и не удаляют публикации. SQL роли не
является миграцией схемы приложения. Если DROP ROLE не проходит, проверьте
оставшиеся grants; не применяйте CASCADE/удаление данных.
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -eu
temporary=$(mktemp)
app_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
trap 'rm -f -- "$temporary"' EXIT HUP INT TERM
crontab -l 2>/dev/null | grep -v 'postgres-backup.sh' > "$temporary" || true
printf '%s\n' "30 3 * * 0 flock -w 3600 /tmp/miem-postgres-backup.lock $app_dir/infra/postgres-backup.sh || logger -t miem-postgres-backup -- 'backup skipped or failed'" >> "$temporary"
crontab "$temporary"
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
set -eu
umask 077
app_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
backup_dir="$HOME/backups/postgres"
backup="$backup_dir/postgres-latest.sql"
temporary="$backup.tmp.$$"
log() { logger -t miem-postgres-backup -- "$*" 2>/dev/null || true; }
mkdir -p "$backup_dir"
trap 'status=$?; rm -f -- "$temporary"; [ "$status" -eq 0 ] || log "backup failed"; exit "$status"' EXIT HUP INT TERM
cd "$app_dir"
docker compose exec -T postgres sh -c 'pg_dumpall -U "$POSTGRES_USER"' > "$temporary"
test -s "$temporary"
mv "$temporary" "$backup"
find "$HOME/backups/miem_workers" -maxdepth 1 -type f -name 'miem_workers-*.sql' -delete 2>/dev/null || true
log "created $backup"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "0.7.7"
version = "0.10.2"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# Install root-owned; caller cannot choose container, executable, input or env.
set -eu
case "${1:-}" in
dry-run) mode=dry-run ;;
apply) mode=apply ;;
*) exit 1 ;;
esac
[ "$#" -eq 1 ] || exit 1
if [ "$mode" = apply ]; then
exec /usr/bin/docker exec --user 65534:65534 \
-e PGSERVICEFILE=/opt/miem-import/pg_service.conf \
-e PGSERVICE=miem_publication_import \
-e PGPASSFILE=/opt/miem-import/pgpass \
-e PYTHONNOUSERSITE=1 \
miem_workers-api-1 python -I /opt/miem-import/import_publications.py \
/opt/miem-import/articles.csv --apply --report /tmp/miem-import/apply.json
fi
exec /usr/bin/docker exec --user 65534:65534 \
-e PGSERVICEFILE=/opt/miem-import/pg_service.conf \
-e PGSERVICE=miem_publication_import \
-e PGPASSFILE=/opt/miem-import/pgpass \
-e PYTHONNOUSERSITE=1 \
miem_workers-api-1 python -I /opt/miem-import/import_publications.py \
/opt/miem-import/articles.csv --report /tmp/miem-import/dry-run.json
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# Forced SSH command: no arguments, paths, forwarding or shell from the client.
set -eu
case "${SSH_ORIGINAL_COMMAND:-}" in
'import-publications dry-run') exec sudo -n /usr/local/sbin/miem-publication-import dry-run ;;
'import-publications apply') exec sudo -n /usr/local/sbin/miem-publication-import apply ;;
*) printf '%s\n' 'Allowed: import-publications dry-run | import-publications apply' >&2; exit 1 ;;
esac
+192
View File
@@ -0,0 +1,192 @@
"""Owner-run CSV enrichment; no app startup, .env loading, DDL or deletes."""
import argparse
import csv
import hashlib
import json
import re
import unicodedata
from collections import Counter, defaultdict
from pathlib import Path
def normalized(value):
return " ".join(unicodedata.normalize("NFKC", value or "").casefold().replace("ё", "е").split())
def doi(value):
return re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", (value or "").strip(), flags=re.I).lower()
def read_rows(path):
with path.open(encoding="utf-8-sig", newline="") as source:
reader = csv.DictReader(source, delimiter=";")
required = {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ"}
if not required <= set(reader.fieldnames or []):
raise ValueError("Missing required CSV headers")
rows = []
for number, row in enumerate(reader, 2):
if None in row or any(v is None for v in row.values()):
raise ValueError(f"Malformed CSV record {number}")
rows.append((number, {k.strip(): v.strip() for k, v in row.items()}))
return rows
def plan(rows, employees, publications):
names = defaultdict(list)
by_employee = defaultdict(list)
for employee in employees:
if normalized(employee["full_name"]):
names[normalized(employee["full_name"])].append(employee["id"])
for publication in publications:
by_employee[publication["employee_id"]].append(dict(publication))
actions, audit, seen = [], [], set()
for number, row in rows:
title, identifier = row["Название публикации"], doi(row["DOI"])
author_names = sorted(set(normalized(a) for a in row["Авторы НИУ ВШЭ"].split(";")) - {""})
year = int(row["Год"]) if row["Год"].isdigit() else None
if not title or not year or not 1000 <= year <= 9999 or not author_names or (identifier and not re.fullmatch(r"10\.\d{4,9}/\S+", identifier)):
audit.append({"row": number, "employee_id": None, "result": "skip", "reason": "invalid_fields"})
continue
for author in author_names:
ids = names.get(author, [])
entry = {"row": number, "employee_id": ids[0] if len(ids) == 1 else None}
def skip(reason):
audit.append({**entry, "result": "skip", "reason": reason})
if len(ids) != 1:
skip("employee_not_found" if not ids else "ambiguous_employee")
continue
employee_id = ids[0]
candidates = by_employee[employee_id]
exact = [p for p in candidates if identifier and doi(p.get("doi_url")) == identifier]
title_matches = [p for p in candidates if normalized(p["title"]) == normalized(title) and p.get("year") in (None, year)]
matches = {p["id"]: p for p in exact + title_matches}
if len(matches) > 1:
skip("ambiguous_publication")
continue
existing = next(iter(matches.values()), None)
if existing and identifier and doi(existing.get("doi_url")) and doi(existing["doi_url"]) != identifier:
skip("doi_conflict")
continue
if existing and existing.get("year") and existing["year"] != year:
skip("year_conflict")
continue
key = (employee_id, identifier or (normalized(title), year))
duplicate = key in seen
seen.add(key)
metadata = {k: v for k, v in row.items() if v}
raw = existing.get("raw_data") if existing else None
if raw is not None and not isinstance(raw, dict):
skip("unsupported_raw_data")
continue
raw = dict(raw or {})
old_metadata = raw.get("csv_enrichment", {})
if not isinstance(old_metadata, dict):
skip("unsupported_csv_metadata")
continue
conflicts = sorted(k for k, v in metadata.items() if old_metadata.get(k) and old_metadata[k] != v)
if conflicts:
entry["conflict_fields"] = conflicts
merged = dict(old_metadata)
for k, v in metadata.items():
if not merged.get(k):
merged[k] = v
raw["csv_enrichment"] = merged
values = {"raw_data": raw}
if identifier and (not existing or not existing.get("doi_url")):
values["doi_url"] = "https://doi.org/" + identifier
if not existing or not existing.get("year"):
values["year"] = year
if not existing or not existing.get("title"):
values["title"] = title
if existing:
values = {k: v for k, v in values.items() if existing.get(k) != v}
if not values:
skip("metadata_conflict_first_value_kept" if conflicts else "csv_duplicate" if duplicate else "already_present")
continue
if isinstance(existing["id"], str):
actions[int(existing["id"].split("-")[1])]["values"].update(values)
existing.update(values)
skip("metadata_conflict_first_value_kept" if conflicts else "merged_csv_duplicate")
continue
action = {"result": "update", "id": existing["id"], "values": values}
existing.update(values)
else:
source_hash = hashlib.sha256(json.dumps(key, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
values.update(employee_id=employee_id, source_hash=source_hash)
action = {"result": "insert", "values": values}
by_employee[employee_id].append({**values, "id": f"planned-{len(actions)}"})
actions.append(action)
audit.append({**entry, "result": action["result"], "reason": "metadata_conflict_first_value_kept" if conflicts else ""})
return actions, audit
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("file", type=Path)
parser.add_argument("--apply", action="store_true", help="Commit; default is read-only dry-run")
parser.add_argument("--report", type=Path, required=True)
args = parser.parse_args()
pending_report = args.report.with_suffix(args.report.suffix + ".pending")
if args.file.resolve() in (args.report.resolve(), pending_report.resolve()):
raise ValueError("Report must not overwrite input")
source_digest = hashlib.sha256(args.file.read_bytes()).hexdigest()
rows = read_rows(args.file)
# libpq environment/service only; never load application settings or .env.
import psycopg
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from psycopg import sql
# Check report access before any possible commit; pending file is not a success report.
with pending_report.open("x", encoding="utf-8"):
pass
with psycopg.connect("", row_factory=dict_row) as connection:
connection.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE" + ("" if args.apply else " READ ONLY"))
connection.execute("SET LOCAL search_path = pg_catalog, public")
connection.execute("SET LOCAL statement_timeout = '60s'")
connection.execute("SET LOCAL lock_timeout = '5s'")
if args.apply:
locked = connection.execute("SELECT pg_try_advisory_xact_lock(716203841)").fetchone()
if not next(iter(locked.values())):
raise RuntimeError("Another import is running")
employees = connection.execute("SELECT id, full_name FROM public.employees").fetchall()
publications = connection.execute("SELECT id, employee_id, title, year, doi_url, raw_data FROM public.employee_publications").fetchall()
actions, audit = plan(rows, employees, publications)
if args.apply:
for action in actions:
values = dict(action["values"])
if "raw_data" in values:
values["raw_data"] = Jsonb(values["raw_data"])
columns = list(values)
if action["result"] == "insert":
query = sql.SQL("INSERT INTO public.employee_publications ({}, created_at, updated_at) VALUES ({}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)").format(
sql.SQL(", ").join(map(sql.Identifier, columns)), sql.SQL(", ").join(sql.Placeholder() for _ in columns))
connection.execute(query, list(values.values()))
else:
query = sql.SQL("UPDATE public.employee_publications SET {}, updated_at = CURRENT_TIMESTAMP WHERE id = %s").format(
sql.SQL(", ").join(sql.SQL("{} = %s").format(sql.Identifier(k)) for k in columns))
result = connection.execute(query, [*values.values(), action["id"]])
if result.rowcount != 1:
raise RuntimeError("Publication changed concurrently")
if hashlib.sha256(args.file.read_bytes()).hexdigest() != source_digest:
raise RuntimeError("Input changed during import")
summary = {"mode": "apply" if args.apply else "dry-run", "csv_rows": len(rows),
"sha256": source_digest,
"counts": {key: sum(item["result"] == key for item in audit) for key in ("insert", "update", "skip")},
"metadata_conflicts": sum(bool(item.get("conflict_fields")) for item in audit),
"skip_reasons": dict(Counter(item["reason"] for item in audit if item["result"] == "skip"))}
pending_report.write_text(json.dumps({"summary": summary, "records": audit}, ensure_ascii=False, indent=2), encoding="utf-8")
if not args.apply:
connection.rollback()
pending_report.replace(args.report)
print(json.dumps(summary, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except Exception as error:
# Driver errors may contain connection credentials or personal data.
print(f"Import failed ({type(error).__name__}); transaction not partially applied. Check configuration and permissions.")
raise SystemExit(1)
+143
View File
@@ -0,0 +1,143 @@
"""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")
skip_link = page.get_by_role("link", name="Перейти к содержимому")
assert skip_link.evaluate("e => getComputedStyle(e).left === '-999px'")
page.keyboard.press("Tab")
expect(skip_link).to_be_focused()
assert skip_link.evaluate("e => getComputedStyle(e).left === '8px'")
page.keyboard.press("Tab")
assert skip_link.evaluate("e => getComputedStyle(e).left === '-999px'")
for width in (1440, 1024, 921, 920, 768, 621, 620, 390, 320):
page.set_viewport_size({"width": width, "height": 844})
assert page.locator(".directory__filters").evaluate("e => e.scrollWidth <= e.clientWidth")
assert page.locator(".directory__input").evaluate_all("""controls => controls.every(control => {
const field = control.closest('.directory__field').getBoundingClientRect();
const rect = control.getBoundingClientRect();
return rect.left >= field.left - 1 && rect.right <= field.right + 1;
})""")
page.set_viewport_size({"width": 1440, "height": 1000})
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()
+26
View File
@@ -0,0 +1,26 @@
from app.services.academic_degrees import academic_degrees
def test_academic_degrees_extracts_all_degree_names_without_surrounding_text():
data = {
"sections": [
{
"title": "Образование",
"year_entries": [
{"text": "2008 — кандидат технических наук, доцент"},
{"text": "2018 — Доктор физико-математических наук"},
],
},
{
"title": "Учёные степени",
"table": {"rows": [{"cells": ["Ph.D.", "Доктор физико-математических наук"]}]},
},
{"title": "Публикации", "items": ["Доктор медицинских наук выступил автором статьи"]},
]
}
assert academic_degrees(data) == [
"кандидат технических наук",
"Доктор физико-математических наук",
"Ph.D.",
]
+12
View File
@@ -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(
+44 -23
View File
@@ -17,11 +17,11 @@ def test_directory_template_is_russian_and_uses_display_dates():
template = Path("app/templates/directory.html").read_text(encoding="utf-8")
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
@@ -29,7 +29,17 @@ def test_directory_template_is_russian_and_uses_display_dates():
assert "employee.first_seen_display" in template
assert "employee.last_seen_display" in template
assert "employee.dismissed_display" in template
assert "verification_required" in template
assert "verification_required" in template
assert '<label class="directory__field">Статус' in template
assert 'id="columns-dialog"' in template
assert 'aria-controls="columns-dialog"' in template
assert 'aria-labelledby="columns-dialog-title"' in template
assert "Сбросить фильтры" in template
assert 'data-columns-preset="review"' in template
assert '<dialog class="columns-modal"' in template
assert 'id="columns-dialog"' in template
assert 'aria-controls="columns-dialog"' in template
assert "Сбросить фильтры" in template
assert "Directory" not in template
assert "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():
@@ -86,22 +93,36 @@ def test_dashboard_has_dismissed_status_refresh_action():
assert 'action="/admin/dismissed/refresh"' in template
assert "Проверить уволенных" in template
def test_progress_template_has_accessible_progressbar():
for name in ("dashboard.html", "runs.html"):
template = Path(f"app/templates/{name}").read_text(encoding="utf-8")
assert 'role="progressbar"' in template
assert 'aria-valuemin="0"' in template
assert 'aria-valuemax="100"' in template
assert "Прогресс парсинга" in template
def test_dashboard_latest_run_rows_link_to_run_detail():
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():
source = Path("app/static/admin.js").read_text(encoding="utf-8")
assert 'addEventListener("keydown"' in source
assert '"Enter"' in source
assert '" "' in source
def test_admin_js_uses_native_dialog_for_column_settings():
source = Path("app/static/admin.js").read_text(encoding="utf-8")
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
+29 -5
View File
@@ -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,11 +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"
def test_mcp_endpoint_is_removed():
assert TestClient(app).get("/mcp").status_code == 404
assert response.json()["backend_version"] == "0.10.2"
def test_api_employees_and_stats_require_admin_session():
@@ -81,6 +78,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 +133,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()
+9
View File
@@ -11,3 +11,12 @@ def test_numeric_crawl_limit_is_parsed():
settings = Settings(crawl_limit="25")
assert settings.crawl_limit == 25
def test_dismissal_safety_settings_have_conservative_defaults(monkeypatch):
monkeypatch.delenv("DISMISSAL_CONFIRMATION_RUNS", raising=False)
monkeypatch.delenv("MAX_AUTO_DISMISSALS_PER_RUN", raising=False)
settings = Settings(_env_file=None)
assert settings.dismissal_confirmation_runs == 3
assert settings.max_auto_dismissals_per_run == 25
+76
View File
@@ -0,0 +1,76 @@
import csv
from datetime import date
from fastapi.testclient import TestClient
from app.config import Settings, get_settings
from app.db import get_db
from app.main import app
from app.models import Employee
from app.security import SESSION_COOKIE, sign_session
from app.services import council
def test_council_counts_unique_articles_and_only_active_doctors(db_session, monkeypatch, tmp_path):
fields = ["Название публикации", "Год", "DOI", "EID (Scopus)", "UT (WoS)",
"Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"]
export = tmp_path / "articles.csv"
with export.open("w", encoding="utf-8-sig", newline="") as output:
writer = csv.DictWriter(output, fieldnames=fields, delimiter=";")
writer.writeheader()
for number in range(10):
writer.writerow({"Название публикации": f"Article {number}", "Год": 2024,
"DOI": f"10.1234/{number}", "EID (Scopus)": f"eid-{number}",
"Авторы НИУ ВШЭ": "Иванов Иван Иванович; Петров Пётр Петрович",
"Квартиль scopus": "Q1"})
writer.writerow({"Название публикации": "Article 0", "Год": 2024, "DOI": "10.1234/0",
"UT (WoS)": "ut-0", "Авторы НИУ ВШЭ": "Иванов Иван Иванович",
"Квартиль wos": "Q2"})
monkeypatch.setattr(council, "EXPORT", export)
council._articles_by_author.cache_clear()
db_session.add_all([
Employee(profile_key="doctor", canonical_url="https://example.org/1", full_name="Иванов Иван Иванович",
status="active", current_data={"sections": [{"title": "Учёные степени",
"items": ["Доктор технических наук"]}]}),
Employee(profile_key="candidate", canonical_url="https://example.org/2", full_name="Петров Пётр Петрович",
status="active", current_data={"sections": [{"title": "Учёные степени",
"items": ["Кандидат технических наук"]}]}),
])
db_session.commit()
result = council.council_candidates(db_session, date(2026, 9, 30))
assert result["total"] == 1
assert result["candidates"][0]["count"] == 10
assert len(result["candidates"][0]["articles"][0]["sources"]) == 2
assert council.period(date(2024, 2, 29))[0] == date(2019, 2, 28)
settings = Settings(_env_file=None, session_secret="test-session-secret")
monkeypatch.setattr("app.admin.council_candidates", lambda db: council.council_candidates(db, date(2026, 9, 30)))
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[get_settings] = lambda: settings
try:
client = TestClient(app)
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
response = client.get("/admin/council")
assert response.status_code == 200
assert "Иванов Иван Иванович" in response.text
assert "Article 0" in response.text
assert "публикаций по выгрузке" in response.text
finally:
app.dependency_overrides.clear()
council._articles_by_author.cache_clear()
def test_council_page_requires_admin_and_explains_preliminary_result(db_session, monkeypatch):
monkeypatch.setattr(council, "_articles_by_author", lambda: {})
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
try:
client = TestClient(app)
assert client.get("/admin/council", follow_redirects=False).status_code in (302, 303, 401, 403)
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
response = client.get("/admin/council")
assert response.status_code == 200
assert "Предварительный список" in response.text
assert "К1 или RSCI могут отсутствовать" in response.text
finally:
app.dependency_overrides.clear()
+117 -2
View File
@@ -1,4 +1,5 @@
import gzip
import gzip
import pytest
from datetime import datetime, timezone
from app.models import (
@@ -434,7 +435,7 @@ def test_upsert_employee_saves_publications_and_reuses_existing_rows(db_session)
assert publications[0].authors == [{"id": "1", "title_ru": "Автор"}]
def test_upsert_employee_records_publication_errors_without_failing_employee(monkeypatch, db_session):
def test_upsert_employee_records_publication_errors_without_failing_employee(monkeypatch, db_session):
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
db_session.add(run)
db_session.commit()
@@ -573,3 +574,117 @@ def _parsed_employee_with_news(profile_id: str) -> dict:
}
]
return parsed
def test_csv_enrichment_survives_repeated_crawl_without_duplicate(db_session):
from scripts.import_publications import plan
from app.services.crawler import _sync_employee_publications
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
db_session.add(run)
db_session.commit()
employee, _ = _upsert_employee(db_session, run, _parsed_employee_with_publication("csv-preserved"))
db_session.commit()
existing = db_session.query(EmployeePublication).filter_by(employee_id=employee.id).one()
rows = [(number, {"Название публикации": title, "Год": str(year), "DOI": identifier,
"Авторы НИУ ВШЭ": employee.full_name, "Квартиль scopus": "Q1"})
for number, title, year, identifier in (
(2, existing.title, existing.year, ""),
(3, "CSV only", 2024, "10.1234/csv-only"),
(4, "Later HSE", 2024, "10.1234/later"))]
actions, _ = plan(rows, [{"id": employee.id, "full_name": employee.full_name}],
[{field: getattr(existing, field) for field in
("id", "employee_id", "title", "year", "doi_url", "raw_data")}])
for action in actions:
if action["result"] == "insert":
db_session.add(EmployeePublication(**action["values"]))
else:
for field, value in action["values"].items():
setattr(existing, field, value)
db_session.add(EmployeePublication(employee_id=employee.id, title="Obsolete HSE",
source_hash="obsolete", raw_data={"id": "obsolete"}))
db_session.commit()
original_id = existing.id
imported_ids = {p.title: p.id for p in db_session.query(EmployeePublication).all()}
parsed = _parsed_employee_with_publication("csv-preserved")
publication = parsed["sections"][0]["publications"][0]
publication.pop("year")
publication.pop("doi_url")
publication["raw_data"]["fresh"] = True
for _ in range(2):
_sync_employee_publications(db_session, employee, parsed)
db_session.commit()
remaining = db_session.query(EmployeePublication).filter_by(employee_id=employee.id).all()
assert len(remaining) == 3
assert all(p.raw_data["csv_enrichment"]["Квартиль scopus"] == "Q1" for p in remaining)
refreshed = db_session.get(EmployeePublication, original_id)
assert refreshed.raw_data["fresh"] is True
assert refreshed.year == 2023 and refreshed.doi_url == "https://doi.org/10.1/test"
assert db_session.get(EmployeePublication, imported_ids["CSV only"]) is not None
parsed["sections"][0]["publications"].append({
"publication_id": "later-id", "title": "Later HSE", "year": 2024,
"doi_url": "http://dx.doi.org/10.1234/LATER", "raw_data": {"id": "later-id"}})
for _ in range(2):
_sync_employee_publications(db_session, employee, parsed)
db_session.commit()
assert db_session.query(EmployeePublication).filter_by(employee_id=employee.id).count() == 3
adopted = db_session.get(EmployeePublication, imported_ids["Later HSE"])
assert adopted.publication_id == "later-id"
assert adopted.raw_data["csv_enrichment"]["Квартиль scopus"] == "Q1"
def test_ambiguous_csv_match_rolls_back_publication_sync(db_session):
from app.services.crawler import _try_sync_employee_publications
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
db_session.add(run)
db_session.commit()
employee, _ = _upsert_employee(db_session, run, _parsed_employee("csv-ambiguous"))
for index in range(2):
db_session.add(EmployeePublication(employee_id=employee.id, title="Same", year=2024,
doi_url="https://doi.org/10.1234/same", source_hash=f"csv-{index}",
raw_data={"csv_enrichment": {"Год": "2024"}}))
db_session.commit()
parsed = {"sections": [{"type": "publications", "publications": [
{"publication_id": "new", "title": "New"},
{"publication_id": "same", "title": "Same", "doi_url": "https://doi.org/10.1234/same", "year": 2024}]}]}
_try_sync_employee_publications(db_session, run, employee, parsed)
db_session.commit()
assert db_session.query(EmployeePublication).filter_by(employee_id=employee.id).count() == 2
assert db_session.query(CrawlError).filter_by(crawl_run_id=run.id, error_type="ValueError").count() == 1
@pytest.mark.parametrize("bound_id", [None, "hse-A"])
@pytest.mark.parametrize("incoming_ids", [("hse-A", "hse-B"), ("hse-B", "hse-A")])
def test_two_hse_ids_cannot_overwrite_one_csv_record(db_session, bound_id, incoming_ids):
from app.services.crawler import _try_sync_employee_publications
from app.services.admin_data import run_detail_payload
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
db_session.add(run)
db_session.commit()
employee, _ = _upsert_employee(db_session, run, _parsed_employee("csv-hse-id-conflict"))
original_raw = {"csv_enrichment": {"Год": "2024"}, "original": "keep"}
original_authors = [{"title_ru": "Synthetic original author"}]
record = EmployeePublication(employee_id=employee.id, title="Same", year=2024,
publication_id=bound_id, source_hash="csv-original", raw_data=original_raw,
authors=original_authors)
db_session.add(record)
db_session.commit()
original_id = record.id
parsed = {"sections": [{"type": "publications", "publications": [
{"publication_id": identifier, "title": "Same", "year": 2024,
"authors": [{"id": identifier}], "raw_data": {"id": identifier}}
for identifier in incoming_ids]}]}
_try_sync_employee_publications(db_session, run, employee, parsed)
db_session.commit()
db_session.expire_all()
kept = db_session.get(EmployeePublication, original_id)
assert db_session.query(EmployeePublication).filter_by(employee_id=employee.id).count() == 1
assert kept.publication_id == bound_id and kept.source_hash == "csv-original"
assert kept.raw_data == original_raw and kept.authors == original_authors
errors = run_detail_payload(db_session, run)["errors"]
assert len(errors) == 1
assert errors[0]["error_type"] == "ValueError"
assert "Ambiguous HSE IDs" in errors[0]["message"]
assert "hse-A" in errors[0]["message"] and "hse-B" in errors[0]["message"]
+233
View File
@@ -0,0 +1,233 @@
"""Offline checks: synthetic identities only, no settings or database access."""
from scripts.import_publications import plan, read_rows
from pathlib import Path
def row(title="Test", doi="10.1234/test", year="2024", authors="Test Person"):
return {"Название публикации": title, "DOI": doi, "Год": year,
"Авторы НИУ ВШЭ": authors}
EMPLOYEES = [{"id": 1, "full_name": "Test Person"}]
def test_repeat_and_no_new_employees():
rows = [(2, row()), (3, row()), (4, row(authors="Missing Person"))]
actions, audit = plan(rows, EMPLOYEES, [])
assert len(actions) == 1 and actions[0]["result"] == "insert"
assert [a["reason"] for a in audit] == ["", "csv_duplicate", "employee_not_found"]
stored = [{"id": 11, **actions[0]["values"]}]
repeat, _ = plan(rows, EMPLOYEES, stored)
assert repeat == []
def test_ambiguity_and_conflicts():
assert not plan([(2, row())], EMPLOYEES * 2, [])[0]
stored = [{"id": 1, "employee_id": 1, "title": "Test", "year": 2024,
"doi_url": "https://doi.org/10.1234/other", "raw_data": {}}]
assert plan([(2, row())], EMPLOYEES, stored)[1][0]["reason"] == "doi_conflict"
stored[0]["doi_url"] = "https://doi.org/10.1234/test"
assert plan([(2, row(year="2025"))], EMPLOYEES, stored)[1][0]["reason"] == "year_conflict"
assert plan([(2, row())], EMPLOYEES, stored * 2)[0] # same id is one identity
stored.append({**stored[0], "id": 2})
assert plan([(2, row())], EMPLOYEES, stored)[1][0]["reason"] == "ambiguous_publication"
def test_fill_only_preserves_metadata_and_empty_values():
stored = [{"id": 1, "employee_id": 1, "title": "Test", "year": 2024,
"doi_url": None, "raw_data": {"hse": "keep", "csv_enrichment": {"extra": "keep"}}}]
actions, _ = plan([(2, {**row(), "extra": "", "new": "added"})], EMPLOYEES, stored)
values = actions[0]["values"]
assert set(values) == {"doi_url", "raw_data"}
assert values["raw_data"]["hse"] == "keep"
assert values["raw_data"]["csv_enrichment"]["extra"] == "keep"
assert values["raw_data"]["csv_enrichment"]["new"] == "added"
assert "new" not in stored[0]["raw_data"]["csv_enrichment"]
def test_missing_doi_then_doi_does_not_update_synthetic_id():
actions, _ = plan([(2, row(doi="")), (3, row())], EMPLOYEES, [])
assert len(actions) == 1 and actions[0]["result"] == "insert"
assert actions[0]["values"]["doi_url"] == "https://doi.org/10.1234/test"
def test_invalid_rows_and_owner_file():
assert not plan([(2, row(year="oops")), (3, row(doi="bad"))], EMPLOYEES, [])[0]
path = Path(__file__).resolve().parents[2] / "Статьи.csv"
if path.exists():
rows = read_rows(path)
assert len(rows) == 1797
actions, audit = plan(rows, [], [])
assert not actions and audit and all(a["result"] == "skip" for a in audit)
def test_cli_read_only_and_atomic_failure(tmp_path, monkeypatch):
import sys
import psycopg
from scripts import import_publications
source = tmp_path / "input.csv"
source.write_text("Название публикации;Год;DOI;Авторы НИУ ВШЭ\nFirst;2024;10.1234/first;Test Person\nSecond;2024;10.1234/second;Test Person\n", encoding="utf-8")
class Result:
rowcount = 1
def __init__(self, rows):
self.rows = rows
def fetchall(self):
return self.rows
def fetchone(self):
return self.rows[0]
class Connection:
def __init__(self, fail=False):
self.queries, self.writes = [], []
self.fail, self.rolled_back, self.committed = fail, False, False
def __enter__(self):
return self
def __exit__(self, kind, *_):
if kind:
self.rollback()
elif not self.rolled_back:
self.committed = True
def rollback(self):
self.rolled_back = True
self.writes.clear()
def execute(self, query, params=None):
query = query if isinstance(query, str) else query.as_string()
self.queries.append(query)
if query.startswith("SELECT id, full_name"):
return Result(EMPLOYEES)
if query.startswith("SELECT id, employee_id"):
return Result([])
if "pg_try_advisory" in query:
return Result([{"locked": True}])
if query.startswith("INSERT"):
self.writes.append(params)
if self.fail and len(self.writes) == 2:
raise RuntimeError("Synthetic second-write failure")
return Result([])
report = tmp_path / "dry.json"
dry = Connection()
monkeypatch.setattr(psycopg, "connect", lambda *a, **k: dry)
monkeypatch.setattr(sys, "argv", ["import_publications", str(source), "--report", str(report)])
import_publications.main()
assert dry.rolled_back and not dry.committed and not dry.writes
assert not any(q.startswith(("INSERT", "UPDATE", "DELETE")) for q in dry.queries)
assert "READ ONLY" in dry.queries[0]
assert report.exists()
failed = Connection(fail=True)
monkeypatch.setattr(psycopg, "connect", lambda *a, **k: failed)
report = tmp_path / "apply.json"
monkeypatch.setattr(sys, "argv", ["import_publications", str(source), "--report", str(report), "--apply"])
import pytest
with pytest.raises(RuntimeError):
import_publications.main()
assert failed.rolled_back and not failed.committed and not failed.writes
assert not report.exists()
success = Connection()
monkeypatch.setattr(psycopg, "connect", lambda *a, **k: success)
monkeypatch.setattr(sys, "argv", ["import_publications", str(source), "--report", str(tmp_path / "success.json"), "--apply"])
import_publications.main()
assert success.committed and len(success.writes) == 2
assert not any(q.startswith(("DELETE", "ALTER", "CREATE")) for q in success.queries)
def test_report_cannot_overwrite_source(tmp_path, monkeypatch):
import sys
import pytest
from scripts import import_publications
source = tmp_path / "input.csv"
source.write_text("unchanged", encoding="utf-8")
monkeypatch.setattr(sys, "argv", ["import_publications", str(source), "--report", str(source)])
with pytest.raises(ValueError):
import_publications.main()
assert source.read_text() == "unchanged"
def test_duplicate_rows_fill_late_metadata_and_report_conflicts():
rows = [(2, {**row(), "EID (Scopus)": "", "Квартиль scopus": ""}),
(3, {**row(), "EID (Scopus)": "new-id", "Квартиль scopus": "Q1"}),
(4, {**row(), "EID (Scopus)": "different-id", "Квартиль scopus": "Q2", "UT (WoS)": "late-wos"})]
actions, audit = plan(rows, EMPLOYEES, [])
assert len(actions) == 1 and actions[0]["result"] == "insert"
metadata = actions[0]["values"]["raw_data"]["csv_enrichment"]
assert metadata["EID (Scopus)"] == "new-id" and metadata["Квартиль scopus"] == "Q1"
assert metadata["UT (WoS)"] == "late-wos"
assert audit[1]["reason"] == "merged_csv_duplicate"
assert audit[2]["conflict_fields"] == ["EID (Scopus)", "Квартиль scopus"]
assert audit[2]["result"] == "skip"
repeated, repeated_audit = plan(rows, EMPLOYEES, [{"id": 11, **actions[0]["values"]}])
assert repeated == []
assert repeated_audit[2]["reason"] == "metadata_conflict_first_value_kept"
assert "different-id" not in str(audit)
def test_cli_apply_year_only_preserves_existing_json(tmp_path, monkeypatch):
import json
import sys
import psycopg
from scripts import import_publications
source = tmp_path / "input.csv"
fields = row()
source.write_text(";".join(fields) + "\n" + ";".join(fields.values()) + "\n", encoding="utf-8")
original_raw = {"hse": "keep", "csv_enrichment": fields.copy()}
stored = {"id": 11, "employee_id": 1, "title": "Test", "year": None,
"doi_url": "https://doi.org/10.1234/test", "raw_data": original_raw}
actions, _ = plan([(2, fields)], EMPLOYEES, [stored])
assert actions[0]["values"] == {"year": 2024}
class Result:
rowcount = 1
def __init__(self, rows):
self.rows = rows
def fetchall(self):
return self.rows
def fetchone(self):
return self.rows[0]
class Connection:
committed = False
updates = []
def __enter__(self):
return self
def __exit__(self, error, *_):
self.committed = error is None
def execute(self, query, params=None):
query = query if isinstance(query, str) else query.as_string()
if query.startswith("SELECT id, full_name"):
return Result(EMPLOYEES)
if query.startswith("SELECT id, employee_id"):
return Result([stored])
if "pg_try_advisory" in query:
return Result([{"locked": True}])
if query.startswith("UPDATE"):
self.updates.append((query, params))
return Result([])
connection = Connection()
report = tmp_path / "report.json"
monkeypatch.setattr(psycopg, "connect", lambda *a, **k: connection)
monkeypatch.setattr(sys, "argv", ["import_publications", str(source), "--report", str(report), "--apply"])
import_publications.main()
assert connection.committed and len(connection.updates) == 1
query, params = connection.updates[0]
assert '"year" = %s' in query and "raw_data" not in query
assert params == [2024, 11]
assert stored["raw_data"] == original_raw and stored["year"] is None
assert json.loads(report.read_text())["summary"]["counts"] == {"insert": 0, "update": 1, "skip": 0}