forked from admin/miem_workers
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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,104 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: sqlite:///./ci.db
|
||||
PYTHONUNBUFFERED: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
- run: python -m pip install --upgrade pip
|
||||
- run: python -m pip install -r requirements.txt
|
||||
- run: python -m pytest -q
|
||||
- run: node --check app/static/admin.js
|
||||
- run: sh -n infra/postgres-backup.sh
|
||||
- run: sh -n infra/install-postgres-backup-cron.sh
|
||||
|
||||
deploy:
|
||||
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: production-deploy
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
PROD_PATH: /srv/miem_workers
|
||||
RELEASE_NAME: miem-workers-release-${{ github.run_id }}.tar
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Prepare release
|
||||
id: prepare
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
backend_version="$(sed -n 's/^BACKEND_VERSION = "\(.*\)"$/\1/p' app/version.py)"
|
||||
case "$backend_version" in
|
||||
''|*[!0-9.]*) exit 1 ;;
|
||||
esac
|
||||
echo "backend_version=$backend_version" >> "$GITHUB_OUTPUT"
|
||||
tar --exclude=.git --exclude=.env -cf "$RUNNER_TEMP/$RELEASE_NAME" .
|
||||
printf '%s\n' "$PROD_SSH_KEY" > "$RUNNER_TEMP/deploy_key"
|
||||
printf '%s\n' "$PROD_KNOWN_HOSTS" > "$RUNNER_TEMP/known_hosts"
|
||||
chmod 600 "$RUNNER_TEMP/deploy_key" "$RUNNER_TEMP/known_hosts"
|
||||
env:
|
||||
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
|
||||
PROD_KNOWN_HOSTS: ${{ secrets.PROD_KNOWN_HOSTS }}
|
||||
- name: Upload release
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scp -i "$RUNNER_TEMP/deploy_key" \
|
||||
-o BatchMode=yes \
|
||||
-o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" \
|
||||
"$RUNNER_TEMP/$RELEASE_NAME" \
|
||||
"$PROD_USER@$PROD_HOST:$RELEASE_NAME"
|
||||
env:
|
||||
PROD_HOST: ${{ secrets.PROD_HOST }}
|
||||
PROD_USER: ${{ secrets.PROD_USER }}
|
||||
- name: Deploy and verify
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh -i "$RUNNER_TEMP/deploy_key" \
|
||||
-o BatchMode=yes \
|
||||
-o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" \
|
||||
"$PROD_USER@$PROD_HOST" \
|
||||
"PROD_PATH='$PROD_PATH' RELEASE_NAME='$RELEASE_NAME' EXPECTED_BACKEND_VERSION='$EXPECTED_BACKEND_VERSION' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
cd "$PROD_PATH"
|
||||
release="$HOME/$RELEASE_NAME"
|
||||
test -f "$release"
|
||||
archive_version="$(tar -xOf "$release" ./app/version.py | sed -n 's/^BACKEND_VERSION = "\(.*\)"$/\1/p')"
|
||||
test "$archive_version" = "$EXPECTED_BACKEND_VERSION"
|
||||
docker run --rm \
|
||||
-v "$release:/src/release.tar:ro" \
|
||||
-v "$PROD_PATH":/dst \
|
||||
alpine:3.20 sh -c 'tar -xf /src/release.tar -C /dst'
|
||||
sh /srv/miem_workers/infra/install-postgres-backup-cron.sh
|
||||
docker compose up -d --build
|
||||
runtime_version="$(docker compose exec -T api python -c 'from app.version import BACKEND_VERSION; print(BACKEND_VERSION)')"
|
||||
test "$runtime_version" = "$EXPECTED_BACKEND_VERSION"
|
||||
health="$(curl --fail --silent --show-error http://127.0.0.1:8000/api/health)"
|
||||
case "$health" in
|
||||
*"\"status\":\"ok\""*"\"backend_version\":\"$EXPECTED_BACKEND_VERSION\""*) ;;
|
||||
*) echo "Unexpected health response: $health" >&2; exit 1 ;;
|
||||
esac
|
||||
docker compose ps
|
||||
rm -f "$release"
|
||||
REMOTE
|
||||
env:
|
||||
PROD_HOST: ${{ secrets.PROD_HOST }}
|
||||
PROD_USER: ${{ secrets.PROD_USER }}
|
||||
EXPECTED_BACKEND_VERSION: ${{ steps.prepare.outputs.backend_version }}
|
||||
@@ -1,5 +1,67 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.15
|
||||
|
||||
- Исправлен production deploy: cron installer запускается через `sh` и не требует executable-бита.
|
||||
|
||||
## 0.8.14
|
||||
|
||||
- Production CD больше не создаёт backup при каждом deploy; еженедельная задача
|
||||
сохраняет один полный PostgreSQL backup с атомарной заменой; ошибки и
|
||||
пропущенные из-за lock запуски пишутся в system log.
|
||||
|
||||
## 0.8.12
|
||||
|
||||
- Передача версии в production deploy ограничена безопасным форматом.
|
||||
|
||||
## 0.8.11
|
||||
|
||||
- CSS-ассеты админки получают версию в URL и обновляются после релиза без ручной очистки кэша браузера.
|
||||
|
||||
## 0.8.10
|
||||
|
||||
- Production deploy проверяет совпадение версии release-архива и запущенного API.
|
||||
|
||||
## 0.8.9
|
||||
|
||||
- Контейнеры API, worker и PostgreSQL автоматически перезапускаются после остановки или перезагрузки хоста.
|
||||
|
||||
## 0.8.8
|
||||
|
||||
- Исправлено вертикальное выравнивание кнопки и ссылки в действиях фильтров справочника.
|
||||
|
||||
## 0.8.7
|
||||
|
||||
- Production CD оставляет только последний PostgreSQL backup после успешного деплоя.
|
||||
|
||||
## 0.8.6
|
||||
|
||||
- Исправлена загрузка release-архива production CD в домашний каталог deploy-пользователя.
|
||||
|
||||
## 0.8.5
|
||||
|
||||
- Исправлена адаптивная компоновка фильтров справочника и скрытие skip-link до клавиатурного фокуса.
|
||||
|
||||
## 0.8.4
|
||||
|
||||
- Подключены к конфигурации ограничения массового автоматического увольнения, используемые плановым обходом.
|
||||
|
||||
## 0.8.3
|
||||
|
||||
- Удалены устаревшие пояснение о недоступном endpoint и проверка его отсутствия.
|
||||
|
||||
## 0.8.2
|
||||
|
||||
- Добавлен production CD workflow с backup PostgreSQL и проверкой healthcheck после обновления.
|
||||
|
||||
## 0.8.1
|
||||
|
||||
- Улучшены доступность, фильтры, состояния и адаптивность административного каталога.
|
||||
|
||||
## 0.7.8
|
||||
|
||||
- В колонке учёной степени отображаются все найденные степени без лишнего текста.
|
||||
|
||||
## 0.7.7
|
||||
|
||||
- Удалена неиспользуемая интеграция обмена данными и связанная документация и тесты.
|
||||
|
||||
@@ -43,9 +43,15 @@ uvicorn app.main:app --reload
|
||||
|
||||
В админке доступны:
|
||||
|
||||
- `Dashboard`: общая статистика, последний добавленный сотрудник, прогресс текущего/последнего парсинга и ручной запуск.
|
||||
- `Directory`: настраиваемая таблица сотрудников с фильтрами, сортировкой, пагинацией и выбором колонок.
|
||||
- `Runs`: история запусков, ошибки и progress bar.
|
||||
- «Обзор»: статистика, последний добавленный сотрудник, прогресс парсинга и ручной запуск.
|
||||
- «Сотрудники»: поиск, фильтры, сортировка, пагинация и выбор колонок. По умолчанию показаны ФИО, статус, должности, дата последнего обнаружения и внешний профиль. В диалоге колонок доступны наборы «Проверка», «Контакты» и «Все поля»; ранее сохранённый выбор сохраняется.
|
||||
- «Запуски»: история обходов, ошибки и доступный индикатор прогресса.
|
||||
|
||||
Все фильтры применяются кнопкой «Применить фильтры» с переходом на первую страницу. «Сбросить» очищает условия; при устаревшем номере страницы каталог возвращает первую. Подписи полей показывают выбранные условия, над таблицей указан диапазон результатов. Пояснения дат и статусов находятся под фильтрами.
|
||||
|
||||
Каталог поддерживает навигацию с клавиатуры и системный диалог колонок с Escape и возвратом фокуса. На узких экранах таблица прокручивается внутри страницы, колонка ФИО закреплена. Используется системный шрифт без внешних загрузок.
|
||||
|
||||
Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
@@ -56,7 +62,9 @@ docker compose up -d --build --remove-orphans
|
||||
По умолчанию:
|
||||
|
||||
- API и админка: `http://localhost:8000`
|
||||
- Postgres: `localhost:5432`
|
||||
- PostgreSQL: `postgres:5432` внутри сети Compose; порт на хост не опубликован.
|
||||
|
||||
Compose запускает `api`, `worker` и `postgres`. API привязан к localhost; для внешнего доступа нужен настроенный reverse proxy. REST API данных требует сессию администратора.
|
||||
|
||||
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например `crawl_runs.skipped_count`. SQL-миграции для ручного применения лежат в `migrations/`.
|
||||
|
||||
@@ -69,16 +77,20 @@ docker compose up -d --build --remove-orphans
|
||||
- краткий список остается внутри `employees.current_data.sections[].publications` для обратной совместимости;
|
||||
- детальные записи сохраняются в `employee_publications` и связываются с сотрудником через `employee_id`.
|
||||
|
||||
`employee_publications` содержит `publication_id`, название, год, тип публикации, язык, статус, ссылку на карточку HSE Publications, DOI, внешние/document-ссылки, citation text, аннотацию, описание, авторов, raw JSON ответа `searchPubs` и `source_hash` для безопасного повторного upsert. Уникальность поддерживается по `(employee_id, publication_id)` и `(employee_id, source_hash)`, поэтому повторный crawl не должен создавать дубликаты.
|
||||
|
||||
`list_employee_publications` сначала читает `employee_publications`; если детальных строк еще нет, возвращает старые публикации из `current_data`.
|
||||
|
||||
Новости сотрудников также хранятся в двух видах:
|
||||
`employee_publications` содержит `publication_id`, название, год, тип публикации, язык, статус, ссылку на карточку HSE Publications, DOI, внешние/document-ссылки, citation text, аннотацию, описание, авторов, raw JSON ответа `searchPubs` и `source_hash` для безопасного повторного upsert. Уникальность поддерживается по `(employee_id, publication_id)` и `(employee_id, source_hash)`, поэтому повторный crawl не должен создавать дубликаты.
|
||||
|
||||
Новости сотрудников также хранятся в двух видах:
|
||||
|
||||
- краткий список остается внутри `employees.current_data.sections[].news_links`;
|
||||
- нормализованные карточки из вкладки «В новостях» сохраняются в `employee_news_links`.
|
||||
|
||||
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
|
||||
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
|
||||
|
||||
## Кандидаты в диссертационный совет
|
||||
|
||||
Ссылка в админке открывает `/admin/council`. Страница использует действующих сотрудников и докторские степени из их профилей, а публикации — из предоставленной выгрузки `app/data/council_articles.csv`. Публикации сопоставляются только по полному ФИО при единственном совпадении; повторный DOI (или одинаковые название и год при отсутствии DOI) учитывается один раз. Порог — 10 предварительно подходящих публикаций за скользящие пять лет от текущей даты по Москве.
|
||||
|
||||
Список **предварительный**: в CSV указаны год и квартиль Scopus/WoS, но нет точной даты, типа материала, журнала, источника квартиля, подтверждения К1 и RSCI. На границах периода год не позволяет проверить точную дату. Статьи с Q1/Q2 учитываются с пометкой источника; отсутствие кандидата не доказывает недостаточную публикационную активность. Для подтверждённого отбора нужны проверка журналов и дат и дополнительные источники публикаций. Выгрузка поставляется вместе с релизом и обновляется отдельным изменением репозитория.
|
||||
|
||||
## Парсинг
|
||||
|
||||
@@ -113,7 +125,28 @@ curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=.
|
||||
docker compose logs -f api
|
||||
docker compose logs -f worker
|
||||
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Версия сервиса: `0.7.7`. Админка всегда показывает версии backend и frontend в footer.
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Production deploy выполняется job `deploy` workflow `CI` после успешного `test` на push в `main` или вручную. Для него нужны Actions secrets `PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY` и `PROD_KNOWN_HOSTS`; `.env` и ключи в репозиторий не добавляются. PostgreSQL backup выполняется на VPS раз в неделю user cron deploy-пользователя через `infra/postgres-backup.sh`: `pg_dumpall` сохраняет весь кластер в `~/backups/postgres/postgres-latest.sql`. Новый dump сначала создаётся во временный файл и заменяет старый только после успешного завершения; предыдущие dump-файлы `miem_workers` удаляются после успешного создания нового. Одновременно выполняется только один production deploy.
|
||||
|
||||
## Проверки
|
||||
|
||||
После установки зависимостей из `requirements.txt` выполните:
|
||||
|
||||
```bash
|
||||
python -m pytest -q
|
||||
node --check app/static/admin.js
|
||||
```
|
||||
|
||||
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
|
||||
|
||||
Версия сервиса: `0.10.4`. Админка всегда показывает версии backend и frontend в footer.
|
||||
|
||||
## Разовый импорт публикаций
|
||||
|
||||
Инструкция, минимальные права временной роли и ограниченный SSH-запуск: [docs/publication-import.md](docs/publication-import.md). По умолчанию импортер выполняет dry-run; production-применение требует отдельного решения владельца.
|
||||
|
||||
Интеграционная проверка импорта на отдельном PostgreSQL: [docs/postgresql-import-verification.md](docs/postgresql-import-verification.md).
|
||||
|
||||
Отдельный тестовый стенд на VPS и read-only проверка ресурсов: [docs/postgresql-import-vps.md](docs/postgresql-import-vps.md). Стенд запускает только владелец после оценки ресурсов.
|
||||
+38
-21
@@ -2,6 +2,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
@@ -16,8 +17,9 @@ from app.services.admin_data import (
|
||||
run_payload,
|
||||
stats_payload,
|
||||
)
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.services.crawler import refresh_dismissed_status, refresh_employee
|
||||
from app.services.council import council_candidates
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
@@ -72,8 +74,8 @@ def employees(
|
||||
return RedirectResponse("/admin/directory", status_code=303)
|
||||
|
||||
|
||||
@router.get("/directory", response_class=HTMLResponse)
|
||||
def directory(
|
||||
@router.get("/directory", response_class=HTMLResponse)
|
||||
def directory(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
@@ -93,19 +95,23 @@ def directory(
|
||||
parsed_started_to = _parse_date(started_to)
|
||||
parsed_has_email = None if has_email in (None, "") else has_email == "true"
|
||||
parsed_has_academic_degree = None if has_academic_degree in (None, "") else has_academic_degree == "true"
|
||||
page = list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
q=q,
|
||||
started_from=parsed_started_from,
|
||||
started_to=parsed_started_to,
|
||||
has_email=parsed_has_email,
|
||||
has_academic_degree=parsed_has_academic_degree,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
try:
|
||||
page = list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
q=q,
|
||||
started_from=parsed_started_from,
|
||||
started_to=parsed_started_to,
|
||||
has_email=parsed_has_email,
|
||||
has_academic_degree=parsed_has_academic_degree,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _render(request, "directory_error.html", {}, status_code=503)
|
||||
return _render(
|
||||
request,
|
||||
"directory.html",
|
||||
@@ -121,13 +127,24 @@ def directory(
|
||||
"sort": sort,
|
||||
"direction": direction,
|
||||
"limit": page["limit"],
|
||||
"offset": offset,
|
||||
"offset": page["offset"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/council", response_class=HTMLResponse)
|
||||
def council(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings)
|
||||
try:
|
||||
result = council_candidates(db)
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
db.rollback()
|
||||
return _render(request, "council.html", {"error": True}, status_code=503)
|
||||
return _render(request, "council.html", {"error": False, **result})
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
|
||||
def employee_detail(
|
||||
employee_id: int,
|
||||
request: Request,
|
||||
|
||||
@@ -13,6 +13,8 @@ class Settings(BaseSettings):
|
||||
request_timeout: int = 30
|
||||
request_delay_seconds: float = 1.0
|
||||
parser_use_playwright: bool = False
|
||||
dismissal_confirmation_runs: int = Field(default=3, ge=1)
|
||||
max_auto_dismissals_per_run: int = Field(default=25, ge=1)
|
||||
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "admin"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,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,117 @@
|
||||
"""Preliminary council candidates from the supplied publication export."""
|
||||
|
||||
import calendar
|
||||
import csv
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import date, datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Employee
|
||||
from app.services.academic_degrees import academic_degrees
|
||||
|
||||
|
||||
EXPORT = Path(__file__).resolve().parents[1] / "data" / "council_articles.csv"
|
||||
DOCTOR = re.compile(r"^доктор\b.+\bнаук$", re.IGNORECASE)
|
||||
DOI = re.compile(r"10\.\d{4,9}/\S+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _name(value: str) -> str:
|
||||
return " ".join(value.casefold().replace("ё", "е").split())
|
||||
|
||||
|
||||
def _doi(value: str) -> str:
|
||||
value = value.strip().lower()
|
||||
for prefix in ("https://doi.org/", "http://doi.org/", "doi:"):
|
||||
if value.startswith(prefix):
|
||||
value = value[len(prefix):]
|
||||
return value
|
||||
|
||||
|
||||
def period(today: date) -> tuple[date, date]:
|
||||
year = today.year - 5
|
||||
return date(year, today.month, min(today.day, calendar.monthrange(year, today.month)[1])), today
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _articles_by_author() -> dict[str, list[dict]]:
|
||||
articles = defaultdict(list)
|
||||
with EXPORT.open(encoding="utf-8-sig", newline="") as source:
|
||||
for row_number, row in enumerate(csv.DictReader(source, delimiter=";"), 2):
|
||||
sources = []
|
||||
for label, quartile, identifier in (
|
||||
("Scopus", "Квартиль scopus", "EID (Scopus)"),
|
||||
("Web of Science", "Квартиль wos", "UT (WoS)"),
|
||||
):
|
||||
value = (row[quartile] or "").strip().upper()
|
||||
if value in {"Q1", "Q2"}:
|
||||
sources.append({"name": label, "quartile": value, "identifier": (row[identifier] or "").strip()})
|
||||
if not sources:
|
||||
continue
|
||||
try:
|
||||
year = int(row["Год"])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
title = (row["Название публикации"] or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
doi = _doi(row["DOI"] or "")
|
||||
article = {
|
||||
"title": title,
|
||||
"year": year,
|
||||
"doi": doi,
|
||||
"doi_url": f"https://doi.org/{quote(doi, safe='/')}" if DOI.fullmatch(doi) else None,
|
||||
"sources": sources,
|
||||
"row_number": row_number,
|
||||
}
|
||||
for author in set(_name(value) for value in (row["Авторы НИУ ВШЭ"] or "").split(";")) - {""}:
|
||||
articles[author].append(article)
|
||||
return articles
|
||||
|
||||
|
||||
def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]:
|
||||
unique = {}
|
||||
for article in articles:
|
||||
if not start.year <= article["year"] <= end.year:
|
||||
continue
|
||||
key = article["doi"] or (_name(article["title"]), article["year"])
|
||||
if key not in unique:
|
||||
unique[key] = {**article, "sources": list(article["sources"])}
|
||||
else:
|
||||
known = {item["name"] for item in unique[key]["sources"]}
|
||||
unique[key]["sources"].extend(
|
||||
item for item in article["sources"] if item["name"] not in known
|
||||
)
|
||||
return sorted(unique.values(), key=lambda item: (-len(item["sources"]), -item["year"], item["title"].casefold()))
|
||||
|
||||
|
||||
def council_candidates(db: Session, today: date | None = None) -> dict:
|
||||
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
|
||||
start, end = period(today)
|
||||
employees = db.scalars(select(Employee).where(Employee.status == "active")).all()
|
||||
name_counts = Counter(_name(employee.full_name or "") for employee in employees)
|
||||
by_author = _articles_by_author()
|
||||
candidates = []
|
||||
for employee in employees:
|
||||
author = _name(employee.full_name or "")
|
||||
if not author or name_counts[author] != 1:
|
||||
continue
|
||||
try:
|
||||
degrees = academic_degrees(employee.current_data if isinstance(employee.current_data, dict) else {})
|
||||
except (AttributeError, TypeError):
|
||||
continue
|
||||
degree = next((value for value in degrees if DOCTOR.fullmatch(value)), None)
|
||||
if not degree:
|
||||
continue
|
||||
articles = _unique_articles(by_author.get(author, []), start, end)
|
||||
if len(articles) >= 10:
|
||||
candidates.append({"id": employee.id, "name": employee.full_name, "degree": degree,
|
||||
"count": len(articles), "articles": articles})
|
||||
candidates.sort(key=lambda item: (-item["count"], item["name"].casefold()))
|
||||
return {"candidates": candidates, "start": start, "end": end, "total": len(candidates)}
|
||||
+60
-10
@@ -2,7 +2,8 @@ import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
@@ -398,8 +399,11 @@ def _employee_publications_table_exists(db: Session) -> bool:
|
||||
return inspect(db.connection()).has_table(EmployeePublication.__tablename__)
|
||||
|
||||
|
||||
def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -> None:
|
||||
publications = _publication_payloads(parsed)
|
||||
def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -> None:
|
||||
publications = _publication_payloads(parsed)
|
||||
imported = [item for item in db.scalars(select(EmployeePublication).where(
|
||||
EmployeePublication.employee_id == employee.id
|
||||
)).all() if _csv_enrichment(item) is not None]
|
||||
seen_hashes = set()
|
||||
for publication in publications:
|
||||
source_hash = _publication_hash(publication)
|
||||
@@ -420,8 +424,10 @@ def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -
|
||||
EmployeePublication.source_hash == source_hash,
|
||||
)
|
||||
)
|
||||
if not existing:
|
||||
existing = EmployeePublication(employee_id=employee.id, source_hash=source_hash, title=_publication_title(publication))
|
||||
if not existing:
|
||||
existing = _match_imported_publication(imported, publication)
|
||||
if not existing:
|
||||
existing = EmployeePublication(employee_id=employee.id, source_hash=source_hash, title=_publication_title(publication))
|
||||
db.add(existing)
|
||||
_apply_publication(existing, publication, source_hash)
|
||||
|
||||
@@ -432,8 +438,39 @@ def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -
|
||||
EmployeePublication.source_hash.not_in(seen_hashes),
|
||||
)
|
||||
).all()
|
||||
for item in stale:
|
||||
db.delete(item)
|
||||
for item in stale:
|
||||
if _csv_enrichment(item) is None:
|
||||
db.delete(item)
|
||||
|
||||
|
||||
def _csv_enrichment(publication: EmployeePublication) -> dict | None:
|
||||
raw = publication.raw_data
|
||||
metadata = raw.get("csv_enrichment") if isinstance(raw, dict) else None
|
||||
return metadata if isinstance(metadata, dict) else None
|
||||
|
||||
|
||||
def _match_imported_publication(imported: list[EmployeePublication], publication: dict) -> EmployeePublication | None:
|
||||
def doi(value):
|
||||
return re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", (value or "").strip(), flags=re.I).lower()
|
||||
|
||||
def title(value):
|
||||
return " ".join(unicodedata.normalize("NFKC", value or "").casefold().replace("ё", "е").split())
|
||||
|
||||
identifier = doi(publication.get("doi_url"))
|
||||
year = _int_or_none(publication.get("year"))
|
||||
name = title(publication.get("title") or publication.get("text"))
|
||||
matches = [item for item in imported if
|
||||
(identifier and doi(item.doi_url) == identifier) or
|
||||
(name and title(item.title) == name and item.year in (None, year))]
|
||||
if len(matches) > 1:
|
||||
raise ValueError("Ambiguous imported publication; synchronization rolled back")
|
||||
if not matches:
|
||||
return None
|
||||
match = matches[0]
|
||||
if ((identifier and doi(match.doi_url) and identifier != doi(match.doi_url)) or
|
||||
(year and match.year and year != match.year)):
|
||||
raise ValueError("Conflicting imported publication; synchronization rolled back")
|
||||
return match
|
||||
|
||||
|
||||
def _publication_payloads(parsed: dict) -> list[dict]:
|
||||
@@ -447,8 +484,16 @@ def _publication_payloads(parsed: dict) -> list[dict]:
|
||||
return publications
|
||||
|
||||
|
||||
def _apply_publication(target: EmployeePublication, publication: dict, source_hash: str) -> None:
|
||||
target.publication_id = _clean_optional(publication.get("publication_id") or publication.get("id"))
|
||||
def _apply_publication(target: EmployeePublication, publication: dict, source_hash: str) -> None:
|
||||
enrichment = _csv_enrichment(target)
|
||||
publication_id = _clean_optional(publication.get("publication_id") or publication.get("id"))
|
||||
if enrichment is not None and target.publication_id and publication_id and target.publication_id != publication_id:
|
||||
raise ValueError(
|
||||
f"Ambiguous HSE IDs for CSV publication {target.id}: "
|
||||
f"{target.publication_id} != {publication_id}; publication synchronization rolled back"
|
||||
)
|
||||
preserved = {field: getattr(target, field) for field in ("title", "year", "doi_url")}
|
||||
target.publication_id = (publication_id or target.publication_id) if enrichment is not None else publication_id
|
||||
target.title = _publication_title(publication)
|
||||
target.year = _int_or_none(publication.get("year"))
|
||||
target.publication_type = _clean_optional(publication.get("publication_type") or publication.get("type"))
|
||||
@@ -462,7 +507,12 @@ def _apply_publication(target: EmployeePublication, publication: dict, source_ha
|
||||
target.annotation = publication.get("annotation") if isinstance(publication.get("annotation"), dict) else None
|
||||
target.description = publication.get("description") if isinstance(publication.get("description"), dict) else None
|
||||
target.authors = publication.get("authors") if isinstance(publication.get("authors"), list) else None
|
||||
target.raw_data = publication.get("raw_data") if isinstance(publication.get("raw_data"), dict) else publication
|
||||
target.raw_data = publication.get("raw_data") if isinstance(publication.get("raw_data"), dict) else publication
|
||||
if enrichment is not None:
|
||||
target.raw_data = {**target.raw_data, "csv_enrichment": enrichment}
|
||||
for field in ("title", "year", "doi_url"):
|
||||
if not getattr(target, field) or (field == "title" and not (publication.get("title") or publication.get("text"))):
|
||||
setattr(target, field, preserved[field])
|
||||
target.source_hash = source_hash
|
||||
|
||||
|
||||
|
||||
+281
-53
@@ -5,9 +5,23 @@
|
||||
flex-direction: column;
|
||||
color: #1f2937;
|
||||
background: #f6f7f9;
|
||||
font-family: Arial, sans-serif;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.admin__skip-link {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: -999px;
|
||||
z-index: 100;
|
||||
padding: 8px 12px;
|
||||
color: #ffffff;
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.admin__skip-link:focus-visible {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.admin__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -16,6 +30,7 @@
|
||||
padding: 18px 32px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #d9dee7;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin__brand {
|
||||
@@ -40,6 +55,16 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin__link[aria-current="page"] {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
:is(a, button, input, select, summary):focus-visible {
|
||||
outline: 3px solid #0f766e;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.admin__main {
|
||||
flex: 1;
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
@@ -107,10 +132,6 @@
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table__row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table__row:hover {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
@@ -161,8 +182,10 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 10px 14px;
|
||||
.button {
|
||||
box-sizing: border-box;
|
||||
min-height: 40px;
|
||||
padding: 10px 14px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
@@ -178,6 +201,19 @@
|
||||
|
||||
.button--compact {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.code {
|
||||
@@ -426,25 +462,25 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.progress-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.progress-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
color: #0f766e;
|
||||
background: #ccfbf1;
|
||||
}
|
||||
|
||||
.progress-panel__body {
|
||||
.progress-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.progress-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
color: #0f766e;
|
||||
background: #ccfbf1;
|
||||
}
|
||||
|
||||
.progress-panel__body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -467,6 +503,12 @@
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.progress-panel__error {
|
||||
margin: 0;
|
||||
color: #991b1b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
@@ -478,7 +520,6 @@
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: #0f766e;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.directory {
|
||||
@@ -503,9 +544,9 @@
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.directory__filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.7fr) repeat(6, minmax(120px, 1fr));
|
||||
.directory__filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 2fr) minmax(0, 1.25fr) minmax(0, 2fr);
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
@@ -513,13 +554,58 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.directory__input {
|
||||
.directory__filter-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.directory__filter-group--search {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.directory__filter-legend {
|
||||
grid-column: 1 / -1;
|
||||
margin-bottom: 2px;
|
||||
color: #374151;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.directory__field {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
color: #4b5563;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.directory__input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.directory__filter-actions {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.directory__table-wrap {
|
||||
overflow-x: auto;
|
||||
background: #ffffff;
|
||||
@@ -539,9 +625,18 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.directory__help {
|
||||
color: #4b5563;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.directory__help summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.directory-table {
|
||||
width: 100%;
|
||||
min-width: 1120px;
|
||||
min-width: 640px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
@@ -556,14 +651,19 @@
|
||||
}
|
||||
|
||||
.directory-table__cell {
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 280px;
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.directory-table .badge {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.directory-table__row {
|
||||
cursor: pointer;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.directory-table__row:hover {
|
||||
@@ -576,33 +676,32 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.directory-table__empty span,
|
||||
.directory-table__empty a {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.directory-table__cell--hidden,
|
||||
.directory-table__head--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.columns-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
width: min(620px, calc(100% - 40px));
|
||||
max-height: min(720px, calc(100vh - 40px));
|
||||
margin: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.columns-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.columns-modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
.columns-modal::backdrop {
|
||||
background: rgba(17, 24, 39, 0.54);
|
||||
}
|
||||
|
||||
.columns-modal__panel {
|
||||
position: relative;
|
||||
width: min(620px, 100%);
|
||||
max-height: min(720px, calc(100vh - 40px));
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
@@ -640,16 +739,37 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.columns-modal__checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.columns-modal__checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.columns-modal__presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.columns-modal__preset-label {
|
||||
color: #4b5563;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.directory__filters {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.directory__filter-group--search,
|
||||
.directory__filter-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.progress-panel__header,
|
||||
.directory__header,
|
||||
.employee-card__header {
|
||||
@@ -658,8 +778,116 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
@media (max-width: 620px) {
|
||||
.directory-table [data-column="full_name"] {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
min-width: 130px;
|
||||
max-width: 160px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.admin__header {
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin__nav {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin__main {
|
||||
width: min(100% - 24px, 1180px);
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.directory__filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.directory__filter-group,
|
||||
.directory__filter-group--search,
|
||||
.directory__filter-actions {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.directory__field {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.council {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
color: #25313a;
|
||||
}
|
||||
|
||||
.council__hero {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
padding: clamp(24px, 4vw, 48px);
|
||||
color: #f9f6f0;
|
||||
background: #263640;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.council__hero-copy { max-width: 680px; }
|
||||
.council__title { margin: 0; font-family: Georgia, serif; font-size: clamp(29px, 4vw, 48px); line-height: 1.12; letter-spacing: -0.025em; }
|
||||
.council__intro { max-width: 60ch; margin: 16px 0 0; color: #e3e8e5; line-height: 1.55; }
|
||||
.council__total { margin: 0; min-width: 160px; color: #e3e8e5; font-size: 14px; line-height: 1.4; }
|
||||
.council__total strong { display: block; color: #f6d49b; font-size: 42px; font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
|
||||
.council__criteria {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
padding-block: 6px 22px;
|
||||
border-bottom: 1px solid #cdd4d5;
|
||||
}
|
||||
|
||||
.council__criteria p { display: grid; align-content: start; gap: 5px; margin: 0; }
|
||||
.council__criteria strong { font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
.council__criteria span { color: #4c5b64; font-size: 13px; }
|
||||
.council__notice { padding: 16px 20px; background: #f6efe2; border-radius: 10px; line-height: 1.55; }
|
||||
.council__notice strong { color: #593b20; }
|
||||
.council__list-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.council__list-heading h3 { margin: 0; font-family: Georgia, serif; font-size: 27px; }
|
||||
.council__list-heading p { margin: 0; color: #4c5b64; font-size: 14px; }
|
||||
.council__list { display: grid; gap: 10px; }
|
||||
|
||||
.council__candidate { overflow: hidden; background: #fff; border: 1px solid #d6dddc; border-radius: 12px; }
|
||||
.council__candidate[open] { border-color: #8fa8a5; }
|
||||
.council__candidate-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 20px; min-height: 76px; padding: 14px 20px; cursor: pointer; list-style: none; }
|
||||
.council__candidate-summary::-webkit-details-marker { display: none; }
|
||||
.council__candidate-summary:hover { background: #f3f7f5; }
|
||||
.council__candidate-identity { display: grid; gap: 4px; min-width: 0; }
|
||||
.council__candidate-identity strong { font-size: 17px; overflow-wrap: anywhere; }
|
||||
.council__candidate-identity small, .council__candidate-count small { color: #4c5b64; font-size: 12px; }
|
||||
.council__candidate-count { display: grid; gap: 2px; text-align: end; }
|
||||
.council__candidate-count strong { font-size: 25px; font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
.council__candidate-cue { width: 9px; height: 9px; border-inline-end: 2px solid #345e59; border-bottom: 2px solid #345e59; transform: rotate(45deg); }
|
||||
.council__candidate[open] .council__candidate-cue { transform: rotate(225deg); }
|
||||
.council__candidate-body { padding: 20px; border-top: 1px solid #e4e9e8; }
|
||||
.council__articles { display: grid; gap: 0; margin: 16px 0 0; padding-inline-start: 22px; }
|
||||
.council__article { padding: 14px 4px; border-top: 1px solid #e7ebea; }
|
||||
.council__article::marker { color: #5e746f; font-variant-numeric: tabular-nums; }
|
||||
.council__article-heading { display: flex; justify-content: space-between; gap: 16px; line-height: 1.45; }
|
||||
.council__article-heading strong { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.council__article-heading span { color: #4c5b64; font-variant-numeric: tabular-nums; }
|
||||
.council__article-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; margin-top: 9px; color: #4c5b64; font-size: 12px; }
|
||||
.council__source { padding: 4px 8px; color: #244b46; background: #e7f0eb; border-radius: 4px; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.council__message { padding: 28px; background: #fff; border-radius: 12px; }
|
||||
.council__message h3, .council__message h4 { margin: 0 0 8px; font-size: 19px; }
|
||||
.council__message p { margin: 0; color: #4c5b64; line-height: 1.5; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.council__hero, .council__list-heading { align-items: start; flex-direction: column; }
|
||||
.council__total strong { display: inline; margin-inline-end: 8px; font-size: 28px; }
|
||||
.council__criteria { grid-template-columns: 1fr; gap: 14px; }
|
||||
.council__candidate-summary { grid-template-columns: minmax(0, 1fr) auto 14px; gap: 10px; padding: 14px; }
|
||||
.council__candidate-count small { max-width: 72px; }
|
||||
.council__candidate-body { padding: 14px; }
|
||||
}
|
||||
+57
-32
@@ -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();
|
||||
})();
|
||||
@@ -4,21 +4,23 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}MIEM Employees{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
<link rel="stylesheet" href="/static/admin.css?v={{ frontend_version }}">
|
||||
</head>
|
||||
<body class="admin">
|
||||
<a class="admin__skip-link" href="#main-content">Перейти к содержимому</a>
|
||||
<header class="admin__header">
|
||||
<h1 class="admin__brand"><a class="admin__brand-link" href="/admin">MIEM Employees</a></h1>
|
||||
<nav class="admin__nav">
|
||||
<a class="admin__link" href="/admin">Обзор</a>
|
||||
<a class="admin__link" href="/admin/directory">Сотрудники</a>
|
||||
<a class="admin__link" href="/admin/runs">Запуски</a>
|
||||
<a class="admin__link" href="/admin"{% if request.url.path == "/admin" %} aria-current="page"{% endif %}>Обзор</a>
|
||||
<a class="admin__link" href="/admin/directory"{% if request.url.path == "/admin/directory" %} aria-current="page"{% endif %}>Сотрудники</a>
|
||||
<a class="admin__link" href="/admin/council"{% if request.url.path == "/admin/council" %} aria-current="page"{% endif %}>Диссертационный совет</a>
|
||||
<a class="admin__link" href="/admin/runs"{% if request.url.path.startswith("/admin/runs") %} aria-current="page"{% endif %}>Запуски</a>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button class="button button--ghost" type="submit">Выйти</button>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="admin__main">
|
||||
<main class="admin__main" id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="admin__footer">
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Кандидаты в диссертационный совет · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="council">
|
||||
<header class="council__hero">
|
||||
<div class="council__hero-copy">
|
||||
<h2 class="council__title">Кандидаты в диссертационный совет</h2>
|
||||
<p class="council__intro">Сотрудники с докторской степенью в профиле и публикациями с отметками Q1 или Q2 в имеющейся выгрузке. Список помогает начать проверку состава комиссии.</p>
|
||||
</div>
|
||||
{% if not error %}<p class="council__total"><strong>{{ total }}</strong> предварительно подходят</p>{% endif %}
|
||||
</header>
|
||||
|
||||
{% if error %}
|
||||
<div class="council__message" role="alert">
|
||||
<h3>Не удалось загрузить список</h3>
|
||||
<p>Проверьте доступность базы сотрудников и файла публикаций, затем обновите страницу.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="council__criteria" aria-label="Правила предварительного отбора">
|
||||
<p><strong>10 и более</strong><span>различных публикаций</span></p>
|
||||
<p><strong>{{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}</strong><span>скользящие пять лет</span></p>
|
||||
<p><strong>К1 · RSCI · Q1/Q2</strong><span>итоговые основания отбора</span></p>
|
||||
</div>
|
||||
<div class="council__notice" role="note">
|
||||
<strong>Предварительный список.</strong> Сейчас учитываются только отметки Q1/Q2 в CSV для Scopus и Web of Science. В нём нет названия журнала, точной даты статьи, типа материала и подтверждения К1 или RSCI. Год на границе периода и принадлежность к журналу нужно проверить. Сотрудники с публикациями только в К1 или RSCI могут отсутствовать.
|
||||
</div>
|
||||
|
||||
<div class="council__list-heading">
|
||||
<h3>Сотрудники</h3>
|
||||
<p>Откройте строку, чтобы увидеть публикации и основания.</p>
|
||||
</div>
|
||||
{% if candidates %}
|
||||
<div class="council__list">
|
||||
{% for candidate in candidates %}
|
||||
<details class="council__candidate">
|
||||
<summary class="council__candidate-summary">
|
||||
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}</small></span>
|
||||
<span class="council__candidate-count"><strong>{{ candidate.count }}</strong><small>публикаций по выгрузке</small></span>
|
||||
<span class="council__candidate-cue" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="council__candidate-body">
|
||||
<a class="admin__link" href="/admin/employees/{{ candidate.id }}">Открыть профиль сотрудника</a>
|
||||
<ol class="council__articles">
|
||||
{% for article in candidate.articles %}
|
||||
<li class="council__article">
|
||||
<div class="council__article-heading">
|
||||
<strong>{{ article.title }}</strong>
|
||||
<span>{{ article.year }}</span>
|
||||
</div>
|
||||
<div class="council__article-meta">
|
||||
{% for source in article.sources %}
|
||||
<span class="council__source">{{ source.name }} {{ source.quartile }}{% if source.identifier %} · {{ source.identifier }}{% else %} · ID не указан{% endif %}</span>
|
||||
{% endfor %}
|
||||
{% if article.doi_url %}<a class="admin__link" href="{{ article.doi_url }}" target="_blank" rel="noopener noreferrer">DOI</a>{% endif %}
|
||||
<span>Строка {{ article.row_number }} в CSV</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="council__message">
|
||||
<h4>Кандидаты пока не найдены</h4>
|
||||
<p>По доступной выгрузке нет действующих сотрудников с докторской степенью, указанной в профиле, и десятью предварительно подходящими публикациями. Это не означает, что таких сотрудников нет.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -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.7"
|
||||
FRONTEND_VERSION = "0.7.7"
|
||||
BACKEND_VERSION = "0.7.7"
|
||||
APP_VERSION = "0.10.4"
|
||||
FRONTEND_VERSION = "0.10.4"
|
||||
BACKEND_VERSION = "0.10.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:
|
||||
@@ -27,6 +29,7 @@ services:
|
||||
|
||||
worker:
|
||||
build: .
|
||||
restart: always
|
||||
command: python -m app.worker
|
||||
env_file: .env
|
||||
environment:
|
||||
|
||||
@@ -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"
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.7.7"
|
||||
version = "0.10.4"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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,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,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(
|
||||
|
||||
@@ -17,11 +17,11 @@ def test_directory_template_is_russian_and_uses_display_dates():
|
||||
template = Path("app/templates/directory.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "Сотрудники" in template
|
||||
assert "Колонки" in template
|
||||
assert "Применить" in template
|
||||
assert "На странице: {{ value }}" in template
|
||||
assert "колонки" in template
|
||||
assert "Применить фильтры" in template
|
||||
assert "Сотрудников на странице" in template
|
||||
assert "{% for value in [25, 50, 100] %}" in template
|
||||
assert "Найдено:" in template
|
||||
assert "Показаны" in template
|
||||
assert "Новости" in template
|
||||
assert "Есть учёная степень" in template
|
||||
assert 'data-column="academic_degree"' in template
|
||||
@@ -29,7 +29,17 @@ def test_directory_template_is_russian_and_uses_display_dates():
|
||||
assert "employee.first_seen_display" in template
|
||||
assert "employee.last_seen_display" in template
|
||||
assert "employee.dismissed_display" in template
|
||||
assert "verification_required" in template
|
||||
assert "verification_required" in template
|
||||
assert '<label class="directory__field">Статус' in template
|
||||
assert 'id="columns-dialog"' in template
|
||||
assert 'aria-controls="columns-dialog"' in template
|
||||
assert 'aria-labelledby="columns-dialog-title"' in template
|
||||
assert "Сбросить фильтры" in template
|
||||
assert 'data-columns-preset="review"' in template
|
||||
assert '<dialog class="columns-modal"' in template
|
||||
assert 'id="columns-dialog"' in template
|
||||
assert 'aria-controls="columns-dialog"' in template
|
||||
assert "Сбросить фильтры" in template
|
||||
assert "Directory" not in template
|
||||
assert "employees found" not in template
|
||||
|
||||
@@ -50,12 +60,9 @@ def test_dashboard_limits_latest_runs_to_five():
|
||||
def test_runs_template_links_to_run_detail():
|
||||
template = Path("app/templates/runs.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'onclick="window.location.href=\'/admin/runs/{{ run.id }}\'"' in template
|
||||
assert "onkeydown=\"if (event.key === 'Enter' || event.key === ' ')" in template
|
||||
assert 'role="link"' in template
|
||||
assert 'tabindex="0"' in template
|
||||
assert 'data-row-href="/admin/runs/{{ run.id }}"' not in template
|
||||
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' not in template
|
||||
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' in template
|
||||
assert "onclick=" not in template
|
||||
assert 'role="link"' not in template
|
||||
|
||||
|
||||
def test_run_detail_template_extends_base_and_shows_change_groups():
|
||||
@@ -86,22 +93,36 @@ def test_dashboard_has_dismissed_status_refresh_action():
|
||||
|
||||
assert 'action="/admin/dismissed/refresh"' in template
|
||||
assert "Проверить уволенных" in template
|
||||
|
||||
|
||||
def test_progress_template_has_accessible_progressbar():
|
||||
for name in ("dashboard.html", "runs.html"):
|
||||
template = Path(f"app/templates/{name}").read_text(encoding="utf-8")
|
||||
assert 'role="progressbar"' in template
|
||||
assert 'aria-valuemin="0"' in template
|
||||
assert 'aria-valuemax="100"' in template
|
||||
assert "Прогресс парсинга" in template
|
||||
|
||||
|
||||
def test_dashboard_latest_run_rows_link_to_run_detail():
|
||||
template = Path("app/templates/dashboard.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'onclick="window.location.href=\'/admin/runs/{{ run.id }}\'"' in template
|
||||
assert "onkeydown=\"if (event.key === 'Enter' || event.key === ' ')" in template
|
||||
assert 'role="link"' in template
|
||||
assert 'tabindex="0"' in template
|
||||
assert 'data-row-href="/admin/runs/{{ run.id }}"' not in template
|
||||
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' not in template
|
||||
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' in template
|
||||
assert "onclick=" not in template
|
||||
assert 'role="link"' not in template
|
||||
|
||||
|
||||
def test_admin_js_supports_keyboard_activation_for_clickable_rows():
|
||||
source = Path("app/static/admin.js").read_text(encoding="utf-8")
|
||||
|
||||
assert 'addEventListener("keydown"' in source
|
||||
assert '"Enter"' in source
|
||||
assert '" "' in source
|
||||
def test_admin_js_uses_native_dialog_for_column_settings():
|
||||
source = Path("app/static/admin.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "showModal()" in source
|
||||
assert "modal.close()" in source
|
||||
assert 'setAttribute("aria-expanded", "true")' in source
|
||||
assert "data-progress-retry" in source
|
||||
|
||||
|
||||
def test_base_template_has_skip_link_and_current_navigation_state():
|
||||
template = Path("app/templates/base.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'href="#main-content"' in template
|
||||
assert 'aria-current="page"' in template
|
||||
+29
-5
@@ -3,6 +3,7 @@ from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -17,11 +18,7 @@ def test_health_returns_versions():
|
||||
response = TestClient(app).get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "0.7.7"
|
||||
|
||||
|
||||
def test_mcp_endpoint_is_removed():
|
||||
assert TestClient(app).get("/mcp").status_code == 404
|
||||
assert response.json()["backend_version"] == "0.10.4"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
@@ -81,6 +78,14 @@ def test_api_employees_and_stats_require_admin_session():
|
||||
assert stats.json()["new_in_last_run"] == 1
|
||||
assert run_details.status_code == 200
|
||||
assert run_details.json()["changes"]["new"][0]["full_name"] == "Alpha Person"
|
||||
directory = client.get("/admin/directory?q=Alpha&offset=100")
|
||||
assert directory.status_code == 200
|
||||
assert "Показаны 1–1 из 1" in directory.text
|
||||
assert 'name="offset"' not in directory.text
|
||||
assert "Alpha Person" in directory.text
|
||||
empty = client.get("/admin/directory?q=missing&offset=100")
|
||||
assert "Показаны 0–0 из 0" in empty.text
|
||||
assert "Сбросить фильтры" in empty.text
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@@ -128,3 +133,22 @@ def test_admin_refresh_employee_route_updates_only_requested_employee(monkeypatc
|
||||
assert response.headers["location"] == f"/admin/employees/{employee_id}?refresh_status=success"
|
||||
assert calls == [(employee_id, settings)]
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_directory_database_error_has_retry_and_preserves_filters(db_session, monkeypatch):
|
||||
settings = Settings(_env_file=None, session_secret="test-session-secret")
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
def unavailable(*args, **kwargs):
|
||||
raise OperationalError("SELECT", {}, Exception("private database details"))
|
||||
monkeypatch.setattr("app.admin.list_employees_page", unavailable)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
|
||||
response = client.get("/admin/directory?q=Alice")
|
||||
assert response.status_code == 503
|
||||
assert 'href="/admin/directory?q=Alice"' in response.text
|
||||
assert "Повторить загрузку" in response.text
|
||||
assert "private database details" not in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
@@ -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,76 @@
|
||||
import csv
|
||||
from datetime import date
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import Employee
|
||||
from app.security import SESSION_COOKIE, sign_session
|
||||
from app.services import council
|
||||
|
||||
|
||||
def test_council_counts_unique_articles_and_only_active_doctors(db_session, monkeypatch, tmp_path):
|
||||
fields = ["Название публикации", "Год", "DOI", "EID (Scopus)", "UT (WoS)",
|
||||
"Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"]
|
||||
export = tmp_path / "articles.csv"
|
||||
with export.open("w", encoding="utf-8-sig", newline="") as output:
|
||||
writer = csv.DictWriter(output, fieldnames=fields, delimiter=";")
|
||||
writer.writeheader()
|
||||
for number in range(10):
|
||||
writer.writerow({"Название публикации": f"Article {number}", "Год": 2024,
|
||||
"DOI": f"10.1234/{number}", "EID (Scopus)": f"eid-{number}",
|
||||
"Авторы НИУ ВШЭ": "Иванов Иван Иванович; Петров Пётр Петрович",
|
||||
"Квартиль scopus": "Q1"})
|
||||
writer.writerow({"Название публикации": "Article 0", "Год": 2024, "DOI": "10.1234/0",
|
||||
"UT (WoS)": "ut-0", "Авторы НИУ ВШЭ": "Иванов Иван Иванович",
|
||||
"Квартиль wos": "Q2"})
|
||||
monkeypatch.setattr(council, "EXPORT", export)
|
||||
council._articles_by_author.cache_clear()
|
||||
db_session.add_all([
|
||||
Employee(profile_key="doctor", canonical_url="https://example.org/1", full_name="Иванов Иван Иванович",
|
||||
status="active", current_data={"sections": [{"title": "Учёные степени",
|
||||
"items": ["Доктор технических наук"]}]}),
|
||||
Employee(profile_key="candidate", canonical_url="https://example.org/2", full_name="Петров Пётр Петрович",
|
||||
status="active", current_data={"sections": [{"title": "Учёные степени",
|
||||
"items": ["Кандидат технических наук"]}]}),
|
||||
])
|
||||
db_session.commit()
|
||||
result = council.council_candidates(db_session, date(2026, 9, 30))
|
||||
assert result["total"] == 1
|
||||
assert result["candidates"][0]["count"] == 10
|
||||
assert len(result["candidates"][0]["articles"][0]["sources"]) == 2
|
||||
assert council.period(date(2024, 2, 29))[0] == date(2019, 2, 28)
|
||||
settings = Settings(_env_file=None, session_secret="test-session-secret")
|
||||
monkeypatch.setattr("app.admin.council_candidates", lambda db: council.council_candidates(db, date(2026, 9, 30)))
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
try:
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
|
||||
response = client.get("/admin/council")
|
||||
assert response.status_code == 200
|
||||
assert "Иванов Иван Иванович" in response.text
|
||||
assert "Article 0" in response.text
|
||||
assert "публикаций по выгрузке" in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
council._articles_by_author.cache_clear()
|
||||
|
||||
|
||||
def test_council_page_requires_admin_and_explains_preliminary_result(db_session, monkeypatch):
|
||||
monkeypatch.setattr(council, "_articles_by_author", lambda: {})
|
||||
settings = Settings(_env_file=None, session_secret="test-session-secret")
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
try:
|
||||
client = TestClient(app)
|
||||
assert client.get("/admin/council", follow_redirects=False).status_code in (302, 303, 401, 403)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
|
||||
response = client.get("/admin/council")
|
||||
assert response.status_code == 200
|
||||
assert "Предварительный список" in response.text
|
||||
assert "К1 или RSCI могут отсутствовать" in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
+117
-2
@@ -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"]
|
||||
@@ -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