Compare commits

...
Author SHA1 Message Date
codex 4c609cc7ea test: prepare resource-limited isolated VPS PostgreSQL stand
CI / test (pull_request) Successful in 9m10s
CI / deploy (pull_request) Has been skipped
2026-10-05 12:48:06 +03:00
codex 8adaa4d6ea test: prepare isolated PostgreSQL publication integration checks
CI / test (pull_request) Successful in 9m12s
CI / deploy (pull_request) Has been skipped
2026-10-05 12:29:17 +03:00
admin e0f9ab7411 Merge pull request 'Импорт публикаций CSV с сохранением обогащения при обходах' (#54) from feature/publication-csv-import into main
CI / test (push) Successful in 9m12s
CI / deploy (push) Failing after 10s
Reviewed-on: #54
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-05 09:18:45 +00:00
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
admin e142138c1b refactor: remove dataset sync leftovers 2026-08-27 17:08:33 +03:00
admin c031fb9f4a refactor: remove unused data integration 2026-08-27 17:04:03 +03:00
admin 70a4719bab Merge pull request 'fix: restore scheduled crawl worker' (#34) from fix/restore-scheduled-worker into main
Reviewed-on: #34
2026-08-26 15:23:46 +00:00
55 changed files with 4825 additions and 2073 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 }}
+66
View File
@@ -1,5 +1,71 @@
# 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
- Удалена неиспользуемая интеграция обмена данными и связанная документация и тесты.
## 0.7.6
- Возвращён еженедельный автоматический запуск обхода сотрудников.
-671
View File
@@ -1,671 +0,0 @@
# MCP: описание работы, структуры и тулзов
Документ описывает MCP endpoint сервиса `miem-employees` по текущей реализации в `app/mcp.py`.
## Где находится MCP
- FastAPI router: `app.mcp.router`
- Подключение к приложению: `app/main.py`
- HTTP endpoint: `POST /mcp`
- Локально при обычном запуске API: `http://localhost:8000/mcp`
- В Docker Compose endpoint обслуживает `api`: `http://localhost:8000/mcp`
- Авторизация на уровне приложения: отсутствует. Заголовок `Authorization` не проверяется и не влияет на ответ.
Если доступ к MCP нужно ограничить, это должно делаться внешним контуром: bind на localhost, VPN, firewall, reverse proxy или отдельная сетевая политика.
## Протокол
Endpoint принимает JSON-RPC 2.0 over HTTP.
Общий формат запроса:
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}
```
Общий формат успешного ответа:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {}
}
```
Общий формат ошибки:
```json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32601,
"message": "Method not found"
}
}
```
Поддерживаемая версия MCP-протокола:
```text
2024-11-05
```
Имя сервиса:
```text
miem-employees
```
Версия сервера берется из `app.version.BACKEND_VERSION`.
## Поддерживаемые JSON-RPC методы
### initialize
Возвращает метаданные MCP-сервера и capabilities.
Запрос:
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {}
}
```
Ответ:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"serverInfo": {
"name": "miem-employees",
"version": "0.7.0"
},
"capabilities": {
"tools": {}
}
}
}
```
### tools/list
Возвращает список доступных tools с JSON Schema для аргументов.
Запрос:
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}
```
Ответ содержит массив `result.tools`.
### tools/call
Вызывает один tool по имени.
Запрос:
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "search_employees",
"arguments": {
"query": "Сергеев",
"limit": 20
}
}
}
```
Ответ tool всегда заворачивается в MCP content-массив:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "{\"items\":[]}"
}
]
}
}
```
Поле `text` содержит сериализованный JSON с `ensure_ascii=false`. Клиент должен распарсить это поле как JSON, если ему нужна структурированная нагрузка.
## Ошибки
- Неизвестный JSON-RPC метод: `code = -32601`, `message = "Method not found"`.
- Исключения при обработке tool: `code = -32000`, `message` содержит текст исключения.
- Если сущность не найдена внутри отдельных tools, HTTP и JSON-RPC ответ остаются успешными, а полезная нагрузка содержит `{"error": "not_found"}`.
## Источники данных
MCP читает данные из основной базы через SQLAlchemy session из `app.db.get_db`.
Основные таблицы и модели:
- `employees`: текущая карточка сотрудника, статус, профиль, `current_data`, checksum.
- `employee_publications`: нормализованные публикации сотрудников с авторами, DOI, аннотацией, описанием, citation text и raw JSON из HSE Publications.
- `employee_news_links`: нормализованные ссылки на новости из блока профиля «В новостях» с заголовком, URL, кратким описанием, датой, годом публикации и raw JSON карточки.
- `crawl_runs`: история запусков парсинга.
- `crawl_run_employee_changes`: детальные изменения сотрудников в рамках запуска.
- `crawl_errors`: ошибки парсинга в рамках запуска.
- `dataset_versions`: версии полного набора сотрудников.
- `dataset_version_items`: состав конкретной версии набора сотрудников.
## Общая структура employee payload
Краткая карточка сотрудника:
```json
{
"profile_key": "staff:avsergeev",
"profile_id": "avsergeev",
"full_name": "Сергеев Алексей Викторович",
"status": "active",
"canonical_url": "https://www.hse.ru/staff/avsergeev",
"last_seen_at": "2026-05-14T10:00:00+00:00",
"dismissed_at": null
}
```
В sync payload дополнительно отдается `checksum`.
Полная карточка дополнительно содержит:
```json
{
"data": {
"contacts": {},
"sections": []
}
}
```
`data` соответствует распарсенному JSON профиля сотрудника. Внутри `sections` могут быть секции с публикациями, курсами, ВКР, новостями, таблицами, ссылками и произвольными текстовыми блоками.
Пример секции новостей внутри `data.sections`:
```json
{
"title": "В новостях",
"slug": "v_novostyah",
"type": "news",
"news_count": 1,
"news_links": [
{
"title": "Название новости",
"url": "https://www.hse.ru/news/edu/1153850518.html",
"summary": "Краткое описание новости.",
"published_at": "2026-04-28T00:00:00+00:00",
"published_year": 2026
}
]
}
```
Для новостей отдельного MCP tool сейчас нет: они доступны через `get_employee(...).data.sections` или через полную синхронизацию `sync_employees(include_data=true)`.
## Tools
### get_service_info
Назначение: вернуть метаданные сервиса, список tools и текущую версию набора сотрудников.
Аргументы: отсутствуют.
Возвращает:
```json
{
"service_name": "miem-employees",
"backend_version": "0.7.0",
"protocolVersion": "2024-11-05",
"tools": [],
"dataset": {
"hash": "sha256",
"previous_hash": "sha256 или null",
"created_at": "2026-05-14T10:00:00+00:00",
"crawl_run_id": 123,
"employee_count": 100,
"active_count": 95,
"dismissed_count": 5
}
}
```
Особенность: перед ответом сервис создает актуальную `dataset_version`, если текущий набор сотрудников еще не имеет версии.
### sync_employees
Назначение: синхронизировать клиентский кэш сотрудников по hash набора данных.
Аргументы:
```json
{
"client_hash": "sha256 или null",
"include_data": true
}
```
- `client_hash`: hash версии, которая уже есть у клиента. Если не передан, отдается полный snapshot.
- `include_data`: управляет включением полного `data` в карточки сотрудников. По умолчанию `true`.
Полный ответ без `client_hash`:
```json
{
"mode": "full",
"from_hash": null,
"to_hash": "current-sha256",
"dataset": {},
"items": []
}
```
Если клиентский hash совпадает с текущим:
```json
{
"mode": "delta",
"from_hash": "current-sha256",
"to_hash": "current-sha256",
"dataset": {},
"changes": {
"added": [],
"updated": [],
"dismissed": [],
"removed": []
}
}
```
Если `client_hash` неизвестен серверу:
```json
{
"mode": "full",
"from_hash": "missing",
"to_hash": "current-sha256",
"dataset": {},
"items": [],
"reason": "unknown_client_hash"
}
```
Если `client_hash` найден и отличается от текущего:
```json
{
"mode": "delta",
"from_hash": "old-sha256",
"to_hash": "current-sha256",
"dataset": {},
"changes": {
"added": [],
"updated": [],
"dismissed": [],
"removed": []
}
}
```
Логика delta:
- `added`: сотрудник появился в новой версии.
- `updated`: изменился checksum или статус, и сотрудник активен.
- `dismissed`: сотрудник есть в новой версии, но получил статус `dismissed`.
- `removed`: `profile_key` был в старой версии, но отсутствует в новой.
Hash набора считается по отсортированному списку `{profile_key, status, checksum}`.
### search_employees
Назначение: найти сотрудников по ФИО или canonical URL.
Аргументы:
```json
{
"query": "Сергеев",
"status": "active",
"limit": 20
}
```
- `query`: обязательный по schema, но в коде пустая строка означает поиск без текстового фильтра.
- `status`: опционально, только `active` или `dismissed`.
- `limit`: максимум 100, по умолчанию 20.
Возвращает массив кратких employee payload без `data`:
```json
[
{
"profile_key": "staff:avsergeev",
"profile_id": "avsergeev",
"full_name": "Сергеев Алексей Викторович",
"status": "active",
"canonical_url": "https://www.hse.ru/staff/avsergeev",
"last_seen_at": "2026-05-14T10:00:00+00:00",
"dismissed_at": null
}
]
```
### get_employee
Назначение: получить одну карточку сотрудника.
Аргументы:
```json
{
"profile_id_or_url": "avsergeev"
}
```
Поиск выполняется по:
- `profile_key`
- `profile_id`
- точному `canonical_url`
- частичному совпадению `canonical_url`
Возвращает полный employee payload с `data`.
Если сотрудник не найден:
```json
{
"error": "not_found"
}
```
### list_employee_publications
Назначение: вернуть публикации сотрудника. Если есть нормализованные строки в `employee_publications`, tool возвращает детальные публикационные данные: авторов, DOI, аннотацию, описание, citation text, год, тип, язык, статус и ссылки. Если детальная таблица еще не заполнена, tool использует старый fallback из `employees.current_data.sections[].publications`.
Аргументы:
```json
{
"profile_id_or_url": "avsergeev"
}
```
Поиск сотрудника выполняется так же, как в `get_employee`: по `profile_key`, `profile_id`, точному или частичному `canonical_url`.
Порядок источников:
- сначала `employee_publications`, отсортированные по году, названию и внутреннему id;
- если записей нет, секции `current_data.sections` с `type = "publications"` и массивами `publications`.
Ответ:
```json
{
"employee": {
"profile_key": "org_person:803294906",
"profile_id": "803294906",
"full_name": "Борисов Сергей Петрович",
"status": "active",
"canonical_url": "https://www.hse.ru/org/persons/803294906",
"last_seen_at": "2026-05-14T10:00:00+00:00",
"dismissed_at": null
},
"items": [
{
"id": "888959076",
"publication_id": "888959076",
"title": "Название публикации",
"text": "Краткое описание или citation",
"url": "https://publications.hse.ru/view/888959076",
"year": 2023,
"type": "ARTICLE",
"publication_type": "ARTICLE",
"language": "ru",
"status": 1,
"doi_url": "https://doi.org/10.53921/18195822_2023_23_4_624",
"other_url": "https://example.test",
"document_url": "https://example.test/file.pdf",
"citation_text": "Авторы. Название публикации // Журнал. 2023.",
"annotation": {
"ru": "Аннотация",
"en": "Abstract"
},
"description": {
"main": "Авторы. Название публикации // Журнал. 2023."
},
"authors": [
{
"id": "803294906",
"href": "https://www.hse.ru/org/persons/803294906",
"title_ru": "Борисов С. П.",
"title_en": "",
"reverse_title_ru": "С. П. Борисов",
"reverse_title_en": "",
"alt_name": "S. P. Borisov",
"other_name": null,
"is_current_employee": true
}
]
}
]
}
```
В fallback-режиме из `current_data` старые элементы могут содержать только базовые поля `title`, `text`, `url` и `id`.
Если сотрудник не найден:
```json
{
"items": []
}
```
Если сотрудник найден, но публикаций нет:
```json
{
"employee": {},
"items": []
}
```
### list_employee_courses
Назначение: вернуть курсы преподавания сотрудника из распарсенных секций профиля.
Аргументы:
```json
{
"profile_id_or_url": "avsergeev"
}
```
Сервис ищет секции `current_data.sections` с `type = "courses_by_year"` и объединяет массивы `courses`.
Ответ:
```json
{
"employee": {},
"items": [
{
"title": "Название курса",
"url": "https://..."
}
]
}
```
Если сотрудник или данные профиля отсутствуют:
```json
{
"items": []
}
```
### get_crawl_status
Назначение: вернуть последний запуск парсинга.
Аргументы: отсутствуют.
Ответ:
```json
{
"id": 123,
"status": "completed",
"source_url": "https://miem.hse.ru/persons",
"started_at": "2026-05-14T10:00:00+00:00",
"finished_at": "2026-05-14T10:10:00+00:00",
"found_count": 100,
"parsed_count": 98,
"error_count": 2,
"dismissed_count": 1
}
```
Если запусков еще не было:
```json
{
"status": "never_run"
}
```
### get_crawl_run_details
Назначение: вернуть детальную информацию по конкретному запуску парсинга: summary, изменения сотрудников и ошибки.
Аргументы:
```json
{
"run_id": 123
}
```
Ответ:
```json
{
"id": 123,
"source_url": "https://miem.hse.ru/persons",
"status": "completed",
"status_display": "Завершен",
"started_at": "2026-05-14T10:00:00+00:00",
"finished_at": "2026-05-14T10:10:00+00:00",
"started_display": "14.05.2026 13:00",
"finished_display": "14.05.2026 13:10",
"found_count": 100,
"parsed_count": 98,
"new_count": 3,
"error_count": 2,
"dismissed_count": 1,
"processed_count": 100,
"progress_percent": 100.0,
"message": null,
"changes_detail_available": true,
"changes": {
"new": [],
"missing_from_source": [],
"dismissed": []
},
"errors": []
}
```
Если запуск не найден:
```json
{
"error": "not_found"
}
```
## Примеры curl
Список tools:
```bash
curl http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```
Поиск сотрудника:
```bash
curl http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_employees","arguments":{"query":"Сергеев","limit":5}}}'
```
Полная синхронизация:
```bash
curl http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"sync_employees","arguments":{"include_data":false}}}'
```
Delta-синхронизация:
```bash
curl http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"sync_employees","arguments":{"client_hash":"known-sha256","include_data":true}}}'
```
## Как MCP используется клиентом
1. Клиент вызывает `initialize` и проверяет `protocolVersion`.
2. Клиент вызывает `tools/list`, чтобы получить актуальный список tools и input schemas.
3. Для поиска и точечных запросов клиент вызывает `tools/call` с `search_employees`, `get_employee`, `list_employee_publications`, `list_employee_courses`, `get_crawl_status` или `get_crawl_run_details`.
4. Для локального кэша клиент вызывает `get_service_info` или `sync_employees`.
5. Клиент хранит последний `dataset.hash`.
6. При следующей синхронизации клиент передает hash как `client_hash`.
7. Сервер возвращает пустую delta, delta с изменениями или полный snapshot, если hash неизвестен.
## Важные особенности реализации
- MCP endpoint read-only: tools не запускают парсинг и не меняют сотрудников напрямую.
- `get_service_info` и `sync_employees` могут создать новую запись `dataset_versions`, если состояние сотрудников изменилось и новой версии еще нет.
- Все tool payloads возвращаются как JSON-строка внутри `content[0].text`.
- `search_employees` ищет через `ilike` по `full_name` и `canonical_url`.
- `get_employee` допускает частичный URL, поэтому строка `133709486` может найти `https://www.hse.ru/org/persons/133709486`.
- Временные значения сериализуются через `isoformat()`, display-поля для админских payload формируются в часовом поясе `Europe/Moscow`.
+50 -46
View File
@@ -1,10 +1,10 @@
# MIEM Employees Server
Сервис собирает сотрудников МИЭМ с сайта ВШЭ, хранит карточки и историю обновлений в Postgres, показывает минимальную админку и отдает read-only MCP endpoint для ИИ-агентов.
Сервис собирает сотрудников МИЭМ с сайта ВШЭ, хранит карточки и историю обновлений в Postgres и показывает минимальную админку.
## Архитектура
- `api`: FastAPI, REST API, HTML-админка, MCP endpoint и healthcheck.
- `api`: FastAPI, REST API, HTML-админка и healthcheck.
- `worker`: weekly scheduler, который запускает парсинг по `CRAWL_CRON`.
- `postgres`: основная БД.
@@ -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 учитываются с пометкой источника; отсутствие кандидата не доказывает недостаточную публикационную активность. Для подтверждённого отбора нужны проверка журналов и дат и дополнительные источники публикаций. Выгрузка поставляется вместе с релизом и обновляется отдельным изменением репозитория.
## Парсинг
@@ -107,42 +119,34 @@ curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=.
Во время выполнения парсинга `found_count`, `parsed_count`, `skipped_count` и `error_count` обновляются в базе. Админка опрашивает `/api/crawl-runs/latest` и показывает прогресс как `(parsed_count + skipped_count + error_count) / found_count`.
## MCP
Endpoint: `POST /mcp`, без авторизации на уровне приложения.
Поддерживаемые tools:
- `get_service_info()`
- `sync_employees(client_hash?, include_data?)`
- `search_employees(query, status?, limit?)`
- `get_employee(profile_id_or_url)`
- `list_employee_publications(profile_id_or_url)` — публикации сотрудника; при наличии данных из `employee_publications` возвращает авторов, DOI, аннотацию, описание, citation text, год, тип, язык, статус и ссылку HSE Publications.
- `list_employee_courses(profile_id_or_url)`
- `get_crawl_status()`
- `get_crawl_run_details(run_id)`
`get_service_info` возвращает метаданные сервиса, список tools и текущую версию набора сотрудников. `sync_employees` отдает полный snapshot или delta по `client_hash`; checksum набора строится по сотрудникам, их статусам и текущим checksums. Ответы tools возвращаются как JSON-строка внутри MCP `content[0].text`.
Новости сотрудника отдельной MCP tool не имеют: они доступны в `get_employee(...).data.sections` и `sync_employees(include_data=true)` как секция `type = "news"` с массивом `news_links`.
Пример локального запроса списка tools:
```bash
curl http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```
Если MCP нужно ограничить, делайте это на сетевом уровне: localhost binding, VPN, firewall, reverse proxy или другой внешний контур доступа.
## Обслуживание
## Обслуживание
```bash
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.6`. Админка всегда показывает версии 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.4`. Админка всегда показывает версии backend и frontend в footer.
## Разовый импорт публикаций
Инструкция, минимальные права временной роли и ограниченный SSH-запуск: [docs/publication-import.md](docs/publication-import.md). По умолчанию импортер выполняет dry-run; production-применение требует отдельного решения владельца.
Интеграционная проверка импорта на отдельном PostgreSQL: [docs/postgresql-import-verification.md](docs/postgresql-import-verification.md).
Отдельный тестовый стенд на VPS и read-only проверка ресурсов: [docs/postgresql-import-vps.md](docs/postgresql-import-vps.md). Стенд запускает только владелец после оценки ресурсов.
+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
-2
View File
@@ -4,14 +4,12 @@ from fastapi.staticfiles import StaticFiles
from app.admin import router as admin_router
from app.api import router as api_router
from app.db import init_db
from app.mcp import router as mcp_router
from app.version import BACKEND_VERSION
app = FastAPI(title="MIEM Employees", version=BACKEND_VERSION)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(api_router)
app.include_router(admin_router)
app.include_router(mcp_router)
@app.on_event("startup")
-262
View File
@@ -1,262 +0,0 @@
import json
from fastapi import APIRouter, Depends, Request
from sqlalchemy import desc, or_, select
from sqlalchemy.orm import Session
from app.db import get_db
from app.models import CrawlRun, Employee, EmployeePublication
from app.services.admin_data import run_detail_payload
from app.services.dataset_versions import service_info_payload, sync_employees_payload
from app.version import BACKEND_VERSION
router = APIRouter(prefix="/mcp")
PROTOCOL_VERSION = "2024-11-05"
SERVICE_NAME = "miem-employees"
TOOLS = [
{
"name": "get_service_info",
"description": "Return service metadata, supported tools, and current dataset version.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "sync_employees",
"description": "Synchronize employees by dataset hash. Returns a full snapshot or a delta from client_hash.",
"inputSchema": {
"type": "object",
"properties": {
"client_hash": {"type": "string"},
"include_data": {"type": "boolean", "default": True},
},
},
},
{
"name": "search_employees",
"description": "Search MIEM employees by name or profile URL.",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"status": {"type": "string", "enum": ["active", "dismissed"]},
"limit": {"type": "integer", "default": 20},
},
"required": ["query"],
},
},
{
"name": "get_employee",
"description": "Get one employee by profile id, profile key, or canonical URL.",
"inputSchema": {"type": "object", "properties": {"profile_id_or_url": {"type": "string"}}, "required": ["profile_id_or_url"]},
},
{
"name": "list_employee_publications",
"description": (
"List employee publications with detailed fields when available: authors, DOI URL, annotation, "
"description, citation text, year, publication type, language, status, and HSE Publications URL."
),
"inputSchema": {"type": "object", "properties": {"profile_id_or_url": {"type": "string"}}, "required": ["profile_id_or_url"]},
},
{
"name": "list_employee_courses",
"description": "List teaching courses parsed from an employee profile.",
"inputSchema": {"type": "object", "properties": {"profile_id_or_url": {"type": "string"}}, "required": ["profile_id_or_url"]},
},
{
"name": "get_crawl_status",
"description": "Return the latest crawl run status.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "get_crawl_run_details",
"description": "Return detailed employee changes and errors for one crawl run.",
"inputSchema": {
"type": "object",
"properties": {"run_id": {"type": "integer"}},
"required": ["run_id"],
},
},
]
@router.post("")
async def mcp_http(
request: Request,
db: Session = Depends(get_db),
) -> dict:
payload = await request.json()
method = payload.get("method")
request_id = payload.get("id")
params = payload.get("params") or {}
try:
if method == "initialize":
result = {
"protocolVersion": PROTOCOL_VERSION,
"serverInfo": {"name": SERVICE_NAME, "version": BACKEND_VERSION},
"capabilities": {"tools": {}},
}
elif method == "tools/list":
result = {"tools": TOOLS}
elif method == "tools/call":
result = _call_tool(db, params.get("name"), params.get("arguments") or {})
else:
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32601, "message": "Method not found"}}
return {"jsonrpc": "2.0", "id": request_id, "result": result}
except Exception as exc:
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32000, "message": str(exc)}}
def _call_tool(db: Session, name: str, arguments: dict) -> dict:
if name == "get_service_info":
return _tool_response(
service_info_payload(
db,
tools=TOOLS,
service_name=SERVICE_NAME,
backend_version=BACKEND_VERSION,
protocol_version=PROTOCOL_VERSION,
)
)
if name == "sync_employees":
return _tool_response(
sync_employees_payload(
db,
client_hash=arguments.get("client_hash"),
include_data=bool(arguments.get("include_data", True)),
)
)
if name == "search_employees":
return _tool_response(_search_employees(db, arguments))
if name == "get_employee":
employee = _find_employee(db, arguments["profile_id_or_url"])
return _tool_response(_employee_payload(employee) if employee else {"error": "not_found"})
if name == "list_employee_publications":
employee = _find_employee(db, arguments["profile_id_or_url"])
return _tool_response(_collect_section_items(employee, "publications"))
if name == "list_employee_courses":
employee = _find_employee(db, arguments["profile_id_or_url"])
return _tool_response(_collect_section_items(employee, "courses_by_year"))
if name == "get_crawl_status":
run = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1))
return _tool_response(_run_payload(run) if run else {"status": "never_run"})
if name == "get_crawl_run_details":
run = db.get(CrawlRun, int(arguments["run_id"]))
return _tool_response(run_detail_payload(db, run) if run else {"error": "not_found"})
raise ValueError(f"Unknown tool: {name}")
def _search_employees(db: Session, arguments: dict) -> list[dict]:
query = arguments.get("query", "")
limit = min(int(arguments.get("limit") or 20), 100)
stmt = select(Employee)
if arguments.get("status"):
stmt = stmt.where(Employee.status == arguments["status"])
if query:
pattern = f"%{query}%"
stmt = stmt.where(or_(Employee.full_name.ilike(pattern), Employee.canonical_url.ilike(pattern)))
employees = db.scalars(stmt.order_by(Employee.full_name).limit(limit)).all()
return [_employee_payload(employee, include_data=False) for employee in employees]
def _find_employee(db: Session, value: str) -> Employee | None:
pattern = value.strip()
stmt = select(Employee).where(
or_(
Employee.profile_key == pattern,
Employee.profile_id == pattern,
Employee.canonical_url == pattern,
Employee.canonical_url.ilike(f"%{pattern}%"),
)
)
return db.scalar(stmt.limit(1))
def _collect_section_items(employee: Employee | None, section_type: str) -> dict:
if not employee:
return {"items": []}
if section_type == "publications":
publications = _stored_publications(employee)
if publications:
return {"employee": _employee_payload(employee, include_data=False), "items": publications}
if not employee.current_data:
return {"employee": _employee_payload(employee, include_data=False), "items": []}
items = []
for section in employee.current_data.get("sections") or []:
if section.get("type") != section_type:
continue
if section_type == "publications":
items.extend(section.get("publications") or [])
elif section_type == "courses_by_year":
items.extend(section.get("courses") or [])
return {"employee": _employee_payload(employee, include_data=False), "items": items}
def _stored_publications(employee: Employee) -> list[dict]:
return [_publication_payload(publication) for publication in sorted(employee.publications, key=_publication_sort_key)]
def _publication_sort_key(publication: EmployeePublication) -> tuple:
return (publication.year or 0, publication.title or "", publication.id)
def _publication_payload(publication: EmployeePublication) -> dict:
text = publication.citation_text or publication.title
payload = {
"id": publication.publication_id,
"publication_id": publication.publication_id,
"title": publication.title,
"text": text,
"url": publication.url,
}
optional = {
"year": publication.year,
"type": publication.publication_type,
"publication_type": publication.publication_type,
"language": publication.language,
"status": publication.status,
"doi_url": publication.doi_url,
"other_url": publication.other_url,
"document_url": publication.document_url,
"citation_text": publication.citation_text,
"annotation": publication.annotation,
"description": publication.description,
"authors": publication.authors,
}
payload.update({key: value for key, value in optional.items() if value not in (None, [], {})})
return payload
def _employee_payload(employee: Employee, include_data: bool = True) -> dict:
payload = {
"profile_key": employee.profile_key,
"profile_id": employee.profile_id,
"full_name": employee.full_name,
"status": employee.status,
"canonical_url": employee.canonical_url,
"last_seen_at": employee.last_seen_at.isoformat() if employee.last_seen_at else None,
"dismissed_at": employee.dismissed_at.isoformat() if employee.dismissed_at else None,
}
if include_data:
payload["data"] = employee.current_data
return payload
def _run_payload(run: CrawlRun) -> dict:
return {
"id": run.id,
"status": run.status,
"source_url": run.source_url,
"started_at": run.started_at.isoformat() if run.started_at else None,
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"found_count": run.found_count,
"parsed_count": run.parsed_count,
"skipped_count": run.skipped_count,
"error_count": run.error_count,
"dismissed_count": run.dismissed_count,
}
def _tool_response(data: object) -> dict:
return {"content": [{"type": "text", "text": json.dumps(data, ensure_ascii=False, default=str)}]}
-40
View File
@@ -162,7 +162,6 @@ class CrawlRun(Base):
message: Mapped[str | None] = mapped_column(Text)
employee_changes: Mapped[list["CrawlRunEmployeeChange"]] = relationship(back_populates="crawl_run")
dataset_versions: Mapped[list["DatasetVersion"]] = relationship(back_populates="crawl_run")
class CrawlRunEmployeeChange(Base):
@@ -242,42 +241,3 @@ class ParseResourceCache(Base):
body_snapshot: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
parser_version: Mapped[str | None] = mapped_column(String(32))
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
class DatasetVersion(Base):
__tablename__ = "dataset_versions"
__table_args__ = (
UniqueConstraint("hash", name="uq_dataset_versions_hash"),
Index("ix_dataset_versions_created_at", "created_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
hash: Mapped[str] = mapped_column(String(64), nullable=False)
previous_hash: Mapped[str | None] = mapped_column(String(64))
crawl_run_id: Mapped[int | None] = mapped_column(ForeignKey("crawl_runs.id"))
employee_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
active_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
dismissed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
crawl_run: Mapped[CrawlRun | None] = relationship(back_populates="dataset_versions")
items: Mapped[list["DatasetVersionItem"]] = relationship(back_populates="dataset_version", cascade="all, delete-orphan")
class DatasetVersionItem(Base):
__tablename__ = "dataset_version_items"
__table_args__ = (
UniqueConstraint("dataset_version_id", "profile_key", name="uq_dataset_version_items_version_profile"),
Index("ix_dataset_version_items_hash", "dataset_version_id"),
Index("ix_dataset_version_items_profile_key", "profile_key"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
dataset_version_id: Mapped[int] = mapped_column(ForeignKey("dataset_versions.id"), nullable=False)
profile_key: Mapped[str] = mapped_column(String(255), nullable=False)
employee_id: Mapped[int | None] = mapped_column(ForeignKey("employees.id"))
status: Mapped[str] = mapped_column(String(32), nullable=False)
checksum: Mapped[str] = mapped_column(String(64), nullable=False)
dataset_version: Mapped[DatasetVersion] = relationship(back_populates="items")
employee: Mapped[Employee | None] = relationship()
+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 -14
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
@@ -26,7 +27,6 @@ from app.parser.collector import collect_profile_links
from app.parser.profile import parse_person_profile
from app.parser.profile_url import profile_key
from app.services.academic_degrees import academic_degrees
from app.services.dataset_versions import get_or_create_current_version
from app.services.resource_cache import ResourceCache
HEADERS = {
@@ -102,7 +102,6 @@ def run_crawl(db: Session, settings: Settings) -> CrawlRun:
max_auto_dismissals=settings.max_auto_dismissals_per_run,
)
run.status = "completed"
get_or_create_current_version(db, crawl_run_id=run.id)
except Exception as exc:
run.status = "failed"
run.message = str(exc)
@@ -146,7 +145,6 @@ def refresh_dismissed_status(db: Session, settings: Settings) -> CrawlRun:
)
run.parsed_count += 1
run.status = "completed"
get_or_create_current_version(db, crawl_run_id=run.id)
except Exception as exc:
run.status = "failed"
run.error_count = 1
@@ -194,7 +192,6 @@ def refresh_employee(db: Session, employee: Employee, settings: Settings) -> Cra
else:
run.skipped_count = 1
run.status = "completed"
get_or_create_current_version(db, crawl_run_id=run.id)
except Exception as exc:
run.status = "failed"
run.error_count = 1
@@ -402,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)
@@ -424,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)
@@ -436,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]:
@@ -451,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"))
@@ -466,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
-227
View File
@@ -1,227 +0,0 @@
import hashlib
import json
from dataclasses import dataclass
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.models import DatasetVersion, DatasetVersionItem, Employee
@dataclass(frozen=True)
class EmployeeMarker:
profile_key: str
employee_id: int | None
status: str
checksum: str
def get_or_create_current_version(db: Session, *, crawl_run_id: int | None = None) -> DatasetVersion:
employees = db.scalars(select(Employee).order_by(Employee.profile_key)).all()
markers = [_employee_marker(employee) for employee in employees]
dataset_hash = _dataset_hash(markers)
latest = get_latest_version(db)
if latest and latest.hash == dataset_hash:
return latest
active_count = sum(1 for marker in markers if marker.status == "active")
dismissed_count = sum(1 for marker in markers if marker.status == "dismissed")
version = DatasetVersion(
hash=dataset_hash,
previous_hash=latest.hash if latest else None,
crawl_run_id=crawl_run_id,
employee_count=len(markers),
active_count=active_count,
dismissed_count=dismissed_count,
)
db.add(version)
db.flush()
for marker in markers:
db.add(
DatasetVersionItem(
dataset_version_id=version.id,
profile_key=marker.profile_key,
employee_id=marker.employee_id,
status=marker.status,
checksum=marker.checksum,
)
)
db.flush()
return version
def get_latest_version(db: Session) -> DatasetVersion | None:
return db.scalar(select(DatasetVersion).order_by(desc(DatasetVersion.created_at), desc(DatasetVersion.id)).limit(1))
def get_version_by_hash(db: Session, dataset_hash: str | None) -> DatasetVersion | None:
if not dataset_hash:
return None
return db.scalar(select(DatasetVersion).where(DatasetVersion.hash == dataset_hash).limit(1))
def service_info_payload(db: Session, *, tools: list[dict], service_name: str, backend_version: str, protocol_version: str) -> dict:
version = get_or_create_current_version(db)
db.commit()
return {
"service_name": service_name,
"backend_version": backend_version,
"protocolVersion": protocol_version,
"tools": tools,
"dataset": _version_payload(version),
}
def sync_employees_payload(db: Session, *, client_hash: str | None = None, include_data: bool = True) -> dict:
current = get_or_create_current_version(db)
db.commit()
if not client_hash:
return _full_sync_payload(db, current, include_data=include_data, reason=None)
if client_hash == current.hash:
return {
"mode": "delta",
"from_hash": client_hash,
"to_hash": current.hash,
"dataset": _version_payload(current),
"changes": {"added": [], "updated": [], "dismissed": [], "removed": []},
}
previous = get_version_by_hash(db, client_hash)
if not previous:
return _full_sync_payload(db, current, include_data=include_data, reason="unknown_client_hash", from_hash=client_hash)
return _delta_sync_payload(db, previous, current, include_data=include_data)
def _full_sync_payload(
db: Session,
current: DatasetVersion,
*,
include_data: bool,
reason: str | None,
from_hash: str | None = None,
) -> dict:
employees = db.scalars(select(Employee).order_by(Employee.profile_key)).all()
payload = {
"mode": "full",
"from_hash": from_hash,
"to_hash": current.hash,
"dataset": _version_payload(current),
"items": [_employee_payload(employee, include_data=include_data) for employee in employees],
}
if reason:
payload["reason"] = reason
return payload
def _delta_sync_payload(db: Session, previous: DatasetVersion, current: DatasetVersion, *, include_data: bool) -> dict:
previous_items = _items_by_profile_key(previous)
current_items = _items_by_profile_key(current)
employees = {employee.profile_key: employee for employee in db.scalars(select(Employee)).all()}
added = []
updated = []
dismissed = []
removed = []
for profile_key, current_item in sorted(current_items.items()):
previous_item = previous_items.get(profile_key)
employee = employees.get(profile_key)
if not previous_item:
if employee:
added.append(_employee_payload(employee, include_data=include_data))
continue
if previous_item.checksum == current_item.checksum and previous_item.status == current_item.status:
continue
if current_item.status == "dismissed":
dismissed.append(_tombstone(profile_key, current_item.status, employee))
elif employee:
updated.append(_employee_payload(employee, include_data=include_data))
for profile_key, previous_item in sorted(previous_items.items()):
if profile_key not in current_items:
removed.append(_tombstone(profile_key, "removed", employees.get(profile_key), checksum=previous_item.checksum))
return {
"mode": "delta",
"from_hash": previous.hash,
"to_hash": current.hash,
"dataset": _version_payload(current),
"changes": {
"added": added,
"updated": updated,
"dismissed": dismissed,
"removed": removed,
},
}
def _items_by_profile_key(version: DatasetVersion) -> dict[str, DatasetVersionItem]:
return {item.profile_key: item for item in version.items}
def _version_payload(version: DatasetVersion) -> dict:
return {
"hash": version.hash,
"previous_hash": version.previous_hash,
"created_at": version.created_at.isoformat() if version.created_at else None,
"crawl_run_id": version.crawl_run_id,
"employee_count": version.employee_count,
"active_count": version.active_count,
"dismissed_count": version.dismissed_count,
}
def _employee_marker(employee: Employee) -> EmployeeMarker:
return EmployeeMarker(
profile_key=employee.profile_key,
employee_id=employee.id,
status=employee.status,
checksum=employee.current_checksum or _payload_hash(employee.current_data or {}),
)
def _dataset_hash(markers: list[EmployeeMarker]) -> str:
payload = [
{"profile_key": marker.profile_key, "status": marker.status, "checksum": marker.checksum}
for marker in sorted(markers, key=lambda item: item.profile_key)
]
return _payload_hash(payload)
def _payload_hash(value: object) -> str:
payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _employee_payload(employee: Employee, *, include_data: bool) -> dict:
payload = {
"profile_key": employee.profile_key,
"profile_id": employee.profile_id,
"full_name": employee.full_name,
"status": employee.status,
"canonical_url": employee.canonical_url,
"last_seen_at": employee.last_seen_at.isoformat() if employee.last_seen_at else None,
"dismissed_at": employee.dismissed_at.isoformat() if employee.dismissed_at else None,
"checksum": employee.current_checksum or _payload_hash(employee.current_data or {}),
}
if include_data:
payload["data"] = employee.current_data
return payload
def _tombstone(profile_key: str, status: str, employee: Employee | None, *, checksum: str | None = None) -> dict:
payload = {
"profile_key": profile_key,
"status": status,
"checksum": checksum or (employee.current_checksum if employee else None),
}
if employee:
payload.update(
{
"profile_id": employee.profile_id,
"full_name": employee.full_name,
"canonical_url": employee.canonical_url,
"dismissed_at": employee.dismissed_at.isoformat() if employee.dismissed_at else None,
}
)
return payload
+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.6"
FRONTEND_VERSION = "0.7.6"
BACKEND_VERSION = "0.7.6"
APP_VERSION = "0.10.4"
FRONTEND_VERSION = "0.10.4"
BACKEND_VERSION = "0.10.4"
+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:
+99
View File
@@ -0,0 +1,99 @@
# Проверка импорта на изолированном PostgreSQL
Запускается средствами проекта: pytest, SQLAlchemy, psycopg и отдельный Docker
Compose. Не использует production Compose, `.env`, SSH или данные сотрудников.
PostgreSQL 16 и runner работают в отдельной `internal`-сети без опубликованных
портов. Данные PostgreSQL находятся в tmpfs, без постоянных volumes. Базы:
`miem_import_test` и `miem_crawl_test`. Все сотрудники, статьи и credentials
синтетические; тестовые пароли непригодны для реальных систем.
Dockerfile копирует только нужный код и requirements. Его отдельный dockerignore
исключает `.git`, `.env`, файлы ключей/credentials, CSV владельца, данные
`app/data` и остальные файлы workspace. Runtime не получает переменные хоста.
CLI subprocess дополнительно получает только явно заданные тестовые libpq
параметры; `.pgpass`/service-файлы отключены. Не изменяйте адрес `postgres-test`
или имена тестовых БД для подключения к существующему серверу.
Синтетическая роль `import_test` создаётся только init-скриптом одноразового
тестового кластера. Fixture выдаёт ей документированные column grants и USAGE
sequence. Проверяется отсутствие CREATE схемы, INSERT сотрудников, DELETE
публикаций и sequence UPDATE. Это не создание временного доступа к рабочей БД.
Таблицы создаются моделью приложения и очищаются только в тестовых БД.
HTTP заблокирован test fixture; входные профили полного обхода подставляются
локально. Это проверяет настоящий SQL, транзакции, savepoint и сериализацию JSON,
но не парсинг сайта ВШЭ. Fixtures SQLite и подмена psycopg для интеграционных
тестов не используются.
## Что проверяется
1. Dry-run: все строки всех таблиц и состояния sequences совпадают до/после.
2. Apply: одна вставка, одно обновление, пропуск отсутствующего сотрудника.
3. Повторный импорт: ноль INSERT/UPDATE, неизменные таблицы/sequences.
4. CHECK constraint тестовой БД вызывает ошибку третьего действия после UPDATE
и успешного INSERT; все строки возвращаются в исходное состояние. Sequence
может продвинуться при rollback — это ожидаемое поведение PostgreSQL.
5. Обновление только года без raw_data сохраняет существующий JSONB.
6. Поздние строки CSV дополняют метаданные; первое непустое значение сохраняется
при конфликте, отчёт содержит названия конфликтующих полей. Повтор идемпотентен.
7. Два последовательных crawl сохраняют CSV-статью, отсутствующую у ВШЭ;
появившаяся статья получает HSE ID без второй записи и потери метаданных.
8. Полный `run_crawl` обрабатывает двух сотрудников. Два HSE ID на одну CSV-запись
откатывают savepoint первого, сохраняя JSONB, авторов, ID, хеш и updated_at;
публикация второго сохраняется. Проверяются обе очередности ID и уже
установленная привязка; неоднозначность отражается в CrawlError.
Всего 10 PostgreSQL-тестов (восьмой сценарий параметризован). В обычном
`python -m pytest -q` они пропускаются. Opt-in `MIEM_POSTGRES_INTEGRATION=1`
разрешён только с точным тестовым DATABASE_URL Compose-проекта.
## Команды владельцу
Выполняйте на локальной машине с Docker Engine/Compose v2 из корня репозитория
после независимого ревью. Команды явно выбирают локальный Unix socket и очищают
наследуемое окружение. Для Docker Desktop/Windows используйте Linux/WSL с
доступным локальным `/var/run/docker.sock`. Для отдельного стенда на VPS используйте
[отдельную инструкцию](postgresql-import-vps.md) и `compose.vps.yml`: этот локальный
Compose не предназначен для запуска на сервере с работающими сервисами.
Первый шаг:
```sh
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock version
```
Ожидается exit code 0 и секции `Client` и `Server`. `command not found`,
`permission denied` или только Client с ошибкой подключения означает, что
до запуска этого варианта нужен локальный Docker. При его отсутствии сначала
выполните только read-only диагностику из инструкции VPS.
Дальнейшие точные команды сохранены здесь для воспроизводимого запуска:
```sh
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml config --quiet
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml build tests
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml up --abort-on-container-exit --exit-code-from tests
```
Первые две команды проверяют Compose и собирают runner с уже используемыми
зависимостями. Сборка/скачивание образов требуют registry/PyPI, но тестовая
runtime-сеть не имеет внешнего доступа и запросов к ВШЭ нет. При успехе третьей
команды ожидаются `10 passed` и exit code 0. Фактическую совместимость образа,
column grants и PostgreSQL можно считать подтверждённой **только после такого
выполнения**, не по unit-тестам или статическому чтению файлов.
После сохранения вывода тестов удалите именно этот тестовый проект:
```sh
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml down
```
Это уничтожит только синтетические данные в tmpfs тестового PostgreSQL. Не
используйте `docker system prune`, production Compose или удаление рабочих
volumes. Перед повторным прогоном выполните этот же `down`, чтобы следующий
кластер начинался с чистого init.sql; не запускайте два прогона одновременно.
## Статус подготовки
В среде подготовки Docker отсутствует; настоящие PostgreSQL-тесты не запускались.
Локально доступны только unit-тесты, проверка Python и статические проверки.
Production/VPS и доступ к рабочей БД не изменялись.
+153
View File
@@ -0,0 +1,153 @@
# PostgreSQL-проверка на отдельном стенде VPS
Команды выполняет владелец под root. Подготовка не подключается к VPS и не
запускает стенд. Не используйте production Compose/`.env`, Docker context,
volumes, сети, mounts или production credentials. Никаких production-ролей БД,
SSH-ключей и правил firewall эти команды не создают и не изменяют.
## Сначала только read-only проверка
Следующий блок не создаёт контейнеров, не скачивает образов и не меняет данные.
Docker CLI использует локальный Unix socket и очищенное окружение; конфигурация
CLI указывает на отдельный каталог, который здесь не создаётся.
```sh
free -h
nproc
uptime
df -h / /var/lib/docker
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock info --format 'CPUs={{.NCPU}} RAM_bytes={{.MemTotal}} cgroup={{.CgroupVersion}} DockerRootDir={{.DockerRootDir}} warnings={{json .Warnings}}'
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose version
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}'
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image inspect postgres:16-alpine python:3.12-slim miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55 --format '{{.RepoTags}} id={{.Id}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}'
```
Как читать результат:
- `free`: нужна колонка **available**, а не только free. Предварительный запас —
не менее 2 GiB: стенд ограничен суммарно 1 GiB, ещё минимум 1 GiB остаётся
системе/сервисам. Это ориентир, решение зависит от их нагрузки и пиков.
- `nproc` и `uptime`: сравните число CPU и load average. Постоянный load на уровне
числа CPU или выше — основание отложить тест, а не останавливать сервисы.
- `df`: желательно не менее 4 GiB свободно для загрузки образов и запаса работы
Docker. Если DockerRootDir отличается от `/var/lib/docker`, отдельно выполните
`df -h` для показанного каталога. Не используйте prune для освобождения места.
- `stats`: показывает потребление уже работающих контейнеров, но не их пиковые
требования. Docker warnings о недоступных memory/swap/cgroup limits нужно
устранить до запуска; без действующих лимитов этот вариант не запускать.
- `image inspect`: существующие образы показывают tags, ID и revision.
`No such image` означает отсутствие образа, **не** разрешение на pull/build.
Для запуска нужны два образа `miem-publication-integration-*:pr55` с revision
проверенного commit PR #55. Base-образы нужны только для сборки, не для самого
запуска уже подготовленного стенда.
Пришлите только этот диагностический вывод. Не присылайте `docker inspect`
production-контейнеров целиком, env, конфигурации подключения или секреты.
До оценки ресурсов и завершения независимого review остальные действия ниже
не выполнять.
## Изоляция и лимиты подготовленного стенда
Используется только `tests/postgres/compose.vps.yml`, не override production или
локального Compose. Project name фиксирован:
`miem-publication-integration-vps`.
| Ресурс | Лимит / область |
|---|---|
| PostgreSQL | Отдельный контейнер `postgres-test`, 0.5 CPU, 512 MiB RAM, без дополнительного swap, 128 процессов |
| PostgreSQL настройки | shared_buffers 64 MiB, max_connections 12 |
| Test runner | 0.5 CPU, 512 MiB RAM, без дополнительного swap, 128 процессов; UID 65534, read-only rootfs, no-new-privileges, без capabilities |
| Данные PostgreSQL | Только named volume `miem-publication-integration-vps_pgdata`; local driver tmpfs до 256 MiB |
| Временные файлы runner | tmpfs `/tmp` до 128 MiB; входят в memory limit контейнера |
| Сеть | Только `miem-publication-integration-vps_isolated`, internal; опубликованных портов нет |
| Логи | json-file, до 5 MiB на контейнер, один файл |
Volume с tmpfs выбран намеренно: отдельное имя для адресной очистки и предел
размера синтетических данных. Данные не сохраняются после остановки/размонтирования.
Образы содержат только код и синтетический init.sql. Host bind mounts,
Docker socket, production networks и env_file отсутствуют. Docker socket
доступен только CLI владельца на хосте, ни одному контейнеру он не передаётся.
Нет restart policy. Pull запрещён; ресурсы не создаются автоматически при
отсутствии заранее подготовленного образа.
Лимиты процесса сборки/загрузки образов отличаются от runtime: Compose CPU/RAM
limits **не ограничивают Docker build**. Поэтому на VPS с рабочими сервисами
не выполняйте незапланированную сборку. Если готовых образов нет, отдельно
согласуйте место сборки и окно загрузки после read-only диагностики.
## Подготовка образов после review (не запуск стенда)
На отдельной согласованной машине сборки с Docker, из проверенной копии PR #55,
укажите полный SHA проверенного commit в переменной `TEST_COMMIT` (не секрет).
Сначала `git status --short` должен быть пустым, `git rev-parse HEAD` — совпадать
с TEST_COMMIT. Это не команды для немедленного выполнения на VPS.
```sh
git status --short
git rev-parse HEAD
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock build --label org.opencontainers.image.revision="$TEST_COMMIT" -t miem-publication-integration-runner:pr55 -f tests/postgres/Dockerfile .
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock build --label org.opencontainers.image.revision="$TEST_COMMIT" -t miem-publication-integration-postgres:pr55 -f tests/postgres/Dockerfile.postgres tests/postgres
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock image save --output miem-publication-integration-images.tar miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55
sha256sum miem-publication-integration-images.tar tests/postgres/compose.vps.yml
```
Dockerfile-specific ignore-файлы исключают секреты, реальные CSV и остальные
файлы workspace из обоих контекстов сборки. Передайте владельцу только архив
этих двух образов и `compose.vps.yml` с проверенными SHA256, без полного workspace
и `.env`. Транспорт выбирает владелец; инструкция не получает SSH/production
credentials. Base-образы могут скачиваться только на согласованной машине сборки.
На VPS после оценки ресурсов/отдельного разрешения владельца: файлы размещаются
в `/root/miem-publication-integration`, Compose переименован в `compose.vps.yml`,
архив — в `images.tar`. Проверка checksum должна совпасть с источником.
```sh
install -d -m 700 /root/miem-publication-integration /root/miem-publication-integration/docker-config
sha256sum /root/miem-publication-integration/images.tar /root/miem-publication-integration/compose.vps.yml
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image load --input /root/miem-publication-integration/images.tar
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image inspect miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55 --format '{{.RepoTags}} id={{.Id}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}'
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml config --quiet
```
Проверьте отсутствие уже существующих ресурсов с этим project name. Если они
есть, выясните их назначение; не запускайте второй стенд и не удаляйте чужие
ресурсы. Следующие команды только читают labels Docker Compose:
```sh
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock ps -a --filter label=com.docker.compose.project=miem-publication-integration-vps
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock network ls --filter label=com.docker.compose.project=miem-publication-integration-vps
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock volume ls --filter label=com.docker.compose.project=miem-publication-integration-vps
```
## Запуск и адресная очистка (пока не выполнять)
Только после согласования владельцем ресурсов и проверенного состава стенда:
```sh
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml up --no-build --pull never --abort-on-container-exit --exit-code-from tests
```
Ожидаемый успех — `10 passed`, exit code 0. Сохраните вывод/код выхода и логи:
```sh
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml logs --no-color > /root/miem-publication-integration/test.log
```
**Следующая команда удаляет контейнеры, внутреннюю сеть и named volume именно
этого теста; все его синтетические данные будут потеряны.** Перед ней сохраните
отчёт. Она не затрагивает ресурсы других Compose-проектов:
```sh
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml down --volumes
```
Не используйте `--remove-orphans`, `docker system prune`, общие rm/rmi/volume prune
или down рабочего проекта. Base-образы и образы теста оставьте: они могут быть
общими для других проверок. Архив и журнал также не удаляются этой командой.
Для подтверждения очистки повторите три read-only команды с project label:
в выводе должны остаться только заголовки.
## Статус
VPS-стенд не запускался, Docker/Compose-проверка не выполнялась в среде
подготовки из-за отсутствия Docker. PostgreSQL-интеграция ещё не подтверждена.
+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"
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "miem-workers"
version = "0.7.6"
description = "MIEM employees parser, admin API, and MCP server"
version = "0.10.4"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
"apscheduler>=3.10.4",
+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()
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN python -m pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY scripts/import_publications.py ./scripts/import_publications.py
COPY tests/conftest.py tests/test_publication_import_postgres.py ./tests/
+18
View File
@@ -0,0 +1,18 @@
**
!requirements.txt
!app/
!app/**
!scripts/
!scripts/import_publications.py
!tests/
!tests/conftest.py
!tests/test_publication_import_postgres.py
!tests/postgres/
!tests/postgres/Dockerfile
!tests/postgres/Dockerfile.dockerignore
app/data/
**/__pycache__/
**/.env*
**/*credentials*
**/*.key
**/*.pem
+2
View File
@@ -0,0 +1,2 @@
FROM postgres:16-alpine
COPY init.sql /docker-entrypoint-initdb.d/10-test.sql
@@ -0,0 +1,4 @@
**
!init.sql
!Dockerfile.postgres
!Dockerfile.postgres.dockerignore
+69
View File
@@ -0,0 +1,69 @@
name: miem-publication-integration-vps
services:
postgres-test:
image: miem-publication-integration-postgres:pr55
pull_policy: never
environment:
POSTGRES_USER: test_admin
POSTGRES_PASSWORD: isolated_test_admin
POSTGRES_DB: miem_import_test
command: ["postgres", "-c", "shared_buffers=64MB", "-c", "max_connections=12"]
cpus: 0.5
mem_limit: 512m
memswap_limit: 512m
pids_limit: 128
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "test_admin", "-d", "miem_import_test"]
interval: 2s
timeout: 5s
retries: 30
logging:
driver: json-file
options:
max-size: "5m"
max-file: "1"
networks: [isolated]
tests:
image: miem-publication-integration-runner:pr55
pull_policy: never
user: "65534:65534"
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
cpus: 0.5
mem_limit: 512m
memswap_limit: 512m
pids_limit: 128
tmpfs:
- /tmp:size=128m,mode=1777
environment:
DATABASE_URL: postgresql+psycopg://test_admin:isolated_test_admin@postgres-test:5432/miem_import_test
MIEM_POSTGRES_INTEGRATION: "1"
PYTHONDONTWRITEBYTECODE: "1"
PYTEST_ADDOPTS: "-p no:cacheprovider"
command: ["python", "-m", "pytest", "-q", "tests/test_publication_import_postgres.py"]
depends_on:
postgres-test:
condition: service_healthy
logging:
driver: json-file
options:
max-size: "5m"
max-file: "1"
networks: [isolated]
networks:
isolated:
internal: true
volumes:
pgdata:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
o: size=256m
+36
View File
@@ -0,0 +1,36 @@
name: miem-publication-integration
services:
postgres-test:
image: postgres:16-alpine
environment:
POSTGRES_USER: test_admin
POSTGRES_PASSWORD: isolated_test_admin
POSTGRES_DB: miem_import_test
tmpfs:
- /var/lib/postgresql/data
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/10-test.sql:ro
healthcheck:
test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "test_admin", "-d", "miem_import_test"]
interval: 2s
timeout: 5s
retries: 30
networks: [isolated]
tests:
build:
context: ../..
dockerfile: tests/postgres/Dockerfile
environment:
DATABASE_URL: postgresql+psycopg://test_admin:isolated_test_admin@postgres-test:5432/miem_import_test
MIEM_POSTGRES_INTEGRATION: "1"
command: ["python", "-m", "pytest", "-q", "tests/test_publication_import_postgres.py"]
depends_on:
postgres-test:
condition: service_healthy
networks: [isolated]
networks:
isolated:
internal: true
+6
View File
@@ -0,0 +1,6 @@
-- Synthetic credentials for this ephemeral, private test cluster only.
CREATE DATABASE miem_crawl_test OWNER test_admin;
CREATE ROLE import_test LOGIN PASSWORD 'isolated_test_import'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS
CONNECTION LIMIT 1;
GRANT CONNECT ON DATABASE miem_import_test, miem_crawl_test TO import_test;
+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
+154
View File
@@ -0,0 +1,154 @@
from datetime import datetime, timezone
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
from app.config import Settings, get_settings
from app.db import Base, get_db
from app.main import app
from app.models import CrawlRun, CrawlRunEmployeeChange, Employee
from app.security import SESSION_COOKIE, sign_session
def test_health_returns_versions():
response = TestClient(app).get("/api/health")
assert response.status_code == 200
assert response.json()["backend_version"] == "0.10.4"
def test_api_employees_and_stats_require_admin_session():
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
session_factory = sessionmaker(bind=engine)
db = session_factory()
employee = Employee(
profile_key="staff:alpha",
profile_type="staff",
profile_id="alpha",
canonical_url="https://www.hse.ru/staff/alpha",
full_name="Alpha Person",
status="active",
first_seen_at=datetime.now(timezone.utc),
last_seen_at=datetime.now(timezone.utc),
current_data={"contacts": {"emails": ["alpha@hse.ru"]}, "sections": []},
)
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="completed", new_count=1)
db.add_all([employee, run])
db.commit()
db.add(CrawlRunEmployeeChange(
crawl_run_id=run.id,
employee_id=employee.id,
profile_key=employee.profile_key,
profile_url=employee.canonical_url,
full_name=employee.full_name,
change_type="new",
profile_available=True,
message="added",
))
db.commit()
run_id = run.id
db.close()
settings = Settings(admin_username="admin", admin_password="password", session_secret="session-secret")
def override_db():
session = session_factory()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[get_settings] = lambda: settings
client = TestClient(app)
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
employees = client.get("/api/employees", params={"q": "Alpha", "has_email": True})
stats = client.get("/api/stats")
run_details = client.get(f"/api/crawl-runs/{run_id}")
assert employees.status_code == 200
assert employees.json()["total"] == 1
assert stats.status_code == 200
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()
def test_admin_refresh_employee_route_updates_only_requested_employee(monkeypatch):
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
session_factory = sessionmaker(bind=engine)
db = session_factory()
db.add(Employee(
profile_key="org_person:133709486",
profile_type="org_person",
profile_id="133709486",
canonical_url="https://www.hse.ru/org/persons/133709486",
full_name="Будков Юрий Алексеевич",
status="active",
))
db.commit()
employee_id = db.scalar(select(Employee.id))
db.close()
settings = Settings(admin_username="admin", admin_password="password", session_secret="session-secret")
def override_db():
session = session_factory()
try:
yield session
finally:
session.close()
calls = []
def fake_refresh_employee(db, refreshed_employee, route_settings):
calls.append((refreshed_employee.id, route_settings))
return SimpleNamespace(status="completed")
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[get_settings] = lambda: settings
monkeypatch.setattr("app.admin.refresh_employee", fake_refresh_employee)
client = TestClient(app)
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
response = client.post(f"/admin/employees/{employee_id}/refresh", follow_redirects=False)
assert response.status_code == 303
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()
-532
View File
@@ -1,532 +0,0 @@
import json
from datetime import datetime, timezone
from types import SimpleNamespace
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
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 CrawlRun, CrawlRunEmployeeChange, Employee, EmployeePublication
from app.security import SESSION_COOKIE, sign_session
def test_health_returns_versions():
client = TestClient(app)
response = client.get("/api/health")
assert response.status_code == 200
assert response.json()["backend_version"] == "0.7.3"
def test_mcp_lists_tools_without_auth_and_ignores_auth_header():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
def override_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
without_auth = client.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})
with_auth = client.post(
"/mcp",
headers={"Authorization": "Bearer anything"},
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
)
assert without_auth.status_code == 200
assert with_auth.status_code == 200
tool_names = {tool["name"] for tool in without_auth.json()["result"]["tools"]}
assert "search_employees" in tool_names
assert "get_service_info" in tool_names
assert "sync_employees" in tool_names
assert any(tool["name"] == "get_crawl_run_details" for tool in without_auth.json()["result"]["tools"])
assert with_auth.json()["result"]["tools"] == without_auth.json()["result"]["tools"]
app.dependency_overrides.clear()
def test_mcp_search_employees_returns_matching_employee():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
session.add(
Employee(
profile_key="staff:avsergeev",
profile_type="staff",
profile_id="avsergeev",
canonical_url="https://www.hse.ru/staff/avsergeev",
full_name="Сергеев Алексей Викторович",
status="active",
first_seen_at=datetime.now(timezone.utc),
last_seen_at=datetime.now(timezone.utc),
current_data={"sections": []},
)
)
session.commit()
session.close()
def override_db():
db = Session()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
response = client.post(
"/mcp",
json={
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "search_employees", "arguments": {"query": "Сергеев"}},
},
)
assert response.status_code == 200
assert "Сергеев Алексей Викторович" in response.json()["result"]["content"][0]["text"]
app.dependency_overrides.clear()
def test_mcp_service_info_returns_tools_and_dataset_hash():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
session.add(
Employee(
profile_key="staff:alpha",
profile_type="staff",
profile_id="alpha",
canonical_url="https://www.hse.ru/staff/alpha",
full_name="Alpha Person",
status="active",
current_checksum="a" * 64,
current_data={"sections": []},
)
)
session.commit()
session.close()
def override_db():
db = Session()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
response = client.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_service_info", "arguments": {}}},
)
assert response.status_code == 200
payload = json.loads(response.json()["result"]["content"][0]["text"])
assert payload["service_name"] == "miem-employees"
assert payload["backend_version"] == "0.7.3"
assert payload["dataset"]["hash"]
assert any(tool["name"] == "sync_employees" for tool in payload["tools"])
app.dependency_overrides.clear()
def test_mcp_list_employee_publications_prefers_stored_publications_with_fallback():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
stored_employee = Employee(
profile_key="staff:stored",
profile_type="staff",
profile_id="stored",
canonical_url="https://www.hse.ru/staff/stored",
full_name="Stored Person",
status="active",
current_data={
"sections": [
{
"type": "publications",
"publications": [{"title": "Old JSON Publication", "url": "https://example.test/old"}],
}
]
},
)
fallback_employee = Employee(
profile_key="staff:fallback",
profile_type="staff",
profile_id="fallback",
canonical_url="https://www.hse.ru/staff/fallback",
full_name="Fallback Person",
status="active",
current_data={
"sections": [
{
"type": "publications",
"publications": [{"title": "Fallback Publication", "url": "https://example.test/fallback"}],
}
]
},
)
session.add_all([stored_employee, fallback_employee])
session.commit()
session.add(
EmployeePublication(
employee_id=stored_employee.id,
publication_id="pub-1",
title="Stored Publication",
year=2024,
publication_type="ARTICLE",
url="https://publications.hse.ru/view/pub-1",
doi_url="https://doi.org/10.1/test",
citation_text="Stored Citation",
annotation={"ru": "Аннотация", "en": "Abstract"},
description={"main": "Stored Citation"},
authors=[{"id": "1", "title_ru": "Автор", "is_current_employee": True}],
source_hash="a" * 64,
)
)
session.commit()
session.close()
def override_db():
db = Session()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
stored_response = client.post(
"/mcp",
json={
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "list_employee_publications", "arguments": {"profile_id_or_url": "stored"}},
},
)
fallback_response = client.post(
"/mcp",
json={
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": "list_employee_publications", "arguments": {"profile_id_or_url": "fallback"}},
},
)
stored_payload = json.loads(stored_response.json()["result"]["content"][0]["text"])
fallback_payload = json.loads(fallback_response.json()["result"]["content"][0]["text"])
assert stored_payload["items"][0]["title"] == "Stored Publication"
assert stored_payload["items"][0]["doi_url"] == "https://doi.org/10.1/test"
assert stored_payload["items"][0]["annotation"] == {"ru": "Аннотация", "en": "Abstract"}
assert stored_payload["items"][0]["authors"] == [{"id": "1", "title_ru": "Автор", "is_current_employee": True}]
assert fallback_payload["items"][0]["title"] == "Fallback Publication"
app.dependency_overrides.clear()
def test_mcp_sync_employees_full_empty_and_unknown_hash_modes():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
session.add(
Employee(
profile_key="staff:alpha",
profile_type="staff",
profile_id="alpha",
canonical_url="https://www.hse.ru/staff/alpha",
full_name="Alpha Person",
status="active",
current_checksum="a" * 64,
current_data={"sections": [{"type": "paragraphs"}]},
)
)
session.commit()
session.close()
def override_db():
db = Session()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
full_response = client.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "sync_employees", "arguments": {}}},
)
full_payload = json.loads(full_response.json()["result"]["content"][0]["text"])
current_hash = full_payload["to_hash"]
empty_response = client.post(
"/mcp",
json={
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": "sync_employees", "arguments": {"client_hash": current_hash}},
},
)
empty_payload = json.loads(empty_response.json()["result"]["content"][0]["text"])
unknown_response = client.post(
"/mcp",
json={
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {"name": "sync_employees", "arguments": {"client_hash": "missing"}},
},
)
unknown_payload = json.loads(unknown_response.json()["result"]["content"][0]["text"])
assert full_payload["mode"] == "full"
assert full_payload["items"][0]["data"] == {"sections": [{"type": "paragraphs"}]}
assert empty_payload["mode"] == "delta"
assert empty_payload["changes"] == {"added": [], "updated": [], "dismissed": [], "removed": []}
assert unknown_payload["mode"] == "full"
assert unknown_payload["reason"] == "unknown_client_hash"
app.dependency_overrides.clear()
def test_mcp_get_crawl_run_details_returns_changes():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="completed", new_count=1)
employee = Employee(
profile_key="staff:new",
profile_type="staff",
profile_id="new",
canonical_url="https://www.hse.ru/staff/new",
full_name="New Person",
status="active",
first_seen_at=datetime.now(timezone.utc),
last_seen_at=datetime.now(timezone.utc),
)
session.add_all([run, employee])
session.commit()
session.add(
CrawlRunEmployeeChange(
crawl_run_id=run.id,
employee_id=employee.id,
profile_key=employee.profile_key,
profile_url=employee.canonical_url,
full_name=employee.full_name,
change_type="new",
profile_available=True,
message="added",
)
)
session.commit()
run_id = run.id
session.close()
def override_db():
db = Session()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
response = client.post(
"/mcp",
json={
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "get_crawl_run_details", "arguments": {"run_id": run_id}},
},
)
assert response.status_code == 200
text = response.json()["result"]["content"][0]["text"]
assert "New Person" in text
assert "changes_detail_available" in text
app.dependency_overrides.clear()
def test_mcp_protected_resource_metadata_route_is_removed():
client = TestClient(app)
response = client.get("/.well-known/oauth-protected-resource")
assert response.status_code == 404
def test_api_employees_and_stats_require_admin_session():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
db = Session()
db.add(
Employee(
profile_key="staff:alpha",
profile_type="staff",
profile_id="alpha",
canonical_url="https://www.hse.ru/staff/alpha",
full_name="Alpha Person",
status="active",
first_seen_at=datetime.now(timezone.utc),
last_seen_at=datetime.now(timezone.utc),
current_data={"contacts": {"emails": ["alpha@hse.ru"]}, "sections": []},
)
)
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="completed", new_count=1)
db.add(run)
db.commit()
db.add(
CrawlRunEmployeeChange(
crawl_run_id=run.id,
employee_id=1,
profile_key="staff:alpha",
profile_url="https://www.hse.ru/staff/alpha",
full_name="Alpha Person",
change_type="new",
profile_available=True,
message="added",
)
)
db.commit()
run_id = run.id
db.close()
settings = Settings(admin_username="admin", admin_password="password", session_secret="session-secret")
def override_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[get_settings] = lambda: settings
client = TestClient(app)
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
employees = client.get("/api/employees", params={"q": "Alpha", "has_email": True})
stats = client.get("/api/stats")
run_details = client.get(f"/api/crawl-runs/{run_id}")
assert employees.status_code == 200
assert employees.json()["total"] == 1
assert stats.status_code == 200
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"
app.dependency_overrides.clear()
def test_admin_refresh_employee_route_updates_only_requested_employee(monkeypatch):
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
db = Session()
db.add(
Employee(
profile_key="org_person:133709486",
profile_type="org_person",
profile_id="133709486",
canonical_url="https://www.hse.ru/org/persons/133709486",
full_name="Будков Юрий Алексеевич",
status="active",
)
)
db.commit()
employee_id = db.scalar(select(Employee.id))
db.close()
settings = Settings(admin_username="admin", admin_password="password", session_secret="session-secret")
def override_db():
session = Session()
try:
yield session
finally:
session.close()
calls = []
def fake_refresh_employee(db, refreshed_employee, route_settings):
calls.append((refreshed_employee.id, route_settings))
return SimpleNamespace(status="completed")
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[get_settings] = lambda: settings
monkeypatch.setattr("app.admin.refresh_employee", fake_refresh_employee)
client = TestClient(app)
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
response = client.post(f"/admin/employees/{employee_id}/refresh", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == f"/admin/employees/{employee_id}?refresh_status=success"
assert calls == [(employee_id, settings)]
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"]
-88
View File
@@ -1,88 +0,0 @@
from datetime import datetime, timezone
from app.models import Employee
from app.services.dataset_versions import get_or_create_current_version, sync_employees_payload
def _employee(profile_key: str, checksum: str, *, status: str = "active") -> Employee:
return Employee(
profile_key=profile_key,
profile_type=profile_key.split(":", 1)[0],
profile_id=profile_key.split(":", 1)[1],
canonical_url=f"https://www.hse.ru/{profile_key}",
full_name=profile_key,
status=status,
first_seen_at=datetime.now(timezone.utc),
last_seen_at=datetime.now(timezone.utc),
current_data={"profile_key": profile_key},
current_checksum=checksum,
)
def test_dataset_version_hash_is_stable_for_same_employee_state(db_session):
db_session.add(_employee("staff:alpha", "a" * 64))
db_session.commit()
first = get_or_create_current_version(db_session)
db_session.commit()
second = get_or_create_current_version(db_session)
assert second.id == first.id
assert second.hash == first.hash
assert second.employee_count == 1
def test_dataset_version_hash_changes_when_employee_checksum_changes(db_session):
employee = _employee("staff:alpha", "a" * 64)
db_session.add(employee)
db_session.commit()
first = get_or_create_current_version(db_session)
db_session.commit()
employee.current_checksum = "b" * 64
db_session.commit()
second = get_or_create_current_version(db_session)
assert second.hash != first.hash
assert second.previous_hash == first.hash
def test_sync_employees_diff_spans_multiple_intermediate_versions(db_session):
alpha = _employee("staff:alpha", "a" * 64)
db_session.add(alpha)
db_session.commit()
first = get_or_create_current_version(db_session)
db_session.commit()
beta = _employee("staff:beta", "b" * 64)
db_session.add(beta)
db_session.commit()
get_or_create_current_version(db_session)
db_session.commit()
alpha.current_checksum = "c" * 64
alpha.current_data = {"profile_key": "staff:alpha", "changed": True}
db_session.commit()
payload = sync_employees_payload(db_session, client_hash=first.hash, include_data=False)
assert payload["mode"] == "delta"
assert [item["profile_key"] for item in payload["changes"]["added"]] == ["staff:beta"]
assert [item["profile_key"] for item in payload["changes"]["updated"]] == ["staff:alpha"]
assert payload["changes"]["dismissed"] == []
assert payload["changes"]["removed"] == []
def test_sync_employees_reports_dismissed_as_tombstone(db_session):
alpha = _employee("staff:alpha", "a" * 64)
db_session.add(alpha)
db_session.commit()
first = get_or_create_current_version(db_session)
db_session.commit()
alpha.status = "dismissed"
db_session.commit()
payload = sync_employees_payload(db_session, client_hash=first.hash, include_data=False)
assert payload["changes"]["dismissed"][0]["profile_key"] == "staff:alpha"
assert payload["changes"]["dismissed"][0]["status"] == "dismissed"
+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}
+280
View File
@@ -0,0 +1,280 @@
"""Real PostgreSQL checks; opt in only inside tests/postgres/compose.yml."""
import copy
import csv
import json
import os
import subprocess
import sys
from contextlib import contextmanager
from pathlib import Path
import pytest
import requests
from sqlalchemy import create_engine, select, text
from sqlalchemy.orm import Session
from app.config import Settings
from app.db import Base
from app.models import CrawlError, CrawlRun, Employee, EmployeePublication
from app.services.crawler import _try_sync_employee_publications, run_crawl
ADMIN_URL = "postgresql+psycopg://test_admin:isolated_test_admin@postgres-test:5432/miem_import_test"
pytestmark = pytest.mark.skipif(
os.environ.get("MIEM_POSTGRES_INTEGRATION") != "1", reason="Requires isolated PostgreSQL Compose project"
)
@pytest.fixture(autouse=True)
def isolated_environment(monkeypatch):
assert os.environ.get("DATABASE_URL") == ADMIN_URL, "Use the dedicated test Compose project"
def forbidden_request(*args, **kwargs):
raise AssertionError("External HTTP requests are forbidden in PostgreSQL integration tests")
monkeypatch.setattr(requests.sessions.Session, "request", forbidden_request)
@contextmanager
def database(name):
assert name in {"miem_import_test", "miem_crawl_test"}
engine = create_engine(ADMIN_URL.replace("/miem_import_test", "/" + name))
try:
with engine.connect() as connection:
assert connection.scalar(text("SELECT current_database()")) == name
Base.metadata.create_all(engine)
with engine.begin() as connection:
connection.execute(text("GRANT USAGE ON SCHEMA public TO import_test"))
connection.execute(text("GRANT SELECT (id, full_name) ON public.employees TO import_test"))
connection.execute(text("GRANT SELECT (id, employee_id, title, year, doi_url, raw_data) ON public.employee_publications TO import_test"))
connection.execute(text("GRANT INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at) ON public.employee_publications TO import_test"))
connection.execute(text("GRANT UPDATE (title, year, doi_url, raw_data, updated_at) ON public.employee_publications TO import_test"))
# Created from the actual model: not an assumed sequence name.
sequence = connection.scalar(text("SELECT pg_get_serial_sequence('public.employee_publications', 'id')"))
assert sequence == "public.employee_publications_id_seq"
connection.execute(text("GRANT USAGE ON SEQUENCE public.employee_publications_id_seq TO import_test"))
permissions = connection.execute(text("""SELECT
has_schema_privilege('import_test', 'public', 'CREATE'),
has_table_privilege('import_test', 'public.employees', 'INSERT'),
has_table_privilege('import_test', 'public.employee_publications', 'DELETE'),
has_sequence_privilege('import_test', 'public.employee_publications_id_seq', 'UPDATE')
""")).one()
assert not any(permissions)
with Session(engine) as session:
yield session, name
finally:
# Only the dedicated ephemeral test DB; never DROP DATABASE or roles.
Base.metadata.drop_all(engine)
engine.dispose()
@pytest.fixture
def pg_import_db():
with database("miem_import_test") as db:
yield db
@pytest.fixture
def pg_crawl_db():
with database("miem_crawl_test") as db:
yield db
def employee(session, name="Synthetic One", key="one"):
record = Employee(full_name=name, profile_key="staff:" + key,
canonical_url="https://synthetic.invalid/staff/" + key)
session.add(record)
session.commit()
return record
def article(title="Synthetic article", identifier="10.1234/test", year="2024", author="Synthetic One", **metadata):
return {"Название публикации": title, "Год": year, "DOI": identifier,
"Авторы НИУ ВШЭ": author, **metadata}
def write_csv(path, rows):
columns = list(dict.fromkeys(key for row in rows for key in row))
with path.open("w", encoding="utf-8-sig", newline="") as output:
writer = csv.DictWriter(output, fieldnames=columns, delimiter=";")
writer.writeheader()
writer.writerows(rows)
def cli(tmp_path, db, rows, *, apply=False, expected_code=0):
_, name = db
source = tmp_path / "articles.csv"
report = tmp_path / "report.json"
write_csv(source, rows)
command = [sys.executable, str(Path(__file__).resolve().parents[1] / "scripts/import_publications.py"),
str(source), "--report", str(report)]
if apply:
command.append("--apply")
# Explicit whitelist: no production env, .env, pg_service or ~/.pgpass.
environment = {"PATH": os.environ["PATH"], "LANG": "C.UTF-8", "PGHOST": "postgres-test",
"PGPORT": "5432", "PGDATABASE": name, "PGUSER": "import_test",
"PGPASSWORD": "isolated_test_import", "PGPASSFILE": "/dev/null",
"PGSERVICEFILE": "/dev/null", "PGCONNECT_TIMEOUT": "5"}
result = subprocess.run(command, env=environment, cwd=tmp_path, capture_output=True, text=True, timeout=60)
assert result.returncode == expected_code, result.stdout + result.stderr
assert "Synthetic One" not in result.stdout and "isolated_test_import" not in result.stdout
return json.loads(report.read_text()) if report.exists() else None
def snapshot(session):
session.expire_all()
tables = {table.name: session.execute(select(table).order_by(*table.primary_key.columns)).all()
for table in Base.metadata.sorted_tables}
sequences = session.execute(text("SELECT sequencename, last_value FROM pg_sequences WHERE schemaname = 'public' ORDER BY sequencename")).all()
session.commit()
return tables, sequences
def test_postgres_dry_run_changes_neither_rows_nor_sequences(pg_import_db, tmp_path):
session, _ = pg_import_db
employee(session)
before = snapshot(session)
report = cli(tmp_path, pg_import_db, [article()])
assert report["summary"]["counts"] == {"insert": 1, "update": 0, "skip": 0}
assert snapshot(session) == before
def test_postgres_apply_and_reimport(pg_import_db, tmp_path):
session, _ = pg_import_db
person = employee(session)
existing = EmployeePublication(employee_id=person.id, title="Existing", year=2024,
source_hash="existing", raw_data={"hse": "keep"})
session.add(existing)
session.commit()
rows = [article(title="Existing"), article(title="New", identifier="10.1234/new"),
article(title="Unknown author", identifier="10.1234/unknown", author="Synthetic Missing")]
report = cli(tmp_path, pg_import_db, rows, apply=True)
assert report["summary"]["counts"] == {"insert": 1, "update": 1, "skip": 1}
session.expire_all()
assert session.query(Employee).count() == 1
assert session.query(EmployeePublication).count() == 2
assert existing.doi_url == "https://doi.org/10.1234/test" and existing.raw_data["hse"] == "keep"
before = snapshot(session)
repeated = cli(tmp_path, pg_import_db, rows, apply=True)
assert repeated["summary"]["counts"] == {"insert": 0, "update": 0, "skip": 3}
assert snapshot(session) == before
def test_postgres_apply_failure_rolls_back_update_and_insert(pg_import_db, tmp_path):
session, _ = pg_import_db
person = employee(session)
existing = EmployeePublication(employee_id=person.id, title="Existing", year=2024,
source_hash="existing", raw_data={"hse": "keep"})
session.add(existing)
session.commit()
# Test-only constraint forces a real server error after update + successful INSERT.
session.execute(text("ALTER TABLE public.employee_publications ADD CONSTRAINT test_failure CHECK (title <> 'Reject')"))
session.commit()
before, _ = snapshot(session)
rows = [article(title="Existing"), article(title="Accepted", identifier="10.1234/accepted"),
article(title="Reject", identifier="10.1234/reject")]
assert cli(tmp_path, pg_import_db, rows, apply=True, expected_code=1) is None
after, _ = snapshot(session)
assert after == before # nextval may advance on rollback; rows must not change.
assert (tmp_path / "report.json.pending").exists()
def test_postgres_year_only_update_preserves_json(pg_import_db, tmp_path):
session, _ = pg_import_db
person = employee(session)
row = article()
raw = {"hse": {"keep": True}, "csv_enrichment": row.copy()}
record = EmployeePublication(employee_id=person.id, title=row["Название публикации"],
year=None, doi_url="https://doi.org/10.1234/test",
source_hash="existing", raw_data=raw)
session.add(record)
session.commit()
report = cli(tmp_path, pg_import_db, [row], apply=True)
session.expire_all()
assert report["summary"]["counts"]["update"] == 1
assert record.year == 2024 and record.raw_data == raw
def test_postgres_late_csv_metadata_and_conflicts(pg_import_db, tmp_path):
session, _ = pg_import_db
employee(session)
rows = [article(), article(**{"EID (Scopus)": "late-id", "Квартиль scopus": "Q1"}),
article(**{"EID (Scopus)": "conflict-id", "UT (WoS)": "late-wos"})]
report = cli(tmp_path, pg_import_db, rows, apply=True)
session.expire_all()
assert session.query(EmployeePublication).count() == 1
metadata = session.query(EmployeePublication).one().raw_data["csv_enrichment"]
assert metadata["EID (Scopus)"] == "late-id" and metadata["Квартиль scopus"] == "Q1"
assert metadata["UT (WoS)"] == "late-wos"
assert report["summary"]["metadata_conflicts"] == 1
assert report["records"][2]["conflict_fields"] == ["EID (Scopus)"]
before = snapshot(session)
repeated = cli(tmp_path, pg_import_db, rows, apply=True)
assert repeated["summary"]["counts"]["insert"] == repeated["summary"]["counts"]["update"] == 0
assert snapshot(session) == before
def test_postgres_crawler_preserves_import_and_adopts_hse_id(pg_crawl_db, tmp_path):
session, _ = pg_crawl_db
person = employee(session)
cli(tmp_path, pg_crawl_db, [article(), article(title="CSV only", identifier="10.1234/only")], apply=True)
session.expire_all()
ids = {p.title: p.id for p in session.query(EmployeePublication).all()}
run = CrawlRun(source_url="https://synthetic.invalid/persons", status="running")
session.add(run)
session.commit()
parsed = {"sections": [{"type": "publications", "publications": [
{"publication_id": "hse-one", "title": "Synthetic article", "year": 2024,
"doi_url": "https://doi.org/10.1234/test", "raw_data": {"id": "hse-one"}}]}]}
for _ in range(2):
_try_sync_employee_publications(session, run, person, copy.deepcopy(parsed))
session.commit()
session.expire_all()
assert session.query(EmployeePublication).count() == 2
kept = session.get(EmployeePublication, ids["Synthetic article"])
assert kept.publication_id == "hse-one" and "csv_enrichment" in kept.raw_data
assert session.get(EmployeePublication, ids["CSV only"]).raw_data["csv_enrichment"]
assert session.query(CrawlError).count() == 0
@pytest.mark.parametrize("bound_id", [None, "hse-A"])
@pytest.mark.parametrize("incoming_ids", [("hse-A", "hse-B"), ("hse-B", "hse-A")])
def test_postgres_hse_id_conflict_does_not_block_other_employee(pg_crawl_db, tmp_path, monkeypatch, bound_id, incoming_ids):
session, _ = pg_crawl_db
first = employee(session)
second = employee(session, "Synthetic Two", "two")
cli(tmp_path, pg_crawl_db, [article(identifier="")], apply=True)
session.expire_all()
original = session.query(EmployeePublication).filter_by(employee_id=first.id).one()
original.publication_id = bound_id
original.authors = [{"id": "original-author"}]
session.commit()
fields = ("publication_id", "source_hash", "raw_data", "authors", "updated_at")
before = {field: copy.deepcopy(getattr(original, field)) for field in fields}
session.commit()
def profile(person, publications):
return {"source_url": person.canonical_url, "profile_type": "staff",
"profile_id": person.profile_key.split(":")[1], "full_name": person.full_name,
"parser_version": "integration-test", "tabs": [],
"sections": [{"type": "publications", "publications": publications}]}
profiles = {
first.canonical_url: profile(first, [{"publication_id": key, "title": "Synthetic article", "year": 2024,
"authors": [{"id": key}], "raw_data": {"id": key}} for key in incoming_ids]),
second.canonical_url: profile(second, [{"publication_id": "second-article", "title": "Other employee article",
"year": 2024, "raw_data": {"id": "second-article"}}]),
}
monkeypatch.setattr("app.services.crawler.collect_profile_links", lambda *a, **k: list(profiles))
monkeypatch.setattr("app.services.crawler.parse_person_profile", lambda _, url, *a, **k: copy.deepcopy(profiles[url]))
settings = Settings(_env_file=None, database_url=ADMIN_URL.replace("miem_import_test", "miem_crawl_test"),
source_url="https://synthetic.invalid/persons", request_delay_seconds=0)
run = run_crawl(session, settings)
session.expire_all()
assert run.status == "completed" and run.parsed_count == 2
kept = session.query(EmployeePublication).filter_by(employee_id=first.id).one()
assert {field: getattr(kept, field) for field in fields} == before
assert session.query(EmployeePublication).filter_by(employee_id=second.id).one().publication_id == "second-article"
error = session.query(CrawlError).filter_by(crawl_run_id=run.id).one()
assert "Ambiguous HSE IDs" in error.message