Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f851c62b8a | ||
|
|
86f0ee1137 | ||
|
|
00c818e681 | ||
|
|
76bcb0db35 | ||
|
|
74f32a3e67 | ||
|
|
dffe1afb14 | ||
|
|
9f5e508b47 | ||
|
|
881aba6b35 | ||
|
|
675a3b5c6d | ||
|
|
7eaed9aa8a | ||
|
|
d92338d252 | ||
|
|
64618354be | ||
|
|
ff11da2a56 | ||
|
|
6bcef05d27 | ||
|
|
2bfed87f98 | ||
|
|
8ab36a7f52 | ||
|
|
784dd68a3b | ||
|
|
a56fb723dd | ||
|
|
5bda837ee2 | ||
|
|
9324639c58 | ||
|
|
2a99ff4df2 | ||
|
|
30ea1c9257 | ||
|
|
0af278247a | ||
|
|
2dc63098e4 | ||
|
|
339dd71f63 | ||
|
|
4aeb26abe9 | ||
|
|
6bd1f31f3a | ||
|
|
32e26ccdd2 | ||
|
|
fac0431a03 | ||
|
|
ed9d0b48c9 | ||
|
|
c6c6866235 | ||
|
|
66c8e9e0c3 | ||
|
|
f39b5dd512 | ||
|
|
b8a27758b6 | ||
|
|
c975e7418b | ||
|
|
4c609cc7ea | ||
|
|
8adaa4d6ea | ||
|
|
e0f9ab7411 | ||
|
|
25eb6481f7 | ||
|
|
72afb31049 | ||
|
|
05a62d18e6 | ||
|
|
111ee15289 | ||
|
|
cb15d11df4 | ||
|
|
07bd42927b | ||
|
|
e49bcf6ba6 | ||
|
|
4cf6cba77c | ||
|
|
9fc7de113a | ||
|
|
69c1dd52cf | ||
|
|
b933f95302 | ||
|
|
384cd550d5 | ||
|
|
5c3883beca | ||
|
|
144e62a35e | ||
|
|
8102556862 | ||
|
|
711c6ae7b0 | ||
|
|
580df29849 | ||
|
|
186f2d0804 | ||
|
|
e4e64b92c1 | ||
|
|
aa43da2857 | ||
|
|
bee3dc9848 | ||
|
|
d95133e257 | ||
|
|
2bf6ff570c | ||
|
|
e58156fbeb | ||
|
|
ddb3ad1a72 | ||
|
|
f6a01500d7 | ||
|
|
ec7aec310a | ||
|
|
0f91ed0d8f | ||
|
|
1e9cc50a73 | ||
|
|
10d2cf0101 | ||
|
|
44949996bc | ||
|
|
baad49b976 | ||
|
|
ceca2017cb | ||
|
|
83e726efe5 | ||
|
|
7d6d8759a5 | ||
|
|
5d89ae3891 | ||
|
|
880ac6c53e | ||
|
|
2d19eef9c2 | ||
|
|
a1d60984e3 | ||
|
|
d5f41e015f | ||
|
|
e1ba77c337 | ||
|
|
a989c5c53c | ||
|
|
a82930027d | ||
|
|
78493b9248 | ||
|
|
5d3fb68c2f | ||
|
|
dc060985a3 | ||
|
|
1d8a854a12 | ||
|
|
d07711f665 | ||
|
|
cf810f9cad | ||
|
|
e142138c1b | ||
|
|
c031fb9f4a | ||
|
|
70a4719bab | ||
|
|
3bdb8b1d90 | ||
|
|
f3fb714126 | ||
|
|
3ed1dd8832 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
app/data/council_articles.csv text eol=lf
|
||||
app/data/council_articles.json text eol=lf
|
||||
app/data/k1_journals.json text eol=lf
|
||||
app/data/k1_specialties.json text eol=lf
|
||||
@@ -0,0 +1,121 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
diagnostic_only:
|
||||
description: 'Print key fingerprint; production deployment is temporarily disabled'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
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:
|
||||
# Production deployment is disabled until separate Workers deployment access is installed.
|
||||
name: Fingerprint diagnostics (production deployment temporarily disabled)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.diagnostic_only == 'true'
|
||||
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 +x
|
||||
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: Deployment key fingerprint
|
||||
shell: bash
|
||||
run: |
|
||||
set +x
|
||||
set -euo pipefail
|
||||
trap 'rm -f "$RUNNER_TEMP/deploy_key" "$RUNNER_TEMP/known_hosts"' EXIT
|
||||
ssh-keygen -y -P '' -f "$RUNNER_TEMP/deploy_key" | ssh-keygen -lf - -E sha256 | awk '{print $2}'
|
||||
- name: Upload release
|
||||
if: ${{ false }}
|
||||
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
|
||||
if: ${{ false }}
|
||||
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 }}
|
||||
@@ -9,3 +9,5 @@ pytest-cache-files-*/
|
||||
htmlcov/
|
||||
postgres_data/
|
||||
MCP_DESCRIPTION.md
|
||||
|
||||
.impeccable/review/
|
||||
+107
@@ -1,5 +1,112 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.4
|
||||
|
||||
- Исправлена аналогичная ошибка SHA-256 в справочнике специальностей К1: хеш исходного JSON рассчитывается с LF, оба генератора справочников записывают LF, формат закреплён в Git. Добавлена проверка одинаковой генерации из LF/CRLF; журналы и связи со специальностями не изменены.
|
||||
|
||||
## 0.15.3
|
||||
|
||||
- Исправлена воспроизводимость справочника публикаций на Windows и Linux: при подготовке CSV окончания CRLF нормализуются в LF, JSON записывается с LF, а SHA-256 совпадает с CSV из Git. Исходный CSV не перезаписывается; проверка воспроизводимости сохранена и дополнена тестом обоих форматов строк.
|
||||
|
||||
## 0.15.2
|
||||
|
||||
- Портал использует подготовленный читаемый JSON из 1797 публикаций и индекс 825 ФИО. CSV сохранён для проверки и импорта; преобразование выполняется отдельным атомарным скриптом, а готовые данные кэшируются на процесс. Номера строк, источники и правила сопоставления сохранены.
|
||||
- При подготовке кандидатов в диссертационный совет докторская степень проверяется до разбора публикаций сотрудника, исключая повторную обработку данных неподходящих сотрудников.
|
||||
|
||||
## 0.15.1
|
||||
|
||||
- В XLSX диссертационного совета добавлены отдельные колонки с кодами и названиями подтверждённых специальностей ВАК для сотрудников и публикаций, включая выгрузку без фильтра. При выборе специальности показаны число подходящих работ сотрудника и отметка соответствия каждой публикации.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
- На странице «Диссертационный совет» добавлен подбор докторов наук по 12 специальностям ВАК: учитывается хотя бы одна публикация за последние пять лет в релевантном журнале К1. Показаны основания соответствия и количество подходящих работ; поиск, пагинация и XLSX сохраняют выбранную специальность.
|
||||
- Добавлен справочник связей всех 773 журналов К1 со специальностями из Перечня ВАК на 15.09.2026, с ISSN, страницами, строками источника и датами назначений. Исключены истёкшие назначения; одинаковое название «Нефтегазовое дело» для двух ISSN помечено как неоднозначное и не используется в автоматическом подборе.
|
||||
- Добавлен воспроизводимый скрипт извлечения и проверенные варианты названий. Сервис читает готовый JSON без обработки PDF при запросах.
|
||||
|
||||
## 0.14.3
|
||||
|
||||
- Из XLSX диссертационного совета удалён столбец «Статус». Колонка «В БД» переименована в «Страница ВШЭ» на листах «Сотрудники» и «Публикации»; расчёт значений сохранён.
|
||||
|
||||
## 0.14.2
|
||||
|
||||
- Исправлено сопоставление К1 для переименованного журнала: действующее и явно указанное в исходном PDF прежнее название распознаются как один журнал. Административное примечание хранится отдельно от названия; конфликтующие алиасы не присваиваются автоматически.
|
||||
|
||||
## 0.14.1
|
||||
|
||||
- В списке кандидатов в диссертационный совет и XLSX заменён «CSV по критериям» на Q1, Q2, К1 и ВСЕГО за последние пять лет; ВСЕГО учитывает объединение категорий без дублей.
|
||||
- Добавлен извлечённый справочник 773 журналов К1 из предоставленного PDF; PDF не читается при запросах. Названия журналов берутся из данных ВШЭ или необязательного поля CSV.
|
||||
- Сортировка поддерживает новые счётчики. При расхождении годов источников критерии проверяются по году каждой записи, независимо от их порядка; расхождение отмечено в перечне публикаций.
|
||||
|
||||
## 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
|
||||
|
||||
- Возвращён еженедельный автоматический запуск обхода сотрудников.
|
||||
|
||||
## 0.7.5
|
||||
|
||||
- Production Compose запускает только API и PostgreSQL.
|
||||
|
||||
@@ -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`.
|
||||
@@ -1,10 +1,11 @@
|
||||
# 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`: основная БД.
|
||||
|
||||
Парсер использует фиксированный источник сотрудников, по умолчанию `https://miem.hse.ru/persons`. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, новости, JSON-снапшот и сжатый HTML-снапшот. Детальные публикации дополнительно нормализуются в отдельную таблицу `employee_publications`, а новости из блока «В новостях» — в `employee_news_links`. Ссылки обходятся только из меню профиля самого сотрудника (`person-menu`), например `#sci`, `#teaching`, `#main`.
|
||||
@@ -20,7 +21,8 @@ cp .env.example .env
|
||||
Основные настройки:
|
||||
|
||||
- `DATABASE_URL`: строка подключения SQLAlchemy.
|
||||
- `SOURCE_URL`: список сотрудников МИЭМ.
|
||||
- `SOURCE_URL`: список сотрудников МИЭМ.
|
||||
- `CRAWL_CRON`: расписание в формате crontab, по умолчанию `0 3 * * 1`.
|
||||
- `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска.
|
||||
- `ADMIN_USERNAME`, `ADMIN_PASSWORD`: логин и пароль админки.
|
||||
- `SESSION_SECRET`: секрет подписи cookie.
|
||||
@@ -41,9 +43,15 @@ uvicorn app.main:app --reload
|
||||
|
||||
В админке доступны:
|
||||
|
||||
- `Dashboard`: общая статистика, последний добавленный сотрудник, прогресс текущего/последнего парсинга и ручной запуск.
|
||||
- `Directory`: настраиваемая таблица сотрудников с фильтрами, сортировкой, пагинацией и выбором колонок.
|
||||
- `Runs`: история запусков, ошибки и progress bar.
|
||||
- «Обзор»: статистика, последний добавленный сотрудник, прогресс парсинга и ручной запуск.
|
||||
- «Сотрудники»: поиск, фильтры, сортировка, пагинация и выбор колонок. По умолчанию показаны ФИО, статус, должности, дата последнего обнаружения и внешний профиль. В диалоге колонок доступны наборы «Проверка», «Контакты» и «Все поля»; ранее сохранённый выбор сохраняется.
|
||||
- «Запуски»: история обходов, ошибки и доступный индикатор прогресса.
|
||||
|
||||
Все фильтры применяются кнопкой «Применить фильтры» с переходом на первую страницу. «Сбросить» очищает условия; при устаревшем номере страницы каталог возвращает первую. Подписи полей показывают выбранные условия, над таблицей указан диапазон результатов. Пояснения дат и статусов находятся под фильтрами.
|
||||
|
||||
Каталог поддерживает навигацию с клавиатуры и системный диалог колонок с Escape и возвратом фокуса. На узких экранах таблица прокручивается внутри страницы, колонка ФИО закреплена. Используется системный шрифт без внешних загрузок.
|
||||
|
||||
Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
@@ -54,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/`.
|
||||
|
||||
@@ -67,20 +77,36 @@ 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` доступна без авторизации. Она показывает сотрудников с докторской степенью в профиле и хотя бы одной публикацией в CSV или таблице `employee_publications`; минимальный порог не применяется. Уволенные сотрудники отмечены отдельно. Доступны поиск по ФИО, сортировка по каждому счётчику и страницы по 25 сотрудников. XLSX по `/admin/council/export.xlsx` содержит всех найденных сотрудников с выбранной сортировкой, их публикации и параметры расчёта.
|
||||
|
||||
Счётчики **Q1**, **Q2**, **К1** и **ВСЕГО** учитывают уникальные публикации за последние пять лет по году. Q1/Q2 берутся из отметок Scopus или Web of Science в CSV и сохранённом CSV-обогащении БД. К1 определяется по совпадению нормализованного названия журнала с итоговой категорией в предоставленном справочнике ВАК. ВСЕГО — объединение Q1 ИЛИ Q2 ИЛИ К1: работа в нескольких категориях учитывается один раз. При расхождении годов источников каждый критерий проверяется по году своей записи; неизвестный год наследуется только при единственном согласованном годе. Счётчики «В БД» и «Все публикации» включают все годы независимо от категорий.
|
||||
|
||||
Подготовленный JSON `app/data/council_articles.json` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по DOI, затем по нормализованным названию и году, если нет конфликтующих DOI. Импорт CSV в БД не выдаётся за данные сайта. Для К1 поддерживаются необязательные колонки CSV «Название журнала» или «Журнал»; в исходном CSV их нет. В БД название берётся из поля `journal` в `raw_data` или короткого описания ВШЭ (`description.short`/`shortLeft`, название перед годом). Название самой публикации для определения журнала не используется. Неизвестный или несовпавший журнал не считается К1; перевод и нечёткое сопоставление не применяются. Явно указанное в PDF прежнее название журнала сохраняется как проверенный алиас; служебное примечание о переименовании хранится отдельно от названия.
|
||||
|
||||
Справочник `app/data/k1_journals.json` содержит 773 журнала К1, номера строк, даты категорирования и SHA256 исходного «ВАК категории.pdf». При извлечении проверены все 3134 строки; для записей с историей смены категории взята последняя категория. PDF при запросах не читается. Для обновления справочника разово установите `pdfplumber` в среду разработки и выполните:
|
||||
|
||||
```powershell
|
||||
python scripts/extract_k1_journals.py "app/data/ВАК категории.pdf"
|
||||
```
|
||||
|
||||
После замены CSV или JSON нужно пересобрать Docker-образ и пересоздать API: данные включены в образ и кешируются в процессе.
|
||||
|
||||
Счётчики предварительные: для пограничных лет нужно проверить точную дату, для неизвестного типа — подтвердить, что это статьи. Материалы с известным другим типом не входят в Q1/Q2/К1 и ВСЕГО. К1 означает итоговую категорию в предоставленном справочнике, а не историческую категорию на дату публикации. Отсутствие отметок не доказывает недостаточную активность. Публичная страница не открывает контакты и служебные операции.
|
||||
|
||||
## Парсинг
|
||||
|
||||
Ручной запуск доступен в админке на `Dashboard` и странице `Runs` или через REST:
|
||||
Worker запускает обход по `CRAWL_CRON`. Ручной запуск также доступен в админке на `Dashboard` и странице `Runs` или через REST:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..."
|
||||
@@ -105,41 +131,99 @@ 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 api
|
||||
docker compose logs -f worker
|
||||
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Версия сервиса: `0.7.5`. Админка всегда показывает версии backend и frontend в footer.
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Production deployment временно отключён до установки отдельного deployment-доступа Workers. Workflow `CI` запускает тесты на push в `main` и pull_request в `main`; шаги Upload release и Deploy and verify безусловно отключены для всех событий и значений input. Стандартные имена deployment 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` удаляются после успешного создания нового. Эта правка не меняет существующий backup cron.
|
||||
|
||||
Ручной запуск с `diagnostic_only=true` (по умолчанию) выводит только SHA256 fingerprint ключа `PROD_SSH_KEY`, без подключения к VPS. Публичный ключ передаётся через pipe, не сохраняется в файл или artifacts; shell tracing отключён. При `diagnostic_only=false` диагностический job пропускается: это значение также не разрешает deployment. Возврат production deployment требует отдельной reviewed правки после установки нового доступа.
|
||||
|
||||
## Проверки
|
||||
|
||||
После установки зависимостей из `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.15.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). Стенд запускает только владелец после оценки ресурсов.
|
||||
|
||||
### Подбор по специальностям ВАК
|
||||
|
||||
На странице `/admin/council` доступны 12 специальностей. При выборе специальности
|
||||
показаны доктора наук с хотя бы одной учитываемой за последние пять лет публикацией
|
||||
в подходящем журнале К1. Публикации без года или названия журнала и материалы с
|
||||
известным типом, отличным от статьи, не подтверждают специальность. Счётчики Q1/Q2/К1
|
||||
сохраняют прежний смысл; число работ по выбранной специальности указано внутри
|
||||
карточки сотрудника. Фильтр сохраняется при поиске, смене страницы и экспорте XLSX.
|
||||
На листах «Сотрудники» и «Публикации» XLSX отдельная колонка содержит коды и названия
|
||||
подтверждённых специальностей, включая выгрузку без фильтра. При выбранном фильтре
|
||||
добавлены число подходящих работ сотрудника и отметка «Да/Нет» для каждой публикации.
|
||||
|
||||
`app/data/k1_specialties.json` содержит все 773 журнала из `k1_journals.json`, коды
|
||||
действующих специальностей, ISSN, страницы и строки исходного перечня, историю
|
||||
назначений и SHA-256 источников. Специальности взяты из «ВАК Перечень.pdf» на
|
||||
15.09.2026; категория К1 — из отдельного предоставленного справочника. Это связь по
|
||||
журналу, без оценки содержания статьи или исторической специальности на дату её
|
||||
публикации. Одинаковое название «Нефтегазовое дело» относится к двум ISSN: связь
|
||||
помечена как неоднозначная и не используется для автоматического подбора.
|
||||
Неполные даты начала из исходного PDF сохранены без восстановления даты; текущее
|
||||
назначение определяется по отсутствию истёкшего срока окончания в снимке перечня.
|
||||
|
||||
Для воспроизводимого обновления нужен `pdfplumber` только в среде разработки:
|
||||
|
||||
```sh
|
||||
python scripts/extract_k1_specialties.py "app/data/ВАК Перечень.pdf"
|
||||
```
|
||||
|
||||
Скрипт проверяет последовательность всех 3231 строк. Проверенные различия названий
|
||||
сохранены в `scripts/k1_specialty_matches.json`; каждое сопоставление проверяется по
|
||||
номеру строки и точному исходному названию. Нечёткий поиск не применяется.
|
||||
Сервис читает готовый JSON и не требует PDF или `pdfplumber` при запуске. Хеш
|
||||
`k1_source_sha256` рассчитывается для исходного JSON с окончаниями строк LF; оба
|
||||
JSON-справочника записываются с LF для воспроизводимости на Windows и Linux.
|
||||
|
||||
### Подготовка публикаций для портала
|
||||
|
||||
Портал читает `app/data/council_articles.json`: публикации представлены отдельными
|
||||
объектами с названием, годом, DOI, массивами авторов, квартилей и журналов.
|
||||
Дополнительные поля исходного экспорта сохранены в `metadata`; `source_row` связывает
|
||||
запись со строкой исходного CSV. `by_author` — готовый индекс нормализованных ФИО
|
||||
со ссылками на публикации, без копирования одной работы для каждого соавтора.
|
||||
|
||||
Исходный `app/data/council_articles.csv` сохранён для проверки и отдельного импорта
|
||||
в БД. Данные файла и индекс кэшируются на процесс, CSV не преобразуется при
|
||||
HTTP-запросах. Название источника «Файл CSV» и номера строк в интерфейсе и XLSX
|
||||
сохранены как происхождение данных. Перед разбором публикаций сотрудников из БД
|
||||
проверяется докторская степень; публикации остальных сотрудников не разбираются.
|
||||
|
||||
После обновления CSV пересоздайте JSON до сборки/развёртывания сервиса:
|
||||
|
||||
```sh
|
||||
python scripts/prepare_council_articles.py app/data/council_articles.csv
|
||||
```
|
||||
|
||||
Нужна только стандартная библиотека Python. Скрипт проверяет структуру CSV и индекс,
|
||||
сохраняет число записей и SHA-256 источника с окончаниями строк LF и заменяет JSON
|
||||
атомарно после проверки. CRLF нормализуются в LF только при чтении; исходный CSV
|
||||
не перезаписывается. JSON и хеш одинаковы на Windows и Linux; Git фиксирует эти
|
||||
файлы с LF.
|
||||
После замены файла перезапустите процессы API, чтобы сбросить их кэш. Обновления
|
||||
публикаций из БД продолжают отображаться без перезапуска.
|
||||
+53
-22
@@ -1,7 +1,8 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
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,10 @@ 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.services.council_export import council_xlsx
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
@@ -72,8 +75,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 +96,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 +128,37 @@ 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, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "",
|
||||
db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty)
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
db.rollback()
|
||||
return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503)
|
||||
return _render(request, "council.html", {"error": False, "public_page": True, **result})
|
||||
|
||||
|
||||
@router.get("/council/export.xlsx")
|
||||
def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc", specialty: str = "",
|
||||
db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty)
|
||||
content = council_xlsx(result)
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
db.rollback()
|
||||
return _render(request, "council.html", {"error": True, "public_page": True, "export_error": True}, status_code=503)
|
||||
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="council.xlsx"'})
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
|
||||
def employee_detail(
|
||||
employee_id: int,
|
||||
request: Request,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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)}]}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Public publication evidence from the council export and employee database."""
|
||||
|
||||
import calendar
|
||||
import json
|
||||
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, urlsplit
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models import Employee
|
||||
from app.services.academic_degrees import academic_degrees
|
||||
from app.services.council_source import _doi, _journal_names, _name, _quartiles, load_reference
|
||||
|
||||
|
||||
EXPORT = Path(__file__).resolve().parents[1] / "data" / "council_articles.json"
|
||||
K1_JOURNALS = EXPORT.with_name("k1_journals.json")
|
||||
K1_SPECIALTIES = EXPORT.with_name("k1_specialties.json")
|
||||
DOCTOR = re.compile(r"^доктор\b.+\bнаук$", re.IGNORECASE)
|
||||
TYPES = {"ARTICLE": "Статья", "BOOK": "Книга", "CHAPTER": "Глава книги",
|
||||
"BOOK_CHAPTER": "Глава книги", "REPORT": "Доклад", "PATENT": "Патент",
|
||||
"PREPRINT": "Препринт", "CONFERENCE_PAPER": "Материал конференции"}
|
||||
|
||||
|
||||
def _safe_url(value: str | None) -> str | None:
|
||||
try:
|
||||
url = urlsplit(value or "")
|
||||
if url.scheme in {"http", "https"} and url.hostname and not url.username and not url.password:
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _journal_key(value: str) -> str:
|
||||
return " ".join(re.findall(r"\w+", _name(value)))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _k1_journals() -> dict[str, str]:
|
||||
data = json.loads(K1_JOURNALS.read_text(encoding="utf-8"))
|
||||
journals = {}
|
||||
for journal in data["journals"]:
|
||||
for name in [journal["name"], *journal.get("aliases", [])]:
|
||||
key = _journal_key(name)
|
||||
if key in journals and journals[key] != journal["name"]:
|
||||
raise ValueError("Ambiguous K1 journal alias")
|
||||
journals[key] = journal["name"]
|
||||
return journals
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _k1_specialties() -> dict:
|
||||
data = json.loads(K1_SPECIALTIES.read_text(encoding="utf-8"))
|
||||
return {**data, "by_journal": {j["name"]: j["specialty_codes"] for j in data["journals"]}}
|
||||
|
||||
|
||||
def _description_journals(description: dict) -> list[str]:
|
||||
names = []
|
||||
for field in ("short", "shortLeft"):
|
||||
value = description.get(field)
|
||||
values = value.values() if isinstance(value, dict) else [value]
|
||||
for text in values:
|
||||
if isinstance(text, str):
|
||||
text = BeautifulSoup(text, "html.parser").get_text(" ", strip=True)
|
||||
# HSE short descriptions start with the journal, followed by the year.
|
||||
names.append(re.split(r"\b(?:19|20)\d{2}\b", text, maxsplit=1)[0].strip(" .,:;"))
|
||||
return names
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _articles_by_author() -> dict[str, list[dict]]:
|
||||
return load_reference(EXPORT)
|
||||
|
||||
def _database_article(publication) -> dict:
|
||||
raw = publication.raw_data if isinstance(publication.raw_data, dict) else {}
|
||||
imported = raw.get("csv_enrichment")
|
||||
from_site = bool(publication.publication_id or publication.url or any(key != "csv_enrichment" for key in raw))
|
||||
origins = ["БД miem-workers · сайт ВШЭ"] if from_site else []
|
||||
if imported:
|
||||
origins.append("БД miem-workers · импорт CSV")
|
||||
kind = publication.publication_type
|
||||
description = publication.description if isinstance(publication.description, dict) else raw.get("description")
|
||||
journals = _journal_names(raw)
|
||||
if isinstance(imported, dict):
|
||||
journals.extend(_journal_names(imported))
|
||||
if isinstance(description, dict):
|
||||
journals.extend(_description_journals(description))
|
||||
return {"title": publication.title, "year": publication.year, "doi": _doi(publication.doi_url or ""),
|
||||
"types": [TYPES.get(kind.upper(), f"Другой тип: {kind}")] if kind else [],
|
||||
"quartiles": _quartiles(imported) if isinstance(imported, dict) else [],
|
||||
"journals": journals,
|
||||
"origins": origins or ["БД miem-workers"], "rows": [], "in_csv": False, "in_db": True,
|
||||
"url": _safe_url(publication.url)}
|
||||
|
||||
|
||||
def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]:
|
||||
k1_journals = _k1_journals()
|
||||
specialties = _k1_specialties()["by_journal"]
|
||||
title_years = defaultdict(set)
|
||||
for article in articles:
|
||||
if article["year"] is not None:
|
||||
title_years[_name(article["title"])].add(article["year"])
|
||||
def title_key(article):
|
||||
title = _name(article["title"])
|
||||
years = title_years[title]
|
||||
year = article["year"]
|
||||
return title, next(iter(years)) if year is None and len(years) == 1 else year
|
||||
|
||||
# A title/year fallback is safe only when it identifies at most one DOI.
|
||||
title_dois = defaultdict(set)
|
||||
for article in articles:
|
||||
if article["doi"]:
|
||||
title_dois[title_key(article)].add(article["doi"])
|
||||
unique = {}
|
||||
for article in articles:
|
||||
fallback = title_key(article)
|
||||
identifiers = title_dois[fallback]
|
||||
identifier = article["doi"] or (next(iter(identifiers)) if len(identifiers) == 1 else "")
|
||||
key = identifier or fallback
|
||||
if key not in unique:
|
||||
unique[key] = {**article, "doi": identifier,
|
||||
**{field: list(article[field]) for field in ("types", "quartiles", "journals", "origins", "rows")},
|
||||
"evidence": []}
|
||||
merged = unique[key]
|
||||
merged["evidence"].append(article)
|
||||
for field in ("types", "quartiles", "journals", "origins", "rows"):
|
||||
merged[field] = list(dict.fromkeys([*merged[field], *article[field]]))
|
||||
merged["in_csv"] |= article["in_csv"]
|
||||
merged["in_db"] |= article["in_db"]
|
||||
merged["url"] = merged["url"] or article["url"]
|
||||
for article in unique.values():
|
||||
evidence = article.pop("evidence")
|
||||
years = sorted({item["year"] for item in evidence if item["year"] is not None})
|
||||
# Never pick a conflicting source year merely because that row came first.
|
||||
year = article["year"] = years[0] if len(years) == 1 else None
|
||||
article["criteria"] = list(article["quartiles"])
|
||||
matches = list(dict.fromkeys(k1_journals[key] for name in article["journals"]
|
||||
if (key := _journal_key(name)) in k1_journals))
|
||||
article["criteria"].extend(f"К1 · {name} (справочник ВАК)" for name in matches)
|
||||
eligible_type = not article["types"] or article["types"] == ["Статья"]
|
||||
article["q1"] = article["q2"] = article["k1"] = False
|
||||
article["specialty_codes"] = []
|
||||
for item in evidence:
|
||||
source_year = item["year"] if item["year"] is not None else year
|
||||
if not eligible_type or source_year is None or not start.year <= source_year <= end.year:
|
||||
continue
|
||||
article["q1"] |= any(q.endswith(" Q1") for q in item["quartiles"])
|
||||
article["q2"] |= any(q.endswith(" Q2") for q in item["quartiles"])
|
||||
item_journals = {k1_journals[key] for name in item["journals"]
|
||||
if (key := _journal_key(name)) in k1_journals}
|
||||
article["k1"] |= bool(item_journals)
|
||||
article["specialty_codes"] = sorted(set(article["specialty_codes"]).union(
|
||||
*(specialties.get(name, []) for name in item_journals)))
|
||||
article["qualifies"] = article["q1"] or article["q2"] or article["k1"]
|
||||
if len(years) > 1:
|
||||
article["criteria"].append("Годы источников расходятся: " + ", ".join(map(str, years))
|
||||
+ "; критерии учитываются по году каждой исходной записи")
|
||||
elif year is None:
|
||||
article["criteria"].append("Год не указан — период не подтверждён")
|
||||
elif start.year <= year <= end.year:
|
||||
article["criteria"].append("В пределах пяти лет по году; точную дату нужно проверить" if year in {start.year, end.year}
|
||||
else "В пределах пяти лет")
|
||||
else:
|
||||
article["criteria"].append("Вне пятилетнего периода")
|
||||
if not article["quartiles"] and not matches:
|
||||
article["criteria"].append("Q1/Q2 и К1 не подтверждены доступными данными")
|
||||
article["doi_url"] = f"https://doi.org/{quote(article['doi'], safe='/')}" if article["doi"] else None
|
||||
article["type_label"] = " / ".join(article["types"]) or "Тип не указан — статья не подтверждена"
|
||||
if article["types"] and article["types"] != ["Статья"]:
|
||||
article["criteria"].append("Тип материала не подтверждён как статья — не включён в счётчики Q1/Q2/К1")
|
||||
return sorted(unique.values(), key=lambda item: (not item["qualifies"], -(item["year"] or 0), item["title"].casefold()))
|
||||
|
||||
|
||||
def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
|
||||
sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "") -> dict:
|
||||
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
|
||||
start, end = period(today)
|
||||
specialties = _k1_specialties()["specialties"]
|
||||
specialty = specialty if specialty in {s["code"] for s in specialties} else ""
|
||||
specialty_name = next((s["name"] for s in specialties if s["code"] == specialty), "")
|
||||
employees = db.scalars(select(Employee).options(selectinload(Employee.publications))).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 "")
|
||||
ambiguous = bool(author and name_counts[author] > 1 and by_author.get(author))
|
||||
csv_articles = by_author.get(author, []) if author and name_counts[author] == 1 else []
|
||||
data = employee.current_data if isinstance(employee.current_data, dict) else {}
|
||||
try:
|
||||
degrees = academic_degrees(data)
|
||||
except (AttributeError, TypeError):
|
||||
degrees = []
|
||||
if not any(DOCTOR.fullmatch(degree) for degree in degrees):
|
||||
continue
|
||||
articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end)
|
||||
if not articles:
|
||||
continue
|
||||
specialty_count = sum(specialty in a["specialty_codes"] for a in articles) if specialty else 0
|
||||
for article in articles:
|
||||
article["matches_specialty"] = bool(specialty and specialty in article["specialty_codes"])
|
||||
if article["matches_specialty"]:
|
||||
article["criteria"].append(f"По специальности {specialty}: {specialty_name} (журнал К1, Перечень ВАК)")
|
||||
candidates.append({"specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано", "degree": "; ".join(degrees) or "Учёная степень не указана",
|
||||
"count": len(articles),
|
||||
**{f"{key}_count": sum(a[key] for a in articles) for key in ("q1", "q2", "k1")},
|
||||
"total_count": sum(a["qualifies"] for a in articles),
|
||||
"db_count": sum(a["in_db"] for a in articles), "articles": articles,
|
||||
"profile_url": _safe_url(employee.canonical_url), "ambiguous": ambiguous,
|
||||
"dismissed": employee.status == "dismissed"})
|
||||
sort = sort if sort in {"full_name", "q1_count", "q2_count", "k1_count", "total_count", "db_count", "count"} else "total_count"
|
||||
direction = direction if direction in {"asc", "desc"} else "desc"
|
||||
candidates.sort(key=lambda item: _name(item["name"]), reverse=sort == "full_name" and direction == "desc")
|
||||
if sort != "full_name":
|
||||
candidates.sort(key=lambda item: (item[sort], item["count"]), reverse=direction == "desc")
|
||||
total = len(candidates)
|
||||
query = _name(q)
|
||||
candidates = [item for item in candidates if query in _name(item["name"])
|
||||
and (not specialty or item["specialty_count"] > 0)]
|
||||
matched = len(candidates)
|
||||
limit = 25
|
||||
offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0
|
||||
return {"candidates": candidates[offset:offset + limit] if paginate else candidates,
|
||||
"start": start, "end": end, "total": total,
|
||||
"specialties": specialties, "specialty": specialty, "specialty_name": specialty_name,
|
||||
"matched": matched, "q": q, "offset": offset, "limit": limit, "sort": sort, "direction": direction}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""XLSX report containing the same public evidence as the council page."""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
|
||||
def _append(sheet, values):
|
||||
values = [ILLEGAL_CHARACTERS_RE.sub("", value) if isinstance(value, str) else value for value in values]
|
||||
if any(isinstance(value, str) and len(value) > 32767 for value in values):
|
||||
raise ValueError("Text exceeds Excel cell limit")
|
||||
sheet.append(values)
|
||||
|
||||
|
||||
def council_xlsx(result: dict) -> bytes:
|
||||
workbook = Workbook()
|
||||
workbook.properties.creator = "MIEM Employees"
|
||||
employees = workbook.active
|
||||
employees.title = "Сотрудники"
|
||||
publications = workbook.create_sheet("Публикации")
|
||||
parameters = workbook.create_sheet("Параметры")
|
||||
specialty_labels = {item["code"]: f"{item['code']}. — {item['name']}" for item in result["specialties"]}
|
||||
def specialties_text(codes):
|
||||
return "\n".join(label for code, label in specialty_labels.items() if code in codes) or "Не подтверждены"
|
||||
|
||||
_append(employees, ["№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "Страница ВШЭ", "Все публикации",
|
||||
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности"])
|
||||
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
|
||||
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "Страница ВШЭ", "DOI", "Ссылка на сайте", "Строки CSV", "Специальности ВАК", "По выбранной специальности"])
|
||||
for number, candidate in enumerate(result["candidates"], 1):
|
||||
note = "Совпадение ФИО: публикации CSV не присвоены автоматически" if candidate["ambiguous"] else ""
|
||||
specialty_codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
|
||||
_append(employees, [number, candidate["name"], candidate["degree"], candidate["q1_count"], candidate["q2_count"],
|
||||
candidate["k1_count"], candidate["total_count"],
|
||||
candidate["db_count"], candidate["count"],
|
||||
candidate["profile_url"], note, specialties_text(specialty_codes),
|
||||
candidate["specialty_count"] if result["specialty"] else None])
|
||||
for article in candidate["articles"]:
|
||||
_append(publications, [number, candidate["name"], article["title"], article["year"], article["type_label"],
|
||||
"; ".join(article["origins"]), "; ".join(article["criteria"]),
|
||||
*["Да" if article[key] else "Нет" for key in ("q1", "q2", "k1", "qualifies", "in_db")],
|
||||
article["doi_url"], article["url"], ", ".join(map(str, article["rows"])), specialties_text(article["specialty_codes"]),
|
||||
("Да" if article["matches_specialty"] else "Нет") if result["specialty"] else None])
|
||||
_append(parameters, ["Параметр", "Значение"])
|
||||
for values in [
|
||||
["Специальность ВАК", f"{result['specialty']}. — {result['specialty_name']}" if result["specialty"] else "Все специальности"],
|
||||
["Подбор по специальности", "Хотя бы одна публикация за пять лет в релевантном журнале К1 по Перечню ВАК на 15.09.2026; содержание статьи и исторические специальности не проверяются."],
|
||||
["Начало периода", result["start"]], ["Конец периода", result["end"]], ["Поиск по ФИО", result["q"]],
|
||||
["Сортировать по", {"full_name": "ФИО", "q1_count": "Q1", "q2_count": "Q2", "k1_count": "К1",
|
||||
"total_count": "ВСЕГО", "db_count": "Страница ВШЭ", "count": "Все публикации"}[result["sort"]]],
|
||||
["Направление", "По возрастанию" if result["direction"] == "asc" else "По убыванию"],
|
||||
["Сотрудников в выгрузке", result["matched"]], ["Всего сотрудников в списке", result["total"]],
|
||||
["Отбор по степени", "Доктор наук в профиле; достаточно хотя бы одной публикации из CSV или БД"],
|
||||
["Счётчики Q1/Q2/К1", "Уникальные публикации в пятилетнем периоде по году. Q1/Q2 — отметки Scopus или Web of Science. "
|
||||
"Для пограничных лет нужно проверить дату, для неизвестного типа — подтвердить, что это статья. "
|
||||
"Материалы с известным другим типом исключены. К1 — совпадение названия журнала с итоговой категорией "
|
||||
"в справочнике из ВАК категории.pdf; историческая категория не подтверждается. "
|
||||
"Без названия журнала К1 не подтверждён."],
|
||||
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — специальности релевантного журнала К1, учитываемые за пять лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
|
||||
["Работ / По выбранной специальности", "При выбранном фильтре показаны число соответствующих публикаций сотрудника и отметка Да/Нет для каждой работы. Без фильтра ячейки пустые; список специальностей остаётся заполненным."],
|
||||
["ВСЕГО", "Уникальные публикации, входящие в Q1 ИЛИ Q2 ИЛИ К1; пересечения учитываются один раз."],
|
||||
["Страница ВШЭ / Все публикации", "Все годы и категории; общий перечень объединяет CSV и БД без дублей. "
|
||||
"Страница ВШЭ учитывает сохранённые публикации, включая импортированные из CSV; источник указан на листе Публикации."],
|
||||
]:
|
||||
_append(parameters, values)
|
||||
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55, 80, 28]),
|
||||
(publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 20, 45, 45, 22, 80, 28]),
|
||||
(parameters, [32, 95])]:
|
||||
sheet.freeze_panes = "A2"
|
||||
sheet.auto_filter.ref = sheet.dimensions
|
||||
for column, width in enumerate(widths, 1):
|
||||
sheet.column_dimensions[get_column_letter(column)].width = width
|
||||
for row in sheet:
|
||||
for cell in row:
|
||||
if isinstance(cell.value, str):
|
||||
# CSV/profile content is text, never an Excel formula or error code.
|
||||
cell.data_type = "s"
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
for cell in sheet[1]:
|
||||
cell.font = Font(bold=True, color="FFFFFF")
|
||||
cell.fill = PatternFill("solid", fgColor="263640")
|
||||
output = BytesIO()
|
||||
workbook.save(output)
|
||||
return output.getvalue()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Prepared council publication data and shared source normalization."""
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from io import StringIO
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
DOI = re.compile(r"10\.\d{4,9}/\S+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _name(value: str) -> str:
|
||||
return " ".join(unicodedata.normalize("NFKC", value).casefold().replace("ё", "е").split())
|
||||
|
||||
|
||||
def _doi(value: str) -> str:
|
||||
value = re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", value.strip(), flags=re.I).lower()
|
||||
return value if DOI.fullmatch(value) else ""
|
||||
|
||||
|
||||
def _quartiles(row: dict) -> list[str]:
|
||||
return [f"{label} {str(row.get(field) or '').strip().upper()}"
|
||||
for label, field in (("Scopus", "Квартиль scopus"), ("Web of Science", "Квартиль wos"))
|
||||
if str(row.get(field) or '').strip().upper() in {"Q1", "Q2"}]
|
||||
|
||||
|
||||
def _journal_names(data: dict) -> list[str]:
|
||||
names = []
|
||||
for field in ("Название журнала", "Журнал", "journal"):
|
||||
value = data.get(field)
|
||||
if isinstance(value, dict):
|
||||
value = value.get("name")
|
||||
if isinstance(value, str) and value.strip():
|
||||
names.append(value.strip())
|
||||
return names
|
||||
|
||||
|
||||
def prepare_reference(source: Path) -> dict:
|
||||
publications = []
|
||||
by_author = defaultdict(list)
|
||||
# Git stores this text export with LF; canonicalize Windows CRLF before
|
||||
# parsing and hashing so the prepared artifact is identical on both hosts.
|
||||
source_bytes = source.read_bytes().replace(b"\r\n", b"\n")
|
||||
with StringIO(source_bytes.decode("utf-8-sig"), newline="") as stream:
|
||||
reader = csv.DictReader(stream, delimiter=";")
|
||||
required = {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"}
|
||||
if not required <= set(reader.fieldnames or []):
|
||||
raise ValueError("Missing council CSV headers")
|
||||
if len(reader.fieldnames) != len(set(reader.fieldnames)):
|
||||
raise ValueError("Duplicate council CSV headers")
|
||||
for row_number, row in enumerate(reader, 2):
|
||||
if None in row or any(value is None for value in row.values()):
|
||||
raise ValueError("Malformed council CSV record")
|
||||
authors = list(dict.fromkeys(value.strip() for value in row["Авторы НИУ ВШЭ"].split(";") if value.strip()))
|
||||
record = {"source_row": row_number, "title": row["Название публикации"].strip(),
|
||||
"year": int(row["Год"]) if row["Год"].strip().isdigit() else None,
|
||||
"doi": _doi(row["DOI"]), "authors": authors, "quartiles": _quartiles(row),
|
||||
"journals": _journal_names(row),
|
||||
"metadata": {key: value for key, value in row.items()
|
||||
if key not in {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ"}}}
|
||||
index = len(publications)
|
||||
publications.append(record)
|
||||
if record["title"]:
|
||||
for author in sorted({_name(value) for value in authors} - {""}):
|
||||
by_author[author].append(index)
|
||||
return {"schema_version": 1, "source_file": source.name,
|
||||
"source_sha256": hashlib.sha256(source_bytes).hexdigest(), "source_hash_eol": "LF",
|
||||
"source_rows": len(publications), "publications": publications, "by_author": dict(by_author)}
|
||||
|
||||
|
||||
def load_reference(path: Path) -> dict[str, list[dict]]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict) or type(data.get("schema_version")) is not int or data.get("schema_version") != 1:
|
||||
raise ValueError("Unsupported council reference schema")
|
||||
publications = data.get("publications")
|
||||
by_author = data.get("by_author")
|
||||
if not isinstance(publications, list) or not isinstance(by_author, dict) or data.get("source_rows") != len(publications):
|
||||
raise ValueError("Invalid council reference records")
|
||||
articles = []
|
||||
expected = defaultdict(list)
|
||||
for index, record in enumerate(publications):
|
||||
if not isinstance(record, dict) or not isinstance(record.get("title"), str) or not isinstance(record.get("doi"), str):
|
||||
raise ValueError("Invalid council publication text")
|
||||
if record["doi"] != _doi(record["doi"]) or (record.get("year") is not None and type(record["year"]) is not int):
|
||||
raise ValueError("Invalid council publication DOI/year")
|
||||
if record.get("source_row") != index + 2:
|
||||
raise ValueError("Invalid council source row")
|
||||
for field in ("authors", "quartiles", "journals"):
|
||||
if not isinstance(record.get(field), list) or not all(isinstance(value, str) for value in record[field]):
|
||||
raise ValueError("Invalid council publication list")
|
||||
if not set(record["quartiles"]) <= {"Scopus Q1", "Scopus Q2", "Web of Science Q1", "Web of Science Q2"}:
|
||||
raise ValueError("Invalid council quartiles")
|
||||
if record["title"]:
|
||||
for author in sorted({_name(value) for value in record["authors"]} - {""}):
|
||||
expected[author].append(index)
|
||||
articles.append({"title": record["title"], "year": record["year"], "doi": record["doi"],
|
||||
"types": [], "quartiles": record["quartiles"], "journals": record["journals"],
|
||||
"origins": ["Файл CSV"], "rows": [record["source_row"]],
|
||||
"in_csv": True, "in_db": False, "url": None})
|
||||
if by_author != dict(expected) or any(type(i) is not int for indexes in by_author.values() for i in indexes):
|
||||
raise ValueError("Council author index disagrees with publication authors")
|
||||
return {author: [articles[index] for index in indexes] for author, indexes in by_author.items()}
|
||||
+60
-14
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
+307
-52
@@ -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,143 @@
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
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-summary .council__candidate-identity { grid-column: 1; }
|
||||
.council__candidate-cue { grid-column: 2; grid-row: 1; }
|
||||
.council__candidate-summary { grid-template-columns: minmax(0, 1fr) 14px; }
|
||||
.council__counts { grid-column: 1 / -1; justify-content: space-between; }
|
||||
.council__candidate-count { text-align: start; }
|
||||
.council__candidate-body { padding: 14px; }
|
||||
}
|
||||
|
||||
.council__search { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; }
|
||||
.council__search .directory__field { flex: 1 1 240px; max-width: 520px; }
|
||||
.council__notice summary { cursor: pointer; padding-block: 6px; }
|
||||
.council__notice p { max-width: 75ch; margin-bottom: 0; }
|
||||
.council__counts { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: clamp(8px, 1.5vw, 20px); }
|
||||
@media (max-width: 700px) {
|
||||
.council__counts { grid-template-columns: repeat(3, minmax(0, 1fr)); row-gap: 14px; }
|
||||
}
|
||||
.council__candidate-count small { max-width: 105px; line-height: 1.4; }
|
||||
.council__hint { margin: 0; color: #4c5b64; font-size: 14px; line-height: 1.5; }
|
||||
.council__candidate-body .council__hint { margin-top: 12px; }
|
||||
.council__evidence { display: flex; flex-wrap: wrap; gap: 4px 20px; padding-inline-start: 18px; margin: 10px 0 0; color: #4c5b64; font-size: 13px; line-height: 1.5; }
|
||||
.council__article-heading span { flex-shrink: 0; }
|
||||
.council ::selection { background: #ccfbf1; color: #25313a; }
|
||||
.council input { caret-color: #0f766e; }
|
||||
.council a { text-underline-offset: 3px; }
|
||||
.council__article { overflow-wrap: anywhere; }
|
||||
@media (max-width: 400px) {
|
||||
.council__title { font-size: clamp(23px, 7vw, 29px); overflow-wrap: anywhere; }
|
||||
.council__article-heading { flex-direction: column; gap: 4px; }
|
||||
}
|
||||
+57
-32
@@ -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();
|
||||
})();
|
||||
+13
-6
@@ -4,21 +4,28 @@
|
||||
<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>
|
||||
<h1 class="admin__brand"><a class="admin__brand-link" href="{{ '/admin/council' if public_page else '/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>
|
||||
{% if public_page %}
|
||||
<a class="admin__link" href="/admin/council" aria-current="page">Диссертационный совет</a>
|
||||
<a class="admin__link" href="/admin/login">Вход в админку</a>
|
||||
{% else %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
<main class="admin__main">
|
||||
<main class="admin__main" id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="admin__footer">
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Диссертационный совет · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="council" aria-labelledby="council-title">
|
||||
<header class="council__hero">
|
||||
<div class="council__hero-copy">
|
||||
<h2 class="council__title" id="council-title">Диссертационный совет</h2>
|
||||
<p class="council__intro">Публикации сотрудников для проверки состава совета. В списке сотрудники с докторской степенью в профиле и хотя бы одной публикацией в файле CSV или базе miem-workers. Минимальный порог количества работ не применяется.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if error %}
|
||||
<div class="council__message" role="alert">
|
||||
<h3>{% if export_error %}Не удалось создать XLSX{% else %}Не удалось загрузить публикации{% endif %}</h3>
|
||||
<p>{% if export_error %}Не удалось выгрузить данные. Вернитесь к списку и попробуйте скачать файл позже.{% else %}Данные временно недоступны. Попробуйте обновить страницу позже.{% endif %}</p>
|
||||
<a class="admin__link" href="/admin/council">Повторить загрузку</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<details class="council__notice">
|
||||
<summary><strong>Как читать счётчики и критерии</strong></summary>
|
||||
<p>Q1 и Q2 — уникальные публикации с соответствующей отметкой Scopus или Web of Science. К1 — публикации в журналах, название которых совпало со справочником из «ВАК категории.pdf». ВСЕГО — количество уникальных публикаций, входящих хотя бы в одну из категорий Q1, Q2 или К1. Работа в нескольких категориях учитывается в итоге один раз.</p>
|
||||
<p>Все четыре счётчика используют период {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }} по году публикации. Для пограничных лет нужно проверить точную дату, для неизвестного типа — подтвердить, что это статья. Материалы с известным другим типом исключены. К1 определяется по итоговой категории журнала в предоставленном справочнике, а не по его исторической категории на дату публикации. Если название журнала отсутствует или не совпало, К1 не подтверждён. Отсутствие отметки означает недостаток данных.</p>
|
||||
<p>«В БД» и «Все публикации» показывают количество работ за все годы, независимо от категорий. Общий перечень объединяет CSV и БД без дублей. Наличие публикаций не подтверждает соответствие всем требованиям совета.</p>
|
||||
<p>При выборе специальности показаны сотрудники с хотя бы одной учитываемой за пять лет публикацией в журнале К1, которому эта специальность присвоена в «ВАК Перечень.pdf» на 15.09.2026. Это соответствие по журналу; содержание статьи и исторический перечень специальностей на дату публикации не проверяются.</p>
|
||||
</details>
|
||||
|
||||
<form class="council__search" method="get" action="/admin/council" role="search">
|
||||
<label class="directory__field" for="council-specialty">Специальность ВАК
|
||||
<select class="directory__input" id="council-specialty" name="specialty">
|
||||
<option value="">Все специальности</option>
|
||||
{% for item in specialties %}
|
||||
<option value="{{ item.code }}"{% if specialty == item.code %} selected{% endif %}>{{ item.code }}. — {{ item.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="directory__field" for="council-search">Поиск по ФИО
|
||||
<input class="directory__input" id="council-search" name="q" type="search" value="{{ q }}" placeholder="Фамилия или полное имя">
|
||||
</label>
|
||||
<label class="directory__field">Сортировать по
|
||||
<select class="directory__input" name="sort">
|
||||
{% for value, label in [('full_name', 'ФИО'), ('q1_count', 'Q1'), ('q2_count', 'Q2'), ('k1_count', 'К1'), ('total_count', 'ВСЕГО'), ('db_count', 'Публикации в БД'), ('count', 'Все публикации')] %}
|
||||
<option value="{{ value }}"{% if sort == value %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="directory__field">Направление
|
||||
<select class="directory__input" name="direction">
|
||||
<option value="asc"{% if direction == 'asc' %} selected{% endif %}>По возрастанию</option>
|
||||
<option value="desc"{% if direction == 'desc' %} selected{% endif %}>По убыванию</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="button" type="submit">Применить</button>
|
||||
<button class="button button--secondary" type="submit" formaction="/admin/council/export.xlsx">Скачать XLSX</button>
|
||||
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</form>
|
||||
<div class="council__list-heading">
|
||||
<h3>Сотрудники</h3>
|
||||
<p role="status">{% if matched %}Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }} из {{ matched }}{% else %}Найдено: 0{% endif %}{% if q or specialty %} · Всего в списке: {{ total }}{% endif %}</p>
|
||||
</div>
|
||||
{% if specialty %}<p class="council__hint">{{ specialty }}. — {{ specialty_name }}. Основания отмечены в публикациях каждого сотрудника.</p>{% endif %}
|
||||
{% if candidates %}
|
||||
<p class="council__hint">Откройте сотрудника, чтобы увидеть публикации, источники и основания. XLSX содержит всех найденных сотрудников и их публикации, включая другие страницы списка.</p>
|
||||
<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 }}{% if candidate.dismissed %} · Уволен{% endif %}</small></span>
|
||||
<span class="council__counts">
|
||||
<span class="council__candidate-count"><strong>{{ candidate.q1_count }}</strong><small>Q1</small></span>
|
||||
<span class="council__candidate-count"><strong>{{ candidate.q2_count }}</strong><small>Q2</small></span>
|
||||
<span class="council__candidate-count"><strong>{{ candidate.k1_count }}</strong><small>К1</small></span>
|
||||
<span class="council__candidate-count"><strong>{{ candidate.total_count }}</strong><small>ВСЕГО</small></span>
|
||||
<span class="council__candidate-count"><strong>{{ candidate.db_count }}</strong><small>В БД</small></span>
|
||||
<span class="council__candidate-count"><strong>{{ candidate.count }}</strong><small>Все публикации</small></span>
|
||||
</span>
|
||||
<span class="council__candidate-cue" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="council__candidate-body">
|
||||
{% if specialty %}<p class="council__hint">Публикаций по выбранной специальности: {{ candidate.specialty_count }}</p>{% endif %}
|
||||
{% if candidate.profile_url %}<a class="admin__link" href="{{ candidate.profile_url }}" target="_blank" rel="noopener noreferrer">Профиль на сайте ВШЭ</a>{% endif %}
|
||||
{% if candidate.ambiguous %}<p class="council__hint">В базе есть сотрудники с одинаковым ФИО. Публикации из файла не присвоены автоматически; показаны записи, связанные с этим сотрудником в БД.</p>{% endif %}
|
||||
<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 or 'Год не указан' }}</span>
|
||||
</div>
|
||||
<div class="council__article-meta">
|
||||
{% for origin in article.origins %}<span class="council__source">{{ origin }}</span>{% endfor %}
|
||||
<span>{{ article.type_label }}</span>
|
||||
{% if article.doi_url %}<a class="admin__link" href="{{ article.doi_url }}" target="_blank" rel="noopener noreferrer">DOI</a>{% endif %}
|
||||
{% if article.url %}<a class="admin__link" href="{{ article.url }}" target="_blank" rel="noopener noreferrer">Публикация на сайте</a>{% endif %}
|
||||
{% if article.rows %}<span>Строки CSV: {{ article.rows|join(', ') }}</span>{% endif %}
|
||||
</div>
|
||||
<ul class="council__evidence" aria-label="Критерии и подтверждения">
|
||||
{% for criterion in article.criteria %}<li>{{ criterion }}</li>{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if matched > limit %}
|
||||
<nav class="directory__pagination" aria-label="Страницы сотрудников">
|
||||
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
<span class="directory__page">{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="council__message">
|
||||
<h4>{% if q or specialty %}Сотрудники не найдены{% else %}Сотрудники с докторской степенью и публикациями пока не найдены{% endif %}</h4>
|
||||
<p>{% if q or specialty %}Попробуйте другую фамилию или специальность. Без названия журнала, года или подтверждённого соответствия журналу К1 публикация не подтверждает специальность.{% else %}Для включения в список нужны докторская степень в профиле и хотя бы одна публикация, однозначно связанная с сотрудником.{% endif %}</p>
|
||||
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.7.5"
|
||||
FRONTEND_VERSION = "0.7.5"
|
||||
BACKEND_VERSION = "0.7.5"
|
||||
APP_VERSION = "0.15.4"
|
||||
FRONTEND_VERSION = "0.15.4"
|
||||
BACKEND_VERSION = "0.15.4"
|
||||
@@ -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:
|
||||
@@ -25,5 +27,16 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
worker:
|
||||
build: .
|
||||
restart: always
|
||||
command: python -m app.worker
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-miem}:${POSTGRES_PASSWORD:-miem_password}@postgres:5432/${POSTGRES_DB:-miem_workers}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -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 и доступ к рабочей БД не изменялись.
|
||||
@@ -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-интеграция ещё не подтверждена.
|
||||
@@ -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/удаление данных.
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.7.5"
|
||||
description = "MIEM employees parser, admin API, and MCP server"
|
||||
version = "0.15.4"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"apscheduler>=3.10.4",
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"lxml>=5.2.0",
|
||||
"psycopg[binary]>=3.2.0",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"openpyxl>=3.1.5,<4",
|
||||
"python-multipart>=0.0.9",
|
||||
"requests>=2.32.0",
|
||||
"sqlalchemy>=2.0.32",
|
||||
|
||||
@@ -6,6 +6,7 @@ jinja2>=3.1.4
|
||||
lxml>=5.2.0
|
||||
psycopg[binary]>=3.2.0
|
||||
pydantic-settings>=2.4.0
|
||||
openpyxl>=3.1.5,<4
|
||||
python-multipart>=0.0.9
|
||||
requests>=2.32.0
|
||||
sqlalchemy>=2.0.32
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""One-time extraction: python scripts/extract_k1_journals.py <categories.pdf>.
|
||||
|
||||
Requires pdfplumber for this development operation only; the service reads JSON.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pdfplumber
|
||||
|
||||
|
||||
def extract(source: Path) -> dict:
|
||||
rows = []
|
||||
with pdfplumber.open(source) as document:
|
||||
for page in document.pages:
|
||||
for table in page.extract_tables():
|
||||
for row in table:
|
||||
if row[0] and row[0].isdigit():
|
||||
if len(row) != 4 or not all(row) or not set(row[2].split()) <= {"К1", "К2", "К3"}:
|
||||
raise ValueError(f"Invalid category row: {row}")
|
||||
# Some entries include previous categories; use the final category.
|
||||
row[2] = row[2].split()[-1]
|
||||
row[3] = row[3].splitlines()[-1]
|
||||
datetime.strptime(row[3], "%d.%m.%Y")
|
||||
rows.append(row)
|
||||
if not rows or [int(row[0]) for row in rows] != list(range(1, len(rows) + 1)):
|
||||
raise ValueError("Journal numbering is incomplete or duplicated")
|
||||
journals = []
|
||||
for row in rows:
|
||||
if row[2] != "К1":
|
||||
continue
|
||||
name = " ".join(row[1].split())
|
||||
journal = {"name": name, "categorized_on": row[3], "source_row": int(row[0])}
|
||||
# Only an explicit administrative rename note supplies an alias.
|
||||
# Parentheses in journal names (e.g. bilingual titles) stay unchanged.
|
||||
rename = re.fullmatch(r"(.+?) \((до \d{2}\.\d{2}\.\d{4} г\. наименование в Перечне «(.+)»)\)", name)
|
||||
if rename:
|
||||
journal.update(name=rename[1], note=rename[2], aliases=[rename[3]])
|
||||
journals.append(journal)
|
||||
if not journals:
|
||||
raise ValueError("No K1 journals found")
|
||||
return {"source": source.name, "source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"source_rows": len(rows), "category_counts": dict(Counter(row[2] for row in rows)),
|
||||
"journals": journals}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("--output", type=Path, default=Path("app/data/k1_journals.json"))
|
||||
args = parser.parse_args()
|
||||
result = extract(args.source)
|
||||
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
|
||||
print(f"Saved {len(result['journals'])} K1 journals; verified {result['source_rows']} source rows")
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Extract K1 journal specialties from the supplied VAK list (development only).
|
||||
|
||||
python scripts/extract_k1_specialties.py <list.pdf> [--rows-cache <json>]
|
||||
Requires pdfplumber only for regeneration; requests read the generated JSON.
|
||||
"""
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
SPECIALTIES = [
|
||||
("1.2.2", "Математическое моделирование, численные методы и комплексы программ"),
|
||||
("2.2.2", "Электронная компонентная база микро- и наноэлектроники, квантовых устройств"),
|
||||
("2.3.6", "Методы и системы защиты информации, информационная безопасность"),
|
||||
("2.3.1", "Системный анализ, управление и обработка информации, статистика"),
|
||||
("2.3.7", "Компьютерное моделирование и автоматизация проектирования"),
|
||||
("1.3.8", "Физика конденсированного состояния"),
|
||||
("2.2.15", "Системы, сети и устройства телекоммуникаций"),
|
||||
("1.1.2", "Дифференциальные уравнения и математическая физика"),
|
||||
("2.2.13", "Радиотехника, в том числе системы и устройства телевидения"),
|
||||
("2.3.2", "Вычислительные системы и их элементы"),
|
||||
("2.2.14", "Антенны, СВЧ-устройства и их технологии"),
|
||||
("2.3.5", "Математическое и программное обеспечение вычислительных систем, комплексов и компьютерных сетей"),
|
||||
]
|
||||
CODE = re.compile(r"(?<![\d.])([1-6]\.\d{1,2}\.\d{1,2})\.")
|
||||
|
||||
|
||||
def journal_key(value):
|
||||
# Administrative notes are separate from the title; do not remove ordinary parentheses.
|
||||
value = re.split(r"\s*\(\s*(?:до\s+\d|перевод)", value, flags=re.I)[0]
|
||||
return " ".join(re.findall(r"\w+", unicodedata.normalize("NFKC", value).casefold().replace("ё", "е")))
|
||||
|
||||
|
||||
def extract_rows(source):
|
||||
import pdfplumber
|
||||
rows = []
|
||||
with pdfplumber.open(source) as document:
|
||||
for number, page in enumerate(document.pages, 1):
|
||||
for table in page.find_tables():
|
||||
# Ignore tiny tables detected inside the journal-name cell.
|
||||
if table.bbox[2] - table.bbox[0] < 500:
|
||||
continue
|
||||
for row, values in zip(table.rows, table.extract()):
|
||||
columns = [""] * 5
|
||||
for cell, value in zip(row.cells, values):
|
||||
if cell and value:
|
||||
# This source uses five fixed columns; some pages contain
|
||||
# additional cell borders within those columns.
|
||||
column = sum(cell[0] >= edge - 1 for edge in (71.424, 234.387, 295.39, 498.48))
|
||||
columns[column] += ("\n" if columns[column] else "") + value
|
||||
rows.append({"page": number, "cells": columns})
|
||||
page.close()
|
||||
return rows
|
||||
|
||||
|
||||
def parse_rows(rows):
|
||||
journals = []
|
||||
for row in rows:
|
||||
cells = row["cells"]
|
||||
if len(cells) != 5:
|
||||
raise ValueError("Expected five VAK columns")
|
||||
if re.fullmatch(r"\d+\.", cells[0].strip()):
|
||||
journals.append({"row": int(cells[0].strip(". ")), "name": cells[1], "issn": cells[2],
|
||||
"pages": [], "assignments": []})
|
||||
elif not journals:
|
||||
continue # The first-page header.
|
||||
elif cells[0].strip():
|
||||
raise ValueError(f"Unexpected journal number on page {row['page']}")
|
||||
elif cells[1]:
|
||||
journals[-1]["name"] += " " + cells[1]
|
||||
journal = journals[-1]
|
||||
if row["page"] not in journal["pages"]:
|
||||
journal["pages"].append(row["page"])
|
||||
if cells[2] and not re.fullmatch(r"\d+\.", cells[0].strip()):
|
||||
journal["issn"] += " " + cells[2]
|
||||
if cells[3]:
|
||||
if cells[4]:
|
||||
dates = re.sub(r"\s+", "", cells[4]).lower().replace("c", "с")
|
||||
dates = dates.removeprefix("с")
|
||||
bounds = dates.split("по")
|
||||
if len(bounds) > 2:
|
||||
raise ValueError(f"Unrecognized VAK dates: {cells[4]}")
|
||||
def parse_date(value):
|
||||
try:
|
||||
return datetime.strptime(value, "%d.%m.%Y").date().isoformat()
|
||||
except ValueError:
|
||||
return None
|
||||
start = parse_date(bounds[0])
|
||||
end = parse_date(bounds[1]) if len(bounds) == 2 else None
|
||||
if len(bounds) == 2 and end is None:
|
||||
raise ValueError(f"Unrecognized end date: {cells[4]}")
|
||||
assignment = {"codes": [], "valid_from": start, "valid_until": end,
|
||||
"source_dates": cells[4], "pages": []}
|
||||
if start is None:
|
||||
# Preserve source typos, without inventing a historical start date.
|
||||
assignment["date_note"] = "Дата начала неполная в источнике; текущее назначение определяется по отсутствию истёкшего срока окончания в Перечне."
|
||||
journal["assignments"].append(assignment)
|
||||
elif not journal["assignments"]:
|
||||
raise ValueError(f"Missing initial dates in VAK row {journal['row']}")
|
||||
assignment = journal["assignments"][-1]
|
||||
assignment["codes"] = sorted(set(assignment["codes"]) | set(CODE.findall(cells[3])))
|
||||
if row["page"] not in assignment["pages"]:
|
||||
assignment["pages"].append(row["page"])
|
||||
elif cells[4]:
|
||||
raise ValueError("Dates without specialty text")
|
||||
if not journals or [j["row"] for j in journals] != list(range(1, len(journals) + 1)):
|
||||
raise ValueError("VAK numbering is incomplete or duplicated")
|
||||
for journal in journals:
|
||||
journal["name"] = " ".join(journal["name"].split())
|
||||
journal["issn"] = " ".join(journal["issn"].split())
|
||||
return journals
|
||||
|
||||
|
||||
def active_codes(journal, as_of):
|
||||
return sorted({code for a in journal["assignments"]
|
||||
if (a["valid_from"] is None or a["valid_from"] <= as_of)
|
||||
and (a["valid_until"] is None or as_of <= a["valid_until"])
|
||||
for code in a["codes"]})
|
||||
|
||||
|
||||
def build_reference(journals, k1, overrides, as_of):
|
||||
by_name = defaultdict(list)
|
||||
by_row = {j["row"]: j for j in journals}
|
||||
for journal in journals:
|
||||
by_name[journal_key(journal["name"])].append(journal)
|
||||
reviewed = {o["k1_name"]: o for o in overrides}
|
||||
if len(reviewed) != len(overrides) or not set(reviewed) <= {j["name"] for j in k1["journals"]}:
|
||||
raise ValueError("Invalid or stale reviewed K1 matches")
|
||||
result = []
|
||||
for journal in k1["journals"]:
|
||||
entry = {"name": journal["name"], "specialty_codes": [], "status": "not_found", "vak_entries": []}
|
||||
matches = by_name[journal_key(journal["name"])]
|
||||
if journal["name"] in reviewed:
|
||||
override = reviewed[journal["name"]]
|
||||
candidate = by_row[override["vak_row"]]
|
||||
if candidate["name"] != override["vak_name"]:
|
||||
raise ValueError("Reviewed source journal title changed")
|
||||
matches = [candidate]
|
||||
entry["match_note"] = override["reason"]
|
||||
active = [j for j in matches if active_codes(j, as_of)]
|
||||
if len(active) == 1:
|
||||
entry.update(specialty_codes=active_codes(active[0], as_of), status="matched")
|
||||
elif len(active) > 1:
|
||||
entry["status"] = "ambiguous" # Do not union different journals with identical titles.
|
||||
elif matches:
|
||||
entry["status"] = "no_current_specialties"
|
||||
entry["vak_entries"] = matches
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("--k1", type=Path, default=Path("app/data/k1_journals.json"))
|
||||
parser.add_argument("--matches", type=Path, default=Path("scripts/k1_specialty_matches.json"))
|
||||
parser.add_argument("--output", type=Path, default=Path("app/data/k1_specialties.json"))
|
||||
parser.add_argument("--rows-cache", type=Path)
|
||||
parser.add_argument("--as-of", default="2026-09-15")
|
||||
args = parser.parse_args()
|
||||
date.fromisoformat(args.as_of)
|
||||
rows = json.loads(args.rows_cache.read_text(encoding="utf-8")) if args.rows_cache else extract_rows(args.source)
|
||||
journals = parse_rows(rows)
|
||||
k1 = json.loads(args.k1.read_text(encoding="utf-8"))
|
||||
entries = build_reference(journals, k1, json.loads(args.matches.read_text(encoding="utf-8")), args.as_of)
|
||||
result = {"schema_version": 1, "source": args.source.name,
|
||||
"source_sha256": hashlib.sha256(args.source.read_bytes()).hexdigest(), "as_of": args.as_of,
|
||||
"k1_source_sha256": hashlib.sha256(args.k1.read_bytes().replace(b"\r\n", b"\n")).hexdigest(), "k1_source_hash_eol": "LF", "source_rows": len(journals),
|
||||
"specialties": [{"code": code, "name": name} for code, name in SPECIALTIES], "journals": entries}
|
||||
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
|
||||
from collections import Counter
|
||||
print(len(entries), dict(Counter(j['status'] for j in entries)))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,662 @@
|
||||
[
|
||||
{
|
||||
"k1_name": "Acta Biomedica Scientifica (East Siberian Biomedical Journal)",
|
||||
"vak_row": 3,
|
||||
"vak_name": "Acta biomedica scientifica (перевод наименования на государственный язык Российской Федерации: Научный биомедицинский журнал) (До 02.10.2017 г. наименование в Перечне «Бюллетень Восточно-Сибирского научного центра СО РАМН (Bulletin of the East Siberian Scientific Center SB RAMS\" ISSN 1811-0649)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Acta Linguistica Petropolitana. Труды института лингвистических исследований",
|
||||
"vak_row": 4,
|
||||
"vak_name": "Acta Linguistica Petropolitana. Труды Института лингвистических исследований РАН",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ars Administrandi. Искусство управления",
|
||||
"vak_row": 11,
|
||||
"vak_name": "Ars Administrandi (перевод наименование на государственный язык Российской Федерации: Искусство управления)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Iberoamerica",
|
||||
"vak_row": 1194,
|
||||
"vak_name": "Журнал \"IВЕRОАМЕRIСА\" на испанском языке (перевод названия на государственный язык Российской Федерации: ИБЕРОАМЕРИКА)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "International Journal for Computational Civil and Structural Engineering",
|
||||
"vak_row": 57,
|
||||
"vak_name": "International Journal for Computation Civil and Structural Engineering (Международный журнал по расчету гражданских и строительных конструкций)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Journal of Economic Regulation",
|
||||
"vak_row": 67,
|
||||
"vak_name": "Journal of Economic Regulation (\"Вопросы регулирования экономики\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Journal of Institutional Studies",
|
||||
"vak_row": 69,
|
||||
"vak_name": "Journal of Institutional Studies (\"Журнал институциональных исследований\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Siberian Joumal of Life Sciences and Agriculture",
|
||||
"vak_row": 145,
|
||||
"vak_name": "Siberian Journal of Life Sciences and Agriculture (перевод наименования на государственный язык Российской Федерации: Сибирский журнал медико- биологических и сельскохозяйственных исследований)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Terra Economicus",
|
||||
"vak_row": 158,
|
||||
"vak_name": "Terra Economicus («ПРОСТРАНСТВО ЭКОНОМИКИ»)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Theory and Practice of Meat Processing",
|
||||
"vak_row": 2748,
|
||||
"vak_name": "Теория и практика переработки мяса",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Turczaninowia",
|
||||
"vak_row": 166,
|
||||
"vak_name": "Turczaninowia (Турчаниновия)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Альманах Института коррекционной педагогики",
|
||||
"vak_row": 239,
|
||||
"vak_name": "Альманах Института коррекционной педагогики / Almanac Institute of special education",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Анестезиология и реаниматология (Медиа Сфера)",
|
||||
"vak_row": 253,
|
||||
"vak_name": "Анестезиология и реаниматология",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бизнес-информатика",
|
||||
"vak_row": 325,
|
||||
"vak_name": "Бизнес-информатика Business Informatics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бюллетень НЦССХ им. А.Н. Бакулева РАМН. Сердечно-сосудистые заболевания",
|
||||
"vak_row": 353,
|
||||
"vak_name": "Бюллетень Научного центра сердечно-сосудистой хирургии им. А.Н. Бакулева РАМН \"Сердечно-сосудистые заболевания\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бюллетень Почвенного института им. В.В. Докучаева",
|
||||
"vak_row": 355,
|
||||
"vak_name": "Бюллетень Почвенного института имени В.В. Докучаева",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бюллетень сибирской медицины",
|
||||
"vak_row": 357,
|
||||
"vak_name": "Бюллетень сибирской медицины/Bulletin of Siberian Medicine",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ведомости Научного центра экспертизы средств медицинского применения. Регуляторные исследования и экспертиза лекарственных средств",
|
||||
"vak_row": 2421,
|
||||
"vak_name": "Регуляторные исследования и экспертиза лекарственных средств (до 30.10.2024 наименование в Перечене «Ведомости Научного центра экспертизы средств медицинского применения. Регуляторные исследования и экспертиза лекарственных средств» ISSN 1991-2919)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник государственного университета морского и речного флота им. адмирала С.О. Макарова",
|
||||
"vak_row": 468,
|
||||
"vak_name": "Вестник Государственного университета морского и речного флота имени адмирала С.О. Макарова",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Ленинградского государственного университета им. А.С. Пушкина",
|
||||
"vak_row": 548,
|
||||
"vak_name": "Вестник Ленинградского государственного университета имени А.С. Пушкина",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Магнитогорского государственного технического университета им. Г.И. Носова",
|
||||
"vak_row": 554,
|
||||
"vak_name": "Вестник Магнитогорского государственного технического университета им. Г.И. Носова, Vestnik of Nosov Magnitogorsk State Technical University",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник международных организаций: образование, наука, новая экономика",
|
||||
"vak_row": 570,
|
||||
"vak_name": "Вестник международных организаций: образование, наука, новая экономика International Organisations Research Journal",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Московского университета. Серия 23: Антропология",
|
||||
"vak_row": 612,
|
||||
"vak_name": "Вестник Московского университета. Серия ХХIII. Антропология",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Московского университета. Серия 25: Международные отношения и мировая политика",
|
||||
"vak_row": 611,
|
||||
"vak_name": "Вестник Московского университета. Серия XXV. Международные отношения и мировая политика",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник НПО им. С.А. Лавочкина",
|
||||
"vak_row": 642,
|
||||
"vak_name": "Вестник «НПО имени С.А. Лавочкина»",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Пермского университета. История",
|
||||
"vak_row": 672,
|
||||
"vak_name": "Вестник Пермского университета. Серия История",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Пермского университета. Политология",
|
||||
"vak_row": 670,
|
||||
"vak_name": "Вестник Пермского университета. Серия \"Политология\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Пермского университета. Серия: Экономика",
|
||||
"vak_row": 671,
|
||||
"vak_name": "Вестник Пермского университета. Серия \"Экономика\" = Perm University Herald. ECONOMY",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Православного Свято-Тихоновского гуманитарного университета. Серия 1: Богословие. Философия. Религиоведение",
|
||||
"vak_row": 691,
|
||||
"vak_name": "Вестник Православного Свято- Тихоновского гуманитарного университета. Серия I: Богословие. Философия. Религиоведение (До 11.05.2017 г. наименование в Перечне «Вестник Православного Свято- Тихоновского гуманитарного университета. Серия 1: Богословие. Философия» ISSN 1991-640X)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник РГГУ. Серия: Политология. История. Международные отношения",
|
||||
"vak_row": 702,
|
||||
"vak_name": "Вестник РГГУ. Серия \"Политология. История. Международные отношения\" ( до 26.12.2019 г наименование в Перечне «Вестник РГГУ. Серия \"Политология. История. Международные отношения. Зарубежное регионоведение. Востоковедение\"»)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Томского государственного педагогического университета",
|
||||
"vak_row": 836,
|
||||
"vak_name": "Вестник Томского государственного педагогического университета (Tomsk State Pedagogical University Bulletin)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ветеринария, зоотехния и биотехнология",
|
||||
"vak_row": 1926,
|
||||
"vak_name": "Научно-практический, теоретический журнал \"Ветеринария, зоотехния и биотехнология\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Власть",
|
||||
"vak_row": 2071,
|
||||
"vak_name": "Общенациональный научно- политический журнал \"Власть\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы государственного и муниципального управления",
|
||||
"vak_row": 960,
|
||||
"vak_name": "Вопросы государственного и муниципального управления. Public Administration Issues",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы литературы",
|
||||
"vak_row": 1208,
|
||||
"vak_name": "Журнал критики и литературоведения \"Вопросы литературы\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы образования",
|
||||
"vak_row": 983,
|
||||
"vak_name": "Вопросы образования Educational Studies",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы теории и практики журналистики",
|
||||
"vak_row": 1009,
|
||||
"vak_name": "Вопросы теории и практики журналистики (Theoretical and Practical Issues of Journalism)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы управления",
|
||||
"vak_row": 1011,
|
||||
"vak_name": "Вопросы управления / Management Issues",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вычислительная механика сплошных сред",
|
||||
"vak_row": 1039,
|
||||
"vak_name": "Вычислительная механика сплошных сред — Computational continuum mechanics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вычислительные методы и программирование",
|
||||
"vak_row": 1040,
|
||||
"vak_name": "Вычислительные методы и программирование: Новые вычислительные технологии (Электронный научный журнал)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Гений ортопедии",
|
||||
"vak_row": 1047,
|
||||
"vak_name": "Гений Ортопедии ORTHOPAEDIC QENIUS",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Геология, геофизика и разработка нефтяных и газовых месторождений",
|
||||
"vak_row": 1934,
|
||||
"vak_name": "Научно-технический журнал \"Геология, геофизика и разработка нефтяных и газовых месторождений\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Гидрометеорологические исследования и прогнозы",
|
||||
"vak_row": 1067,
|
||||
"vak_name": "Гидрометеорологические исследования и прогнозы Hydrometeorological research and forecasting ( до 03.04.2019 наименование в Перечне «Труды Гидрометеорологического научно-исследовательского центра Российской Федерации» ISSN 0371-7089)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Горный информационно-аналитический бюллетень (научно-технический журнал)",
|
||||
"vak_row": 1085,
|
||||
"vak_name": "Горный информационно- аналитический бюллетень (научно-технический журнал) Mining informational and analytical bulletin (scientific and technical journal)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Государственное управление. Электронный вестник",
|
||||
"vak_row": 1093,
|
||||
"vak_name": "Государственное управление. Электронный вестник (E-journal \"Public Administration\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Два века русской классики",
|
||||
"vak_row": 1129,
|
||||
"vak_name": "Два века русской классики/ Two centuries of the Russian classics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Диалог со временем",
|
||||
"vak_row": 1145,
|
||||
"vak_name": "Диалог со временем. Альманах интеллектуальной истории",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Животноводство и кормопроизводство",
|
||||
"vak_row": 1189,
|
||||
"vak_name": "Животноводство и кормопроизводство ( до 03.04.2019 наименование в перечне «Вестник мясного скотоводства (Herald of Beef Cattle Breeding)» ISSN 2079- 6250)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал зарубежного законодательства и сравнительного правоведения",
|
||||
"vak_row": 1200,
|
||||
"vak_name": "Журнал зарубежного законодательства и сравнительного правоведения / Journal of Foreign Legislation and Comparative Law",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал неврологии и психиатрии им. C.C. Корсакова",
|
||||
"vak_row": 1212,
|
||||
"vak_name": "Журнал неврологии и психиатрии им. С.С. Корсакова",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал Сибирского федерального университета. Биология",
|
||||
"vak_row": 1224,
|
||||
"vak_name": "Журнал Сибирского федерального университета. Биология. Journal of Siberian Federal University. Biology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал Сибирского федерального университета. Серия: Гуманитарные науки",
|
||||
"vak_row": 1225,
|
||||
"vak_name": "Журнал Сибирского федерального университета. Гуманитарные науки. Journal of Siberian Federal University. Humanities & Social Sciences",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Записки Института истории материальной культуры",
|
||||
"vak_row": 1247,
|
||||
"vak_name": "Записки Института истории материальной культуры Российской академии наук",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Здоровье населения и среда обитания - ЗНиСО",
|
||||
"vak_row": 1252,
|
||||
"vak_name": "Здоровье населения и среда обитания",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Известия Тульского государственного университета. Науки о Земле",
|
||||
"vak_row": 1379,
|
||||
"vak_name": "Известия Тульского государственного университета. Серия \"Науки о Земле\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Интеграция образования",
|
||||
"vak_row": 1438,
|
||||
"vak_name": "Интеграция образования Integration of Education",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "История философии",
|
||||
"vak_row": 1518,
|
||||
"vak_name": "История философии / History of Philosophy",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Кардиологический вестник",
|
||||
"vak_row": 1531,
|
||||
"vak_name": "Кардиологический вестник - бюллетень Российского кардиологического научно- производственного комплекса",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Медицинские технологии. Оценка и выбор",
|
||||
"vak_row": 1704,
|
||||
"vak_name": "Медицинские технологии. Оценка и выбор (MEDICAL TECHNOLOGY ASSESSMENT AND CHOICE)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Мелиорация и гидротехника",
|
||||
"vak_row": 1757,
|
||||
"vak_name": "Мелиорация и гидротехника/Land Reclamation and Hydraulic Engineering (До 28.09.2021 г. наименование в Перечне «Научный журнал Российского НИИ проблем мелиорации» ISSN 2222-1816)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "МИР (Модернизация. Инновации. Развитие)",
|
||||
"vak_row": 1776,
|
||||
"vak_name": "МИР (Модернизация. Инновации. Развитие). Научно- практический журнал",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Музыкальное искусство и образование",
|
||||
"vak_row": 1834,
|
||||
"vak_name": "Музыкальное искусство и образование / Musical Art and Education (до 04.02.2020 наименование в Перечне «Вестник кафедры ЮНЕСКО \"Музыкальное искусство и образование\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Научно-методический журнал \"Физическая культура, спорт - наука и практика\"",
|
||||
"vak_row": 2962,
|
||||
"vak_name": "Физическая культура, спорт – наука и практика",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Научные проблемы материально-технического обеспечения Вооружённых Сил Российской Федерации",
|
||||
"vak_row": 2531,
|
||||
"vak_name": "Сборник научных трудов «Научные проблемы материально-технического обеспечения Вооруженных Сил Российской Федерации»",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Национальный психологический журнал / National Psychological Journal",
|
||||
"vak_row": 1999,
|
||||
"vak_name": "Национальный психологический журнал/ National psychologica ljournal",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Нефтепромысловое дело. Oilfiеld Engineering",
|
||||
"vak_row": 2029,
|
||||
"vak_name": "Нефтепромысловое дело. Oilfield Engineering",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Общественное здоровье и здравоохранение",
|
||||
"vak_row": 1893,
|
||||
"vak_name": "Научно – практический журнал \"Общественное здоровье и здравоохранение\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "ОРГЗДРАВ: новости. мнения. обучения. Вестник ВШОУЗ",
|
||||
"vak_row": 2108,
|
||||
"vak_name": "ОРГЗДРАВ: новости, мнения, обучение. Вестник ВШОУЗ",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Отечественные архивы",
|
||||
"vak_row": 1919,
|
||||
"vak_name": "Научно-практический журнал \"Отечественные архивы\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "П-Economy",
|
||||
"vak_row": 104,
|
||||
"vak_name": "π-Economy (до 20.12.2022 наименование издания в Перечне «Научно- технические ведомости Санкт- Петербургского государственного политехнического университета. Экономические науки = St. Petersburg State Polytechnical University Journal. Economics» ISSN 1994-2354, 2304-9774)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Педиатрия. Журнал им. Г.Н. Сперанского",
|
||||
"vak_row": 2156,
|
||||
"vak_name": "Педиатрия. Журнал имени Г.Н. Сперанского",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Петербургский исторический журнал",
|
||||
"vak_row": 2164,
|
||||
"vak_name": "Петербургский исторический журнал. Исследования по российской и всеобщей истории",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Пищевые системы",
|
||||
"vak_row": 2175,
|
||||
"vak_name": "Пищевые системы – Food systems",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Полет. Общероссийский научно-технический журнал",
|
||||
"vak_row": 2072,
|
||||
"vak_name": "Общероссийский научно- технический журнал \"Полет\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Полития: Анализ. Хроника. Прогноз (Журнал политической философии и социологии политики)",
|
||||
"vak_row": 2203,
|
||||
"vak_name": "ПОЛИТИЯ. Журнал политической философии и социологии политики",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Правосудие",
|
||||
"vak_row": 2236,
|
||||
"vak_name": "Правосудие / Justice",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Прикладная информатика",
|
||||
"vak_row": 2261,
|
||||
"vak_name": "Прикладная информатика / Journal of Applied Informatics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Проблемы анализа риска",
|
||||
"vak_row": 1921,
|
||||
"vak_name": "Научно-практический журнал \"Проблемы анализа риска\" Scientific and Practical Journal \"Issues of Risk Analysis\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Проблемы управления",
|
||||
"vak_row": 2314,
|
||||
"vak_name": "Проблемы управления/Control Sciences",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Птица и птицепродукты",
|
||||
"vak_row": 2372,
|
||||
"vak_name": "Птица и птицепродукты / Poultry & Chicken Products",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Птицеводство",
|
||||
"vak_row": 1927,
|
||||
"vak_name": "Научно-производственный журнал \"Птицеводство\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Российский нейрохирургический журнал им. профессора А.Л. Поленова",
|
||||
"vak_row": 2473,
|
||||
"vak_name": "Российский нейрохирургический журнал имени профессора А.Л. Поленова",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Российский офтальмологический журнал",
|
||||
"vak_row": 2476,
|
||||
"vak_name": "Российский офтальмологический журнал (РОЖ)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Российский электронный журнал лучевой диагностики",
|
||||
"vak_row": 127,
|
||||
"vak_name": "Russian Electronic Journal of Radiology (Российский Электронный Журнал Лучевой диагностики)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Русистика",
|
||||
"vak_row": 2503,
|
||||
"vak_name": "Русистика/ Russian language studies (до 03.04.2019 наименование в перечне «Вестник Российского университета дружбы народов. Серия «Русский и иностранные языки и методика их преподавания» ISSN 2313-2264)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Свиноводство",
|
||||
"vak_row": 1928,
|
||||
"vak_name": "Научно-производственный журнал \"Свиноводство\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "СибСкрипт",
|
||||
"vak_row": 2577,
|
||||
"vak_name": "СибСкрипт = SibScript (до 25.04.2023 наименование издания в Перечне «Вестник Кемеровского государственного университета» ISSN 2078-8975)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Современная конкуренция",
|
||||
"vak_row": 2599,
|
||||
"vak_name": "Современная конкуренция / Journal of Modern Competition",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Социология власти",
|
||||
"vak_row": 1980,
|
||||
"vak_name": "Научный и общественно- политический журнал \"Социология власти\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Социология науки и технологий",
|
||||
"vak_row": 2667,
|
||||
"vak_name": "Социология науки и технологий Sociology of Science and Technology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Средние века",
|
||||
"vak_row": 2684,
|
||||
"vak_name": "Средние века. Исследования по истории Средневековья и раннего Нового времени",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Техника и технология пищевых производств",
|
||||
"vak_row": 2765,
|
||||
"vak_name": "Техника и технология пищевых производств (Food Processing: Techniques and Technology)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Томский журнал лингвистических и антропологических исследований",
|
||||
"vak_row": 2788,
|
||||
"vak_name": "Томский журнал лингвистических и антропологических исследований. Tomsk Journal of Linguistics and Anthropology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Транспорт Российской Федерации",
|
||||
"vak_row": 2802,
|
||||
"vak_name": "Транспорт Российской Федерации. Журнал о науке, практике, экономике",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Зоологического института РАН",
|
||||
"vak_row": 2829,
|
||||
"vak_name": "Труды зоологического института Российской академии наук",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Института государства и права Российской академии наук",
|
||||
"vak_row": 2830,
|
||||
"vak_name": "Труды Института государства и права РАН",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Института математики и механики УpО РАН",
|
||||
"vak_row": 2832,
|
||||
"vak_name": "Труды Института математики и механики УрО РАН",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Крыловского государственного научного центра",
|
||||
"vak_row": 2839,
|
||||
"vak_name": "Труды Крыловского государственного научного центра. Transactions Of The Krylov State Research Centre (До 29.05.2017 г. наименование в Перечне «Труды Центрального научно- исследовательского института имени академика А.Н. Крылова» ISSN 0869-8422)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Управление большими системами: сборник трудов",
|
||||
"vak_row": 2877,
|
||||
"vak_name": "Управление большими системами",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ученые записки университета им. П.Ф. Лесгафта",
|
||||
"vak_row": 2933,
|
||||
"vak_name": "Ученые записки университета имени П.Ф. Лесгафта",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Фармация и фармакология",
|
||||
"vak_row": 2948,
|
||||
"vak_name": "Фармация и фармакология / Pharmacy&Pharmacology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Философский журнал",
|
||||
"vak_row": 2990,
|
||||
"vak_name": "Философский журнал / Philosophy Journal",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Финансы: теория и практика",
|
||||
"vak_row": 3006,
|
||||
"vak_name": "Финансы: теория и практика / Finance: Theory and Practice (До 11.10.2017 г. наименование в Перечне «Вестник Финансового университета» ISSN 2221-1632)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Форсайт",
|
||||
"vak_row": 3012,
|
||||
"vak_name": "ФОРСАЙТ/Foresight and STI Governance",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Человек. Спорт. Медицина",
|
||||
"vak_row": 3062,
|
||||
"vak_name": "Человек. Спорт. Медицина / Human. Sport. Medicine (До 14.12.2016 г. наименование в Перечне Вестник Южно- Уральского государственного университета. Серия \"Образование, здравоохранение, физическая культура\" ISSN 1991-9786, 2410-7026)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Экология промышленного производства",
|
||||
"vak_row": 3080,
|
||||
"vak_name": "Экология промышленного производства – Межотраслевой научно-практический журнал",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Экономическая политика",
|
||||
"vak_row": 3122,
|
||||
"vak_name": "Экономическая политика (Economic Policy)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Экспериментальная и клиническая урология",
|
||||
"vak_row": 3143,
|
||||
"vak_name": "Экспериментальная и клиническая урология (Experimental and Сlinical Urology)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Эпистемология и философия науки",
|
||||
"vak_row": 40,
|
||||
"vak_name": "Epistemology & Philosophy of Sciences /Эпистемология и философия науки",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Японские исследования",
|
||||
"vak_row": 3169,
|
||||
"vak_name": "Электронный научный журнал «Японские исследования»",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Prepare a readable, indexed council JSON from the owner-supplied CSV.
|
||||
|
||||
python scripts/prepare_council_articles.py app/data/council_articles.csv
|
||||
No database access or app startup; only Python's standard library is required.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from app.services.council_source import load_reference, prepare_reference
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("--output", type=Path, default=Path("app/data/council_articles.json"))
|
||||
args = parser.parse_args()
|
||||
if args.source.resolve() == args.output.resolve():
|
||||
parser.error("Output must not overwrite the source CSV")
|
||||
result = prepare_reference(args.source)
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
|
||||
with NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n", dir=args.output.parent,
|
||||
prefix=args.output.name + ".", suffix=".tmp", delete=False) as stream:
|
||||
stream.write(text)
|
||||
temporary = Path(stream.name)
|
||||
try:
|
||||
load_reference(temporary)
|
||||
temporary.replace(args.output)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
print(f"Prepared {result['source_rows']} publications and {len(result['by_author'])} author names")
|
||||
@@ -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()
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Offline public council UX check: python tests/browser_council.py.
|
||||
|
||||
Requires Playwright/Chromium; BROWSER_EXECUTABLE may select an installed browser.
|
||||
Synthetic employees and intercepted requests; no production access.
|
||||
"""
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from openpyxl import load_workbook
|
||||
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.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import Employee, EmployeePublication
|
||||
from app.services import council
|
||||
|
||||
|
||||
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:
|
||||
for number in range(28):
|
||||
person = Employee(profile_key=f"test:{number}", full_name=f"Сотрудник {number:02d}",
|
||||
canonical_url="https://www.hse.ru/staff/test",
|
||||
current_data={"sections": [{"title": "Учёные степени",
|
||||
"items": ["Доктор технических наук"]}]})
|
||||
db.add(person)
|
||||
db.flush()
|
||||
db.add(EmployeePublication(employee_id=person.id, title="Исследование методов передачи данных в фотонных интегральных схемах",
|
||||
year=2024, publication_type="ARTICLE", publication_id=f"{number}",
|
||||
raw_data={"journal": "Бизнес-информатика"} if number == 27 else {},
|
||||
url=f"https://publications.hse.ru/view/{number}", source_hash=f"test:{number}"))
|
||||
if number == 27:
|
||||
db.add(EmployeePublication(employee_id=person.id, title="Дополнительная статья", year=2024,
|
||||
publication_type="ARTICLE", source_hash="test:extra"))
|
||||
for name, degree in [("Без степени", None), ("Кандидат наук", "Кандидат технических наук")]:
|
||||
person = Employee(profile_key=name, full_name=name, canonical_url="https://www.hse.ru/staff/test",
|
||||
current_data={"sections": [{"title": "Учёные степени", "items": [degree] if degree else []}]})
|
||||
db.add(person)
|
||||
db.flush()
|
||||
db.add(EmployeePublication(employee_id=person.id, title="Не включать в совет", year=2024, source_hash=name))
|
||||
db.commit()
|
||||
|
||||
def database():
|
||||
with factory() as db:
|
||||
yield db
|
||||
|
||||
original = council._articles_by_author
|
||||
council._articles_by_author = lambda: {"сотрудник 26": [
|
||||
{"title": f"Публикация из CSV {number}", "year": 2024, "doi": f"10.1234/test-{number}",
|
||||
"types": [], "journals": [], "quartiles": ["Scopus Q1"], "origins": ["Файл CSV"], "rows": [number + 2],
|
||||
"in_csv": True, "in_db": False, "url": None} for number in range(2)]}
|
||||
app.dependency_overrides[get_db] = database
|
||||
client = TestClient(app)
|
||||
output = Path(".impeccable/review")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def serve(route):
|
||||
url = urlsplit(route.request.url)
|
||||
response = client.get(url.path + ("?" + url.query if url.query else ""))
|
||||
route.fulfill(status=response.status_code, body=response.content,
|
||||
headers={key: value for key, value in response.headers.items()
|
||||
if key in {"content-type", "content-disposition"}})
|
||||
|
||||
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/council")
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–25 из 28")
|
||||
assert "Без степени" not in page.content() and "Кандидат наук" not in page.content()
|
||||
expect(page.get_by_role("link", name="Вход в админку")).to_be_visible()
|
||||
page.get_by_role("link", name="Далее").click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 26–28 из 28")
|
||||
page.get_by_role("link", name="Назад").click()
|
||||
summary = page.locator(".council__candidate-summary").first
|
||||
summary.focus()
|
||||
page.keyboard.press("Enter")
|
||||
expect(page.get_by_text("БД miem-workers · сайт ВШЭ").first).to_be_visible()
|
||||
expect(page.get_by_text("Q1/Q2 и К1 не подтверждены доступными данными").first).to_be_visible()
|
||||
assert page.locator(".council__candidate").first.evaluate("e => e.open")
|
||||
page.screenshot(path=str(output / "desktop.png"), full_page=True)
|
||||
page.screenshot(path=str(output / "sorting-desktop.png"), full_page=False)
|
||||
for width in (1024, 768, 700, 390, 320):
|
||||
page.set_viewport_size({"width": width, "height": 844})
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width
|
||||
assert summary.evaluate("e => e.scrollWidth <= e.clientWidth"), width
|
||||
page.set_viewport_size({"width": 390, "height": 844})
|
||||
page.screenshot(path=str(output / "mobile.png"), full_page=True)
|
||||
page.screenshot(path=str(output / "sorting-mobile.png"), full_page=False)
|
||||
for sort, direction, first in [("full_name", "asc", "Сотрудник 00"),
|
||||
("full_name", "desc", "Сотрудник 27"),
|
||||
("db_count", "desc", "Сотрудник 27"),
|
||||
("count", "desc", "Сотрудник 26"),
|
||||
("total_count", "desc", "Сотрудник 26")]:
|
||||
page.get_by_label("Сортировать по").select_option(sort)
|
||||
page.get_by_label("Направление").select_option(direction)
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.locator(".council__candidate-identity strong").first).to_have_text(first)
|
||||
page.get_by_label("Сортировать по").select_option("full_name")
|
||||
page.get_by_label("Направление").select_option("desc")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
page.get_by_role("link", name="Далее").click()
|
||||
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(page.get_by_label("Направление")).to_have_value("desc")
|
||||
expect(page.locator(".council__candidate-identity strong").first).to_have_text("Сотрудник 02")
|
||||
with page.expect_download() as pending_download:
|
||||
page.get_by_role("button", name="Скачать XLSX", exact=True).click()
|
||||
download = pending_download.value
|
||||
assert download.suggested_filename == "council.xlsx"
|
||||
download.save_as(str(output / "council.xlsx"))
|
||||
workbook = load_workbook(output / "council.xlsx")
|
||||
assert workbook["Сотрудники"].max_row == 29
|
||||
assert workbook["Сотрудники"]["B2"].value == "Сотрудник 27"
|
||||
page.get_by_label("Поиск по ФИО").fill("Сотрудник 27")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28")
|
||||
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(page.get_by_label("Направление")).to_have_value("desc")
|
||||
with page.expect_download() as pending_download:
|
||||
page.get_by_role("button", name="Скачать XLSX", exact=True).click()
|
||||
filtered = load_workbook(BytesIO(Path(pending_download.value.path()).read_bytes()))
|
||||
assert filtered["Сотрудники"].max_row == 2
|
||||
page.get_by_label("Поиск по ФИО").fill("Нет совпадений")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_text("Сотрудники не найдены", exact=True)).to_be_visible()
|
||||
page.get_by_role("link", name="Сбросить поиск").first.click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–25 из 28")
|
||||
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(page.get_by_label("Направление")).to_have_value("desc")
|
||||
page.get_by_label("Специальность ВАК").select_option("1.2.2")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28")
|
||||
expect(page.locator(".council__candidate-identity strong")).to_have_text("Сотрудник 27")
|
||||
page.locator(".council__candidate-summary").click()
|
||||
expect(page.get_by_text("Публикаций по выбранной специальности: 1", exact=True)).to_be_visible()
|
||||
expect(page.get_by_text("По специальности 1.2.2:", exact=False)).to_be_visible()
|
||||
for width in (1440, 768, 390, 320):
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width
|
||||
page.screenshot(path=str(output / f"specialty-{width}.png"), full_page=False)
|
||||
with page.expect_download() as pending_download:
|
||||
page.get_by_role("button", name="Скачать XLSX", exact=True).click()
|
||||
filtered = load_workbook(BytesIO(Path(pending_download.value.path()).read_bytes()))
|
||||
assert filtered["Сотрудники"].max_row == 2
|
||||
assert dict(filtered["Параметры"].values)["Специальность ВАК"].startswith("1.2.2.")
|
||||
headers = {cell.value: cell.column for cell in filtered["Сотрудники"][1]}
|
||||
assert filtered["Сотрудники"].cell(2, headers["Специальности ВАК"]).value.startswith("1.2.2.")
|
||||
assert filtered["Сотрудники"].cell(2, headers["Работ по выбранной специальности"]).value == 1
|
||||
pending_download.value.save_as(str(output / "specialty-export.xlsx"))
|
||||
assert not errors, errors
|
||||
browser.close()
|
||||
finally:
|
||||
council._articles_by_author = original
|
||||
app.dependency_overrides.clear()
|
||||
client.close()
|
||||
engine.dispose()
|
||||
print(f"Council browser checks passed; screenshots: {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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.",
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from pathlib import Path
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
|
||||
def test_base_navigation_is_russian_and_has_no_legacy_employees_link():
|
||||
template = Path("app/templates/base.html").read_text(encoding="utf-8")
|
||||
template = Environment(loader=FileSystemLoader("app/templates")).get_template("base.html").render(
|
||||
request=SimpleNamespace(url=SimpleNamespace(path="/admin"))
|
||||
)
|
||||
|
||||
assert "Обзор" in template
|
||||
assert "Сотрудники" in template
|
||||
@@ -17,11 +22,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 +34,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 +65,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 +98,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
|
||||
@@ -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.15.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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,581 @@
|
||||
import csv
|
||||
from datetime import date
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import Employee, EmployeePublication
|
||||
from app.services import council
|
||||
from app.services.council_export import _append
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def export(monkeypatch, tmp_path):
|
||||
path = tmp_path / "articles.csv"
|
||||
prepared = tmp_path / "articles.json"
|
||||
def write(rows):
|
||||
fields = ["Название публикации", "Год", "DOI", "EID (Scopus)", "UT (WoS)", "Название журнала",
|
||||
"Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"]
|
||||
with path.open("w", encoding="utf-8-sig", newline="") as output:
|
||||
writer = csv.DictWriter(output, fieldnames=fields, delimiter=";")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
from app.services.council_source import prepare_reference
|
||||
import json
|
||||
prepared.write_text(json.dumps(prepare_reference(path), ensure_ascii=False), encoding="utf-8")
|
||||
council._articles_by_author.cache_clear()
|
||||
monkeypatch.setattr(council, "EXPORT", prepared)
|
||||
write([])
|
||||
yield write
|
||||
council._articles_by_author.cache_clear()
|
||||
|
||||
|
||||
def employee(db, name="Иванов Иван Иванович", degree="Доктор технических наук", status="active", key="doctor"):
|
||||
person = Employee(profile_key=key, canonical_url="https://www.hse.ru/staff/test", full_name=name,
|
||||
status=status, current_data={"contacts": {"emails": ["private@example.org"]},
|
||||
"sections": [{"title": "Учёные степени", "items": [degree] if degree else []}]})
|
||||
db.add(person)
|
||||
db.flush()
|
||||
return person
|
||||
|
||||
|
||||
def publication(db, person, title="Shared work", doi=None, year=2024, kind=None, **extra):
|
||||
item = EmployeePublication(employee_id=person.id, title=title, year=year, doi_url=doi,
|
||||
publication_type=kind, source_hash=f"hash-{len(db.new)}-{title}-{doi}", **extra)
|
||||
db.add(item)
|
||||
return item
|
||||
|
||||
|
||||
def row(title="Shared work", doi="10.1234/shared", year="2024", author="Иванов Иван Иванович", **extra):
|
||||
return {"Название публикации": title, "Год": year, "DOI": doi, "Авторы НИУ ВШЭ": author,
|
||||
"Квартиль scopus": "Q1", **extra}
|
||||
|
||||
|
||||
def test_council_union_counts_provenance_and_types(db_session, export):
|
||||
export([row(), row(**{"Квартиль scopus": "", "Квартиль wos": "Q2"}),
|
||||
row(title="CSV only", doi="", year="2022"),
|
||||
row(title="Not eligible", doi="10.1234/old", year="2019")])
|
||||
person = employee(db_session, degree="Доктор технических наук")
|
||||
publication(db_session, person, doi="https://dx.doi.org/10.1234/SHARED", kind="ARTICLE",
|
||||
publication_id="100", url="https://publications.hse.ru/view/100")
|
||||
publication(db_session, person, title=" shared WORK ", kind="ARTICLE")
|
||||
publication(db_session, person, title="Book only", kind="BOOK", year=2020)
|
||||
publication(db_session, person, title="Imported", raw_data={"csv_enrichment": {"Квартиль wos": "Q2"}})
|
||||
db_session.commit()
|
||||
result = council.council_candidates(db_session, date(2026, 9, 30))
|
||||
assert result["total"] == 1
|
||||
candidate = result["candidates"][0]
|
||||
assert (candidate["total_count"], candidate["db_count"], candidate["count"]) == (3, 3, 5)
|
||||
shared = next(a for a in candidate["articles"] if a["doi"] == "10.1234/shared")
|
||||
assert shared["type_label"] == "Статья"
|
||||
assert shared["quartiles"] == ["Scopus Q1", "Web of Science Q2"]
|
||||
assert shared["rows"] == [2, 3]
|
||||
assert "Файл CSV" in shared["origins"] and "БД miem-workers · сайт ВШЭ" in shared["origins"]
|
||||
imported = next(a for a in candidate["articles"] if a["title"] == "Imported")
|
||||
assert imported["origins"] == ["БД miem-workers · импорт CSV"]
|
||||
assert imported["qualifies"] and imported["q2"]
|
||||
assert "Тип не указан" in imported["type_label"]
|
||||
assert council.period(date(2024, 2, 29))[0] == date(2019, 2, 28)
|
||||
|
||||
|
||||
def test_only_doctors_with_a_publication_no_count_threshold(db_session, export):
|
||||
export([row(author="Доктор", title="Single qualifying"),
|
||||
row(author="Без степени", title="No degree"),
|
||||
row(author="Кандидат", title="Candidate work"),
|
||||
row(author="PhD", title="PhD work"),
|
||||
row(author="Две степени", title="Mixed degrees", **{"Квартиль scopus": "Q3"}),
|
||||
row(author="Уволен", title="Old", year="2010")])
|
||||
employee(db_session, "Доктор", key="csv-only")
|
||||
for name, degree in [("Без степени", None), ("Кандидат", "Кандидат технических наук"), ("PhD", "Ph.D.")]:
|
||||
person = employee(db_session, name, degree, key=name)
|
||||
publication(db_session, person)
|
||||
employee(db_session, "Две степени", "Кандидат технических наук; Доктор физико-математических наук", key="mixed")
|
||||
employee(db_session, "Уволен", status="dismissed", key="dismissed")
|
||||
only_db = employee(db_session, "Только БД", key="database")
|
||||
publication(db_session, only_db, kind="PATENT")
|
||||
employee(db_session, "Без публикаций", key="empty")
|
||||
db_session.commit()
|
||||
items = council.council_candidates(db_session, date(2026, 9, 30))["candidates"]
|
||||
assert {c["name"] for c in items} == {"Доктор", "Две степени", "Уволен", "Только БД"}
|
||||
assert next(c for c in items if c["name"] == "Доктор")["total_count"] == 1
|
||||
assert next(c for c in items if c["name"] == "Только БД")["db_count"] == 1
|
||||
assert next(c for c in items if c["name"] == "Две степени")["total_count"] == 0
|
||||
assert next(c for c in items if c["name"] == "Уволен")["dismissed"]
|
||||
|
||||
|
||||
def test_no_false_merge_when_title_has_conflicting_dois(db_session, export):
|
||||
export([row(doi="10.1234/one"), row(doi="10.1234/two"), row(doi="")])
|
||||
person = employee(db_session)
|
||||
publication(db_session, person, title="Renamed work", doi="https://doi.org/10.1234/one")
|
||||
db_session.commit()
|
||||
item = council.council_candidates(db_session, date(2026, 9, 30))["candidates"][0]
|
||||
assert item["count"] == 3
|
||||
assert item["total_count"] == 3 and item["db_count"] == 1
|
||||
|
||||
|
||||
def test_csv_same_name_does_not_attribute_to_wrong_employee(db_session, export):
|
||||
export([row()])
|
||||
for key in ["one", "two"]:
|
||||
person = employee(db_session, key=key)
|
||||
publication(db_session, person, title=key)
|
||||
db_session.commit()
|
||||
items = council.council_candidates(db_session, date(2026, 9, 30))["candidates"]
|
||||
assert len(items) == 2
|
||||
assert all(c["ambiguous"] and c["total_count"] == 0 and c["count"] == 1 for c in items)
|
||||
|
||||
|
||||
def test_missing_database_year_merges_only_unambiguous_title_and_non_article_excluded(db_session, export):
|
||||
export([row(title="Known book"), row(title="Article", doi="10.1234/article")])
|
||||
person = employee(db_session)
|
||||
publication(db_session, person, title="Known book", year=None, kind="BOOK")
|
||||
publication(db_session, person, title="Article", year=None, kind="ARTICLE")
|
||||
db_session.commit()
|
||||
candidate = council.council_candidates(db_session, date(2026, 9, 30))["candidates"][0]
|
||||
assert (candidate["count"], candidate["total_count"], candidate["db_count"]) == (2, 1, 2)
|
||||
book = next(a for a in candidate["articles"] if a["type_label"] == "Книга")
|
||||
assert not book["qualifies"]
|
||||
assert "не включён в счётчики Q1/Q2/К1" in book["criteria"][-1]
|
||||
|
||||
|
||||
def test_search_and_pagination_include_all_workers(db_session, export):
|
||||
for number in range(28):
|
||||
person = employee(db_session, f"Петров {number:02d}", key=f"person-{number}")
|
||||
publication(db_session, person, title=f"Work {number}")
|
||||
db_session.commit()
|
||||
first = council.council_candidates(db_session)
|
||||
last = council.council_candidates(db_session, offset=25)
|
||||
assert first["total"] == 28 and len(first["candidates"]) == 25
|
||||
assert len(last["candidates"]) == 3
|
||||
assert {c["name"] for c in first["candidates"]}.isdisjoint(c["name"] for c in last["candidates"])
|
||||
assert council.council_candidates(db_session, q="петров 27")["matched"] == 1
|
||||
assert council.council_candidates(db_session, offset=-1)["offset"] == 0
|
||||
assert council.council_candidates(db_session, offset=999)["offset"] == 25
|
||||
descending = council.council_candidates(db_session, sort="full_name", direction="desc")
|
||||
last_descending = council.council_candidates(db_session, sort="full_name", direction="desc", offset=25)
|
||||
assert descending["candidates"][0]["name"] == "Петров 27"
|
||||
assert [c["name"] for c in last_descending["candidates"]] == ["Петров 02", "Петров 01", "Петров 00"]
|
||||
|
||||
|
||||
def test_sort_by_name_and_each_unique_publication_count(db_session, export):
|
||||
export([row(author="Анна", title=f"CSV {number}", doi=f"10.1234/csv-{number}") for number in range(3)]
|
||||
+ [row(author="Борис", title="CSV Boris", doi="10.1234/boris")])
|
||||
anna = employee(db_session, "Анна", key="anna")
|
||||
publication(db_session, anna, title="CSV 0", doi="https://doi.org/10.1234/csv-0")
|
||||
for name, count in [("Борис", 2), ("Вера", 4)]:
|
||||
person = employee(db_session, name, key=name)
|
||||
for number in range(count):
|
||||
publication(db_session, person, title=f"DB {name} {number}")
|
||||
db_session.commit()
|
||||
expected = {
|
||||
"full_name": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Борис", "Анна"]},
|
||||
"total_count": {"asc": ["Вера", "Борис", "Анна"], "desc": ["Анна", "Борис", "Вера"]},
|
||||
"db_count": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Борис", "Анна"]},
|
||||
"count": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Анна", "Борис"]},
|
||||
}
|
||||
for sort, directions in expected.items():
|
||||
for direction, names in directions.items():
|
||||
result = council.council_candidates(db_session, sort=sort, direction=direction)
|
||||
assert [c["name"] for c in result["candidates"]] == names
|
||||
invalid = council.council_candidates(db_session, sort="unknown", direction="unknown")
|
||||
assert (invalid["sort"], invalid["direction"]) == ("total_count", "desc")
|
||||
assert [c["name"] for c in invalid["candidates"]] == expected["total_count"]["desc"]
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
response = TestClient(app).get("/admin/council?sort=full_name&direction=desc")
|
||||
assert response.status_code == 200
|
||||
assert response.text.index("Вера") < response.text.index("Борис") < response.text.index("Анна")
|
||||
assert '<option value="full_name" selected>' in response.text
|
||||
assert '<option value="desc" selected>' in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_public_page_data_minimization_and_safe_links(db_session, export):
|
||||
export([row(title='<script>alert("csv")</script>', doi="javascript:bad")])
|
||||
person = employee(db_session)
|
||||
person.canonical_url = "javascript:bad"
|
||||
publication(db_session, person, url="javascript:alert(1)", doi="javascript:bad", kind="BOOK")
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/council", follow_redirects=False)
|
||||
assert response.status_code == 200
|
||||
assert "Иванов Иван Иванович" in response.text and "Доктор технических наук" in response.text
|
||||
assert "Минимальный порог количества работ не применяется" in response.text
|
||||
assert "CSV по критериям" not in response.text and "ВСЕГО" in response.text and "Все публикации" in response.text
|
||||
assert "Книга" in response.text and "Тип не указан" in response.text
|
||||
assert "<script>" in response.text and "<script>" not in response.text
|
||||
assert "javascript:" not in response.text and "private@example.org" not in response.text
|
||||
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
|
||||
assert "Backend 0.15.4 · Frontend 0.15.4" in response.text
|
||||
assert "Вход в админку" in response.text
|
||||
assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403)
|
||||
assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_public_error_state_missing_export_and_database_failure(db_session, export, monkeypatch):
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
council.EXPORT.unlink()
|
||||
response = TestClient(app).get("/admin/council")
|
||||
assert response.status_code == 503 and "Повторить загрузку" in response.text
|
||||
def fail(*args, **kwargs):
|
||||
raise OperationalError("query", {}, Exception("private error"))
|
||||
monkeypatch.setattr("app.admin.council_candidates", fail)
|
||||
response = TestClient(app).get("/admin/council")
|
||||
assert response.status_code == 503 and "private error" not in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db_session, export):
|
||||
export([row(author="Петров 27"), row(author="Петров 27", **{"Квартиль wos": "Q2"})])
|
||||
for number in range(28):
|
||||
person = employee(db_session, f"Петров {number:02d}", key=f"xlsx-{number}")
|
||||
publication(db_session, person, title="Shared work" if number == 27 else f"Work {number}",
|
||||
doi="https://doi.org/10.1234/shared" if number == 27 else None,
|
||||
kind="ARTICLE", publication_id=str(number), url=f"https://publications.hse.ru/view/{number}")
|
||||
excluded = employee(db_session, "Без степени", degree=None, key="excluded")
|
||||
publication(db_session, excluded)
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/council/export.xlsx?q=Петров&sort=full_name&direction=desc&offset=25")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
assert response.headers["content-disposition"] == 'attachment; filename="council.xlsx"'
|
||||
book = load_workbook(BytesIO(response.content))
|
||||
assert book.sheetnames == ["Сотрудники", "Публикации", "Параметры"]
|
||||
people, works = book["Сотрудники"], book["Публикации"]
|
||||
assert [cell.value for cell in people[1]] == [
|
||||
"№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "Страница ВШЭ", "Все публикации",
|
||||
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности"
|
||||
]
|
||||
assert people["J2"].value == "https://www.hse.ru/staff/test"
|
||||
assert people["K2"].value is None
|
||||
assert people.max_column == 13
|
||||
assert works["L1"].value == "Страница ВШЭ"
|
||||
assert people.max_row == 29 and works.max_row == 29
|
||||
assert people["B2"].value == "Петров 27" and people["B29"].value == "Петров 00"
|
||||
assert [people.cell(2, n).value for n in [4, 5, 6, 7, 8, 9]] == [1, 1, 0, 1, 1, 1]
|
||||
assert works["C2"].value == "Shared work" and works["E2"].value == "Статья"
|
||||
assert "Файл CSV" in works["F2"].value and "сайт ВШЭ" in works["F2"].value
|
||||
assert "Scopus Q1" in works["G2"].value and "Web of Science Q2" in works["G2"].value
|
||||
assert works["M2"].value == "https://doi.org/10.1234/shared" and works["O2"].value == "2, 3"
|
||||
assert people["D2"].data_type == "n" and people.freeze_panes == "A2"
|
||||
assert works.auto_filter.ref == works.dimensions
|
||||
text = " ".join(str(cell.value) for sheet in book for row in sheet for cell in row)
|
||||
assert "private@example.org" not in text and "Без степени" not in text
|
||||
assert load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=Петров%2027").content))["Сотрудники"].max_row == 2
|
||||
empty = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=missing").content))
|
||||
assert empty["Сотрудники"].max_row == 1 and empty["Публикации"].max_row == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_xlsx_treats_profile_text_as_text_and_rejects_silent_truncation(db_session, export):
|
||||
person = employee(db_session, '=HYPERLINK("https://example.org","name")')
|
||||
publication(db_session, person, title="=1+1\x01")
|
||||
publication(db_session, person, title="#N/A")
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/council/export.xlsx")
|
||||
assert response.status_code == 200
|
||||
book = load_workbook(BytesIO(response.content))
|
||||
assert book["Сотрудники"]["B2"].value == person.full_name
|
||||
assert book["Сотрудники"]["B2"].data_type == "s"
|
||||
titles = {book["Публикации"].cell(n, 3).value: book["Публикации"].cell(n, 3).data_type for n in [2, 3]}
|
||||
assert titles == {"=1+1": "s", "#N/A": "s"}
|
||||
with ZipFile(BytesIO(response.content)) as archive:
|
||||
assert all(b"<f>" not in archive.read(n) for n in archive.namelist() if n.startswith("xl/worksheets/"))
|
||||
person.full_name = "Long " + "x" * 32768
|
||||
db_session.commit()
|
||||
failure = client.get("/admin/council/export.xlsx")
|
||||
assert failure.status_code == 503 and "Не удалось создать XLSX" in failure.text
|
||||
assert "content-disposition" not in failure.headers
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_xlsx_appending_rows_does_not_scan_growing_sheet(monkeypatch):
|
||||
def fail_on_scan(sheet):
|
||||
pytest.fail("Appending rows must not recompute worksheet bounds by scanning existing cells")
|
||||
monkeypatch.setattr(Worksheet, "max_row", property(fail_on_scan))
|
||||
monkeypatch.setattr(Worksheet, "max_column", property(fail_on_scan))
|
||||
sheet = Workbook().active
|
||||
for number in range(1000):
|
||||
_append(sheet, [number, "text"])
|
||||
assert sheet.cell(1000, 1).value == 999
|
||||
assert sheet.cell(1000, 2).value == "text"
|
||||
|
||||
|
||||
def test_category_counts_union_k1_journal_matching_and_period(db_session, export):
|
||||
export([
|
||||
row(title="All categories", doi="10.1234/all", **{"Квартиль wos": "Q2", "Название журнала": "Бизнес-информатика"}),
|
||||
row(title="All categories", doi="10.1234/all"),
|
||||
row(title="Q2 only", doi="10.1234/q2", **{"Квартиль scopus": "", "Квартиль wos": "Q2"}),
|
||||
row(title="Old K1", doi="10.1234/old", year="2020", **{"Квартиль scopus": "", "Название журнала": "Бизнес-информатика"}),
|
||||
row(title="Unknown year", doi="10.1234/unknown", year="", **{"Название журнала": "Бизнес-информатика"}),
|
||||
row(title="Future", doi="10.1234/future", year="2027"),
|
||||
row(title="Known book", doi="10.1234/book", **{"Название журнала": "Бизнес-информатика"}),
|
||||
])
|
||||
person = employee(db_session)
|
||||
publication(db_session, person, title="All categories", doi="https://doi.org/10.1234/all", kind="ARTICLE")
|
||||
publication(db_session, person, title="Known book", doi="https://doi.org/10.1234/book", kind="BOOK")
|
||||
publication(db_session, person, title="K1 only", kind="ARTICLE",
|
||||
description={"short": {"ru": "<b>Бизнес-информатика</b>. 2024. № 2."}})
|
||||
publication(db_session, person, title="Similar journal", kind="ARTICLE",
|
||||
description={"short": {"ru": "Бизнес-информатика и технологии. 2024."}})
|
||||
publication(db_session, person, title="Mention in title is not journal Бизнес-информатика", kind="ARTICLE")
|
||||
db_session.commit()
|
||||
candidate = council.council_candidates(db_session, date(2026, 10, 8))["candidates"][0]
|
||||
assert [candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "total")] == [1, 2, 2, 3]
|
||||
assert candidate["count"] == 9
|
||||
works = {article["title"]: article for article in candidate["articles"]}
|
||||
assert works["All categories"]["q1"] and works["All categories"]["q2"] and works["All categories"]["k1"]
|
||||
assert works["K1 only"]["k1"] and "К1 · Бизнес-информатика (справочник ВАК)" in works["K1 only"]["criteria"]
|
||||
for title in ("Old K1", "Unknown year", "Future", "Known book", "Similar journal"):
|
||||
assert not works[title]["qualifies"]
|
||||
|
||||
|
||||
def test_k1_reference_integrity():
|
||||
import json
|
||||
data = json.loads(council.K1_JOURNALS.read_text(encoding="utf-8"))
|
||||
assert data["source_rows"] == 3134
|
||||
assert sum(data["category_counts"].values()) == 3134
|
||||
assert data["category_counts"]["К1"] == len(data["journals"]) == 773
|
||||
assert len(council._k1_journals()) == 774 # 773 journals and one documented previous name.
|
||||
assert "вестник российских университетов математика" not in council._k1_journals()
|
||||
assert "бизнес информатика" in council._k1_journals()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("journal", [
|
||||
"Ученые записки Казанской академии ветеринарной медицины им. Н.Э. Баумана",
|
||||
"Ученые записки Казанской государственной академии ветеринарной медицины им. Н.Э. Баумана",
|
||||
])
|
||||
@pytest.mark.parametrize("source", ["csv", "journal", "description"])
|
||||
def test_renamed_k1_journal_matches_current_and_documented_previous_name(db_session, export, journal, source):
|
||||
person = employee(db_session)
|
||||
if source == "csv":
|
||||
export([row(**{"Квартиль scopus": "", "Название журнала": journal})])
|
||||
else:
|
||||
fields = {"raw_data": {"journal": journal}} if source == "journal" else {
|
||||
"description": {"short": {"ru": f"{journal}. 2024. № 1."}}
|
||||
}
|
||||
publication(db_session, person, kind="ARTICLE", **fields)
|
||||
db_session.commit()
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8))
|
||||
candidate = result["candidates"][0]
|
||||
assert [candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "total")] == [0, 0, 1, 1]
|
||||
canonical = "Ученые записки Казанской академии ветеринарной медицины им. Н.Э. Баумана"
|
||||
assert f"К1 · {canonical} (справочник ВАК)" in candidate["articles"][0]["criteria"]
|
||||
from app.services.council_export import council_xlsx
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
assert [book["Сотрудники"].cell(2, column).value for column in (6, 7)] == [1, 1]
|
||||
|
||||
|
||||
def test_k1_rename_note_is_preserved_separately():
|
||||
import json
|
||||
data = json.loads(council.K1_JOURNALS.read_text(encoding="utf-8"))
|
||||
journal = next(item for item in data["journals"] if item["source_row"] == 699)
|
||||
assert "наименование в Перечне" not in journal["name"]
|
||||
assert journal["note"].startswith("до 10.04.2026 г. наименование в Перечне")
|
||||
assert len(journal["aliases"]) == 1
|
||||
assert council._k1_journals()[council._journal_key(journal["aliases"][0])] == journal["name"]
|
||||
bilingual = next(item for item in data["journals"] if item["source_row"] == 2)
|
||||
assert bilingual["name"] == "Acta Biomedica Scientifica (East Siberian Biomedical Journal)"
|
||||
|
||||
|
||||
def test_ambiguous_k1_alias_is_rejected(monkeypatch, tmp_path):
|
||||
import json
|
||||
reference = tmp_path / "k1.json"
|
||||
reference.write_text(json.dumps({"journals": [
|
||||
{"name": "One", "aliases": ["Previous"]}, {"name": "Two", "aliases": ["Previous"]}
|
||||
]}), encoding="utf-8")
|
||||
monkeypatch.setattr(council, "K1_JOURNALS", reference)
|
||||
council._k1_journals.cache_clear()
|
||||
try:
|
||||
with pytest.raises(ValueError, match="Ambiguous K1 journal alias"):
|
||||
council._k1_journals()
|
||||
finally:
|
||||
council._k1_journals.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reverse", [False, True])
|
||||
def test_conflicting_source_years_keep_categories_with_their_year(db_session, export, reverse):
|
||||
rows = [row(year="2020", **{"Название журнала": "Бизнес-информатика"}),
|
||||
row(year="2024", **{"Квартиль scopus": "Q2"})]
|
||||
export(list(reversed(rows)) if reverse else rows)
|
||||
person = employee(db_session)
|
||||
# Evidence with no year must not inherit an arbitrary conflicting year.
|
||||
publication(db_session, person, doi="https://doi.org/10.1234/shared", year=None,
|
||||
raw_data={"csv_enrichment": {"Квартиль scopus": "Q1", "Журнал": "Бизнес-информатика"}})
|
||||
db_session.commit()
|
||||
candidate = council.council_candidates(db_session, date(2026, 10, 8))["candidates"][0]
|
||||
assert [candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "total")] == [0, 1, 0, 1]
|
||||
assert candidate["count"] == 1
|
||||
article = candidate["articles"][0]
|
||||
assert article["year"] is None
|
||||
assert any("Годы источников расходятся: 2020, 2024" in criterion for criterion in article["criteria"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sort", ["q1_count", "q2_count", "k1_count", "total_count"])
|
||||
@pytest.mark.parametrize("direction", ["asc", "desc"])
|
||||
def test_sort_category_counters_and_xlsx(db_session, export, sort, direction):
|
||||
export([row(author="Анна", **{"Квартиль wos": "Q2", "Название журнала": "Бизнес-информатика"})])
|
||||
employee(db_session, "Анна", key="anna")
|
||||
person = employee(db_session, "Борис", key="boris")
|
||||
publication(db_session, person, title="Without criteria")
|
||||
db_session.commit()
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8), sort=sort, direction=direction)
|
||||
expected = ["Борис", "Анна"] if direction == "asc" else ["Анна", "Борис"]
|
||||
assert [candidate["name"] for candidate in result["candidates"]] == expected
|
||||
from app.services.council_export import council_xlsx
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
headers = [cell.value for cell in book["Сотрудники"][1]]
|
||||
assert headers[3:7] == ["Q1", "Q2", "К1", "ВСЕГО"]
|
||||
assert "CSV по критериям" not in headers
|
||||
anna_row = 3 if direction == "asc" else 2
|
||||
assert [book["Сотрудники"].cell(anna_row, column).value for column in range(4, 8)] == [1, 1, 1, 1]
|
||||
|
||||
|
||||
def test_specialty_filters_use_eligible_source_journal_not_merged_evidence(db_session, export):
|
||||
export([
|
||||
row(title="Matching", doi="10.1234/matching", **{"Название журнала": "Бизнес-информатика"}),
|
||||
row(title="Matching", doi="10.1234/matching"),
|
||||
row(title="Old journal, new Q1", doi="10.1234/conflict", year="2020", **{"Название журнала": "Бизнес-информатика"}),
|
||||
row(title="Old journal, new Q1", doi="10.1234/conflict", year="2024"),
|
||||
row(title="Book", doi="10.1234/book", **{"Название журнала": "Бизнес-информатика"}),
|
||||
row(title="Unknown year", doi="10.1234/unknown", year="", **{"Название журнала": "Бизнес-информатика"}),
|
||||
])
|
||||
person = employee(db_session)
|
||||
publication(db_session, person, title="Book", doi="10.1234/book", kind="BOOK")
|
||||
publication(db_session, person, title="Unknown year", doi="10.1234/unknown", year=None)
|
||||
other = employee(db_session, name="Другой доктор", key="other")
|
||||
publication(db_session, other, title="Q1 alone", raw_data={"csv_enrichment": {"Квартиль scopus": "Q1"}})
|
||||
db_session.commit()
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8), specialty="1.2.2")
|
||||
assert (result["total"], result["matched"]) == (2, 1)
|
||||
candidate = result["candidates"][0]
|
||||
assert candidate["specialty_count"] == 1
|
||||
assert [a["title"] for a in candidate["articles"] if a["matches_specialty"]] == ["Matching"]
|
||||
assert "По специальности 1.2.2" in " ".join(candidate["articles"][0]["criteria"])
|
||||
assert council.council_candidates(db_session, specialty="2.2.14")["matched"] == 0
|
||||
invalid = council.council_candidates(db_session, specialty="<script>")
|
||||
assert invalid["specialty"] == "" and invalid["matched"] == 2
|
||||
|
||||
|
||||
def test_specialty_page_pagination_search_and_export_agree(db_session, export):
|
||||
for number in range(28):
|
||||
person = employee(db_session, name=f"Доктор {number:02d}", key=str(number))
|
||||
publication(db_session, person, title=f"Work {number}", kind="ARTICLE",
|
||||
raw_data={"journal": "Бизнес-информатика"})
|
||||
person = employee(db_session, name="Не подходит", key="unrelated")
|
||||
publication(db_session, person, raw_data={"journal": "Неизвестный журнал"})
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/council?specialty=1.2.2&sort=full_name&direction=asc")
|
||||
assert response.status_code == 200
|
||||
assert '<option value="1.2.2" selected>' in response.text
|
||||
assert response.text.count('<option value="2.3.5"') == 1
|
||||
assert "Публикаций по выбранной специальности: 1" in response.text
|
||||
assert "Не подходит" not in response.text
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
next_page = soup.find("a", string="Далее")["href"]
|
||||
assert "specialty=1.2.2" in next_page
|
||||
assert "Доктор 27" in client.get("/admin/council" + next_page).text
|
||||
filtered = client.get("/admin/council?specialty=1.2.2&q=27")
|
||||
assert "Показаны 1–1 из 1" in filtered.text
|
||||
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?specialty=1.2.2&sort=full_name&direction=asc").content))
|
||||
assert book["Сотрудники"].max_row == 29
|
||||
params = dict(book["Параметры"].values)
|
||||
assert params["Специальность ВАК"].startswith("1.2.2.")
|
||||
assert "По специальности 1.2.2" in book["Публикации"]["G2"].value
|
||||
empty = client.get("/admin/council?specialty=2.2.14")
|
||||
assert "Сотрудники не найдены" in empty.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_specialty_reference_integrity():
|
||||
import hashlib
|
||||
import json
|
||||
from scripts.extract_k1_specialties import active_codes
|
||||
data = json.loads(council.K1_SPECIALTIES.read_text(encoding="utf-8"))
|
||||
k1 = json.loads(council.K1_JOURNALS.read_text(encoding="utf-8"))
|
||||
assert data["k1_source_sha256"] == hashlib.sha256(council.K1_JOURNALS.read_bytes().replace(b"\r\n", b"\n")).hexdigest()
|
||||
assert [j["name"] for j in data["journals"]] == [j["name"] for j in k1["journals"]]
|
||||
assert len(data["journals"]) == 773 and len(data["specialties"]) == 12
|
||||
assert data["source_rows"] == 3231
|
||||
for journal in data["journals"]:
|
||||
assert journal["vak_entries"]
|
||||
if journal["status"] == "matched":
|
||||
active = [entry for entry in journal["vak_entries"] if active_codes(entry, data["as_of"])]
|
||||
assert len(active) == 1
|
||||
assert journal["specialty_codes"] == active_codes(active[0], data["as_of"])
|
||||
else:
|
||||
assert journal["status"] == "ambiguous" and not journal["specialty_codes"]
|
||||
for specialty in data["specialties"]:
|
||||
assert any(specialty["code"] in j["specialty_codes"] for j in data["journals"])
|
||||
|
||||
|
||||
def test_xlsx_specialties_are_explicit_with_and_without_filter(db_session, export):
|
||||
export([row(title="Old journal", doi="10.1234/conflict", year="2020", **{"Название журнала": "Бизнес-информатика"}),
|
||||
row(title="Old journal", doi="10.1234/conflict", year="2024")])
|
||||
person = employee(db_session)
|
||||
for title in ['Matching', 'Second matching']:
|
||||
publication(db_session, person, title=title, kind='ARTICLE', raw_data={'journal': 'Бизнес-информатика'})
|
||||
publication(db_session, person, title='Book', kind='BOOK', raw_data={'journal': 'Бизнес-информатика'})
|
||||
publication(db_session, person, title='Unknown journal', kind='ARTICLE', raw_data={'journal': 'Other'})
|
||||
other = employee(db_session, name='Без соответствия', key='other')
|
||||
publication(db_session, other, title='Unknown', kind='ARTICLE')
|
||||
db_session.commit()
|
||||
from app.services.council_export import council_xlsx
|
||||
for specialty in ('', '1.2.2'):
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8), specialty=specialty, paginate=False)
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
staff = list(book['Сотрудники'].values)
|
||||
works = list(book['Публикации'].values)
|
||||
candidate = next(dict(zip(staff[0], values)) for values in staff[1:] if values[1] == person.full_name)
|
||||
assert candidate['Специальности ВАК'].count('1.2.2.') == 1
|
||||
assert 'Математическое моделирование' in candidate['Специальности ВАК']
|
||||
assert '2.3.5.' in candidate['Специальности ВАК']
|
||||
assert '5.2.2.' not in candidate['Специальности ВАК'] # Only the site's twelve specialties.
|
||||
assert candidate['Работ по выбранной специальности'] == (2 if specialty else None)
|
||||
records = {values[2]: dict(zip(works[0], values)) for values in works[1:]}
|
||||
assert '1.2.2.' in records['Matching']['Специальности ВАК']
|
||||
assert records['Matching']['По выбранной специальности'] == ('Да' if specialty else None)
|
||||
for title in ('Book', 'Unknown journal', 'Old journal'):
|
||||
assert records[title]['Специальности ВАК'] == 'Не подтверждены'
|
||||
assert records[title]['По выбранной специальности'] == ('Нет' if specialty else None)
|
||||
if not specialty:
|
||||
unmatched = next(dict(zip(staff[0], values)) for values in staff[1:] if values[1] == other.full_name)
|
||||
assert unmatched['Специальности ВАК'] == 'Не подтверждены'
|
||||
assert book['Сотрудники'].auto_filter.ref.endswith(f'M{len(staff)}')
|
||||
assert book['Публикации'].auto_filter.ref.endswith(f'Q{len(works)}')
|
||||
|
||||
|
||||
def test_non_doctor_publications_are_not_parsed(db_session, export, monkeypatch):
|
||||
person = employee(db_session, degree='Кандидат технических наук')
|
||||
publication(db_session, person, kind='ARTICLE', description={'short': {'ru': '<b>Journal</b>. 2024.'}})
|
||||
db_session.commit()
|
||||
def unexpected(publication):
|
||||
pytest.fail('Ineligible employee publications must not be parsed')
|
||||
monkeypatch.setattr(council, '_database_article', unexpected)
|
||||
assert council.council_candidates(db_session)['total'] == 0
|
||||
@@ -0,0 +1,124 @@
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import council
|
||||
from app.services.council_source import load_reference, prepare_reference
|
||||
|
||||
|
||||
def write_csv(path, rows):
|
||||
fields = ['Название публикации', 'Год', 'DOI', 'Авторы НИУ ВШЭ', 'Квартиль scopus', 'Квартиль wos', 'Название журнала', 'EID (Scopus)']
|
||||
with path.open('w', encoding='utf-8-sig', newline='') as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=fields, delimiter=';')
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def test_prepared_reference_preserves_quoted_fields_metadata_and_matching(tmp_path):
|
||||
source = tmp_path / 'source.csv'
|
||||
write_csv(source, [
|
||||
{'Название публикации': 'Quoted; title\ncontinuation', 'Год': '2024', 'DOI': 'https://doi.org/10.1234/TEST',
|
||||
'Авторы НИУ ВШЭ': ' Ёлкин Пётр; ЕЛКИН ПЕТР; Другой автор ', 'Квартиль scopus': ' q1 ',
|
||||
'Квартиль wos': 'Q2', 'Название журнала': 'Бизнес-информатика', 'EID (Scopus)': 'source-id'},
|
||||
{'Название публикации': '', 'Авторы НИУ ВШЭ': 'Другой автор'},
|
||||
{'Название публикации': 'Unknown', 'Год': '', 'DOI': 'invalid', 'Авторы НИУ ВШЭ': ''},
|
||||
])
|
||||
before = source.read_bytes()
|
||||
data = prepare_reference(source)
|
||||
assert source.read_bytes() == before
|
||||
assert data['source_rows'] == 3
|
||||
assert data['source_sha256'] == hashlib.sha256(before.replace(b'\r\n', b'\n')).hexdigest()
|
||||
assert data['by_author'] == {'другой автор': [0], 'елкин петр': [0]}
|
||||
record = data['publications'][0]
|
||||
assert record['title'] == 'Quoted; title\ncontinuation'
|
||||
assert record['doi'] == '10.1234/test' and record['year'] == 2024
|
||||
assert record['quartiles'] == ['Scopus Q1', 'Web of Science Q2']
|
||||
assert record['journals'] == ['Бизнес-информатика']
|
||||
assert record['metadata']['EID (Scopus)'] == 'source-id'
|
||||
output = tmp_path / 'data.json'
|
||||
output.write_text(json.dumps(data, ensure_ascii=False), encoding='utf-8')
|
||||
articles = load_reference(output)
|
||||
assert articles['елкин петр'][0]['rows'] == [2]
|
||||
assert articles['другой автор'][0]['title'] == record['title']
|
||||
assert len(articles['елкин петр']) == 1
|
||||
assert articles['елкин петр'][0]['origins'] == ['Файл CSV']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change', ['author', 'index', 'bool_index', 'year', 'schema', 'source_row', 'list'])
|
||||
def test_corrupt_prepared_reference_is_rejected(tmp_path, change):
|
||||
source = tmp_path / 'source.csv'
|
||||
write_csv(source, [{'Название публикации': 'First', 'Авторы НИУ ВШЭ': 'Author'},
|
||||
{'Название публикации': 'Second', 'Авторы НИУ ВШЭ': 'Other'}])
|
||||
data = prepare_reference(source)
|
||||
if change == 'author': data['by_author'] = {'stranger': [0], 'other': [1]}
|
||||
if change == 'index': data['by_author']['author'] = [3]
|
||||
if change == 'bool_index': data['by_author']['other'] = [True]
|
||||
if change == 'year': data['publications'][0]['year'] = True
|
||||
if change == 'schema': data['schema_version'] = True
|
||||
if change == 'source_row': data['publications'][0]['source_row'] = 77
|
||||
if change == 'list': data['publications'][0]['journals'] = 'Journal'
|
||||
path = tmp_path / 'data.json'
|
||||
path.write_text(json.dumps(data), encoding='utf8')
|
||||
with pytest.raises(ValueError): load_reference(path)
|
||||
|
||||
|
||||
def test_committed_reference_is_reproducible_and_loaded_once(monkeypatch):
|
||||
source = council.EXPORT.with_suffix('.csv')
|
||||
data = json.loads(council.EXPORT.read_text(encoding='utf-8'))
|
||||
assert data == prepare_reference(source)
|
||||
assert data['source_rows'] == 1797 and len(data['by_author']) == 825
|
||||
calls = []
|
||||
original = council.load_reference
|
||||
def counted(path):
|
||||
calls.append(path)
|
||||
return original(path)
|
||||
monkeypatch.setattr(council, 'load_reference', counted)
|
||||
council._articles_by_author.cache_clear()
|
||||
try:
|
||||
first = council._articles_by_author()
|
||||
assert council._articles_by_author() is first
|
||||
assert calls == [council.EXPORT]
|
||||
finally:
|
||||
council._articles_by_author.cache_clear()
|
||||
|
||||
|
||||
def test_converter_does_not_replace_output_when_input_is_invalid(tmp_path):
|
||||
import subprocess
|
||||
import sys
|
||||
source = tmp_path / 'invalid.csv'
|
||||
source.write_text('Wrong header\nvalue\n', encoding='utf8')
|
||||
output = tmp_path / 'prepared.json'
|
||||
output.write_text('previous reference', encoding='utf8')
|
||||
result = subprocess.run([sys.executable, 'scripts/prepare_council_articles.py', str(source), '--output', str(output)],
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode != 0
|
||||
assert output.read_text(encoding='utf8') == 'previous reference'
|
||||
assert not list(tmp_path.glob('*.tmp'))
|
||||
same = subprocess.run([sys.executable, 'scripts/prepare_council_articles.py', str(source), '--output', str(source)],
|
||||
capture_output=True, text=True)
|
||||
assert same.returncode != 0 and source.read_text(encoding='utf8') == 'Wrong header\nvalue\n'
|
||||
|
||||
|
||||
def test_preparation_is_identical_for_lf_and_crlf_sources(tmp_path):
|
||||
lf_dir = tmp_path / 'lf'
|
||||
crlf_dir = tmp_path / 'crlf'
|
||||
lf_dir.mkdir()
|
||||
crlf_dir.mkdir()
|
||||
lf = lf_dir / 'source.csv'
|
||||
crlf = crlf_dir / 'source.csv'
|
||||
write_csv(crlf, [{'Название публикации': 'Multiline\npublication', 'Год': '2024',
|
||||
'Авторы НИУ ВШЭ': 'Author; Coauthor', 'Квартиль scopus': 'Q1'}])
|
||||
canonical = crlf.read_bytes().replace(b'\r\n', b'\n')
|
||||
lf.write_bytes(canonical)
|
||||
crlf.write_bytes(canonical.replace(b'\n', b'\r\n'))
|
||||
before = crlf.read_bytes()
|
||||
from_lf = prepare_reference(lf)
|
||||
from_crlf = prepare_reference(crlf)
|
||||
assert from_lf == from_crlf
|
||||
assert from_crlf['source_sha256'] == hashlib.sha256(canonical).hexdigest()
|
||||
assert from_crlf['source_hash_eol'] == 'LF'
|
||||
assert from_crlf['publications'][0]['title'] == 'Multiline\npublication'
|
||||
assert crlf.read_bytes() == before
|
||||
+117
-2
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
WORKFLOW = (Path(__file__).parents[1] / ".gitea/workflows/ci.yml").read_text()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("keygen_exit", [0, 1])
|
||||
def test_fingerprint_output_and_cleanup(tmp_path, keygen_exit):
|
||||
step = WORKFLOW.split(" - name: Deployment key fingerprint\n", 1)[1]
|
||||
step = step.split(" - name:", 1)[0]
|
||||
script = textwrap.dedent(step.split(" run: |\n", 1)[1])
|
||||
keygen = tmp_path / "ssh-keygen"
|
||||
keygen.write_text(
|
||||
"#!/bin/sh\n"
|
||||
'case "$1" in\n'
|
||||
'-y) printf "PUBLIC_MATERIAL\\n"; exit "$KEYGEN_EXIT" ;;\n'
|
||||
'-lf) cat >/dev/null; printf "256 SHA256:test comment (ED25519)\\n" ;;\n'
|
||||
'*) exit 99 ;;\n'
|
||||
"esac\n"
|
||||
)
|
||||
keygen.chmod(0o700)
|
||||
(tmp_path / "deploy_key").write_text("PRIVATE_MATERIAL")
|
||||
(tmp_path / "known_hosts").write_text("HOST_MATERIAL")
|
||||
result = subprocess.run(
|
||||
["bash", "-x", "-c", script], capture_output=True, text=True,
|
||||
env={**os.environ, "RUNNER_TEMP": str(tmp_path),
|
||||
"PATH": f"{tmp_path}:{os.environ['PATH']}",
|
||||
"KEYGEN_EXIT": str(keygen_exit)},
|
||||
)
|
||||
assert (result.returncode == 0) == (keygen_exit == 0)
|
||||
if keygen_exit == 0:
|
||||
assert result.stdout == "SHA256:test\n"
|
||||
assert "MATERIAL" not in result.stdout + result.stderr
|
||||
assert "comment" not in result.stdout + result.stderr
|
||||
assert not (tmp_path / "deploy_key").exists()
|
||||
assert not (tmp_path / "known_hosts").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("event", ["push", "pull_request", "workflow_dispatch"])
|
||||
@pytest.mark.parametrize("diagnostic_only", ["true", "false", ""])
|
||||
def test_launch_conditions(event, diagnostic_only):
|
||||
job = WORKFLOW.split("\n deploy:\n", 1)[1]
|
||||
condition = re.search(r"^ if: (.+)$", job, re.MULTILINE).group(1)
|
||||
terms = re.findall(r"(github\.[a-z_.]+) == '([^']*)'", condition)
|
||||
assert condition == " && ".join(f"{name} == '{value}'" for name, value in terms)
|
||||
context = {"github.event_name": event,
|
||||
"github.event.inputs.diagnostic_only": diagnostic_only}
|
||||
runs = all(context[name] == value for name, value in terms)
|
||||
assert runs == (event == "workflow_dispatch" and diagnostic_only == "true")
|
||||
|
||||
|
||||
def test_both_vps_steps_are_unconditionally_disabled():
|
||||
for name in ("Upload release", "Deploy and verify"):
|
||||
step = WORKFLOW.split(f" - name: {name}\n", 1)[1]
|
||||
assert step.startswith(" if: ${{ false }}\n")
|
||||
|
||||
|
||||
def test_push_and_pull_request_keep_tests():
|
||||
triggers = WORKFLOW.split("\njobs:\n", 1)[0]
|
||||
for event in ("push", "pull_request"):
|
||||
assert f" {event}:\n branches: [main]\n" in triggers
|
||||
test_job = WORKFLOW.split("\n test:\n", 1)[1].split("\n deploy:\n", 1)[0]
|
||||
assert " if:" not in test_job
|
||||
assert "python -m pytest -q" in test_job
|
||||
@@ -0,0 +1,69 @@
|
||||
from scripts.extract_k1_specialties import active_codes, build_reference, parse_rows
|
||||
import pytest
|
||||
|
||||
|
||||
def test_vak_continuation_and_historical_dates():
|
||||
rows = [
|
||||
{"page": 1, "cells": ["1.", "Journal", "1234-5678", "1.2.2. First", "с 01.02.2022\nпо 01.02.2023"]},
|
||||
{"page": 2, "cells": ["", "continued", "", "2.3.5. Second", ""]},
|
||||
{"page": 2, "cells": ["", "", "", "2.3.6. Current", "с 02.02.2023"]},
|
||||
{"page": 3, "cells": ["", "", "", "2.3.1. Future", "с 01.02.2027"]},
|
||||
]
|
||||
journal = parse_rows(rows)[0]
|
||||
assert journal["name"] == "Journal continued" and journal["pages"] == [1, 2, 3]
|
||||
assert journal["assignments"][0]["codes"] == ["1.2.2", "2.3.5"]
|
||||
assert active_codes(journal, "2026-09-15") == ["2.3.6"]
|
||||
assert active_codes(journal, "2023-02-01") == ["1.2.2", "2.3.5"]
|
||||
with pytest.raises(ValueError, match="numbering"):
|
||||
parse_rows([{"page": 1, "cells": ["2.", "Journal", "1234-5678", "1.2.2. Test", "с 01.02.2022"]}])
|
||||
|
||||
|
||||
def test_ambiguous_titles_do_not_union_specialties_and_reviewed_matches_are_checked():
|
||||
rows = [{"page": n, "cells": [f"{n}.", "Same title", str(n), code + ". Test", "с 01.02.2022"]}
|
||||
for n, code in [(1, "1.2.2"), (2, "2.3.1")]]
|
||||
journals = parse_rows(rows)
|
||||
k1 = {"journals": [{"name": "Same title"}, {"name": "Similar title"}]}
|
||||
result = build_reference(journals, k1, [], "2026-09-15")
|
||||
assert result[0]["status"] == "ambiguous" and result[0]["specialty_codes"] == []
|
||||
assert result[1]["status"] == "not_found" and result[1]["specialty_codes"] == []
|
||||
with pytest.raises(ValueError, match="title changed"):
|
||||
build_reference(journals, k1, [{"k1_name": "Similar title", "vak_row": 1, "vak_name": "Wrong title"}], "2026-09-15")
|
||||
|
||||
|
||||
def test_malformed_source_start_date_is_preserved_without_inventing_date():
|
||||
journal = parse_rows([{"page": 1, "cells": ["1.", "Journal", "1234-5678", "1.3.8. Test", "с 01.02.200"]}])[0]
|
||||
assignment = journal["assignments"][0]
|
||||
assert assignment["source_dates"] == "с 01.02.200"
|
||||
assert assignment["valid_from"] is None and assignment["date_note"]
|
||||
assert active_codes(journal, "2026-09-15") == ["1.3.8"]
|
||||
|
||||
|
||||
def test_k1_extraction_is_reproducible_for_lf_and_crlf(tmp_path):
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
source = tmp_path / 'list.pdf'
|
||||
source.write_bytes(b'PDF source bytes for cached-row extraction')
|
||||
rows = tmp_path / 'rows.json'
|
||||
rows.write_text(json.dumps([{'page': 1, 'cells': ['1.', 'Journal', '1234-5678', '1.2.2. Test', 'с 01.02.2022']}]), encoding='utf8')
|
||||
matches = tmp_path / 'matches.json'
|
||||
matches.write_text('[]', encoding='utf8')
|
||||
canonical = (json.dumps({'journals': [{'name': 'Journal'}]}, indent=2) + '\n').encode('utf8')
|
||||
outputs = []
|
||||
for name, content in [('lf', canonical), ('crlf', canonical.replace(b'\n', b'\r\n'))]:
|
||||
directory = tmp_path / name
|
||||
directory.mkdir()
|
||||
k1 = directory / 'k1.json'
|
||||
k1.write_bytes(content)
|
||||
output = directory / 'specialties.json'
|
||||
subprocess.run([sys.executable, 'scripts/extract_k1_specialties.py', str(source), '--k1', str(k1),
|
||||
'--matches', str(matches), '--rows-cache', str(rows), '--output', str(output)], check=True, capture_output=True)
|
||||
data = json.loads(output.read_text(encoding='utf8'))
|
||||
assert data['k1_source_sha256'] == hashlib.sha256(canonical).hexdigest()
|
||||
assert data['source_sha256'] == hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
assert data['journals'][0]['specialty_codes'] == ['1.2.2']
|
||||
assert k1.read_bytes() == content
|
||||
assert b'\r\n' not in output.read_bytes()
|
||||
outputs.append(output.read_bytes())
|
||||
assert outputs[0] == outputs[1]
|
||||
@@ -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}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user